Skip to main content
A webhook sends a signed POST to your URL whenever something you subscribed to happens in a project: a record is published, a field changes, an asset is deleted. Use it to rebuild a site, clear a cache or sync another system, instead of polling the API.

Create a webhook

In the web app, open your project’s Webhooks page and choose New webhook. Enter the URL and an optional Description. Turn on Published content only to send only changes to published content (see below). Then choose the events to send: turn on All events, or tick a whole group (such as all records, record.*) or single event types. Choose Create webhook, then copy the Signing secret. It is shown only once. Through the API, create one with an API key that has the webhooks:write scope. The response holds the secret, and it is the only one that does:
$PROJECT_ID is your key’s project (prj_…): GET /v1/me returns it as api_key.project_id. The SDKs fill it in for you:
The URL must be https, without a username or password, and on the public internet: addresses on private networks are refused. How many webhooks you can have depends on your organization’s plan. To check your endpoint, use Send test event from the webhook’s menu, or POST /v1/webhooks/{webhook_id}/test. It sends a webhook.test event to that webhook only.

Event types

Event types are the activity log’s, by resource: Merging a draft sends record.updated, with the merged draft’s draft_id, since it changes the record’s content. A subscription lists event types, a resource’s wildcard (record.*) or * for everything. Wildcards include event types added later.

Published content only

A site or app that reads published content only needs to hear about changes to it. With published_only: true, a webhook sends only the events that change what a published-only API key reads:
  • Record events when the record was published before or after the change: publishing, unpublishing, editing a live record (or merging a draft into it), and deleting or restoring a published record. Edits to unpublished records are skipped.
  • Every collection and field event, since published-only keys read the schema.
  • Asset events for assets that published records use. A new upload is skipped until a published record uses it.
It never sends drafts, schedules, redactions (they rewrite history, never current values), uploads (asset.created) or purges (asset.purged), and a published-only webhook can’t subscribe to them by name. Wildcards and * are fine. When a scheduled publish happens, it sends record.published like any other. Test events are always sent. To rebuild a static site, for example through a build service’s deploy hook:
Turn it on for an existing webhook with PATCH /v1/webhooks/{webhook_id} and {"published_only": true}, or with the webhook’s Edit in the web app.

The payload

Each request’s body is one JSON event:
The record above is shortened; events carry the whole record.
  • actor is who made the change (a user, an API key, or the system), and via the client it came through (ui, api, cli, sdk, mcp, system or scheduler). An integration that writes back can skip changes made with its own API key.
  • Record events carry the record as the change left it, with the revision it made and the revision’s ID. Events without a revision (record.scheduled, record.unscheduled, record.schedule_failed, record.redacted) carry the record as it is now. Once a record is purged, every event about it has record: null.
  • Draft events carry the draft as it is now (null once merged or discarded), collection and field events the collection and the schema change, and asset events the asset (only its id once it is purged, as in asset.purged).
The payload is rendered each time it is sent, so values redacted in the meantime show as redacted, even on a retry. Fetch the resource from the API when you need its current state.

Delivery

Every request has Content-Type: application/json and three headers: webhook-id (the event’s ID), webhook-timestamp and webhook-signature.
  • Success is any 2xx response within 10 seconds. Any other status, a timeout or a connection error is a failed attempt. Redirects aren’t followed, so a 3xx fails too.
  • Retries follow a failed attempt after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. After 8 attempts, about a day, the delivery fails.
  • At least once. The same event can arrive more than once, for example when your endpoint was slow to answer. Deduplicate by webhook-id, which stays the same on every attempt.
  • No ordering. A retried event can arrive after later ones. Compare created_at, or a record’s revision, to tell which change is newer.
Answer quickly and do slow work afterwards, in a queue or a background job.

Delivery history and retries

Choose Deliveries on a webhook to see its deliveries for the last 30 days: Delivered, Retrying or Failed, with each attempt’s status code, error, duration and the start of your response, and the payload. Retry now sends a delivery again at once; on a delivered one, Send again does the same. Through the API, GET /v1/webhooks/{webhook_id}/deliveries lists them (newest first; filter[status] takes pending, succeeded or failed, which the web app shows as Retrying, Delivered and Failed), and POST /v1/webhook-deliveries/{delivery_id}/retry sends one again:

Disabling and deleting

There is no automatic disabling: a failing endpoint keeps getting new events, and each one fails on its own after its retries. To pause a webhook, choose Disable from its menu (or set enabled to false). A disabled webhook gets no new events, events from while it was off are not sent later, and its pending retries fail. Delete stops a webhook the same way. A deleted webhook can be restored for 30 days, with its settings, secrets and delivery history (POST /v1/webhooks/{webhook_id}/restore).

Verify signatures

Deliveries are signed per Standard Webhooks, so its libraries verify them too. Always verify before you act on an event. With an SDK, unwrap (unwrapWebhook in TypeScript) checks the signature and the timestamp and returns the typed event: see Verify webhooks. Pass it the raw body, before any JSON parsing, since parsing and serializing again changes the bytes. To verify by hand:
  1. Build the signed string: {webhook-id}.{webhook-timestamp}.{raw body}.
  2. Compute its HMAC-SHA256, keyed with the base64-decoded part of your secret after whsec_, and base64-encode it.
  3. webhook-signature holds one or more space-separated signatures, each v1,<base64>. Accept the request if any of them equals yours, compared in constant time.
  4. Reject the request if webhook-timestamp (Unix seconds, new on each attempt) is more than five minutes from your clock.
In Node, without the SDK:

Rotate the secret

Rotate a webhook’s secret through the API, for example when it may have leaked. POST /v1/webhooks/{webhook_id}/secrets (rotateSecret in TypeScript, rotate_secret in Python and Ruby) returns the new secret, shown only then. For 24 hours after a rotation, each delivery carries a signature for the old secret and one for the new, so you can switch your receiver to the new secret without rejecting any. To stop signing with the old one sooner, find its ID with GET /v1/webhooks/{webhook_id}/secrets and retire it with DELETE /v1/webhook-secrets/{secret_id}. The newest secret can’t be retired: rotate again instead.