Calling the Client Back
Sign the payload with a timestamp, deliver at least once, retry with backoff, and give the integrator a dead letter to inspect.
Stripe-Signature: t=<unix>,v1=<hmac-sha256 of "<t>.<raw body>">A webhook is your server making an HTTP request to someone else's server. Everything hard about calling an unreliable third party is now your problem, and everything hard about accepting an unauthenticated POST from the internet is theirs.
Signing
Sign the body with a shared secret and put a timestamp inside the signed material, so a captured delivery cannot be replayed a week later. Stripe sends Stripe-Signature: t=1492774577,v1=<hex>, where v1 is HMAC-SHA256 over the string timestamp + "." + raw_body keyed by the endpoint secret. The receiver recomputes it, compares in constant time, and rejects anything outside its tolerance — Stripe's libraries default to five minutes. GitHub's equivalent is X-Hub-Signature-256: sha256=<hex> over the raw body. Signing the timestamp is what makes the replay window enforceable; a signature over the body alone is valid forever.
"Raw body" is literal. Any middleware that parses and re-serialises JSON changes the bytes and the signature fails.
At least once
You will deliver duplicates, and out of order. Stripe states plainly that event order is not guaranteed and tells receivers to deduplicate on event id rather than timestamps. That obliges the receiver to make handling idempotent and to re-read current state instead of applying a diff.
Retries and the dead letter
Retry any non-2xx with exponential backoff and jitter — Stripe retries for up to three days in live mode — then park the delivery somewhere the integrator can see the request, the response body and the attempt history, and replay it themselves without opening a support ticket.
Example
// receiver.js — the integrator's side.
import express from 'express';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
// This route MUST be registered before any express.json(), or the bytes
// change and the signature never verifies.
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
// req.get() is the case-insensitive shorthand for
// req.headers['stripe-signature'].
const signature = req.get('Stripe-Signature');
let event;
try {
// Verifies HMAC-SHA256 over `${t}.${rawBody}` and rejects timestamps
// outside the tolerance (5 minutes by default in the official libraries).
event = stripe.webhooks.constructEvent(
req.body, signature, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
// 400, not 500: do not make the sender retry a forged request.
return res.status(400).send(`signature verification failed: ${err.message}`);
}
// At least once, and out of order. Dedupe on event id, never on created.
const claim = await db.query(
`INSERT INTO processed_events (event_id, type) VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING`, [event.id, event.type]);
if (claim.rowCount === 1) {
// Enqueue, do not handle inline: a slow 200 becomes a timeout, and a
// timeout becomes a redelivery of work you already did.
await queue.enqueue('handle-stripe-event', { eventId: event.id });
}
res.status(200).end();
});
app.use(express.json()); // everything else, after the webhook route
// In the worker: re-read current state rather than trusting the payload,
// because an older event can arrive after a newer one.
queue.handle('handle-stripe-event', async ({ eventId }) => {
const event = await stripe.events.retrieve(eventId);
if (event.type === 'payment_intent.succeeded') {
const pi = await stripe.paymentIntents.retrieve(event.data.object.id);
await orders.markPaid(pi.metadata.order_id, pi.status); // idempotent
}
});When to use it
- Letting a partner react to a payment without polling your API every minute
- Hardening a webhook receiver that currently trusts any POST that reaches it
- Explaining to an integrator why they received the same event twice
- Giving integrators a way to replay deliveries their endpoint rejected while broken
More examples
Verifying the signature by hand
Use the official library when there is one; write this when you are the sender and need to document what receivers must compute, or when you are receiving from a provider with no SDK. The three details that get skipped are the tolerance check, ignoring unknown schemes, and `timingSafeEqual` instead of `===`.
import crypto from 'node:crypto';
const TOLERANCE_S = 300; // 5 minutes, Stripe's library default
export function verify(rawBody, header, secret) {
// t=1492774577,v1=5257a8...,v0=6ffbb5...
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=', 2)));
const t = Number(parts.t);
if (!Number.isFinite(t)) throw new Error('no timestamp');
// The replay window. Never set this to 0 — that disables the check entirely.
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) throw new Error('timestamp outside tolerance');
// The timestamp is inside the signed material, so it cannot be edited.
const signed = `${t}.${rawBody}`; // rawBody must be the exact bytes
const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
// Ignore any scheme that is not v1 (downgrade protection), and compare in
// constant time so timing does not leak the expected value.
const got = Buffer.from(parts.v1 ?? '', 'hex');
const want = Buffer.from(expected, 'hex');
if (got.length !== want.length || !crypto.timingSafeEqual(got, want))
throw new Error('signature mismatch');
return JSON.parse(rawBody);
}Sender side: backoff, then a dead letter
The fixed schedule thinning towards hours is what gives a receiver time to fix a deploy; three days is roughly what Stripe allows. The attempts table is the part to build early — without the stored response body, every failed integration becomes a support conversation that starts with 'what did our server return?'
const SCHEDULE_S = [0, 5, 60, 900, 10_800, 64_800, 172_800]; // ~3 days, thinning out
queue.handle('deliver-webhook', async ({ deliveryId }) => {
const d = await db.one('SELECT * FROM webhook_deliveries WHERE id = $1', [deliveryId]);
const body = JSON.stringify(d.payload);
const t = Math.floor(Date.now() / 1000);
const sig = crypto.createHmac('sha256', d.secret).update(`${t}.${body}`).digest('hex');
const started = Date.now();
let status = 0, responseBody = '';
try {
const res = await fetch(d.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json',
'Example-Signature': `t=${t},v1=${sig}`,
'Example-Event-Id': d.event_id },
// A receiver that hangs must not hold your worker. Short, hard timeout.
signal: AbortSignal.timeout(10_000),
});
status = res.status;
responseBody = (await res.text()).slice(0, 2_000); // keep it for the integrator
} catch (err) {
responseBody = String(err);
}
// Record every attempt: this log IS the debugging interface integrators get.
await db.query(
`INSERT INTO webhook_attempts (delivery_id, attempt, status, response_body, duration_ms)
VALUES ($1,$2,$3,$4,$5)`,
[d.id, d.attempt, status, responseBody, Date.now() - started]);
if (status >= 200 && status < 300)
return db.query(`UPDATE webhook_deliveries SET state='delivered' WHERE id=$1`, [d.id]);
const next = SCHEDULE_S[d.attempt + 1];
if (next === undefined)
return db.query(`UPDATE webhook_deliveries SET state='dead' WHERE id=$1`, [d.id]);
await db.query(`UPDATE webhook_deliveries SET attempt=attempt+1 WHERE id=$1`, [d.id]);
// Jitter so a receiver coming back up is not hit by every parked delivery at once.
await queue.enqueue('deliver-webhook', { deliveryId: d.id },
{ delayMs: next * 1000 + Math.random() * next * 100 });
});What the integrator can see and replay
This endpoint pair is what turns a dead letter from your operational problem into the integrator's self-service fix. The comment on the replay matters: resending the captured bytes with the original timestamp would fail the receiver's recency check, so a retry must be freshly signed.
GET /webhook-endpoints/we_01HV/deliveries?state=dead
200 OK
{
"data": [{
"id": "whd_01HV8Z",
"event_id": "evt_01HV8Y",
"event_type": "payment_intent.succeeded",
"state": "dead",
"attempts": [
{"attempt": 0, "at": "2026-10-01T09:00:00Z", "status": 500,
"response_body": "TypeError: Cannot read properties of undefined"},
{"attempt": 1, "at": "2026-10-01T09:00:05Z", "status": 500, "...": "..."},
{"attempt": 6, "at": "2026-10-04T03:00:00Z", "status": 0,
"response_body": "TimeoutError: signal timed out"}
]
}],
"has_more": false
}
# Replay by hand once the receiver is fixed. New timestamp, new signature:
# the old ones are outside the tolerance window on purpose.
POST /webhook-endpoints/we_01HV/deliveries/whd_01HV8Z/retries
202 Accepted
Discussion