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
- The delivery envelope
- Topics and payloads
- Verifying the signature
- Custom headers
- Retry policy
- Auto-warning and auto-disabling
- Operational tips
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.
{
"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" */ }
}| Field | Purpose |
|---|---|
version | Payload contract version. Currently always "v1". A future "v2" will be opt-in per subscriber. |
topic | Which event this is — see the next section for the full catalog. |
occurredAt | When the change happened, ms since epoch. Use this for ordering. |
deliveryUid | Unique per delivery attempt. Each retry produces a new deliveryUid. |
deliveryAt | When this attempt was sent, ms since epoch. Used in the signature — see Verifying the signature. |
metadata.pageUid | Sprii page the event belongs to. |
metadata.subscriberUid | The webhook subscriber that received this delivery. |
metadata.eventUid | Stable across retries and across multiple subscribers receiving the same event. Use this to deduplicate. |
data | The 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):
| Topic | Fires when | data shape |
|---|---|---|
live_event.created | A live broadcast event is created | ApiLiveEventV1 |
live_event.updated | A live broadcast event is updated | ApiLiveEventV1 |
live_event.deleted | A live broadcast event is deleted | ApiLiveEventV1 (the snapshot at deletion) |
shoppable_event.created | A shoppable is created | ApiLiveEventV1 |
shoppable_event.updated | A shoppable is updated | ApiLiveEventV1 |
shoppable_event.deleted | A shoppable is deleted | ApiLiveEventV1 (the snapshot at deletion) |
live_event_product.created | A product is added to a live broadcast | ApiLiveEventProductV1 |
live_event_product.updated | A product on a live broadcast is updated | ApiLiveEventProductV1 |
live_event_product.deleted | A product is removed from a live broadcast | ApiLiveEventProductV1 (the snapshot at deletion) |
shoppable_event_product.created | A product is added to a shoppable | ApiLiveEventProductV1 |
shoppable_event_product.updated | A product on a shoppable is updated | ApiLiveEventProductV1 |
shoppable_event_product.deleted | A product is removed from a shoppable | ApiLiveEventProductV1 (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:
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:
{
"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/liveEndTimeare0while the event hasn't been broadcast yet. They populate once the broadcast actually starts / ends.cdnMp4VideoUrlis empty until VOD processing completes.currentStatusis the canonical state machine —planned→live→processing→vod. The literal string'clip'is the field value Sprii uses internally for shoppables.- On
*.deletedthe 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.*):
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):
{
"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:
priceis 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.campaignUidis the campaign the product belongs to — the same identifier you'd use withPOST /getCampaignProductPrice/{campaignUid}.metadata.eventUidon these deliveries refers to the event-product record, not the live-event record. If you need to attribute it to a specific live event, usedata.campaignUidplus a lookup on your side, or correlate with the most recentlive_event.*/shoppable_event.*delivery for the samecampaignId.
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:
| Header | Meaning |
|---|---|
Content-Type | Always application/json |
User-Agent | Sprii-Webhooks/1 |
X-Sprii-Page-Uid | The Sprii page the event belongs to |
X-Sprii-Subscriber-Uid | Your webhook subscriber id |
X-Sprii-Topic | The topic of this delivery (e.g. live_event.created) |
X-Sprii-Delivery-Uid | Unique per delivery attempt |
X-Sprii-Timestamp | deliveryAt as a numeric string (ms epoch) — part of the signed message |
X-Sprii-Signature | Base64 of HMAC-SHA256(signatureKey, "{X-Sprii-Timestamp}.{rawBody}") |
X-Sprii-Event-Uid | Present on real deliveries; equal to metadata.eventUid. Absent on sprii.test |
Two important details:
- Sign the raw body, not the parsed JSON. Any whitespace change or re-serialisation breaks the signature. Capture the body as a string /
Bufferbefore JSON-parsing it. - Compare in constant time. Don't use
===on the signature strings — use a constant-time comparator (crypto.timingSafeEqualin Node,hmac.compare_digestin Python,hash_equalsin PHP). Otherwise you leak signature bytes via timing.
Node.js (Express)
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
$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)
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 '', 200Custom 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-Idso 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:
| Rule | Value |
|---|---|
| Max headers per subscriber | 5 |
| Name length | 1 to 64 characters |
| Name character set | [A-Za-z0-9-] only |
| Name forbidden prefix | X-Sprii- (case-insensitive — reserved for Sprii headers) |
| Name forbidden values | Content-Type, Content-Length, Host, User-Agent, Transfer-Encoding (case-insensitive) |
| Value length | 1 to 256 characters |
| Value forbidden characters | CR (\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:
| Attempt | Timing |
|---|---|
| 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-leveldata), your handler may see the same event more than once. Idempotency-key onmetadata.eventUidis 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 duration | What Sprii does |
|---|---|
| 30 minutes of consecutive failures | Subscriber flipped to warning state. The merchant gets a notification. Deliveries continue. |
| 60 minutes of consecutive failures | Subscriber 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.
