Stripe webhook retries, signatures and idempotency
Stripe retries a failed webhook delivery with exponential back-off for up to three days in live mode, and three times over a few hours in a sandbox. Anything other than a 2xx response counts as a failure, redirects included. The retry count is not configurable, so the work is on your side: verify the signature over the raw body, store the event id once, answer 200, then process.
In short
- Live mode: retries for up to 3 days with exponential back-off. Sandbox: 3 attempts over a few hours. Not configurable.
- A 3xx, 4xx, 5xx or timeout all count as failures and are retried; each retry is signed with a fresh timestamp.
- Verify HMAC-SHA256 over
timestamp.rawBody, 5-minute tolerance, then insert the event id withON CONFLICT DO NOTHING. - Verification: the provider’s own HMAC signature over the raw body.
- Deduplication: The event
idin the body. - Once verified, the event joins your catalogue with the same retries, log and replay as anything you publish yourself.
How long does Stripe retry a failed webhook?
In live mode Stripe keeps trying for up to three days, with an exponential back-off between attempts. In a sandbox it tries three times over a few hours, which is the schedule most people test against and then mistake for production. Stripe does not publish the exact intervals and the count is not configurable per account, so the useful question is not "how many times" but "what does my handler do when the same event arrives again in two days".
Every attempt is signed afresh: a retry carries a new timestamp and a new signature, so a handler that enforces the five-minute tolerance still accepts a legitimate retry on day three.
| Behaviour | Stripe's figure |
|---|---|
| Retry window, live mode | Up to 3 days, exponential back-off |
| Retries in a sandbox | 3 attempts over a few hours |
| Manual resend from the Dashboard | Up to 15 days after the event was created |
Manual resend from the CLI (stripe events resend) | Up to 30 days |
| Signature timestamp tolerance | 5 minutes by default in the official libraries; a tolerance of 0 disables the check |
| Secret roll overlap | Up to 24 hours; Stripe sends one v1 signature per active secret |
| Webhook endpoints per account | Up to 16 |
| Ordering | Not guaranteed; created has one-second resolution, so two events can share it |
| Transport | HTTPS required in live mode; TLS 1.2 or 1.3 |
What does Stripe count as a failed delivery?
Anything that is not a 2xx. A redirect is a failure, which surprises people who
put the endpoint behind a www rewrite. A 400 from your own signature
check is a failure too, and Stripe will retry it, which is the correct outcome: a bad signature
should be loud, not silently absorbed.
| Your response | What Stripe records | What to do |
|---|---|---|
2xx | Delivered | Nothing. Return it before any slow work. |
3xx | Failed, retried | Register the final URL; redirects are never followed. |
4xx | Failed, retried | Check the route accepts POST and is not behind auth or CSRF middleware. |
5xx | Failed, retried | Read your own logs; the retry will reproduce it. |
| Timed out | Failed, retried | Store first, respond, process afterwards. |
| TLS error | Failed, retried | Fix the chain; Stripe needs a valid certificate and TLS 1.2 or higher. |
How do I verify a Stripe webhook signature in Node without the SDK?
The Stripe-Signature header carries a timestamp (t=) and one or
more v1= signatures. The signed string is the timestamp, a dot, and the raw request
body. Compute HMAC-SHA256 with the endpoint's whsec_ secret, compare in constant
time, and refuse anything outside the tolerance window. The official library does the same in
one call, stripe.webhooks.constructEvent(rawBody, header, secret); this is what it
does, so that a failure is debuggable rather than a black box.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300; // Stripe's own libraries default to five minutes
/** true only for a v1 signature over the raw body inside the tolerance window. */
export function verifyStripe(secret, header, rawBody) {
let timestamp = null;
const signatures = [];
for (const item of String(header ?? '').split(',')) {
const [key, value] = item.trim().split('=', 2);
if (key === 't') timestamp = Number(value);
if (key === 'v1' && value) signatures.push(value); // v0 and unknown schemes are ignored
}
if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody) // the bytes as received, never a re-serialised object
.digest('hex');
const a = Buffer.from(expected, 'hex');
return signatures.some((sig) => {
const b = Buffer.from(sig, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
});
}
How do I make a Stripe webhook handler idempotent?
Stripe's delivery is at-least-once. The same evt_ id can arrive twice when your
first 200 was lost in transit, not only when your handler failed, so the guard
belongs in your handler. The cheapest correct guard is the primary key: insert the event id
first, and let the database refuse the second insert.
import express from 'express';
import { verifyStripe } from './verify-stripe.mjs';
const app = express();
// express.raw, not express.json: the signature is over the bytes Stripe sent.
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifyStripe(process.env.STRIPE_WEBHOOK_SECRET, req.get('stripe-signature'), req.body)) {
return res.status(400).send('signature verification failed');
}
const event = JSON.parse(req.body);
// CREATE TABLE stripe_events (
// id text PRIMARY KEY, type text NOT NULL, payload jsonb NOT NULL,
// received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz);
const { rowCount } = await db.query(
`INSERT INTO stripe_events (id, type, payload) VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING`,
[event.id, event.type, event],
);
// Stored durably, so this 200 is a promise the server can keep. The row is
// picked up by a worker that marks processed_at in the same transaction as
// the business effect; rowCount 0 means the id was already there.
res.status(200).json({ received: true, duplicate: rowCount === 0 });
});
Two details matter more than the code. First, key on Stripe's event id, not on a hash of the
payload: Stripe can produce two distinct Event objects for one underlying change, and the docs
say to identify those by data.object.id together with event.type.
Second, do not use created to decide whether you have seen an event; its resolution
is one second and ordering is not guaranteed, so if a decision depends on the latest state,
fetch the object from the API rather than trusting the order of arrival.
What should the endpoint return?
Return 200 once the event is stored, and not before. Stripe's own guidance is
to return a 2xx before any complex logic that could time out. The pattern above stores
the row, answers, and leaves the work to a worker, which is also the shape that survives the
start-of-month renewal spike. Manually resending an event from the Dashboard does not cancel the
automatic retries of a failed delivery; only a 2xx on the original attempt or the
three-day window ending does.
Receive Stripe webhooks through a verified HookGet source
Receiving Stripe webhooks through HookGet
Everything above is what the receiving end has to do by hand. A HookGet source does the verification and the deduplication before the event exists, and the event then gets the retry schedule, dead-letter queue and per-attempt log described on the rest of this site.
The contract
| Field | Value |
|---|---|
| Provider key | stripe |
| Header checked | Stripe-Signature: t=<unix>,v1=<hex> |
| Scheme | HMAC-SHA256 over <timestamp>.<raw body>. The timestamp must be inside a five-minute window, which is what makes a captured request useless later. |
| Deduplicated on | The event id in the body |
| Event type | The type field becomes stripe.payment_intent.succeeded and similar. |
Setting it up
# 1. create a verified source; the secret is shown once
curl -X POST https://api.hookget.com/v1/sources \
-H "authorization: Bearer $HOOKGET_KEY" \
-H "content-type: application/json" \
-d '{"provider":"stripe","name":"Production"}'
# {"id":"src_…","secret":"whsec_…","ingest_path":"/ingest/src_…"}
- In the Stripe dashboard, open Developers → Webhooks → Add endpoint.
- Paste the ingest URL. Stripe issues its own signing secret — put that value into the HookGet source as the secret.
- Select the event types you need.
Worth knowing. The signature covers the timestamp as well as the body, so a replayed request outside the window is refused even though the HMAC is arithmetically correct. That is the point of it.
What you get after verification
- The event appears in your log with a namespaced type, so Stripe traffic never collides with your own.
- It fans out to your destinations with the same retry schedule and dead-letter behaviour as any other event.
- The source secret rotates with an overlap window, so you can update Stripe at your own pace.
- Each inbound source is rate limited on its own, so a busy provider cannot exhaust your publish budget.
Questions
How many times does Stripe retry a failed webhook?
Stripe does not publish a count. In live mode it retries with exponential back-off for up to three days from the event; in a sandbox it retries three times over a few hours. Each attempt carries a fresh timestamp and signature, and the schedule cannot be changed per account.
Why does my handler see the same Stripe event id more than once?
Because delivery is at-least-once. A retry fires whenever Stripe did not receive a 2xx, including when your handler succeeded and the response was lost. Log processed event ids and skip repeats; a primary key on the id is the simplest correct version of that log.
Why is my Stripe webhook signature verification failing?
The usual causes, in order: the body was parsed and re-serialised by JSON middleware
before verification; the secret belongs to a different endpoint or mode (each endpoint has
its own, and test and live differ); the server clock is more than five minutes off; or a
proxy re-encoded the body. The raw bytes and the right whsec_ fix nearly all of
them.
Should I process the Stripe event before or after returning 200?
Store it, return 200, then process from the stored row. Stripe's guidance is to return a 2xx before any logic that could time out; a slow email or third-party call inside the request turns a successful delivery into a timeout and a retry.
Does a 3xx or 4xx response stop Stripe from retrying?
No. Stripe treats redirects, client errors, server errors, TLS failures and timeouts all as failed deliveries and keeps retrying on its schedule. Only a 2xx, the three-day window ending, or disabling the endpoint stops the attempts.
How do I verify a Stripe webhook?
HMAC-SHA256 over <timestamp>.<raw body>. The timestamp must be inside a five-minute window, which is what makes a captured request useless later. With HookGet you do not implement it: the source is created with a secret,
and every request is checked against Stripe-Signature: t=<unix>,v1=<hex> before the event exists.
What happens if Stripe sends the same event twice?
Redeliveries are deduplicated on The event id in the body, namespaced per source, so a repeat becomes
the same event rather than a second one.
What if verification fails?
The request is refused with 401 and nothing enters the pipeline. The rejection is logged on our side as a signal worth watching; the caller learns nothing beyond the status.
Start delivering webhooks today
Point your webhooks at HookGet and watch the first delivery arrive, signed, in under a minute.
Create a free account Try the free webhook tester
10,000 deliveries a month free, no credit card. The free tier blocks rather than bills, so trying it cannot produce an invoice.