Stripe Webhook Testing Without Stripe CLI (2026 Guide)

#1Stripe webhook testing without the Stripe CLI
What we tested: We sent requests from our browser-based HTTP tools to test endpoints on localhost and remote servers. Header inspection, status code handling, and CORS preflight behavior were verified against Express.js, Fastify, and nginx backends.
You can test Stripe webhooks end-to-end without the Stripe CLI.
The practical split is straightforward: capture a real payload, sign the raw body correctly, and replay it against your local endpoint until verification behaves exactly the way production does.
#2The Stripe-Signature Scheme in 90 Seconds
Every Stripe webhook arrives with a header like this:
Stripe-Signature: t=1727398123,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdTwo fields:
t, the Unix timestamp (seconds) at which Stripe generated the signature.v1, the HMAC-SHA256 signature itself.
The signed payload is the string ${t}.${rawBody}, the timestamp, a literal dot, and the raw bytes of the request body. Compute HMAC-SHA256(endpoint_secret, signed_payload), hex-encode it, compare to the value in v1. Reject if it does not match. Also reject if |now - t| > 5 minutes, Stripe's defence against replay.
That is the whole protocol. The complexity is in three operational details:
- Raw bytes are non-negotiable. If anything between Stripe and your HMAC sees JSON and re-serializes it, the bytes change and the signature fails. The single most common Stripe webhook bug.
- Multiple signatures are possible. The header can contain
v1=..., v1=...separated by commas, typically two during a webhook secret rotation window. Accept the request if anyv1matches. - The "endpoint secret" is per-endpoint. Stripe issues a new
whsec_...for each webhook endpoint you create. Reading fromSTRIPE_WEBHOOK_SECRETenv var assumes a single endpoint, make sure that is actually true for your service.
#2Capture Real Stripe Payloads (No CLI)
To learn what a payment_intent.succeeded event actually looks like, full body, full headers, exact bytes, you need to see one in flight.
Option A, Generic browser capture. Open the Webhook Tester to get a unique URL. Paste it into the Stripe dashboard as an endpoint. Trigger a test event from the Stripe dashboard ("Send test webhook" on any endpoint). The full request, headers, body, IP, appears in your browser instantly.
Option B, Capture against your own URL. Point the Stripe webhook at https://your-tunnel/webhook-stripe (Cloudflare Tunnel, Tailscale Funnel, or your existing staging host) and log the full raw request server-side, including the Stripe-Signature header.
Either way, you now have a known-good signed payload. Save the body to a file (captured.json), and the Stripe-Signature header to a variable. You will use both to replay against localhost in tight iterations.
#2Sign Your Own Payloads in the Browser
Once you want to test handler logic against many event types, customer.subscription.updated, invoice.payment_failed, charge.dispute.created, capturing each from the Stripe dashboard is slow.
The Stripe Webhook Tester does the signing for you in the browser:
- Paste your endpoint secret (
whsec_...). - Paste or pick a sample event body (the tool ships realistic fixtures for the common event types).
- The tool computes
t = now, builds${t}.${body}, HMACs it with your secret, and emits the fullStripe-Signatureheader value. - Copy the header and the body; you now have a valid signed payload to POST anywhere.
Nothing leaves the browser tab, the HMAC happens locally with the Web Crypto API. The endpoint secret never goes over the network.
For automated test suites you can do the same thing in Node:
import crypto from "node:crypto";
function stripeSignature(body, secret, ts = Math.floor(Date.now() / 1000)) {
const signedPayload = `${ts}.${body}`;
const v1 = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");
return `t=${ts},v1=${v1}`;
}Three lines. The same logic in Python with hmac.new(secret.encode(), signed.encode(), "sha256").hexdigest(), trivial in every language.
#2Replay Against localhost
Now you can hit your handler in a loop without going through Stripe at all:
TS=$(date +%s)
BODY=$(cat captured.json)
SECRET="whsec_..."
SIG=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST http://localhost:3000/webhooks/stripe \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=${TS},v1=${SIG}" \
--data-binary "@captured.json"Three subtleties in this snippet that matter:
--data-binarynot--data.--datastrips newlines from the body. The bytes change. The HMAC fails. Use--data-binaryalways for webhook replay.printfnotecho.echomay append a newline;printfdoes not. The byte-exactness of the signed payload depends on this.$(date +%s)is fresh. If you reuse a saved timestamp, Stripe's 5-minute replay window will reject the request and force you to chase a moving target. Always recomputetat replay time, then recompute the signature.
#2A Correct Stripe Webhook Handler (Express, but the Shape Is Universal)
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const ENDPOINT_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
const app = express();
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }), // (1) raw body, NOT json
async (req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, ENDPOINT_SECRET); // (2)
} catch (err) {
console.error("signature failed:", err.message);
return res.sendStatus(400);
}
// (3) Dedupe before any side effect
const inserted = await db.query(
"INSERT INTO stripe_events (id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING id",
[event.id],
);
if (inserted.rowCount === 0) {
return res.sendStatus(200); // duplicate, already processed
}
// (4) Acknowledge fast, work async
await queue.enqueue("process-stripe-event", event);
res.sendStatus(200);
},
);Four annotations:
- Raw body,
express.rawkeeps the bytes as aBuffer. Do not useexpress.json()on this route; if you do,req.bodybecomes a parsed object and the signature check fails. - Stripe SDK helper,
constructEventhandles the signature parsing, HMAC, comparison, and replay window check in one call. Roll your own only if you cannot use the SDK. - Dedupe on
event.id, Stripe re-delivers failed webhooks. The event ID (evt_...) is your idempotency key. TheINSERT ... ON CONFLICT DO NOTHING RETURNINGpattern is atomic and one round-trip. - Enqueue and return, anything slow (provisioning, email, Slack notification) goes on a queue. The HTTP response is sub-second. Stripe's webhook timeout is 30 seconds, but their own docs recommend 5 seconds; the enqueue pattern keeps you well under either.
#2Common Failures and How the Browser Tester Catches Them
#31. Webhook signature verification failed
Stripe SDK throws this when v1 does not match the recomputed HMAC. Walk through:
- Is
req.bodyaBufferor a parsed object? If it is parsed, your middleware ran first. Moveexpress.rawbefore any global parser, or scope the parser to a specific route. - Is your endpoint secret correct?
whsec_from the dashboard, not your test API key (sk_test_...), those are different things and a frequent mix-up. - Is your server's clock right? Stripe rejects payloads with timestamps more than 5 minutes off either side. Check
date -uon the server. - Are you in a multi-endpoint setup? Each Stripe endpoint has its own secret. The
whsec_you are using must match the endpoint that sent this webhook.
Reproduce with the Stripe Webhook Tester: sign the same body with your secret in the browser, then send the result with curl. If that succeeds and the live one fails, the difference is somewhere in your middleware stack.
#32. Timestamp outside the tolerance zone
The 5-minute replay window rejected the request. Almost always one of:
- Server clock drift, fix NTP.
- You replayed an old captured payload, recompute
tfresh. - Cold-start latency in a serverless function, the function took 35 seconds to wake up and the timestamp from Stripe's send was already > 5 minutes old by the time you read it. Treat the wall-clock test as best-effort under serverless cold start; use queued retry-friendly handlers.
#33. Handler runs twice (or three times)
Stripe retries every webhook that does not get a 2xx within 30 seconds. If your handler is fast but your response is slow (TLS handshake, body serialization), Stripe sees a timeout and retries. Symptoms: same event.id processed multiple times, double-billing, duplicate notifications.
Fix: the INSERT ... ON CONFLICT DO NOTHING RETURNING dedupe shown above, run before the side effect, in the same transaction. Stripe-side: check the dashboard's webhook delivery log, if the response code column shows 408 or 5xx for the original attempt, the retry is expected and your dedupe should handle it silently.
#34. Different stripe-signature casing
The Stripe SDK does case-insensitive header lookup, but some Stripe-like proxies and curl casings can surprise you. If you read req.headers["stripe-signature"] versus req.headers["Stripe-Signature"] in Express, only the former works (Express lowercases incoming header names). In Go's http.Request, both work via r.Header.Get. Worth double-checking when you ported handler logic between frameworks.
#35. The endpoint was added but never enabled
In the Stripe dashboard, an endpoint can be listed but disabled or restricted to specific events. If you do not see deliveries arriving even though the URL is configured, check the endpoint status and the event-type filter. The dashboard's "Send test webhook" works on disabled endpoints, which can mislead you into thinking the wiring is live when it is not.
#2When to Use the Stripe CLI Anyway
This guide is "without Stripe CLI" because the CLI is not always available. But it is a real tool and there are cases where it is the right answer:
- You want production-realistic event chains.
stripe trigger payment_intent.succeededfires a real Stripe-side flow that emits all related events (charge.succeeded,charge.updated, etc.) in order. Replicating the full event chain manually is tedious. - You want a long-running local tunnel.
stripe listen --forward-to localhost:3000/webhooks/striperuns persistently and signs every event with your real endpoint secret. For an all-day debug session, it is hard to beat. - Stripe is your only payment provider and the CLI installs cleanly. If
brew install stripeworks on your machine and you have a Stripe account logged in, the CLI is the right default.
The browser-based flow described above is the right answer when any of those preconditions is missing: corporate machine without admin, Cloud IDE, ephemeral PR-preview environment, automated test suite, or just "I want to iterate on the verification code without a daemon process."
#2Frequently Asked Questions
#3How do I test a Stripe webhook locally without the Stripe CLI?
Two pieces: capture a real signed payload from Stripe once (use the Webhook Tester as the receiving URL in the Stripe dashboard, then trigger a test event), and sign forged payloads in the browser with the Stripe Webhook Tester for the event types you want to exercise. Replay either against localhost with curl -X POST -H "Stripe-Signature: ..." --data-binary @body.json. Use --data-binary rather than --data, the latter strips newlines and breaks the HMAC. The Stripe CLI is convenient but not necessary; the browser-plus-curl flow works in any environment where you cannot install binaries.
#3Why does Stripe webhook signature verification keep failing?
Number-one cause: a JSON body parser ran before your verifier, so the bytes you HMAC are not the bytes Stripe HMACed. Fix: use raw-body middleware on the webhook route only (express.raw({ type: "application/json" }), FastAPI await request.body() before pydantic, Go io.ReadAll(r.Body) before json.Unmarshal). Number two: wrong endpoint secret, you copied the whsec_ from a different endpoint, or you used your API key (sk_test_) by mistake. Number three: server clock drift more than 5 minutes off, fix NTP. Reproduce with the Stripe Webhook Tester and you will see exactly which of the three is wrong.
#3How does Stripe sign webhooks?
Stripe signs the literal string ${unix_timestamp}.${raw_body} with HMAC-SHA256 using your endpoint secret, then hex-encodes the result. The header looks like Stripe-Signature: t=1727398123,v1=5257a86.... Your verifier should: (1) parse t and v1 from the header, (2) reject if |now - t| > 300 seconds, (3) recompute HMAC-SHA256(secret, "${t}.${rawBody}") and constant-time-compare to v1. During endpoint-secret rotation the header may contain multiple v1= values separated by commas; accept the request if any match. The Stripe SDKs do all of this for you via constructEvent, only roll your own if you cannot use an SDK.
#3How do I make my Stripe webhook handler idempotent?
Dedupe on event.id (the evt_... ID Stripe issues for every event) before running any side effect. The atomic SQL pattern is INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING id. If you get a row back, you are the first to process this event, go run the side effects. If you do not, it is a redelivery, return 200 immediately. Run this in the same transaction as the side effect so a crash mid-handler does not leave you with a dedupe row but unfinished work. Stripe's at-least-once delivery means you will see the same event multiple times in production; without this pattern, you will eventually double-bill someone.
#3What is the right response time for a Stripe webhook?
Stripe's hard timeout is 30 seconds, but their own documentation recommends responding within 5 seconds. The correct pattern is enqueue-then-200: verify the signature, dedupe on event ID, push the event onto a background queue, return 200 immediately. The actual work (database writes, email sends, downstream API calls) happens in a worker out-of-band. With this pattern your response time is in the tens of milliseconds and Stripe will never retry due to slowness. Without it, a single slow downstream call cascades into duplicate processing across every retry attempt.
You do not need the Stripe CLI to develop Stripe webhook handlers, you need raw-byte handling, a correct HMAC, and a fast 200. The Stripe Webhook Tester handles signing in the browser, curl --data-binary handles replay, and the INSERT ... ON CONFLICT dedupe pattern handles retries. From there, every other Stripe event type is a parameter change, not a tooling change.
Sign and verify any Stripe webhook payload now at the Stripe Webhook Tester, endpoint secret stays in your browser tab, nothing leaves the page. For the broader webhook playbook covering GitHub, Shopify, Discord, and Slack alongside Stripe, see the How to Debug Webhooks guide. For the surrounding HTTP and identity surface, the HTTP Headers Reference and JWT Tokens Explained round out the toolkit.
Written by Rahul Jalavadiya, founder of AllDevToolsHub. All tools run locally in your browser.
#2Sources / Further reading
- Stripe - Webhooks documentation
- Stripe - Webhook endpoint verification
- HMAC - RFC 2104
Quick Summary
>- How to test Stripe webhooks locally without the Stripe CLI β capture real Stripe-Signature payloads in the browser, replay them with curl, sign forged events with your endpoint secret, and validate the v1 HMAC scheme exactly the way Stripe does. Covers handler timeouts, idempotency, the 31-minute clock-skew rule, and the most common verification failures.
Tools Mentioned in This Article
Tools, tactics, and toughened-up tips, once a week
New tools, deep-dives on developer workflows, and the occasional gem we found this week. No spam, no tracking. Unsubscribe anytime.
Found an error or have feedback?
We correct errors quickly and document changes in our changelog. Report issues at support@alldevtoolshub.com.