How to Test Stripe Webhooks
Set up a Stripe webhook endpoint, see exactly what Stripe sends, verify the Stripe-Signature header in Node.js and Python, and forward events to your local app.
Updated:
Stripe sends webhook events when payments succeed, subscriptions renew or disputes open. To build a reliable handler, you need to see the real payload and headers, verify the signature correctly and respond fast. This guide walks through all three, using a public capture URL to inspect events before they reach your code.
Step 1: Get a capture URL
Open Webhook Inspector and create a URL, for example:
https://webhook.voronyuk.com/abcd23efgh/stripe/webhook
Any method, sub-path and query string is captured. The default response is 200 with {"ok":true,"request_id":N}, which Stripe treats as a successful delivery.
Step 2: Create the endpoint in Stripe
- In the Stripe Dashboard, open Workbench and go to the Webhooks tab.
- Click Create an event destination and choose Your account.
- Select the event types you need, for example
payment_intent.succeeded,checkout.session.completedorinvoice.paid. Subscribe only to what your integration handles. - Choose Webhook endpoint as the destination type and paste your capture URL.
- On the endpoint page, click to reveal the signing secret. It starts with
whsec_.
Stripe only delivers to publicly accessible HTTPS URLs. The capture URL meets that requirement with a valid certificate.
Now trigger an event, for instance with the Stripe CLI:
stripe trigger payment_intent.succeeded
The request appears live in the inspector with the full JSON body pretty-printed and every header listed.
Step 3: Read the Stripe-Signature header
Each event carries a Stripe-Signature header on a single line:
Stripe-Signature: t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
tis a Unix timestamp.v1is the signature. There can be more than onev1while you're rolling a secret.- Test events may also include a
v0value. Ignore every scheme exceptv1.
The signature is an HMAC-SHA256 of the string {t}.{raw body}, keyed with your endpoint's whsec_... secret, encoded as hex. The key detail is raw body: if your framework parses and re-serializes the JSON, the bytes change and verification fails.
Step 4: Verify in Node.js
The official library does the parsing, the HMAC, the constant-time comparison and a timestamp tolerance check (5 minutes by default):
const express = require('express');
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; // whsec_...
const app = express();
// express.raw keeps the body as a Buffer, which constructEvent needs
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
endpointSecret
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
if (event.type === 'payment_intent.succeeded') {
// enqueue the work, don't do it inline
}
res.json({ received: true });
});
app.listen(3000);
Register the raw-body route before any global express.json() middleware, or exclude it, so the body isn't parsed first.
Step 5: Verify manually in Python
If you don't want the SDK, verification is short. This Flask example reads the raw bytes with request.get_data():
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["STRIPE_WEBHOOK_SECRET"].encode() # whsec_...
TOLERANCE = 300 # seconds
def verify(payload: bytes, header: str) -> bool:
parts = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
timestamp = next((v for k, v in parts if k == "t"), None)
signatures = [v for k, v in parts if k == "v1"]
if not timestamp or not signatures:
return False
if abs(time.time() - int(timestamp)) > TOLERANCE:
return False
signed = timestamp.encode() + b"." + payload
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
@app.post("/webhook")
def webhook():
payload = request.get_data()
if not verify(payload, request.headers.get("Stripe-Signature", "")):
abort(400)
event = json.loads(payload)
print(event["type"], event["id"])
return "", 200
if __name__ == "__main__":
app.run(port=3000)
Step 6: Forward events to localhost
Once you've seen the payload, send events to your local handler. In the Forwarding panel, set the target to http://localhost:3000. The sub-path is appended, so .../abcd23efgh/stripe/webhook arrives at http://localhost:3000/webhook. Use the browser relay (your server must allow CORS) or the CLI agent:
WI_TOKEN='<token>' inspector listen https://webhook.voronyuk.com/abcd23efgh/stripe --to localhost:3000
Copy the exact command with your token from the panel. The agent reconnects and replays requests it missed. More on these options in testing webhooks locally.
Alternative: stripe listen
Stripe's own CLI can also forward events:
stripe listen --forward-to localhost:3000/webhook
It prints a separate whsec_... secret for that session. Use it in your handler while testing with the CLI; it is not the same as your Dashboard endpoint's secret.
Retries, duplicates and timing
- Return 2xx quickly. Acknowledge first, then process in a queue. Slow handlers time out and count as failures.
- Retries. In live mode, Stripe keeps retrying a failed event for up to three days with exponential backoff. In a sandbox, it retries three times over a few hours.
- Redirects fail. A
3xxresponse is treated as a failed delivery. - Duplicates happen. Store processed
event.idvalues and skip repeats. Make side effects idempotent. - Order isn't guaranteed. Don't assume
invoice.createdarrives beforeinvoice.paid.
To see how your setup handles failures, set a custom response on the capture URL, such as status 500 or a delay, then watch Stripe's Event deliveries tab. You can also replay any captured event from the Stripe webhook tester without triggering a new payment.
Related guides: GitHub webhooks and Shopify webhooks.
FAQ
How do I test Stripe webhooks locally?
Either run stripe listen --forward-to localhost:3000/webhook, or register a public capture URL in Workbench and forward it to localhost with the browser relay or CLI agent. The second option keeps a history of every event you can replay.
Why does Stripe signature verification fail?
Almost always the body was modified before verification, usually parsed as JSON by middleware. Pass the raw bytes to constructEvent. Also check that you're using the right whsec_ secret: Dashboard endpoints and stripe listen sessions have different secrets.
Where do I find the Stripe webhook secret?
In Workbench, open the Webhooks tab, select the endpoint and reveal the signing secret. It starts with whsec_.
How long does Stripe retry failed webhooks?
In live mode, up to three days with exponential backoff. Events created in a sandbox are retried three times over a few hours.
What status code should a Stripe webhook return?
Any 2xx, returned as quickly as possible. Non-2xx codes, redirects and timeouts are treated as failures and retried.