---
title: Receiving webhooks
description: What a delivery looks like, how to verify its signature, what to answer, and what happens when you answer badly.
type: how-to
product: webhooks
summary: Verify the signature, answer quickly, and treat every delivery as possibly sent twice
updated: 2026-09-30
order: 1
---

A [Webhook Endpoint](/docs/api/webhook_endpoints) 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](https://www.standardwebhooks.com).

<KeyValue
	items={[
		{ key: 'webhook-id', value: 'Stable across every attempt of one delivery', mono: true },
		{ key: 'webhook-timestamp', value: 'Unix seconds when this attempt was signed', mono: true },
		{ key: 'webhook-signature', value: 'One or more v1,<base64> signatures, separated by spaces', mono: true },
	]}
/>

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

```ts
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`](/docs/api/webhook_endpoints#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

<PropertyTable
	properties={[
		{
			name: '2xx',
			type: 'delivered',
			description: 'The delivery is done and is not sent again.',
		},
		{
			name: '408, 425, 429, 5xx',
			type: 'retried',
			description: 'No answer at all, or one of these statuses: the attempt failed and the next one is scheduled.',
		},
		{
			name: 'Any other 4xx, or a redirect',
			type: 'dead-lettered',
			description: '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`](/docs/api/webhook_endpoints#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`](/docs/api/webhook_endpoints#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.

<RelatedDocs
	links={[
		{
			href: '/docs/api/webhook_endpoints',
			label: 'The webhook_endpoints collection',
			description: 'Every method, field and example, including rotate_secret, replay and test.',
		},
		{
			href: '/docs/api/webhook_deliveries',
			label: 'The webhook_deliveries collection',
			description: 'Read a delivery, its attempts and what your endpoint answered.',
		},
	]}
/>
