Development
Key points

Key points

  1. 1Webhooks are delivered at least once, so receivers must expect duplicates, retries and events that arrive out of order.
  2. 2Every webhook should be signed over its ID, timestamp and raw body, and receivers should verify the signature with a constant-time comparison and reject stale timestamps.
  3. 3Receivers should verify, enqueue and return a 2xx response quickly, then process the event in the background with an idempotency check keyed on the event ID.
  4. 4Retries handle short outages, replay handles longer ones, and reconciliation against the source of truth catches anything both of them miss.

Why webhooks are harder than they look

The Standard Webhooks specification describes webhooks as a kind of reverse API: instead of a client calling a service, the service calls the client to report an event. That reversal creates most of the difficulty. The sender cannot know whether the receiver is up, slow, or has changed its code. The receiver cannot control when events arrive or how many times.

Hookdeck's guide to webhooks at scale lists what follows from that: webhooks are usually delivered at-least-once, so you will get duplicates and out-of-order events, and with webhooks you face all the problems of an event-driven system without controlling the producer.

Every design decision below answers one of these facts.

FactConsequence
Networks fail and receivers go downSenders must retry
Senders retryReceivers get duplicates and must be idempotent
Retries and parallel delivery reorder eventsReceivers cannot assume order
Webhook URLs are publicReceivers must verify who sent each request
Events arrive in burstsReceivers must acknowledge fast and queue the work

Designing the payload

Standard Webhooks recommends a JSON body with three top-level fields: a type such as invoice.paid, a timestamp for when the event occurred, and a data object with the event details. Event types should be grouped hierarchically with dots, which lets consumers subscribe to and route families of events.

{
  "type": "invoice.paid",
  "timestamp": "2026-02-11T09:30:12.512Z",
  "data": {
    "id": "inv_8Kd2",
    "customer_id": "cus_19aF",
    "amount": 4900,
    "currency": "usd"
  }
}

The bigger choice is how much data to send. The specification compares thin and full payloads. A full payload includes everything about the changed object, so the consumer rarely needs another API call. A thin payload includes only identifiers, which keeps messages small and forces the consumer to fetch current state. The specification also recommends keeping payloads small, and sending a link rather than embedding large assets.

Thin payloads have a reliability benefit that is easy to miss. Because the consumer fetches the current state when it processes the event, a delayed or out-of-order event cannot overwrite newer data with older data. Hookdeck notes that Stripe offers thin events for some event types for this reason.

Two other habits help consumers.

  • Publish a schema for every event type and an example payload, as the specification recommends.
  • Treat new event types and new fields as additive. GitHub warns that it continues to add new event types and new actions, so consumers should check the event type and action before processing and ignore what they do not handle.

Signing and verifying every delivery

A webhook endpoint is a public URL. Anyone who finds it can send it requests, so the consumer must verify that each delivery came from the real provider and was not altered. The webhooks.fyi reference describes HMAC as by far the most popular authentication and message security method used on webhook requests: the provider and the consumer share a secret, the provider signs each message with it, and the consumer recomputes the signature to check it.

Standard Webhooks specifies what to sign: the message ID, the timestamp and the body, joined with full stops, as msg_id.timestamp.payload. It sends them in three headers.

webhook-id: msg_2KWPBgLlAfxdpx2AI54pPJ85f4W
webhook-timestamp: 1674087231
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

Signing the ID and timestamp along with the body matters. The specification's verification guidance says consumers should check that the webhook-timestamp header has a timestamp that is within some allowable tolerance of the current time, which blocks an attacker from replaying an old, validly signed request.

On the receiving side, three mistakes are common.

  1. Verifying a re-serialized body. The specification warns that signatures break on the smallest change, and that consumers often parse the JSON and serialize it again before verifying. Always verify against the raw request bytes.
  2. Comparing signatures with ==. GitHub's validation guide says never use a plain == operator and recommends a constant-time comparison such as crypto.timingSafeEqual to resist timing attacks.
  3. Putting secrets in the URL. GitHub's best practices warn against including API keys or credentials in the payload URL and recommend a high-entropy webhook secret instead.

Providers have their own duties. Standard Webhooks recommends a unique signing key per endpoint, and its signature header is a space-separated list so that a provider can sign with both the old and new key during rotation. Consumers try each signature until one matches, and the secret changes without downtime.

Sending with retries and timeouts

A provider is responsible for delivery. The Standard Webhooks specification puts it directly: it is up to the webhook producer to retry sending the webhook until an attempt succeeds or delivery is judged impossible.

Its recommendations for providers:

  • Treat only 2xx as success. Any other status, a timeout or a reset connection is a failure.
  • Retry with exponential backoff and jitter over a schedule that spans multiple days. The jitter prevents retries from all arriving at once and adding to the load that caused the failure.
  • Honor the receiver's signals. A 410 Gone means the endpoint no longer wants events and should be disabled. A retry-after header should shape the next attempt.
  • Use a reasonable request timeout. The specification suggests a value between 15 and 30 seconds as of the version read on 2026-10-17.
  • Disable endpoints that fail for a long time and tell the customer through another channel, such as email.
  • Protect against server-side request forgery. Customers choose the URLs, so an attacker can point a webhook at internal services. The specification recommends sending through a proxy that blocks internal addresses and isolating the delivery workers.

