Stripe webhooks: how to set up, verify, and test them
To set up Stripe webhooks, register a public HTTPS URL as an event destination in the Workbench Webhooks tab or through the API, subscribe it only to the events you need, and copy its whsec_ signing secret. Your handler verifies each request’s Stripe-Signature header against the raw body, returns a 2xx quickly, and does the real work asynchronously.
A Stripe webhook is an HTTPS POST that Stripe sends to your server when something changes in your account, such as a payment succeeding or a customer disputing a charge. The body is a JSON Event with an id, a type such as payment_intent.succeeded (its event type), and the affected object under data.object. Stripe now calls the receiving side an event destination; a webhook endpoint is one kind, alongside Amazon EventBridge and Azure Event Grid. New to the concept? Start with what is a webhook.
Snapshot or thin events
When you create a destination, Stripe asks for a payload format. Snapshot events carry the full Event, including a copy of the resource as it looked when the event fired. Thin events carry only the event type and the related object’s ID, and your code fetches the current state from the API. Stripe recommends thin events for new integrations because their payloads are unversioned and typed in its SDKs.
This guide uses snapshot events, because they’re what constructEvent, stripe listen --forward-to, and Stripe’s own webhook quickstart use. With thin events the steps are the same, but event names gain a v1. prefix, you parse requests with parseEventNotification(), and you forward them locally with --forward-thin-to.
Step 1: Create an event destination
Skip this step while developing locally: the Stripe CLI forwards events without a registered URL (step 4). A deployed endpoint needs a public HTTPS URL such as https://example.com/webhook, and Stripe allows up to 16 event destinations per live or sandbox account.
In the Dashboard
- Open the Webhooks tab in Workbench and click Create an event destination.
- Select Your account. Connect platforms can pick Connected accounts to receive events from their connected accounts instead.
- Choose Snapshot as the payload format and select an API version.
- Select the event types you want, then click Continue.
- Choose Webhook endpoint as the destination type, click Continue, and enter the endpoint URL.
- On the endpoint’s settings page, click Reveal secret and copy the
whsec_value into an environment variable such asSTRIPE_WEBHOOK_SECRET.
With the API
The v1 webhook endpoints API creates the same thing. enabled_events is required, and the response returns the signing secret in its secret field, so store it when you create the endpoint:
curl https://api.stripe.com/v1/webhook_endpoints \
-u "$STRIPE_SECRET_KEY:" \
--data-urlencode "url=https://example.com/webhook" \
-d "enabled_events[]=checkout.session.completed" \
-d "enabled_events[]=invoice.paid" \
-d "enabled_events[]=invoice.payment_failed"
Stripe’s newer v2 Event Destinations API (POST /v2/core/event_destinations) covers the same ground, and it’s the one Stripe documents for thin-event destinations.
Step 2: Subscribe only to the events you handle
The API accepts ['*'] and the Dashboard lets you select everything, but Stripe advises against it because extra events load your server for nothing. Subscribe to what your code acts on. A Checkout integration usually needs checkout.session.completed, plus checkout.session.async_payment_succeeded and checkout.session.async_payment_failed if you accept delayed payment methods such as bank debits. Subscriptions typically need invoice.paid, invoice.payment_failed, and customer.subscription.deleted. You can edit the list later.
Step 3: Write the handler
The handler verifies the signature against the raw body, hands the event off, and returns a 2xx. The raw body is where most integrations go wrong: Stripe signs the exact bytes it sent, so if a JSON body parser runs first, verification fails.
Here it is in Node with Express. enqueue stands in for your job queue or an events table:
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
const HANDLED_EVENTS = new Set([
'checkout.session.completed',
'invoice.paid',
'invoice.payment_failed',
]);
const app = express();
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
endpointSecret
);
} catch (err) {
console.error(`Signature verification failed: ${err.message}`);
return res.sendStatus(400);
}
if (HANDLED_EVENTS.has(event.type)) {
try {
await enqueue(event);
} catch (err) {
return res.sendStatus(500);
}
}
res.sendStatus(200);
});
// JSON parsing for every other route goes after the webhook route
app.use(express.json());
app.listen(4242, () => console.log('Listening on port 4242'));
express.raw() keeps req.body as a Buffer for this one route. Register express.json() after the webhook route, as above, or it parses the body before Stripe’s library sees it.
The same handler in Python with Flask, run with flask --app server run --port=4242:
import os
import stripe
from flask import Flask, request
app = Flask(__name__)
endpoint_secret = os.environ["STRIPE_WEBHOOK_SECRET"]
HANDLED_EVENTS = {"checkout.session.completed", "invoice.paid", "invoice.payment_failed"}
@app.post("/webhook")
def webhook():
try:
event = stripe.Webhook.construct_event(
request.get_data(),
request.headers.get("Stripe-Signature"),
endpoint_secret,
)
except (ValueError, stripe.SignatureVerificationError):
return "", 400
if event.type in HANDLED_EVENTS:
enqueue(event)
return "", 200
Here request.get_data() is the raw body. Both versions return 200 for event types they ignore, so Stripe doesn’t retry them. If your framework enforces CSRF tokens on POST requests, as Rails and Django do, exempt the webhook route. When verification keeps failing, why webhook signature verification fails walks through the usual causes.
Step 4: Test locally with the Stripe CLI
Install the Stripe CLI, then log in and start forwarding events to your local server:
stripe login
stripe listen --forward-to localhost:4242/webhook
stripe listen prints Ready! Your webhook signing secret is whsec_.... Use that value as STRIPE_WEBHOOK_SECRET locally. It stays the same between restarts, but it isn’t the secret of any endpoint you created in the Dashboard, which is the most common reason code that worked locally fails after deploying. Add --events checkout.session.completed,invoice.paid to forward only what you handle.
In a second terminal, fire a test event:
stripe trigger checkout.session.completed
stripe trigger makes real API calls in your sandbox, so it creates the objects it needs and often produces related events: triggering payment_intent.succeeded also sends payment_intent.created.
How Stripe signature verification works
Every delivery carries a header like Stripe-Signature: t=1492774577,v1=5257a869.... The t value is a Unix timestamp, and v1 is an HMAC-SHA256 of the string {t}.{raw body}, keyed with the endpoint’s signing secret. constructEvent recomputes it and compares it in constant time against each v1 signature in the header (there can be several while you rotate a secret), ignoring the extra v0 signature Stripe adds to test events.
The library also rejects events signed more than five minutes ago by default, which stops an attacker who captured a valid request from replaying it later. You can pass a different timestamp tolerance, in seconds, as an optional fourth argument, but never pass 0, which turns the check off. Keep your server clock synced with NTP, since drift looks the same as a stale request. Stripe signs each retry with a fresh timestamp, so retries pass.
For secret rotation, choose Roll secret from the endpoint’s overflow menu; you can keep the old secret valid for up to 24 hours while you deploy the new one. Stripe also publishes the IP addresses it sends webhooks from and recommends allowlisting them as a second check.
Respond fast and process asynchronously
Stripe counts anything other than a 2xx as a failed delivery, including redirects and requests that hit a webhook timeout. So verify, enqueue, return 200, and let a worker do the slow part: database writes, emails, provisioning access. A background queue also absorbs spikes such as the start of the month, when subscriptions renew together. If the enqueue fails, return a 5xx so Stripe retries instead of you losing the event. Best practices for receiving webhooks covers this pattern for any provider.
Retries and endpoints that keep failing
In live mode, Stripe retries a failed delivery for up to three days with exponential backoff. Sandbox deliveries get three retries over a few hours. Each endpoint’s Event deliveries tab shows every attempt, its HTTP status, and when the next retry is due.
If an endpoint keeps failing for several days, Stripe stops trying and disables it, and retries that come due while it’s disabled are dropped. After you fix and re-enable it, recover the gap yourself: list events through the Events API with delivery_success=false (it covers the last 30 days) and run them through the same idempotent processing. For one-off fixes, click Resend on an event in the Dashboard (up to 15 days old) or run stripe events resend <event_id> --webhook-endpoint=<endpoint_id> (up to 30 days). A manual resend doesn’t cancel the automatic retries, so expect the event again.
Handle duplicate and out-of-order events
Stripe delivery is at-least-once: retries, manual resends, and recovery scripts can all deliver the same event twice. Make processing idempotent by recording each event.id in the same database transaction as the side effect and skipping IDs you’ve already seen. Stripe occasionally generates two separate events for one change, so also deduplicate on data.object.id plus event.type. Idempotency and deduplication covers the storage patterns.
Order isn’t guaranteed either. Creating a subscription can produce customer.subscription.created, invoice.created, and invoice.paid in any sequence, and the created field has one-second resolution, so don’t sort by it. Treat each event as a prompt to fetch the subscription or invoice from the API and act on its current state, which also protects you from snapshot payloads that went stale in your queue.
Go live with a separate endpoint and secret
Live mode needs its own destination. Create it again there, which gives it a different signing secret even for the same URL, and set your production STRIPE_WEBHOOK_SECRET to that live value, not the sandbox or CLI one. Live mode requires HTTPS with a valid certificate on TLS 1.2 or 1.3. A snapshot destination’s API version is fixed when you create it, so pick the version your code expects; statically typed SDKs (.NET, Java, Go) fail to deserialize events on a mismatch.
Receiving Stripe alongside other providers
Stripe is rarely the only service sending you webhooks, and each provider has its own signature scheme and retry behavior. Svix Ingest gives you one place to receive them: it verifies provider signatures, including Stripe’s, queues events, and retries delivery to your app. For how Stripe’s webhook design holds up on signing, retries, and delivery logs, see our Stripe webhooks review.