An event delivered to your endpoint
What this platform POSTs to the url you registered with POST /api/v1/webhook-endpoints. Nothing here is a route you call.
VERIFY THE SIGNATURE ON EVERY DELIVERY. This is the only thing that distinguishes us from anyone who learns your endpoint url.
The scheme is Standard Webhooks (standardwebhooks.com), so any Standard Webhooks library verifies these deliveries without custom code. To implement it yourself:
- The HMAC key is the secret with its
whsec_prefix REMOVED and the remainder base64-decoded to raw bytes. It is not the string as returned, and using that string directly is the single most common reason a verifier never matches. - The signed input is the three values joined by full stops:
webhook-id,webhook-timestamp, then the RAW request body, asid.timestamp.payload. Sign the bytes you received; do not parse and re-serialise the JSON first, which changes them. - Compute HMAC-SHA256 over that input with the key from step 1 and base64-encode the result.
- Compare it against the
webhook-signatureheader, whose value isv1,<base64>. Compare in constant time.
THE HEADER CAN CARRY MORE THAN ONE SIGNATURE, SPACE-SEPARATED, and the delivery is authentic if ANY of them verifies. Do not compare the header for equality: split it on spaces and accept on the first match. This is not hypothetical. POST /api/v1/webhook-endpoints//rotate-secret signs every delivery with BOTH the old and the new secret for 7 days, which is what lets you redeploy on your own schedule, and a verifier that expects exactly one signature rejects every delivery for that entire window.
Reject a delivery whose webhook-timestamp is far from your own clock,
five minutes being the usual tolerance. Without that check a signed
delivery captured once can be replayed at you forever.
DELIVERY IS AT-LEAST-ONCE. The same event WILL reach you more than
once eventually, and your handler has to be ready for it. Keep a
record of the webhook-id values you have processed and ignore one
you have seen before: it is stable across every retry of the same
delivery, which is exactly what makes it the deduplication key.
A delivery is retried on any non-2xx response and on any transport failure, up to 8 attempts including the first. The delays between attempts are 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours and then 6 hours. A receiver that stays broken therefore has roughly 10 hours to recover before the delivery is abandoned, after which only a manual replay through POST /api/v1/webhook-deliveries//redeliver can recover it.
A TIMEOUT IS NOT A FAILURE WE CAN TELL FROM ONE. You have 5 seconds to answer, and a receiver that commits its work and then exceeds that is retried even though it succeeded. Acknowledge with a 2xx first and do the slow work asynchronously. This is the mirror of the Idempotency-Key this API requires from you on every write: the same defence is needed in the other direction.
Authorizations
A secret API key. Publishable keys cannot reach this API. A key may carry an expiry, and an expired key is refused exactly like an unknown one, with a 401 that names no reason; check the key's expires_at in the dashboard rather than inferring it from a response. When a merchant rolls a key's secret they choose a grace window of up to 3 days, and for its duration BOTH the new secret and the one it replaced authenticate, so an integration moves over on its own deploy schedule instead of at the instant the button is pressed. Move before the window closes: after it, the old secret is refused. Nothing else about this contract moves with a roll. The key keeps its id and its scopes, so the only thing an integration updates is the credential itself.
Headers
Unique id for this delivery, stable across every retry of it. Deduplicate on this value.
Unix seconds at which the delivery was signed. Part of the signed input, and what you check to refuse a replayed capture.
One or more space-separated signatures, each v1,<base64 hmac-sha256>. Accept if any verifies.
The topic that fired, matching the envelope's event field. Lets you route before parsing the body.
Body
The body of a webhook delivery. Every topic uses this envelope, so one
parser handles all of them and routing is a switch on event rather
than on which handler received the request.
COMPATIBILITY. Changes are additive: new fields may appear inside
data at any time, so parse leniently and ignore what you do not
recognise. A field is never removed or retyped under the same
schema_version; a change that would break a receiver arrives as a
bumped schema_version, which is what makes branching on it possible
instead of guessing from the fields present.
There is deliberately no id field in the body. The delivery id travels as the webhook-id header and is the value to deduplicate on.
The payload contract version. Branch on it rather than on field presence; a receiver written for one version cannot be assumed to handle the next.
"1"The topic that fired, one of the values in WebhookTopic other than the * wildcard, which subscribes but never appears on the wire. It is also sent as the webhook-event-type header, so a receiver can route before parsing.
When the event happened, UTC. NOT when the delivery was sent: a retry hours later carries the original value, because the payload is a snapshot of the moment rather than a fresh read.
The resource the event is about, snapshotted when the event occurred. Its fields depend on event; see the examples on the delivery request below, which are the frozen bodies this platform actually sends.
Response
Any 2xx acknowledges the delivery and it is not retried. Answer before doing slow work. A body is read and discarded.
