Webhooks Best Practices (2026): Retries, Idempotency and Signing
Every SaaS integration I have worked on eventually grew a webhook handler, and almost every one of them first shipped with the same three bugs: it parsed the JSON before checking the signature, it assumed each event arrives exactly once, and it assumed events arrive in order. None of those assumptions are promised by any provider I know of. This post covers webhooks best practices as they stand in 2026: what delivery guarantees you actually get, how retries and backoff work at Stripe, GitHub, Shopify and Svix, how to use idempotency keys, why ordering is your problem, how signing works (HMAC, the Standard Webhooks spec, ed25519), and when to stop using webhooks and consume an event stream instead. The hands-on version, with tested Node and Python code, is in the companion guide on how to verify webhook signatures and handle retries safely.
What delivery guarantees do webhooks actually give you?
A webhook is an HTTP POST from someone else's server to yours, and the contract is whatever the sender documents. In practice: at least once, in no particular order, and only if you answer quickly.
Stripe is explicit on each point. Its webhook docs say endpoints "might occasionally receive the same event more than once", that Stripe does not guarantee delivery in the order events were generated, and that your endpoint must return a 2xx "before any complex logic that might cause a timeout". The same page notes that snapshot events record created in whole seconds, so two events can share a timestamp, and tells you not to use it for ordering or deduplication. Track event IDs instead.
GitHub's contract is narrower. Its best practices page requires a 2xx within 10 seconds or the delivery counts as failed, and its redelivery docs say plainly that GitHub does not automatically redeliver failed deliveries. You can redeliver deliveries from the past three days by hand or through the API. If your receiver was down for an afternoon, those events are gone unless you fetch them.
Shopify sits in between: its troubleshooting docs give you five seconds to respond, retry up to eight times in four hours, and remove the subscription if failures persist.
So assume duplicates, assume misordering, and remember that slow handlers become failed deliveries, which become retries, which become more duplicates.
How should webhook retries and exponential backoff work?
If you send webhooks, the retry policy is the most visible reliability feature you own. The Standard Webhooks spec recommends a retry schedule "spanning multiple days, with an exponential backoff" plus random jitter, and gives an example: immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, 20 hours and 24 hours, with the last attempt about 75.6 hours after the first. It also says only a 2xx counts as success, a 3xx should be treated as a failure rather than followed, 410 Gone means the receiver wants you to disable the endpoint, 502 and 504 suggest you should throttle, and a Retry-After header should be respected. The recommended request timeout is 15 to 30 seconds.
Here is how the main providers compare.
| Provider | Signature header and encoding | Timestamp signed? | Response deadline | Automatic retries | Source |
|---|---|---|---|---|---|
| Stripe | Stripe-Signature: t=...,v1=<hex> |
Yes | Not published; "quickly" | Up to 3 days, exponential backoff (live mode); 3 times over a few hours in a sandbox | Stripe webhooks |
| GitHub | X-Hub-Signature-256: sha256=<hex> |
No | 10 seconds | None; manual redelivery for 3 days | GitHub validating, redelivery |
| Shopify | X-Shopify-Hmac-SHA256: <base64> |
No (X-Shopify-Triggered-At is sent but not signed) |
5 seconds | Up to 8 times in 4 hours | Shopify verify, troubleshooting |
| Svix | svix-signature: v1,<base64> (webhook- prefix optional on paid plans) |
Yes | 30 seconds | 8 attempts, last gap 10 hours (about 27.6 hours in total) | Svix retries, verifying |
| Standard Webhooks (spec) | webhook-signature: v1,<base64> (or v1a for ed25519) |
Yes | 15 to 30 seconds recommended | Example: 10 attempts over about 75.6 hours | Spec |
If you are the producer, a retry policy is about twenty lines. This is the one I would start from: the spec's schedule, jitter, Retry-After, and 410 handling.
# sender.py: producer-side retry policy (Python 3.12, no dependencies)
import random
# Delays from the Standard Webhooks example schedule, in seconds
SCHEDULE = [0, 5, 5 * 60, 30 * 60, 2 * 3600, 5 * 3600,
10 * 3600, 14 * 3600, 20 * 3600, 24 * 3600]
def next_delay(attempt: int, status: int | None, retry_after: str | None) -> float | None:
"""Seconds to wait before attempt+1, or None to stop retrying."""
if status is not None and 200 <= status < 300:
return None # delivered
if status == 410:
return None # receiver said Gone: disable the endpoint
if attempt + 1 >= len(SCHEDULE):
return None # out of attempts: alert the customer
base = SCHEDULE[attempt + 1]
if retry_after and retry_after.isdigit():
base = max(base, int(retry_after)) # honour Retry-After on 429/503
return base * random.uniform(0.9, 1.1) # +/-10% jitter spreads the herd
if __name__ == "__main__":
total = sum(SCHEDULE)
print(f"{len(SCHEDULE)} attempts over {total / 3600:.1f} hours") # 10 attempts over 75.6 hours
The part people skip is what happens after the last attempt. The spec suggests notifying the customer another way and considering disabling the endpoint; Svix disables an endpoint after five days of consistent failures. Whatever you choose, document it.
Idempotency keys: how do you deduplicate webhooks?
Use the event's ID as an idempotency key and make the "have I seen this?" check atomic. Every serious provider gives you a stable ID: Stripe's evt_... event ID, GitHub's X-GitHub-Delivery header (which keeps its value on redelivery), Shopify's X-Shopify-Webhook-Id, and the Standard Webhooks webhook-id header, which the spec says stays the same across retries.
The spec's own example is to keep seen IDs in Redis for five minutes. That stops a fast retry storm, but Stripe retries for three days, so a five-minute window will not catch a retry that arrives on day two. I prefer a database table with a unique key on (provider, event_id) and an INSERT ... ON CONFLICT DO NOTHING: if the insert affects no rows, it is a duplicate and you return 200 without doing anything. The same table doubles as an inbox that a worker drains, so the HTTP handler stays fast; the guide has the schema and worker.
Two refinements matter. First, Stripe notes that in some cases it generates two separate Event objects for the same change, so for money-moving logic you also want idempotency on the business object (the invoice or PaymentIntent ID plus the event type), not only on the event. Second, the side effects themselves should be idempotent where possible: upsert the subscription state rather than inserting a "payment received" row. If the handler calls another API, pass an idempotency key there too. It is the same discipline as a zero-downtime migration: assume every step can run twice.
Webhook event ordering: why you should fetch current state
Because ordering is not guaranteed, a handler that applies events as deltas will eventually apply them in the wrong order. Stripe's example is a new subscription producing customer.subscription.created, invoice.created, invoice.paid and charge.created, which can arrive in any order. If invoice.paid lands first, a handler that expects the invoice row to exist fails, and the retry hides it until the day it does not.
There are two reliable patterns. The first is to treat the webhook as a notification and fetch the current state from the API before acting. Stripe has leaned into this: its docs now recommend thin events for most new integrations, which carry a reference to the related object and expect you to call fetchRelatedObject() for the latest version. The second, when you must apply the payload, is to compare a version or updated field on the object and ignore anything older than what you already stored.
How do webhook signatures work? HMAC, Standard Webhooks and ed25519
Signing proves the POST came from the provider unmodified. Almost everyone uses HMAC SHA-256 with a shared secret, but the details differ:
- What is signed. GitHub and Shopify sign the raw body only. Stripe signs
timestamp.body. Standard Webhooks signsmsg_id.timestamp.body, which binds the ID and timestamp so neither can be swapped. - Encoding. GitHub and Stripe send hex; Shopify and Standard Webhooks send base64. Compare in the same encoding you computed.
- The key. Stripe uses the
whsec_...string as the HMAC key as-is. Standard Webhooks also prefixes secrets withwhsec_, but the part after the prefix is base64 and must be decoded to bytes first. Same prefix, different handling: a reliable way to lose an afternoon.
The one rule that applies everywhere: verify against the exact bytes you received. The spec calls out re-serialising parsed JSON as "a very common failure mode", and Stripe's docs warn that any manipulation of the raw body breaks verification. Use a constant-time comparison (crypto.timingSafeEqual in Node, hmac.compare_digest in Python) so the check does not leak how many characters matched.
Standard Webhooks itself is worth knowing about. It is an Apache-2.0 spec with a technical steering committee drawn from Zapier, Twilio, Lob, Mux, ngrok, Supabase, Svix and Kong, and libraries in most languages; the latest tagged release is v1.0.2 from February 2026. Its site lists 36 compatible implementations, including OpenAI, Anthropic, Google Gemini, Twilio, PagerDuty and Kong. If you are designing a new producer, adopting it costs nothing and spares every customer a bespoke verifier.
The spec also defines asymmetric signatures: ed25519, identifier v1a, with whsk_ and whpk_ key prefixes. It recommends preferring asymmetric schemes, because the consumer then holds only a public key, and a leaked verification key cannot be used to forge events. HMAC remains the common default because it is fast and available everywhere; if you use it, generate one secret per endpoint, as both the spec and Stripe do, and treat it like any other credential. Leaked webhook secrets show up in the same places as leaked API keys.
Replay protection and secret rotation
A valid signature does not prove a request is fresh. Someone who captures one delivery can resend it forever unless the timestamp is signed and checked. Stripe's libraries default to a five-minute tolerance and warn that a tolerance of 0 disables the check entirely; the official Standard Webhooks libraries also use five minutes (I read the tolerance constant in the Node and Python packages). Stripe signs every retry with a fresh timestamp, so a tight window does not break retries. Keep clocks on NTP; drift looks exactly like a replay.
GitHub does not sign a timestamp at all, so its replay protection is the delivery ID: dedupe on X-GitHub-Delivery and an attacker's resend becomes a harmless duplicate. That is another reason the dedupe table should keep IDs for days, not minutes.
Rotation should be boring. Stripe lets you roll a secret and keep the old one valid for up to 24 hours, sending one signature per active secret, and the Standard Webhooks header is a space-separated list for the same reason. Your verifier should accept any listed signature that matches any of your current secrets, which makes rotation a config change rather than an outage.
Producers have one more security job: the URL is customer-supplied, which makes your sender an SSRF tool. The spec recommends sending through an egress proxy such as Stripe's smokescreen that blocks internal addresses, from a subnet that cannot reach internal services.
Svix, Hookdeck or build it yourself?
Sending webhooks reliably means a queue, a retry scheduler, per-endpoint secrets, a delivery log, replay and a customer dashboard. That is a product, hence the vendors.
Svix handles the sending side. Its free tier includes 50,000 messages a month, paid plans start at $20 a month (Basic) and $490 a month (Professional), and overage is $0.0001 per message; the core server is MIT-licensed on GitHub, at server v1.104.0 as of 28 October 2026 (the client libraries are at v2.10.0). Hookdeck started on the receiving side: its Event Gateway sits in front of your endpoint, queues and retries, and has a free Developer plan with 10,000 events a month and three days of retention, Team from $39 a month and Growth from $499 a month. For sending, Hookdeck's Outpost is Apache-2.0 and self-hostable, at v1.7.0 as of 23 October 2026.
My rule of thumb: if webhooks are a customer-facing feature and you have more than a handful of customers, buy or self-host one of these rather than writing the scheduler yourself. If you only receive, a table and a worker are enough for most teams; reach for a gateway when spikes (Stripe mentions the start of the month, when subscriptions renew) exceed what your handler can absorb.
Webhooks vs event streams: when to switch
Webhooks are a fine notification mechanism and a poor system of record: events vanish once the retry window ends, and replay is whatever the provider's dashboard allows. If you need durable history, arbitrary replay or many internal consumers, you want an event stream.
The providers know this. Stripe can deliver events straight to Amazon EventBridge or Azure Event Grid instead of an HTTP endpoint, and its List Events API returns the last 30 days, filterable to events that failed delivery. A common shape is to receive the webhook, verify it, write it to your own durable log (a Postgres inbox, or Kafka if you already run it, see Kafka in 2026), and have internal services consume from there. The webhook becomes a fast doorbell; the stream is the record. Add a nightly reconciliation job that lists recent events from the provider's API and fills gaps, and an outage on your side stops being data loss.
FAQ
Are webhooks delivered exactly once?
No. Stripe, Svix and the Standard Webhooks spec all describe at-least-once delivery with retries, and GitHub may deliver nothing if your endpoint was down, since it does not retry automatically. Deduplicate on the provider's event or delivery ID and make your side effects idempotent.
How long should a webhook endpoint take to respond?
As little as possible: verify, store, return 2xx, and process later. GitHub fails a delivery after 10 seconds and Shopify after five, while the Standard Webhooks spec recommends producers wait 15 to 30 seconds. Design for the shortest deadline you have to meet.
What is the Standard Webhooks spec?
An open specification for sending webhooks: webhook-id, webhook-timestamp and webhook-signature headers, HMAC SHA-256 over id.timestamp.body (or ed25519), whsec_ secrets, and guidance on retries, timeouts and SSRF. Its site lists 36 compatible implementations, and its libraries verify signatures in a few lines.
What timestamp tolerance should I use for replay protection?
Five minutes is the common default, used by Stripe's libraries and the official Standard Webhooks libraries. Never set it to zero, which in Stripe's libraries disables the check, and keep clocks synced with NTP so legitimate requests are not rejected.
This quarter I would do three things on any system that receives webhooks: put a unique constraint on the event ID and move processing out of the HTTP handler, add a daily reconciliation job against the provider's events API, and alert on the provider's delivery failure rate rather than waiting for a customer to ask why their invoice never showed as paid. If I were shipping webhooks as a producer, I would adopt the Standard Webhooks headers and signature format on day one, with ed25519 signatures if the customer base can handle them. None of this is new engineering. Webhooks fail the way distributed systems always fail, and the boring controls still work.
Related: How to Set Up Usage-Based Billing with Stripe Meters (2026), Secrets Sprawl 2026: 28.65M Leaked Keys and 64% Still Valid Years Later and Structured Logging Best Practices in 2026: JSON, Trace IDs, PII, Cost.
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Fix Google Cloud 'Permission Denied' 403 Errors (2026)
Fix Google Cloud PERMISSION_DENIED and 403 errors step by step: read the missing permission from the error, untangle gcloud auth vs ADC, run Policy Troubleshooter, fix impersonation, enable APIs and quota projects, and spot org policy, VPC Service Controls and VM scope denials.
12 minNov 10, 2026Google Cloud IAM Explained (2026): New Basic Roles, Deny and PAB
How Google Cloud IAM works in 2026: the resource hierarchy, principals, basic vs predefined vs custom roles (and the new Admin, Writer and Reader roles), service accounts without keys, Workload Identity Federation, IAM Conditions, deny policies, Principal Access Boundary and org policies.
12 minNov 10, 2026How to Self-Host Appsmith with Docker Compose: HTTPS, Backups (2026)
Step-by-step Appsmith self-hosting on Docker Compose with the image pinned to v2.6.0: Caddy 2.11.9 for HTTPS, signup locked down, a read-only Postgres datasource, SSO options, scheduled backups with a tested restore, safe upgrades and a troubleshooting table.
11 min