Skip to content
Console
Menu

Queues

Workflows

Getting Started

Authentication

KV Store

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:

  1. Refuse a webhook-timestamp more than five minutes from your clock.
  2. Recompute the HMAC over the raw body, not over a re-serialised copy of it.
  3. Compare against every v1, signature in the header with a constant-time comparison, and accept the delivery if any one matches.
TypeScript
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

FieldTypeWhat it is
2xxdeliveredThe delivery is done and is not sent again.
408, 425, 429, 5xxretriedNo answer at all, or one of these statuses: the attempt failed and the next one is scheduled.
Any other 4xx, or a redirectdead-letteredThe 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:

SQL
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.