Give customers a delivery log with each attempt's status and response, and a button to resend. Without it, every failed delivery becomes a support ticket.

Receiving fast and processing later

The receiver's first job is to acknowledge quickly. GitHub's documentation, as read on 2026-10-17, says your server should respond with a 2XX response within 10 seconds, or GitHub terminates the connection and counts the delivery as a failure. It suggests a queue so the server can respond at once and process the payload in the background.

Hookdeck makes the same argument for scale. Webhooks often arrive in bursts, such as billing runs or mass retries after an outage, and processing them synchronously leads to timeouts, more retries and a retry storm. The fix is to queue everything first.

A receiving endpoint should do only this:

  1. Read the raw body.
  2. Verify the signature and timestamp. Reject failures with a 4xx.
  3. Write the event to a durable queue or table, keyed by its ID.
  4. Return 2xx.

Everything else, including database updates, emails and calls to other APIs, happens in a worker that reads from the queue. Watch the queue's depth and the age of its oldest message, the two back-pressure measures Hookdeck recommends, so you know when workers fall behind.

Idempotency and ordering

Because delivery is at least once, every handler must be safe to run twice. Hookdeck's idempotency guide explains that automatic retries are the primary source of duplicate webhook deliveries, and that timeouts are the most dangerous case: you processed the event, but responded too slowly, so a retry is already on its way.

Standard Webhooks gives every event a unique ID that stays the same across retries, intended for use as an idempotency key. Two implementations work well.

Use a natural unique key. If the event creates an order, put a unique constraint on the provider's order ID. A duplicate insert fails, and the handler treats that failure as success.

Record processed events. Keep a table of event IDs with a unique constraint.

CREATE TABLE processed_webhooks (
  event_id     text PRIMARY KEY,
  received_at  timestamptz NOT NULL DEFAULT now(),
  status       text NOT NULL DEFAULT 'processing'
);

Insert the ID before doing the work. If the insert fails, the event is already handled or in progress, so skip it. Hookdeck's guide stresses the order: mark events as processed before executing side effects, because sending an email first and recording it second means a crash in between sends the email again on retry. Where possible, do the insert and the database changes in one transaction.

Ordering needs a separate rule. Events can arrive in any order, so never apply one blindly over current state. Either fetch the current state from the provider's API when processing, the thin payload approach, or compare the event's timestamp or version with what you have stored and ignore older updates.

Recovery with retry replay and reconcile

Even with all of the above, events go missing: an endpoint is down longer than the retry window, a bug drops events for a day, or a provider has an incident. Hookdeck describes three layers of recovery: retries handle minutes of failure, replay handles hours, and reconciliation handles everything else.

  • Retries come from the provider automatically.
  • Replay means resending stored events after you fix a problem. GitHub recommends redelivering missed webhooks once your server is back up, and notes that a redelivery carries the same delivery ID, so idempotency keeps replays safe.
  • Reconciliation is a periodic job that reads current state from the provider's API and corrects your copy. For payments, subscriptions and anything else that affects money or access, build it before you need it.

A checklist covering both sides:

ProviderConsumer
Stable event ID and event timestampVerify signature on the raw body with constant-time comparison
Sign ID, timestamp and bodyReject stale timestamps
Unique secret per endpoint, rotation supportReturn 2xx fast and queue the work
Exponential backoff with jitter over daysIdempotency keyed on event ID
Delivery logs and manual resendHandle out-of-order events
SSRF protection on outgoing requestsReconcile critical data on a schedule

Agents and automations increasingly react to webhooks, which raises the cost of a duplicate or a missed event. Quanta Intelligence agents that act on incoming events follow the same rule: verify, record the event ID, then act.

FAQ

FAQ

What status code should my webhook endpoint return?

Return a 2xx code as soon as the request is verified and safely queued, even before the business logic runs. Return a 4xx code for invalid signatures or malformed requests. Avoid returning errors for events you have already processed, since that only triggers more retries.

How do I stop processing the same webhook twice?

Use the event's unique ID as an idempotency key. Insert it into a table with a unique constraint before doing any work, and skip the event if the insert fails. A unique constraint on a natural key, such as an order ID, works the same way.

Should webhook payloads include the full object or just an ID?

Both are valid. Full payloads save the consumer an API call, while thin payloads stay small and force the consumer to fetch the current state, which makes out-of-order delivery harmless. Many providers send a thin payload with a few commonly used fields.

Why does my signature verification fail when the secret is correct?

The most common cause is verifying a body that was parsed and re-serialized, which changes whitespace or key order. Verify against the raw request bytes exactly as received. Also check that you are using the right header and algorithm, and that your timestamp tolerance accounts for clock differences.

How long should a provider keep retrying a failed webhook?

Standard Webhooks recommends a schedule that spans multiple days with exponential backoff and jitter. After repeated failures over a long period, the provider should disable the endpoint and notify the customer by another channel. Consumers should not rely on retries alone and should be able to replay and reconcile.

Sources

Sources

  1. https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
  2. https://hookdeck.com/blog/webhooks-at-scale
  3. https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks
  4. https://webhooks.fyi/security/hmac
  5. https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
  6. https://hookdeck.com/webhooks/guides/implement-webhook-idempotency
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles