Webhooks

Instead of polling, register an HTTPS endpoint and Contents.team will POST a signed event to it as a claim moves through the pipeline.

Register an endpoint

In the app: Settings, Connections, Webhooks (owners and admins). Or with a key that has the webhooks:manage scope:

Register
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 a ctk_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

EventSent when
claim.createdA claim was created: in the app, by the API, or from a connected integration.
photos.analysis_completedEvery photo of one upload has finished AI analysis.
items.pricedEvery photo of one upload is analyzed and none of its items is still being priced.
inventory.approvedItems on the claim were approved: one item, a bulk approval or an accepted replacement, in the app or through the API.
export.readyA report or export finished (PDF, photo appendix, Excel, ESX or photo ZIP).
webhook.pingYou 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.

Delivery body
{
  "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 the Contents-Event-Id header) and order by createdAt.
  • 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:

HeaderValue
Contents-Signaturet=<unix seconds>,v1=<hex>: the hex HMAC-SHA256 of <t>.<raw body>, keyed with the whole secret (including the whsec_ prefix).
Contents-Event-IdThe event id (evt_...). Use it to de-duplicate.
Contents-Event-TypeThe event type.
Contents-Delivery-IdThis 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.

Node.js (Express)
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);
});
Python
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.

NextTest modeBuild against canned results with test keys; nothing is billed.