HookGet Start free

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 with ON CONFLICT DO NOTHING.
  • Verification: the provider’s own HMAC signature over the raw body.
  • Deduplication: The event id in 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.

BehaviourStripe's figure
Retry window, live modeUp to 3 days, exponential back-off
Retries in a sandbox3 attempts over a few hours
Manual resend from the DashboardUp to 15 days after the event was created
Manual resend from the CLI (stripe events resend)Up to 30 days
Signature timestamp tolerance5 minutes by default in the official libraries; a tolerance of 0 disables the check
Secret roll overlapUp to 24 hours; Stripe sends one v1 signature per active secret
Webhook endpoints per accountUp to 16
OrderingNot guaranteed; created has one-second resolution, so two events can share it
TransportHTTPS required in live mode; TLS 1.2 or 1.3
Hours of automatic retries after a failed webhook delivery: Stripe 72, HookGet 21, Shopify 4, GitHub 0 Horizontal bar chart. Stripe keeps retrying for about 3 days (72 hours) in live mode. HookGet's default schedule is 8 attempts over about 21 hours. Shopify retries up to 8 times over 4 hours. GitHub does not retry automatically; failed deliveries can be redelivered manually for 3 days, shown as a hatched outline. Stripe, live mode 72 h: up to 3 days, exponential back-off, count not published HookGet default 21 h: 8 attempts, settable per endpoint Shopify 4 h: up to 8 attempts, then the subscription is at risk GitHub 0 h automatic. Manual redelivery only, allowed for 3 days 0 h 24 h 48 h 72 h
Hours of automatic retries after a failed delivery. Stripe keeps trying for about 72 hours; HookGet’s default schedule spans about 21; Shopify stops after 4; GitHub never retries on its own and only allows manual redelivery for 3 days. The numbers are the ones in the table above and on the sibling platform pages.

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 responseWhat Stripe recordsWhat to do
2xxDeliveredNothing. Return it before any slow work.
3xxFailed, retriedRegister the final URL; redirects are never followed.
4xxFailed, retriedCheck the route accepts POST and is not behind auth or CSRF middleware.
5xxFailed, retriedRead your own logs; the retry will reproduce it.
Timed outFailed, retriedStore first, respond, process afterwards.
TLS errorFailed, retriedFix 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.

How an inbound webhook is verified before it enters A provider sends a signed request. HookGet verifies the signature or token and deduplicates on the provider's delivery id before the event exists. A request that fails verification is refused with a 401 and never enters the pipeline. A verified event flows into the same pipeline as any other: retries, timeline, replay. The provider GitHub, Stripe, 13 more Verified at the door signature or token · dedupe The same pipeline retries · timeline · replay Refused bad signature → 401, nothing enters
Verification happens before the event exists, so a forged request is refused at the door — it is never stored, never retried, never seen again. Redeliveries are deduplicated on the provider’s own delivery id.

The contract

FieldValue
Provider keystripe
Header checkedStripe-Signature: t=<unix>,v1=<hex>
SchemeHMAC-SHA256 over <timestamp>.<raw body>. The timestamp must be inside a five-minute window, which is what makes a captured request useless later.
Deduplicated onThe event id in the body
Event typeThe 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_…"}
  1. In the Stripe dashboard, open Developers → Webhooks → Add endpoint.
  2. Paste the ingest URL. Stripe issues its own signing secret — put that value into the HookGet source as the secret.
  3. 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.