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
| type | When |
|---|---|
catalog.run.completed | The 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.failed | The run failed with a failureCode, or the job expired unclosed (JOB_EXPIRED). |
{
"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.
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.
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:
| Attempt | Gap before it | Seconds |
|---|---|---|
| 1 | None: at once | 0 |
| 2 | 1 min | 60 |
| 3 | 5 min | 300 |
| 4 | 30 min | 1800 |
| 5 | 2 h | 7200 |
| 6 | 6 h | 21600 |
| 7 | 12 h | 43200 |
| 8 | 24 h | 86400 |
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.