# 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.

Source: https://howhttpworks.com/guides/webhooks
Last reviewed: 2026-10-04

> **TL;DR:** A webhook is an HTTP `POST` the 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 return `2xx` immediately, 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:

```http
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.

```javascript
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:

```javascript
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:

```javascript
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:

```javascript
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 `2xx` as success, recommends retrying across multiple days with exponential backoff plus jitter, and gives meaning to some failure codes: `410 Gone` means the receiver is no longer interested and the endpoint can be disabled, `429` means rate-limited, and `502`/`504` are 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 `200` for event types you do not handle. A `4xx` on an unknown event type makes the sender retry something you never intend to process.
- Do not log the full `Stripe-Signature` header 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:

```http
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-id` is the unique message identifier, identical across retries.
- `webhook-timestamp` is Unix time in seconds.
- `webhook-signature` is 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

```bash
# 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](https://howhttpworks.com/methods/post) for how request bodies are framed.
