Product Catalog · Push API

Webhooks

Instead of polling a job, give the catalog a URL and it tells you when the work is done. One event per finished job or batch, signed, and retried if your server is down.

Set one up

The merchant sets a webhook URL per vendor, and one for the store key, in the Product Catalog dashboard ( Storefront › Vendors). Creating an endpoint shows its signing secret ( pc_whsec_…) once; store it with your other secrets. The URL must be https, with no explicit port, credentials or fragment, no IP literal, no single-label host and no special-use host (localhost, .local, .internal, .test, .example, .invalid), and at most 2,048 characters; otherwise it is refused with 422 WEBHOOK_URL_INVALID. Redirects are not followed, and each delivery times out after 3 seconds.

Events fire only for sync jobs and vendor single batches. Upload runs and the store key's single batch send none.

Events

typeWhen
catalog.run.completedThe job or batch finished and its rows were applied. Some rows may still have failed (read rowsFailed), and data.failureCode can be FULL_SYNC_ARCHIVE_SKIPPED (nothing was archived): check it.
catalog.run.failedThe run failed with a failureCode, or the job expired unclosed (JOB_EXPIRED).
A catalog.run.completed event
{
  "id": "evt_01",
  "type": "catalog.run.completed",
  "createdAt": "2026-11-01T10:15:00.000Z",
  "data": {
    "runId": "run_01",
    "vendorId": "ven_01",
    "mode": "merge",
    "fullSync": false,
    "failureCode": null,
    "received": 1000,
    "created": 12,
    "updated": 980,
    "unchanged": 5,
    "rowsFailed": 3,
    "rowsWarned": 0,
    "finishedAt": "2026-11-01T10:15:00.000Z",
    "reportExpiresAt": "2026-12-01T10:15:00.000Z"
  }
}

data.runId is the job id for a job. The counts are the same as the progress view and the error report is at …/errors.csv until reportExpiresAt. An event can be delivered more than once: dedupe on its id.

A completion event is not an image-copy event
The event says the rows are written, not that images are copied. Copies continue afterwards; until a copy lands the storefront serves the vendor's own image URL, so keep those URLs public (see known limits).

Verify the signature

Every delivery carries X-Catalog-Signature: t=<seconds>,v1=<hex>, where v1 is the HMAC-SHA256, with your secret, of <t>.<rawBody>. The header can hold up to eight v1 digests (any match passes, so a secret can rotate). Reject a timestamp more than 300 seconds old. Check the raw body text, before any JSON parsing.

Verify, then handle (SDK)
import { verifyCatalogWebhook } from '@usethrottle/catalog/admin';
import type { PushWebhookEvent } from '@usethrottle/catalog/admin';

export async function POST(request: Request): Promise<Response> {
  // The body text exactly as received: parsing and re-serialising breaks the signature.
  const rawBody = await request.text();

  const ok = await verifyCatalogWebhook({
    rawBody,
    signature: request.headers.get('x-catalog-signature') ?? '',
    secret: process.env.CATALOG_WEBHOOK_SECRET ?? '',
  });
  if (!ok) return new Response('bad signature', { status: 401 });

  const event = JSON.parse(rawBody) as PushWebhookEvent;
  // The same event can arrive twice: dedupe on event.id.
  if (event.type === 'catalog.run.failed') console.error(event.data.runId, event.data.failureCode);
  else console.log(event.data.runId, event.data.created, event.data.updated, event.data.rowsFailed);

  return new Response(null, { status: 204 });
}

verifyCatalogWebhook uses WebCrypto only and returns true or false; it never throws. Answer any 2xx quickly and do the work afterwards.

Retries

A delivery your server does not accept with a 2xx is tried again. The retry schedule is a list of gaps after the previous attempt, in seconds, not offsets from the first:

AttemptGap before itSeconds
1None: at once0
21 min60
35 min300
430 min1800
52 h7200
66 h21600
712 h43200
824 h86400

That is 8 attempts over about 44.6 hours in all. A receiver that is down longer than that misses the event. The run is still there to poll (GET /v1/catalog/runs/{runId} or GET /v1/catalog/jobs/{jobId}), so reconcile from the API after an outage.