Stripe webhooks
Inspect, verify, and replay Stripe webhooks without deploying anything.
Get a test URL
Point Stripe at this URL
- Create a test endpoint below and copy its URL.
- In the Stripe Dashboard, open Developers → Webhooks → Add endpoint.
- Paste the Webhook Studio URL as the endpoint URL.
- Select the events you want to receive, then click Add endpoint.
- Trigger an event (Stripe CLI: stripe trigger payment_intent.succeeded) and watch it land in the Inbox.
What a Stripe delivery looks like
A representative body, using Stripe’s field names. Your own deliveries appear in the Inbox exactly as sent, headers and raw bytes included.
{
"id": "evt_3PqR2s2eZvKYlo2C1a2b3c4d",
"object": "event",
"api_version": "2024-06-20",
"created": 1721558400,
"type": "payment_intent.succeeded",
"livemode": false,
"pending_webhooks": 1,
"request": {
"id": "req_9Kd2LmQpXn4t",
"idempotency_key": "b1e2c3d4-5f60-4a7b-8c9d-0e1f2a3b4c5d"
},
"data": {
"object": {
"id": "pi_3PqR2s2eZvKYlo2C1a2b3c4d",
"object": "payment_intent",
"amount": 2400,
"amount_received": 2400,
"currency": "usd",
"status": "succeeded",
"customer": "cus_QZ1a2b3c4d5e6f",
"description": "Pro plan — monthly",
"latest_charge": "ch_3PqR2s2eZvKYlo2C1a2b3c4d",
"payment_method_types": [
"card"
],
"metadata": {
"user_id": "usr_1a2b3c"
},
"created": 1721558397
}
}
}Delivery headers
| Header | What it carries |
|---|---|
Stripe-Signature | Timestamp and signatures, comma-separated: t=<unix seconds>,v1=<hex HMAC>. v1 is the only valid live scheme; a v0 signature is also sent for test events and must be ignored. |
Content-Type | Always application/json; charset=utf-8. |
User-Agent | Stripe/1.0 (+https://stripe.com/docs/webhooks). |
Event types
The events worth handling first. See Stripe’s own documentation for the complete list.
| Event | Fires when |
|---|---|
payment_intent.succeeded | A payment completes successfully. This is the event to fulfil orders on. |
payment_intent.payment_failed | A payment attempt fails — card declined, insufficient funds, or failed authentication. |
checkout.session.completed | A customer finishes a Checkout session. Arrives before the payment may have settled for async methods. |
customer.subscription.created | A subscription starts, including at the end of a trial signup. |
customer.subscription.updated | A subscription changes plan, quantity, or status. Also fires when a trial converts. |
customer.subscription.deleted | A subscription is cancelled and has reached the end of its billing period. |
invoice.paid | An invoice is paid in full — the recurring-billing equivalent of payment_intent.succeeded. |
invoice.payment_failed | A recurring charge fails. Usually the trigger for dunning email. |
charge.refunded | A charge is refunded in whole or in part. |
charge.dispute.created | A customer disputes a charge. Clocks start immediately, so alert on this one. |
Verify the Stripe-Signature header
Stripe signs each delivery with HMAC-SHA256 over the raw request body, keyed on your webhook secret. Verify before you parse, and before you act.
import express from 'express';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
// express.raw() matters: constructEvent hashes the exact bytes Stripe sent.
// A global express.json() upstream would parse and discard them, and every
// signature check would fail with no obvious cause.
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // Buffer, not a parsed object
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET // whsec_... , per endpoint
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Acknowledge first, then do the work. Stripe retries anything that is
// slow enough to time out, and you do not want the retry to be your
// accounting system running twice.
res.status(200).json({ received: true });
if (event.type === 'payment_intent.succeeded') {
const intent = event.data.object;
// fulfil(intent) — idempotently, keyed on event.id
}
});Retries and delivery guarantees
Stripe attempts delivery for up to three days in live mode, with exponential backoff. Events created in a sandbox are retried three times over the course of a few hours. Any 2xx counts as success; everything else — including a timeout — is a failure and will be retried, so your handler must be idempotent on event.id.
Common pitfalls
The signature is computed over the raw request bytes. Any JSON body-parser mounted ahead of the webhook route rewrites those bytes, and verification then fails for every single delivery. Mount the raw parser on the webhook path specifically, above the global one.
Stripe treats a slow handler as a failed delivery and retries it. If your fulfilment runs before the response, a slow database write turns into duplicate fulfilment. Return 2xx first, then process — ideally on a queue.
Stripe does not guarantee ordering. customer.subscription.updated can land before the created event it follows. Read the current state from the API when order matters, rather than reconstructing it from the event stream.
Each endpoint you add in the Dashboard gets its own whsec_ secret. Pointing a second endpoint at the same handler without updating the secret produces failures that look identical to a signature bug.
Stripe sends a fake v0 scheme alongside v1 for test events. Only v1 is a real signature. Hand-rolled verifiers that grab the first signature in the header can end up validating against nothing.
FAQ
Create a test endpoint on this page, paste its URL into the Stripe Dashboard as a webhook destination, and trigger an event. The full request — headers, raw body, and signature — appears in the Inbox immediately, with no code deployed and no account required.
Almost always because the body was parsed before it was verified. Stripe signs the exact bytes it sent, so a JSON body-parser running first invalidates every signature. The second most common cause is using the wrong whsec_ secret — each Dashboard endpoint has its own.
Stripe's official libraries default to a 5-minute tolerance between the timestamp in the header and the current time, to blunt replay attacks. Do not set it to 0 — that disables the recency check entirely rather than tightening it.
Up to three days in live mode with exponential backoff. Sandbox events are retried three times over a few hours. Because retries are expected, your handler must be idempotent — deduplicate on event.id.
Yes. Add a routing rule on your endpoint pointing at your local tunnel, and matching events are forwarded with retries while still being stored here. You keep the full request history even when your local server was down.