Guide
Webhooks: Delivery, Retries and Signature Verification
Build a reliable webhook receiver: respond 2xx fast, verify HMAC over the raw body, reject replays, dedupe by event ID. Covers Stripe and GitHub.
On this page
- What the sender is doing
- Receiver checklist
- 1. Respond fast, process later
- 2. Verify the signature over the raw body
- 3. Compare in constant time
- 4. Reject replays with a timestamp window
- 5. Dedupe by event ID
- What non-2xx means, per sender
- Receiver deployment gotchas
- Sender side: if you ship webhooks
- Standard Webhooks headers
- Test locally
TL;DR: A webhook is an HTTP
POSTthe sender makes to your URL. Verify the HMAC signature over the raw, unparsed body with a constant-time compare, check the timestamp, save the event and return2xximmediately, do the work from a queue, and dedupe by event ID because delivery is at-least-once.
What the sender is doing
A provider sends an event to a URL you registered:
POST /webhooks/stripe HTTP/1.1
Host: shop.example.com
Content-Type: application/json
Stripe-Signature: t=1792400000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{"id":"evt_1Pq...","object":"event","type":"payment_intent.succeeded","data":{"object":{"id":"pi_..."}}}
The sender treats your response as an acknowledgement. Any 2xx means “delivered”. Anything else (4xx, 5xx, timeout, connection failure, and for Stripe also redirects) means “not delivered”, which triggers the sender’s retry policy. Your endpoint is therefore a queue front door: authenticate, persist, acknowledge.
Receiver checklist
1. Respond fast, process later
Both major providers say this explicitly. Stripe: return a successful status “before any complex logic that might cause a timeout”, and use an asynchronous queue so a spike (it cites subscription renewals at the start of the month) does not overwhelm your hosts. GitHub: respond with 2XX within 10 seconds or it terminates the connection and counts the delivery as failed, and suggests queueing payloads and processing them in the background.
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifyStripeSignature(req.body, req.get('stripe-signature'), process.env.STRIPE_WHSEC)) {
return res.sendStatus(400)
}
const event = JSON.parse(req.body.toString('utf8'))
// unique constraint on event_id makes the insert the dedupe step
const inserted = await db.insertEventIfNew(event.id, req.body.toString('utf8'))
if (inserted) await queue.enqueue('process-stripe-event', { eventId: event.id })
res.sendStatus(200) // duplicates also get 200 so the sender stops retrying
})
Return 200 (or 204) for duplicates too. A 409 for “already processed” makes the sender retry something you have already handled.
2. Verify the signature over the raw body
The signature is computed over the exact bytes the sender transmitted. If you parse the JSON and re-serialise it, key order, whitespace and Unicode escaping can change, and the HMAC no longer matches. Stripe’s docs state it plainly: any manipulation of the raw body causes verification to fail.
The Express gotcha: a global app.use(express.json()) consumes the stream and replaces req.body with an object before your route runs. Register the raw parser on the webhook route, and make sure it runs before (or instead of) the JSON parser for that path:
import express from 'express'
const app = express()
// Webhook route first, with a raw Buffer body
app.post('/webhooks/github', express.raw({ type: 'application/json' }), githubHandler)
// Everything else gets parsed JSON
app.use(express.json())
If a proxy, API gateway or middleware rewrites the body (decompressing, re-encoding, or adding a trailing newline), verification fails for the same reason. GitHub’s docs also tell you to treat the payload as UTF-8.
3. Compare in constant time
A plain === on signature strings returns as soon as the first byte differs, which leaks how many leading bytes matched. Both GitHub and Stripe tell you to use a constant-time comparison. In Node that is crypto.timingSafeEqual, which throws if the two buffers have different lengths, so check the length first.
GitHub sends X-Hub-Signature-256: sha256=<hex digest>, an HMAC-SHA256 of the raw payload keyed with the webhook secret:
import crypto from 'node:crypto'
function verifyGithub(rawBody, signatureHeader, secret) {
if (!signatureHeader?.startsWith('sha256=')) return false
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
const a = Buffer.from(signatureHeader)
const b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
GitHub’s docs publish a test vector you can use in a unit test: secret It's a Secret to Everybody, payload Hello, World!, expected sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17.
Stripe sends Stripe-Signature: t=<unix timestamp>,v1=<hex>[,v0=<hex>]. The signed string is the timestamp, a literal ., then the raw body; the HMAC is SHA-256 keyed with the endpoint’s whsec_ secret. Ignore every scheme other than v1, and expect more than one v1 while a rolled secret is still active. In production use the official library’s constructEvent instead; the manual version shows what it does:
function verifyStripeSignature(rawBody, header, secret, toleranceSec = 300) {
if (!header) return false
const parts = header.split(',').map((p) => p.split('='))
const t = parts.find(([k]) => k === 't')?.[1]
const v1s = parts.filter(([k]) => k === 'v1').map(([, v]) => v)
if (!t || v1s.length === 0) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody.toString('utf8')}`)
.digest('hex')
const match = v1s.some((sig) => {
const a = Buffer.from(sig)
const b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
})
if (!match) return false
// replay protection (see below)
return Math.abs(Date.now() / 1000 - Number(t)) <= toleranceSec
}
4. Reject replays with a timestamp window
A valid signature proves who sent a payload, not when. An attacker who captures one request can resend it indefinitely. If the timestamp is part of what is signed, they cannot change it without breaking the signature, so you reject anything older than a tolerance. Stripe’s libraries default to 5 minutes, and its docs warn that a tolerance of 0 disables the check. Keep your server clock NTP-synced, or legitimate deliveries will start failing.
Stripe generates a fresh timestamp and signature for each retry attempt, so a retried event is not a replay.
GitHub’s X-Hub-Signature-256 has no timestamp, so signature checking alone does not stop replays there. X-GitHub-Delivery is a unique ID per delivery, which you can record and reject on repeat, but GitHub notes that a redelivery reuses the original delivery ID, so it is the dedupe key, not proof of freshness.
5. Dedupe by event ID
Delivery is at-least-once. Stripe says endpoints “might occasionally receive the same event more than once” and recommends logging processed event IDs, and in some cases two separate Event objects are generated, so it suggests matching on data.object ID plus type. Stripe also does not guarantee ordering and warns against using created (second resolution) to decide whether you have seen an event. Make the dedupe check the same atomic step as the insert, as in the handler above, rather than a read followed by a write.
| Provider | Unique ID to record |
|---|---|
| Stripe | id in the event body (evt_...) |
| GitHub | X-GitHub-Delivery header |
| Standard Webhooks senders | webhook-id header |
What non-2xx means, per sender
Retry policy belongs to the sender, so do not generalise from one provider.
- Stripe. Live mode: attempts for up to three days with exponential backoff. Sandbox events are retried three times over a few hours. 3xx redirects are treated as failures, so register the final URL. Failed events can be resent from the dashboard for up to 15 days, or with the Stripe CLI for up to 30 days. A manual resend does not cancel the pending automatic retries.
- GitHub. Responds-within-10-seconds rule, and no automatic redelivery: “If your server goes down, you should redeliver missed webhooks once your server is back up”, using the redelivery option in the UI or API.
- Standard Webhooks. Treats
2xxas success, recommends retrying across multiple days with exponential backoff plus jitter, and gives meaning to some failure codes:410 Gonemeans the receiver is no longer interested and the endpoint can be disabled,429means rate-limited, and502/504are a hint to slow down.
About 410: that behaviour is documented in the Standard Webhooks specification, which a number of senders follow. Do not assume a provider honours it; check its docs. Neither the Stripe nor the GitHub pages used for this guide document 410 as an unsubscribe signal.
Receiver deployment gotchas
- Frameworks with CSRF protection (Rails, Django) reject cross-site POSTs. Exempt the webhook route and rely on the signature instead; Stripe’s docs call this out.
- Return
200for event types you do not handle. A4xxon an unknown event type makes the sender retry something you never intend to process. - Do not log the full
Stripe-Signatureheader or the secret. Do log the event ID and your response status so you can match them against the sender’s delivery log. - Rotate secrets without downtime. Stripe lets you keep the old secret active for up to 24 hours and sends one signature per active secret, which is why the verifier above loops over every
v1. - Allow-listing sender IPs is an extra layer where the provider publishes them (Stripe does), not a replacement for signature verification.
Sender side: if you ship webhooks
If you are the provider, you inherit the hard parts of this contract.
- Sign every request. Use HMAC-SHA256 with a per-endpoint secret and include a timestamp in the signed content so receivers can reject replays. Support more than one active secret during rotation.
- Send a stable event ID that is the same on every retry of the same event, so receivers can dedupe.
- Retry with exponential backoff and jitter over a window of hours to days, then mark the endpoint failing and notify the owner. Disable endpoints that have failed continuously for a long period instead of retrying forever.
- Set a timeout on your outbound request. The Standard Webhooks spec suggests 15 to 30 seconds. Do not follow redirects blindly (Stripe counts them as failures).
- Protect yourself from SSRF. Customers choose the URL, so resolve it and refuse private, loopback and link-local addresses, and make the delivery workers egress through a filtered proxy.
- Make redelivery possible, from a dashboard and an API, and keep the delivery log (status code, latency, response snippet) so receivers can self-debug.
Standard Webhooks headers
The Standard Webhooks spec defines three request headers so receivers can verify any compliant sender the same way:
POST /webhooks HTTP/1.1
Host: shop.example.com
Content-Type: application/json
webhook-id: msg_2KWPBgLlAfxdpx2AI54pPJ85f4W
webhook-timestamp: 1792400000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"type":"invoice.paid","timestamp":"2026-10-04T10:13:20Z","data":{"id":"inv_123"}}
webhook-idis the unique message identifier, identical across retries.webhook-timestampis Unix time in seconds.webhook-signatureis a space-delimited list of versioned signatures:v1,<base64>for HMAC-SHA256,v1a,<base64>for ed25519. Multiple entries support key rotation.
The signed content is webhook-id, webhook-timestamp and the raw body joined with dots (msg_id.timestamp.payload), and receivers should use a constant-time comparison for the symmetric scheme. The whsec_-prefixed secret is base64; decode it to get the HMAC key. Check the spec for the exact key encoding before shipping an implementation.
Test locally
# Stripe: forward live events to your local handler and print the signing secret
stripe listen --forward-to localhost:4242/webhooks/stripe
# Hand-roll a GitHub-style signature to test your verifier
BODY='{"action":"ping"}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')"
curl -i -X POST http://localhost:3000/webhooks/github \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: $SIG" \
--data-binary "$BODY"
Use --data-binary so curl does not alter the body, and printf '%s' so no trailing newline is added to what gets signed. A mismatch you cannot explain is nearly always a byte difference between what was signed and what you hashed; see POST for how request bodies are framed.
Frequently asked questions
What HTTP status should a webhook endpoint return?
Any 2xx, returned as soon as you have verified the signature and durably stored or queued the event. Both Stripe and GitHub treat 2xx as delivered. Do the real work afterwards in a background job, because senders enforce a response deadline (GitHub allows 10 seconds) and treat a timeout as a failed delivery.
What happens when my webhook endpoint returns 4xx or 5xx?
The sender counts the delivery as failed. Stripe retries live-mode events for up to three days with exponential backoff, and treats redirects as failures too. GitHub does not redeliver automatically: you have to redeliver failed deliveries yourself from the UI or API. Behaviour is provider-specific, so read the sender documentation.
Why does my webhook signature verification fail in Express?
Almost always because express.json() parsed the body before your handler ran, so you are hashing a re-serialised object instead of the exact bytes that were signed. Mount express.raw({ type: "application/json" }) on the webhook route, before any global JSON parser, and compute the HMAC over the resulting Buffer.
How do I stop replay attacks on webhooks?
Include a timestamp in the signed payload, verify the signature, then reject the request if the timestamp is outside a tolerance window. Stripe signs a timestamp into Stripe-Signature and its libraries default to 5 minutes. Also dedupe by event ID, because a valid signed request can legitimately be delivered more than once.
Should my webhook handler be idempotent?
Yes. Senders deliver at least once, so duplicates will happen, and retries after a slow response can overlap with an earlier delivery that actually succeeded. Record the event ID (Stripe event id, GitHub X-GitHub-Delivery, Standard Webhooks webhook-id) in a unique-constrained table and skip IDs you have processed.
Can I return 410 Gone to unsubscribe from a webhook?
Only if the sender documents it. The Standard Webhooks specification says a 410 Gone response signals that the receiver no longer wants deliveries and the sender should disable the endpoint. Stripe and GitHub do not document 410 as an unsubscribe signal, so remove the endpoint in their dashboard or API instead of relying on a status code.
Sources
- Stripe: Receive Stripe events in your webhook endpointdocs.stripe.com
- GitHub: Validating webhook deliveriesdocs.github.com
- GitHub: Best practices for using webhooksdocs.github.com
- Standard Webhooks specificationgithub.com
- RFC 9110 section 9.3.3: POSTrfc-editor.org
- RFC 2104: HMACrfc-editor.org
- MDN: POSTdeveloper.mozilla.org
Related
HTTP POST Method: Complete Guide with Examples
Learn how the HTTP POST method works. Understand when to use POST requests, request bodies, form submissions, and API calls with practical examples.
410 Gone
Learn what 410 Gone means and when resources are permanently removed. Understand the difference between 410 and 404, and SEO implications for deleted content.
Idempotent
Learn what idempotent means in HTTP. Understand why GET, PUT, and DELETE are idempotent, why POST is not, and how idempotency affects API design.
Retry-After
Learn how the Retry-After header tells clients how long to wait before retrying a request. Understand its use with 503, 429, and 301 status codes.