Menu
Platform
AI
App store purchases
Database
Flags
Jobs and cron
Localization
Monitoring
Notifications
Payments
Queues
Sandboxes
Webhooks
Getting Started
Authentication
KV Store
Deploy & Infrastructure
Reference
Receiving webhooks
Verify the signature, answer quickly, and treat every delivery as possibly sent twice
A Webhook Endpoint is the receiver you write. Write it before you create the endpoint: the endpoint can only tell you in production whether yours answers, verifies a signature and survives a duplicate.
#What the endpoint must be
The URL must be https and public. Redirects are not followed: an endpoint
that answers 301 or 302 fails the delivery, so point the endpoint at the
final address. The address is resolved on every attempt, and a private,
loopback or link-local answer is refused, so an endpoint cannot be aimed at an
internal network.
Each attempt is one POST with Content-Type: application/json. The body is
the event as a CloudEvents document (specversion 1.0), at most 1 MiB.
#The headers on every attempt
Deliveries are signed with Standard Webhooks.
- webhook-id
- Stable across every attempt of one delivery
- webhook-timestamp
- Unix seconds when this attempt was signed
- webhook-signature
- One or more v1,<base64> signatures, separated by spaces
webhook-id is the key to deduplicate on. The timestamp changes on every
attempt, and so does the signature.
#Verify the signature
The signing secret is shown once, when you create the endpoint or rotate its
secret, in status.signing_secret. It starts with whsec_; the key is the
base64 that follows the prefix, decoded.
The signature is a base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body},
where the body is the raw bytes you received. Verify in this order and refuse
on the first failure:
- Refuse a
webhook-timestampmore than five minutes from your clock. - Recompute the HMAC over the raw body, not over a re-serialised copy of it.
- Compare against every
v1,signature in the header with a constant-time comparison, and accept the delivery if any one matches.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(rawBody: Buffer, headers: Record<string, string>, secret: string): boolean {
const id = headers['webhook-id']
const timestamp = headers['webhook-timestamp']
if (!id || !timestamp) return false
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const expected = createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest()
return headers['webhook-signature'].split(' ').some((part) => {
const [version, signature] = part.split(',')
if (version !== 'v1' || !signature) return false
const received = Buffer.from(signature, 'base64')
return received.length === expected.length && timingSafeEqual(received, expected)
})
}Verify before you parse. A body that fails to parse must not be able to throw before the signature is checked.
#Rotating the secret
rotate_secret returns the new
secret once. For 24 hours afterwards each delivery carries a signature under
each secret, so a receiver that accepts any matching signature keeps working
while you switch.
#What to answer
| Field | Type | What it is |
|---|---|---|
2xx | delivered | The delivery is done and is not sent again. |
408, 425, 429, 5xx | retried | No answer at all, or one of these statuses: the attempt failed and the next one is scheduled. |
Any other 4xx, or a redirect | dead-lettered | The delivery is not retried. It moves to the dead letters and can be replayed. |
Answer 2xx only once you have durably accepted the work. An attempt waits at
most 15 seconds for your answer, so record the event and return; do not run the
job inside the request.
#Retries
A delivery that fails is retried on a fixed schedule: the first attempt is immediate, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, eight attempts over about 27.6 hours. A delivery that has used them all is dead-lettered. Nothing is deleted: the delivery and its attempts stay readable.
An endpoint whose attempts have failed without a single 2xx for five days,
and at least 20 times in a row, is disabled. Deliveries queued while it is
disabled are sent, with their full schedule, once you enable it again.
To redeliver what dead-lettered while your receiver was down,
replay sends every dead-lettered
delivery created in a time range to the endpoint again, each as a new attempt.
One replay covers at most 10,000 deliveries.
#Duplicates are normal
Delivery is at least once. A timeout or a 5xx cannot tell Events whether your
receiver finished the work, so it sends again with the same webhook-id. Make
the effect keyed on it:
insert into processed_deliveries (webhook_id, received_at)
values ($1, now())
on conflict (webhook_id) do nothing;
-- Zero rows affected: this delivery was already handled.#Try it
test sends an event of type
sylphx.webhook.test to one endpoint, through the same signing, retries and
delivery log as any other event. Your laptop is not on the public internet, so
put a tunnel in front of your receiver and create the endpoint with the
tunnel's https address.