Introduction to Webhooks
Webhooks allow your application to send real-time HTTP POST notifications to client servers when events occur, reversing the request/response pattern.
Because webhooks execute over the public internet, designing them demands rigorous attention to delivery reliability and receiver security.
1. Secure Payload Signing (HMAC)
Since clients expose public endpoints to receive webhooks, they need a mechanism to verify that incoming requests originated from your server and were not tampered with.
Implementation steps:
- Provide the user with a shared secret key (e.g., in their developer dashboard).
- Before sending the webhook, sign the message ID, the timestamp and the raw request body together using HMAC SHA-256 and the shared secret. Signing the body alone leaves the timestamp open to tampering, which defeats replay protection:
signed_content = "{webhook-id}.{webhook-timestamp}.{body}"signature = base64(HMAC-SHA256(secret, signed_content))
- Send the message ID, timestamp and signature in the three headers defined by Standard Webhooks, rather than inventing your own format:
webhook-id: msg_2KWPBgLlAfxdpx2AI54pPJ85f4Wwebhook-timestamp: 1720440000webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
- The client recomputes the signature with their copy of the secret and compares it in constant time against each
v1entry inwebhook-signature(the header is a space-delimited list, so rotation can carry two). They should also reject requests wherewebhook-timestampdiffers significantly from the current time, and usewebhook-id, which stays the same across retries, to drop duplicates.
2. Delivery & Retry Logic
Network glitches are inevitable. If a client server fails to respond, execute a structured retry strategy:
- Timeouts: Limit HTTP request timeouts to 5-10 seconds to avoid blocking outbound worker threads.
- Retry Schedule: Implement exponential backoff (e.g., retrying after 1 min, 5 mins, 30 mins, 2 hours, 6 hours) over a 24-hour period before disabling the webhook subscription.
- Status Codes: Only treat
2xxresponses as success. Treat redirects (3xx), client errors (4xx), and server errors (5xx) as delivery failures.
Publish the schedule you actually implement. A receiver cannot size its own replay window, or tell “still coming” apart from “given up”, against a policy it has not been told.
Record every attempt while you are at it, rather than the current state of each delivery. See webhook delivery history for why the distinction matters and what each record should hold.
If you would rather not build the queue, retries and signing yourself, webhook infrastructure services such as Svix and Hookdeck Outpost provide them, and both can be self-hosted.
3. Idempotency on the Receiver
Webhook consumers must design their endpoints to be idempotent. Due to delivery retries, they may receive the same event multiple times. They should check the event UUID against a local cache or database before executing business operations.
4. Typed, Versioned Payloads
Every payload should carry an explicit event type, and that type is a contract in its own right.
The reason is stronger than readability. Two payloads over the same underlying object are often structurally similar enough to be mistaken for one another, and a receiver that dispatches on shape rather than on a declared type can be led into treating one as the other. An explicit type removes the ambiguity, and when the payload is signed, it means the signature covers what the message claims to be as well as what it contains.
{ "id": "evt_9d3d1f", "type": "invoice.paid", "created": "2026-09-24T10:15:00Z", "data": { }}Changing a payload
Once consumers are parsing an event, its shape is published. The rules are the same as for any other part of an API contract, with one difference: you cannot see who is depending on which field, because a receiver never tells you what it read.
- Adding an optional field is safe. Receivers should be tolerant readers and ignore what they do not recognize.
- Removing a field, renaming one, or changing what an existing field means is a breaking change, and it is worse than the equivalent break in a request/response API because it fails silently on someone else’s server, hours later, in code you cannot see.
- A breaking change is therefore a new event type, not a quiet mutation of the existing one. Emit
invoice.paid.v2alongsideinvoice.paid, let subscribers choose, and retire the old one on a published timeline the way you would retire an endpoint.
Never reuse an existing type with different semantics. A receiver that was working will keep accepting the message, keep verifying the signature, and start doing the wrong thing.
See versioning and deprecation and sunsetting for the retirement half, and CloudEvents for a standard envelope that already carries type, id and time.