Skip to content

Webhooks

Webhooks are HTTPS POSTs Sprii sends to your backend when something happens — a live event being created, a shoppable going live, a product on an event being updated. Use them when you'd rather be told than have to ask.

The REST API at api.docs.sprii.io is the right tool when you want to pull data on demand (catalog browsing, on-page lookups, backfills). Webhooks are the right tool when you want to react to a change at the moment it happens, without polling — keeping your search index, your CMS, your CRM, or your ERP in sync with Sprii in real time.

This page covers the full delivery contract:

Registering a webhook

There are two ways to register a webhook subscriber for a Sprii page, and they behave identically from there on.

1. From the dashboard. Go to Page settings → Integrations → Webhooks, click Add subscriber, give it a name, paste the URL Sprii should POST to, and pick the topics you want. Sprii shows you the signature key once, on the create-confirm dialog — copy it before closing. You can also rotate the signature key from that screen at any time.

2. From the API. Use the per-page API token your merchant generates under Page settings → Integrations → Generic API as Authorization: Token <api-key>. The webhook endpoints live under /api/v1/webhooks. They cover the same six operations the dashboard does — create, list, get, update, delete, and rotate signature key — and the create response returns the plaintext signatureKey exactly the same way. See the Webhooks tag in the REST API docs for the full request/response schemas and examples.

Either way, the signature key is shown once at create time. There is no reveal endpoint by design. If you lose the key, call POST /webhooks/{webhookUid}/rotateSignatureKey (or click "Signature key" → rotate in the dashboard) — a new key is issued and the previous one stops verifying immediately.

Up to 5 webhook subscribers per Sprii page. Each subscriber has its own signature key, topic list, and optional custom headers.

The delivery envelope

Every webhook delivery has the same outer envelope. The topic-specific data lives in the data field.

jsonc
{
  "version": "v1",                            // payload contract version
  "topic": "live_event.created",              // see Topics below
  "occurredAt": 1748332215123,                // ms epoch — when the change happened
  "deliveryUid": "del_3f9a7c2e1b",            // unique per attempt
  "deliveryAt": 1748332215456,                // ms epoch — when this attempt was sent
  "metadata": {
    "pageUid": "abc123",                      // Sprii page that owns the event
    "subscriberUid": "sub_xyz789",            // the webhook subscriber that received it
    "eventUid": "evt_b4d2a8e7"                // unique per emitted event (stable across retries)
  },
  "data": { /* topic-specific — see "Topics and payloads" */ }
}
FieldPurpose
versionPayload contract version. Currently always "v1". A future "v2" will be opt-in per subscriber.
topicWhich event this is — see the next section for the full catalog.
occurredAtWhen the change happened, ms since epoch. Use this for ordering.
deliveryUidUnique per delivery attempt. Each retry produces a new deliveryUid.
deliveryAtWhen this attempt was sent, ms since epoch. Used in the signature — see Verifying the signature.
metadata.pageUidSprii page the event belongs to.
metadata.subscriberUidThe webhook subscriber that received this delivery.
metadata.eventUidStable across retries and across multiple subscribers receiving the same event. Use this to deduplicate.
dataThe topic-specific payload. Shape depends on the topic.

The body is always a single JSON object — never an array, never a stream. Content-Type is application/json.

Topics and payloads

The topic catalog (v1):

TopicFires whendata shape
live_event.createdA live broadcast event is createdApiLiveEventV1
live_event.updatedA live broadcast event is updatedApiLiveEventV1
live_event.deletedA live broadcast event is deletedApiLiveEventV1 (the snapshot at deletion)
shoppable_event.createdA shoppable is createdApiLiveEventV1
shoppable_event.updatedA shoppable is updatedApiLiveEventV1
shoppable_event.deletedA shoppable is deletedApiLiveEventV1 (the snapshot at deletion)
live_event_product.createdA product is added to a live broadcastApiLiveEventProductV1
live_event_product.updatedA product on a live broadcast is updatedApiLiveEventProductV1
live_event_product.deletedA product is removed from a live broadcastApiLiveEventProductV1 (the snapshot at deletion)
shoppable_event_product.createdA product is added to a shoppableApiLiveEventProductV1
shoppable_event_product.updatedA product on a shoppable is updatedApiLiveEventProductV1
shoppable_event_product.deletedA product is removed from a shoppableApiLiveEventProductV1 (the snapshot at deletion)

