Webhooks
Register an endpoint
In the app: Settings, Connections, Webhooks (owners and admins). Or with a key that has the webhooks:manage scope:
curl https://contents.team/api/v1/webhook-endpoints \
-H "Authorization: Bearer $CONTENTS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"url": "https://example.com/contents-webhooks",
"description": "Job management sync",
"events": ["claim.created", "items.priced", "inventory.approved", "export.ready"]
}'- The response holds the endpoint's signing secret (
whsec_...). It is shown once; store it with your other secrets. - The URL must be https on a public hostname. Private, loopback and link-local addresses are refused, at registration and again on every delivery. Redirects are not followed.
- An endpoint registered with a
ctk_test_key receives test events only ("livemode": false); one registered with actk_live_key receives live events only. Up to 20 endpoints per organization. - An endpoint belongs to the key or OAuth app that registered it. Through the API, only that credential can list, read, test, replay or delete it; for any other it is a 404. Owners and admins see and manage every endpoint in Settings, which shows who registered each one.
- Revoking the key or disconnecting the app does not delete its endpoints: they keep receiving events until an owner or admin deletes them in Settings. A replacement key does not inherit them, so when you rotate, register the endpoint again with the new key and delete the old one in Settings.
Events
| Event | Sent when |
|---|---|
claim.created | A claim was created: in the app, by the API, or from a connected integration. |
photos.analysis_completed | Every photo of one upload has finished AI analysis. |
items.priced | Every photo of one upload is analyzed and none of its items is still being priced. |
inventory.approved | Items on the claim were approved: one item, a bulk approval or an accepted replacement, in the app or through the API. |
export.ready | A report or export finished (PDF, photo appendix, Excel, ESX or photo ZIP). |
webhook.ping | You sent a test event from Settings or POST /api/v1/webhook-endpoints/{endpointId}/test. |
Every event has the same envelope. data holds ids and counts, not the whole resource: fetch the claim or its items with the API when you need them, so you always read the current state. photos.analysis_completed, items.priced and export.ready usually arrive within a minute or two of the change.
{
"id": "evt_0foXqN1lK",
"object": "event",
"type": "items.priced",
"livemode": true,
"createdAt": "2026-10-24T18:41:07.000Z",
"data": {
"claimId": "clm_0a6NnX3I9",
"uploadBatchId": "6a1f0c9e-1d2b-4c55-9a51-0f4b2d7c9e10",
"itemCount": 64,
"pricedCount": 61,
"pricedTotalCents": 1874215
}
}Delivery and retries
- Each delivery is a POST with a JSON body and a 10-second timeout. Answer with any 2xx to acknowledge it.
- Anything else (a non-2xx, a timeout, a refused connection) is retried for about 3 days (12 attempts: 1m, 5m, 30m, 2h, 5h, then every 10h). After that the delivery is marked failed. An endpoint that has failed every delivery for 3 days is disabled; register it again once it is fixed.
- Delivery is at least once and not ordered. De-duplicate on the event
id(also in theContents-Event-Idheader) and order bycreatedAt. - Acknowledge quickly and do the work afterwards, on a queue of your own.
- Every attempt is logged with its HTTP status and the first 1 KB of your response. See the log, and resend any delivery, from Settings or
GET /api/v1/webhook-endpoints/{endpointId}/deliveries.
Verifying signatures
Every delivery carries these headers:
| Header | Value |
|---|---|
Contents-Signature | t=<unix seconds>,v1=<hex>: the hex HMAC-SHA256 of <t>.<raw body>, keyed with the whole secret (including the whsec_ prefix). |
Contents-Event-Id | The event id (evt_...). Use it to de-duplicate. |
Contents-Event-Type | The event type. |
Contents-Delivery-Id | This delivery (whd_...), as shown in the delivery log. |
Compute the HMAC over the raw request bytes, before any JSON parsing, compare in constant time against every v1 value, and reject a t more than 5 minutes from your clock, which stops a captured delivery being replayed.
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.CONTENTS_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
// header is "t=<unix seconds>,v1=<hex>"; accept if any v1 matches.
export function verifyContentsSignature(rawBody, header, secret = SECRET, now = Date.now() / 1000) {
let timestamp = null;
const signatures = [];
for (const part of String(header ?? '').split(',')) {
const [key, value] = part.trim().split('=', 2);
if (key === 't' && /^\d+$/.test(value ?? '')) timestamp = Number(value);
if (key === 'v1' && value) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'),
);
return signatures.some((candidate) => {
const received = Buffer.from(candidate);
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
// express.raw keeps the body as a Buffer: verify before parsing.
app.post('/contents-webhooks', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyContentsSignature(req.body, req.get('Contents-Signature'))) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
// Acknowledge first, then work; de-duplicate on event.id.
res.sendStatus(204);
queue.push(event);
});import hashlib, hmac, os, time
SECRET = os.environ["CONTENTS_WEBHOOK_SECRET"] # whsec_...
TOLERANCE_SECONDS = 300
def verify_contents_signature(raw_body: bytes, header: str, secret: str = SECRET) -> bool:
timestamp, signatures = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1" and value:
signatures.append(value)
if timestamp is None or not signatures or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, candidate) for candidate in signatures)Rotating the secret
Register a second endpoint for the same URL, deploy its secret alongside the old one (accept either), then delete the old endpoint.