Skip to main content
AllDevToolsHub
2026-05-24
Last reviewed: Aug 2026
PAYMENTS
Est Read: 11_MIN

Stripe Webhook Testing Without Stripe CLI (2026 Guide)

Stripe Webhook Testing Without Stripe CLI (2026 Guide)
Processing_Node: 01

#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:

protocol
Stripe-Signature: t=1727398123,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Two 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:

  1. 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.
  2. 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 any v1 matches.
  3. The "endpoint secret" is per-endpoint. Stripe issues a new whsec_... for each webhook endpoint you create. Reading from STRIPE_WEBHOOK_SECRET env 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:

  1. Paste your endpoint secret (whsec_...).
  2. Paste or pick a sample event body (the tool ships realistic fixtures for the common event types).
  3. The tool computes t = now, builds ${t}.${body}, HMACs it with your secret, and emits the full Stripe-Signature header value.
  4. 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:

javascript
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:

bash
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-binary not --data. --data strips newlines from the body. The bytes change. The HMAC fails. Use --data-binary always for webhook replay.
  • printf not echo. echo may append a newline; printf does 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 recompute t at replay time, then recompute the signature.

#2A Correct Stripe Webhook Handler (Express, but the Shape Is Universal)

javascript
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:

  1. Raw body, express.raw keeps the bytes as a Buffer. Do not use express.json() on this route; if you do, req.body becomes a parsed object and the signature check fails.
  2. Stripe SDK helper, constructEvent handles the signature parsing, HMAC, comparison, and replay window check in one call. Roll your own only if you cannot use the SDK.
  3. Dedupe on event.id, Stripe re-delivers failed webhooks. The event ID (evt_...) is your idempotency key. The INSERT ... ON CONFLICT DO NOTHING RETURNING pattern is atomic and one round-trip.
  4. 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.body a Buffer or a parsed object? If it is parsed, your middleware ran first. Move express.raw before 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 -u on 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 t fresh.
  • 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.succeeded fires 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/stripe runs 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 stripe works 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

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.

RJRahul JalavadiyaFounder & Lead Engineer
Published 2026-05-24Last reviewed 2026-08-23

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.

Last reviewed: 2026-08-23
Security Memo
AT

Rahul Jalavadiya

Engineering Protocol V1

Specializing in local-first architecture and Zero-Trust developer workflows. No data leaves the machine.