The routing for live_event.* vs shoppable_event.* matches the REST API split: /api/v1/liveEvents lists live broadcasts only, /api/v1/shoppables lists shoppables only. Product topics follow the same split: live_event_product.* fires for products on a live broadcast, shoppable_event_product.* for products on a shoppable clip. Which one you get is determined by the parent event's status — exactly like the event topics above.

live_event.* and shoppable_event.* — the event

data is an ApiLiveEventV1:

ts
interface ApiLiveEventV1 {
  id: string;
  event: {
    id: string;
    campaignId: string;
    eventName: string;
    eventNameShort: string;
    description: string;
    eventStartTimestamp: number;          // ms epoch, planned start
    liveStartTime: number;                // ms epoch, actual start (0 if never went live)
    liveEndTime: number;                  // ms epoch, actual end
    currentStatus: 'planned' | 'live' | 'vod' | 'cancelled' | 'processing' | 'clip';
    isPublished: boolean;                 // derived: true when event.status === 'public'
    coverPhotoPath: string;
    coverPhotoPortraitModePath: string;
    liveVideoCover: string;
    cdnMp4VideoUrl: string;               // populated after the broadcast is processed to VOD
    videoMetaData?: { width: number; height: number; orientation: VideoOrientation };
    animation: string;
    tags: string[];
    category: string;
    shoppableGroups: string[];
  };
  campaign: {
    deadline: number;                     // ms epoch, when the linked campaign closes
  };
}

Example body for live_event.updated:

jsonc
{
  "version": "v1",
  "topic": "live_event.updated",
  "occurredAt": 1748332215123,
  "deliveryUid": "del_3f9a7c2e1b",
  "deliveryAt": 1748332215456,
  "metadata": {
    "pageUid": "abc123",
    "subscriberUid": "sub_xyz789",
    "eventUid": "evt_b4d2a8e7"
  },
  "data": {
    "id": "evt_b4d2a8e7",
    "event": {
      "id": "evt_b4d2a8e7",
      "campaignId": "cmp_7a9d3f2c",
      "eventName": "Spring drop — premiere",
      "eventNameShort": "Spring drop",
      "description": "Live unveiling of the spring collection.",
      "eventStartTimestamp": 1748340000000,
      "liveStartTime": 0,
      "liveEndTime": 0,
      "currentStatus": "planned",
      "isPublished": true,
      "coverPhotoPath": "https://cdn.sprii.io/.../cover.jpg",
      "coverPhotoPortraitModePath": "https://cdn.sprii.io/.../cover-portrait.jpg",
      "liveVideoCover": "https://cdn.sprii.io/.../live-cover.jpg",
      "cdnMp4VideoUrl": "",
      "animation": "none",
      "tags": ["women", "spring-25"],
      "category": "fashion",
      "shoppableGroups": []
    },
    "campaign": {
      "deadline": 1748420000000
    }
  }
}

Notes:

  • liveStartTime / liveEndTime are 0 while the event hasn't been broadcast yet. They populate once the broadcast actually starts / ends.
  • cdnMp4VideoUrl is empty until VOD processing completes.
  • currentStatus is the canonical state machine — plannedliveprocessingvod. The literal string 'clip' is the field value Sprii uses internally for shoppables.
  • On *.deleted the payload is the last known snapshot of the event. Sprii holds the source row long enough to send the deletion, so you still get the full body — useful for "remove this event from my CMS" handlers that need the event id and any associated metadata.

live_event_product.* and shoppable_event_product.* — products on an event

Both product topic families carry the same payload — an ApiLiveEventProductV1. The topic prefix tells you whether the product belongs to a live broadcast (live_event_product.*) or a shoppable clip (shoppable_event_product.*):

