Skip to main content

Webhooks & Event-Driven API Design

Best practices for webhooks, covering secure payload signing, retry scheduling, receiver idempotency, and typed payloads whose changes are new event types rather than quiet mutations.

Topic:Events & AsyncUpdated:2026-10-17

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:

  1. Provide the user with a shared secret key (e.g., in their developer dashboard).
  2. 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))
  3. Send the message ID, timestamp and signature in the three headers defined by Standard Webhooks, rather than inventing your own format:
    webhook-id: msg_2KWPBgLlAfxdpx2AI54pPJ85f4W
    webhook-timestamp: 1720440000
    webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
  4. The client recomputes the signature with their copy of the secret and compares it in constant time against each v1 entry in webhook-signature (the header is a space-delimited list, so rotation can carry two). They should also reject requests where webhook-timestamp differs significantly from the current time, and use webhook-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 2xx responses 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.v2 alongside invoice.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.

On this page (5 sections)