Webhook consumers fail in production not because the webhook itself is complicated, but because engineers assume delivery is exactly-once when every major provider documents it as at-least-once. Stripe, GitHub, Shopify, and Twilio all retry on timeout, on connection failure, and on any non-2xx response — often for a day or more with exponential backoff. If your handler is not idempotent, a slow database call or a dropped connection turns one event into a duplicate charge, a duplicate email, or a duplicate order. The fix is straightforward: acknowledge fast, deduplicate on event ID, and make every state change safe to apply twice.
What changed in 2026
- Standard
Webhook-ID and Webhook-Timestamp headers spread beyond Stripe. The emerging Standard Webhooks spec, adopted by Svix-backed providers and a growing list of platforms, standardizes signature and ID headers so consumers write one verification path instead of one per vendor.
- Providers extended retry windows. Several major platforms now retry failed deliveries for three to five days with exponential backoff instead of giving up after a few hours, so dedup retention needs to match.
- Queue-backed ingestion became the default pattern. Teams increasingly return 200 immediately and push the raw payload onto a queue, see job queue comparison, rather than processing inline in the HTTP handler.
- Replay tooling matured. Most provider dashboards now let you replay a specific event ID on demand, which makes idempotent handlers a hard requirement rather than a nice-to-have, since replays are routine during incident recovery.
How webhook retries actually behave
| Provider |
Retry trigger |
Backoff |
Window |
| Stripe |
Non-2xx or timeout |
Exponential |
Up to 3 days |
| GitHub |
Non-2xx, timeout, DNS failure |
Exponential |
Up to 24 hours, redelivery available anytime after |
| Shopify |
Non-2xx or timeout |
Exponential |
Up to 48 hours, 19 attempts |
| Twilio |
Non-2xx |
Exponential |
Configurable, default around 4 attempts |
The pattern is consistent: anything other than a fast 2xx response is treated as failure, and failure means retry. Two consequences follow directly. First, your handler must respond within the provider's timeout, typically five to fifteen seconds, or it will be retried even if the work eventually succeeds. Second, because retries are keyed to the HTTP outcome and not to whether you actually processed the event, you will receive genuine duplicates whenever your 2xx response itself is lost to a network blip — the work succeeded, the acknowledgment did not arrive, and the provider resends the same event.
Designing the consumer
- Verify the signature before anything else. HMAC-verify the payload against the provider's signing secret and reject invalid or replayed (stale timestamp) requests with a 4xx before touching the database.
- Deduplicate on the event ID. Insert the provider's event ID into a
processed_webhook_events table with a unique constraint, inside the same transaction as your side effect. A constraint violation means "already handled" — return 200 and stop.
- Acknowledge fast, process async. Once the event is verified and recorded as received, enqueue the actual business logic to a background worker and return 200 immediately. This decouples your response time from downstream latency.
- Make the handler itself idempotent, not just deduplicated. Dedup catches exact repeats; idempotency protects against partial failures mid-processing, see idempotency explained for the underlying pattern. If a worker crashes after step one of three, retrying the whole job should not double-apply step one.
- Guard against out-of-order delivery. Providers do not guarantee ordering across events. Compare a version, sequence number, or
updated_at timestamp in the payload against your stored state, and discard the event if it is older than what you already applied.
- Set a dedup retention window that matches the provider's retry window. A 24-hour retry window needs at least seven days of retained event IDs to stay safe against delayed redelivery and manual replays.
Common mistakes
Processing synchronously inside the HTTP handler. A slow downstream call, such as an email send or a third-party API request, inside the webhook handler risks a timeout, which the provider reads as failure and retries — creating duplicates for work that already started. Acknowledge first, process second.
Deduplicating on payload hash instead of event ID. Two legitimately different events can hash differently even when your business logic should treat them as the same operation, such as a corrected retry with an updated field. Use the provider's stable event ID instead.
Trusting arrival order. Concurrent delivery and provider-side retries mean a payment.updated event can arrive before payment.created. Handlers that assume order silently corrupt state under load.
No dead-letter path. When a webhook consistently fails to process because of bad data, a bug, or a downstream outage, it needs to land somewhere a human can inspect and replay it, not vanish after the provider gives up retrying.
FAQ
Is deduplicating on event ID enough, or do I still need idempotency keys?
Event ID dedup catches exact redeliveries. You still need idempotent handler logic for partial failures, such as a worker crash mid-job that gets retried by your own queue rather than the provider.
How long should I keep processed event IDs?
At least as long as the provider's documented retry window, plus a buffer for manual replays. Seven to thirty days is typical.
What HTTP status code should a webhook handler return on success?
Any 2xx. Most teams standardize on 200 with an empty or minimal body; returning 202 is also common when work is handed off to a queue and not yet complete.
Should I process the webhook payload directly or re-fetch the object from the provider's API?
For anything security- or money-sensitive, re-fetch the current object state from the provider's API using the ID in the payload rather than trusting the payload body verbatim. This protects against stale or tampered data.
Where to go next