ts
interface ApiLiveEventProductV1 {
  id: string;
  uid: string;
  sku: string;
  externalId: string;
  campaignUid: string;
  price: number;
  suggestedRetailPrice: number;
  available: boolean;
  minPurchaseQty?: number;
  maxQtyPerCustomer?: number;
  defaultQuantityToAdd?: number;
  isVirtualProduct: boolean;
  brand: string;
  productCategories?: string[];
  url: string;
  name: string;
  information?: string;
  description?: string;
  image?: { url: string };
  images?: Array<{ url: string }>;
  sorting: number;
  variants?: Array<{
    uid: string;
    sku: string;
    externalId: string;
    variantNames: string[];
    price?: number;
  }>;
}

Example body for live_event_product.created (a shoppable_event_product.* delivery is identical except for the topic string):

jsonc
{
  "version": "v1",
  "topic": "live_event_product.created",
  "occurredAt": 1748332300000,
  "deliveryUid": "del_91a7e8d3c2",
  "deliveryAt": 1748332300120,
  "metadata": {
    "pageUid": "abc123",
    "subscriberUid": "sub_xyz789",
    "eventUid": "ep_8c2d1f4a"
  },
  "data": {
    "id": "prod_5e7a9b3c",
    "uid": "prod_5e7a9b3c",
    "sku": "SKU-DRESS-001-M-RED",
    "externalId": "PROD-12345",
    "campaignUid": "cmp_7a9d3f2c",
    "price": 49.99,
    "suggestedRetailPrice": 59.99,
    "available": true,
    "isVirtualProduct": false,
    "brand": "Acme",
    "productCategories": ["dresses", "spring-25"],
    "url": "https://shop.example.com/products/spring-dress",
    "name": "Spring Dress",
    "information": "100% cotton",
    "image": { "url": "https://cdn.example.com/.../dress.jpg" },
    "images": [
      { "url": "https://cdn.example.com/.../dress.jpg" },
      { "url": "https://cdn.example.com/.../dress-back.jpg" }
    ],
    "sorting": 1,
    "variants": [
      { "uid": "var_001", "sku": "SKU-DRESS-001-S-RED", "externalId": "VAR-001", "variantNames": ["S", "Red"] },
      { "uid": "var_002", "sku": "SKU-DRESS-001-M-RED", "externalId": "VAR-002", "variantNames": ["M", "Red"] }
    ]
  }
}

Notes:

  • price is the campaign-specific price (the live-show price, not the catalog price). Use this to keep your CMS / search index in sync with the in-player price.
  • campaignUid is the campaign the product belongs to — the same identifier you'd use with POST /getCampaignProductPrice/{campaignUid}.
  • metadata.eventUid on these deliveries refers to the event-product record, not the live-event record. If you need to attribute it to a specific live event, use data.campaignUid plus a lookup on your side, or correlate with the most recent live_event.* / shoppable_event.* delivery for the same campaignId.

Verifying the signature

Every delivery includes an HMAC signature so you can verify it actually came from Sprii. You should verify it. Without verification, anyone who knows your URL can post anything to it.

The signing scheme

The signature is HMAC-SHA256 over the concatenation {X-Sprii-Timestamp}.{rawBody}, using the subscriber's signature key as the HMAC secret, base64-encoded.

The headers Sprii sends on every delivery:

HeaderMeaning
Content-TypeAlways application/json
User-AgentSprii-Webhooks/1
X-Sprii-Page-UidThe Sprii page the event belongs to
X-Sprii-Subscriber-UidYour webhook subscriber id
X-Sprii-TopicThe topic of this delivery (e.g. live_event.created)
X-Sprii-Delivery-UidUnique per delivery attempt
X-Sprii-TimestampdeliveryAt as a numeric string (ms epoch) — part of the signed message
X-Sprii-SignatureBase64 of HMAC-SHA256(signatureKey, "{X-Sprii-Timestamp}.{rawBody}")
X-Sprii-Event-UidPresent on real deliveries; equal to metadata.eventUid. Absent on sprii.test

Two important details:

  1. Sign the raw body, not the parsed JSON. Any whitespace change or re-serialisation breaks the signature. Capture the body as a string / Buffer before JSON-parsing it.
  2. Compare in constant time. Don't use === on the signature strings — use a constant-time comparator (crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hash_equals in PHP). Otherwise you leak signature bytes via timing.

Node.js (Express)

js
const express = require('express');
const crypto = require('crypto');

const app = express();
// Capture the raw body so signature verification has the exact bytes Sprii signed.
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); },
}));

const SIGNATURE_KEY = process.env.SPRII_WEBHOOK_SIGNATURE_KEY;

const verifySpriiSignature = (req) => {
  const timestamp = req.header('X-Sprii-Timestamp');
  const signature = req.header('X-Sprii-Signature');
  if (!timestamp || !signature) return false;

  const expected = crypto
    .createHmac('sha256', SIGNATURE_KEY)
    .update(`${timestamp}.${req.rawBody}`)
    .digest('base64');

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
};

app.post('/sprii/webhook', (req, res) => {
  if (!verifySpriiSignature(req)) {
    return res.status(401).send('invalid signature');
  }

  const { topic, data, metadata } = req.body;
  console.log('Received', topic, 'for page', metadata.pageUid);

  // Respond fast — 200 within a few seconds. Do the heavy work async.
  res.status(200).end();
});

app.listen(3000);

PHP

php
<?php
$signatureKey = getenv('SPRII_WEBHOOK_SIGNATURE_KEY');

$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_SPRII_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SPRII_SIGNATURE'] ?? '';

if (!$timestamp || !$signature) {
    http_response_code(401);
    exit('missing headers');
}

$expected = base64_encode(hash_hmac('sha256', $timestamp . '.' . $rawBody, $signatureKey, true));

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('invalid signature');
}

$payload = json_decode($rawBody, true);
// ... handle $payload['topic'] / $payload['data'] / $payload['metadata']

http_response_code(200);

Python (Flask)

python
import hmac
import hashlib
import base64
import os
from flask import Flask, request, abort

app = Flask(__name__)
SIGNATURE_KEY = os.environ['SPRII_WEBHOOK_SIGNATURE_KEY']

@app.post('/sprii/webhook')
def receive():
    timestamp = request.headers.get('X-Sprii-Timestamp')
    signature = request.headers.get('X-Sprii-Signature')
    if not timestamp or not signature:
        abort(401)

    raw_body = request.get_data(as_text=True)
    mac = hmac.new(SIGNATURE_KEY.encode(), f'{timestamp}.{raw_body}'.encode(), hashlib.sha256)
    expected = base64.b64encode(mac.digest()).decode()

    if not hmac.compare_digest(expected, signature):
        abort(401)

    payload = request.get_json()
    # ... handle payload['topic'] / payload['data'] / payload['metadata']
    return '', 200

Custom headers

When you create a webhook subscriber, you can attach up to 5 custom headers that Sprii will send on every delivery. They are useful for:

  • Routing behind an API gateway (e.g. a X-Tenant-Id so your shared receiver can pick the right tenant context).
  • Adding a static bearer/auth header if your endpoint sits behind a reverse-proxy auth layer in addition to signature verification.
  • Marking a delivery channel (e.g. X-Environment: staging) for multi-environment receivers.

Validation rules:

RuleValue
Max headers per subscriber5
Name length1 to 64 characters
Name character set[A-Za-z0-9-] only
Name forbidden prefixX-Sprii- (case-insensitive — reserved for Sprii headers)
Name forbidden valuesContent-Type, Content-Length, Host, User-Agent, Transfer-Encoding (case-insensitive)
Value length1 to 256 characters
Value forbidden charactersCR (\r) and LF (\n) — header-injection guard

Custom headers are sent after Sprii's own X-Sprii-* headers. If you try to redefine a header that's already in the request, the custom one is dropped — Sprii's own headers always win.

Retry policy

Sprii retries failed deliveries on a fixed backoff. The schedule is:

AttemptTiming
1 (initial)At event time
2+ 60 seconds
3+ 180 seconds (3 min)
4 (final)+ 540 seconds (9 min)

Total: 4 attempts over about 13 minutes, then the delivery is considered failed.

Each attempt has a 10-second HTTP timeout. A 2xx response (anywhere from 200 to 299) counts as success and stops the retry chain.

What counts as a retryable failure

  • Connection refused, DNS failure, TLS handshake failure, socket timeout — retried.
  • 5xx responses (500, 502, 503, 504, …) — retried.
  • 408 Request Timeout, 429 Too Many Requests — retried.
  • HTTP responses that don't return in time (10s) — retried.

What is not retried (no-retry 4xx)

Sprii doesn't retry 4xx statuses that signal a permanent problem your end has to fix — re-sending wouldn't help and would just hammer your endpoint. These are dropped after the first attempt:

400, 401, 402, 403, 404, 405, 406, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 422, 423, 424, 425, 426, 428, 431, 451

If you got one of these by accident (you returned 401 because your auth layer is misconfigured, say), fix the receiver and use the Retry button on the failed row in Page settings → Integrations → Webhooks → External Requests to re-attempt the delivery manually.

Practical implications

  • Respond fast. The 10-second per-attempt timeout includes your full handler. If you need to do heavy work, ack with 200 immediately and process the payload async (e.g. push to your own queue).
  • Be idempotent. Because retries reuse the same metadata.eventUid (and same business-level data), your handler may see the same event more than once. Idempotency-key on metadata.eventUid is the simplest defence.
  • Surface 5xx, not 4xx, when in doubt. If your handler crashes on something Sprii could fix by retrying (a transient DB blip, a downstream throttle), return 503 so Sprii retries. Reserve 4xx for genuinely permanent problems (malformed payload, signature mismatch, unknown topic).

Auto-warning and auto-disabling

Sprii watches each subscriber's consecutive failure streak. When deliveries to a subscriber keep failing, Sprii steps in to protect both your endpoint and the system:

Streak durationWhat Sprii does
30 minutes of consecutive failuresSubscriber flipped to warning state. The merchant gets a notification. Deliveries continue.
60 minutes of consecutive failuresSubscriber flipped to disabled state. The merchant gets a second notification. Sprii stops emitting further events to this subscriber until it's reactivated.

A subscriber in warning recovers automatically the moment any delivery succeeds — it flips back to active, no manual step needed.

A subscriber in disabled requires a manual action. The merchant goes to Page settings → Integrations → Webhooks, fixes the underlying problem (often a wrong URL, expired TLS cert, or auth misconfiguration on the receiver), and clicks Activate. Sprii resumes sending immediately, and any new events from that point on are delivered. Events that happened while disabled are not retroactively sent — use the REST API to backfill any state you missed.

This is intentional: an integration that's been failing for an hour is almost always misconfigured, and continuing to send (and retry) deliveries makes the problem harder to diagnose, not easier. The auto-disable also bounds the cost (yours and ours) of a misconfigured endpoint.

Operational tips

Deduplicate on metadata.eventUid

Real-world reasons your handler will see the same event twice:

  • A delivery succeeded but your 2xx arrived late, after Sprii had already retried.
  • An operator clicked Retry on a row in the Webhooks log.
  • Your queue replayed a message after a worker crash.

All retries (automatic or manual) for the same event share the same metadata.eventUid. Persist the last N event UIDs you processed and short-circuit if you've seen one before. The first delivery is authoritative; later ones are redundant.

metadata.deliveryUid is different on every attempt — it identifies the HTTP call, not the event. Use it for log correlation; use eventUid for idempotency.

Test deliveries

From Page settings → Integrations → Webhooks → row menu → Send test, the merchant can fire a synthetic delivery at any subscriber URL. The payload uses topic sprii.test, data: { test: true }, and includes every header a real delivery would — except X-Sprii-Event-Uid (test deliveries don't correspond to a real event). The HTTP status and response excerpt are shown back in the dialog. Useful for sanity-checking signature verification before you point the subscriber at production traffic.

Inspecting deliveries

Every delivery — successful or not — is recorded in External Requests under Page settings → Integrations. Each row shows the full request (URL, headers, body) and response (status, headers, body excerpt). Failed rows get a Retry button to re-fire the delivery to the same subscriber with the same payload, useful when you've just fixed your receiver and want to backfill in-flight events without waiting for new ones.