Stripe Webhook Security Guide (2026): Signature Verification in Python, Node.js, Ruby and Go

Updated September 26, 202619 min read

To verify a Stripe webhook signature, pass the raw request body, the Stripe-Signature header and your endpoint's whsec_ secret to the SDK's constructEvent (stripe.Webhook.construct_event in Python); it checks an HMAC-SHA256 of {timestamp}.{body} in constant time and rejects timestamps older than 5 minutes. Return 400 when it throws.

A Stripe webhook endpoint is a privileged entry point into your payments system. If an attacker can forge a payment_intent.succeeded event your handler accepts, they can unlock paid features, trigger fulfillment, or tamper with ledgers. This guide is a dedicated deep-dive into signing, verifying, testing, and hardening Stripe webhooks — with production-ready code in Python, Node.js, Ruby, and Go. For the broader setup walkthrough, see our Stripe webhooks implementation guide.

Key takeaways

  • Stripe signs {t}.{raw body} with HMAC-SHA256. The Stripe-Signature header looks like t=…,v1=…; only v1 counts in live mode.
  • Raw bytes or it fails. Flask request.get_data(), FastAPI await request.body(), Express express.raw(), Next.js await req.text().
  • The SDK default tolerance is 300 seconds. Never pass 0, which disables the freshness check; Stripe re-signs every retry with a new timestamp.
  • Rolling a secret is zero-downtime. Keep the old secret for up to 24 hours; Stripe sends one v1 per active secret, so either verifies.
  • Return 400 on failure and test it in CI. Build a valid header from a fixture secret and assert that tampered and stale requests are rejected.
  • Check against real deliveries. Capture a sandbox event on a Hooklistener endpoint, verify its signature with your stored secret, and replay it to localhost re-signed with a fresh timestamp.

Why Stripe Webhook Security Matters

Your Stripe webhook URL is discoverable. It appears in CI logs, browser dev tools, old Git commits, container images, and third-party services that proxy traffic. Assume any attacker who cares enough will find it. Everything downstream of the endpoint — your fulfillment pipeline, accounting, access control — must therefore rely on cryptographic authentication rather than URL secrecy.

The threat model for a Stripe integration includes at least four classes of attack. Endpoint spoofing: an attacker POSTs a hand-crafted charge.succeeded body to your URL, hoping your handler only checks event.type. Replay attacks: an attacker captures a legitimate event (for example, via a logging sidecar that archives raw payloads) and re-sends it hours later to double-credit an account. Payload tampering: an intermediary rewrites the amount field before it reaches your server. Secret leakage: the whsec_ signing secret ends up in a public repository, a disclosed env file, or a compromised laptop.

The consequences are direct: unauthorized entitlement grants, refund fraud, inflated revenue metrics that corrupt downstream analytics, and regulatory exposure if PII embedded in the event is mishandled. Signature verification plus a narrow timestamp tolerance closes the first three attack classes. Secret rotation and least-privilege storage close the fourth. Skipping any of these puts your payments flow one curl command away from a compromise.

How does Stripe webhook signature verification work?

Stripe computes an HMAC-SHA256 of the delivery timestamp and the raw body with your endpoint secret and sends it in the Stripe-Signature header; you recompute it and compare. Every webhook Stripe sends includes a Stripe-Signature HTTP header. It is not a single value — it is a comma-separated list of key/value pairs that Stripe can extend without breaking older clients. A typical header looks like this:

Stripe-Signature: t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39

The t pair is the Unix timestamp (in seconds) when Stripe generated the signature. The v1 pair is the HMAC-SHA256 signature and the only valid live-mode scheme. v0 is a fake signature Stripe adds to test events; ignore every scheme except v1 so an attacker cannot downgrade you to a weaker one. Stripe generates a new timestamp and signature for every delivery attempt, so retries always arrive freshly signed.

The signed string is the timestamp, a literal dot, and the body: timestamp.raw_payload. The HMAC is computed with SHA-256, keyed on your endpoint signing secret (the whsec_... value from the Stripe Dashboard), and hex-encoded. Crucially, the payload portion must be the exact raw bytes of the request body — not a re-serialized JSON object. Any whitespace change, key reordering, or Unicode normalization breaks the signature.

Stripe supports rolling secrets: when you roll an endpoint's secret in Workbench you can expire the old one immediately or keep it active for up to 24 hours. While both are active, Stripe generates one v1 signature per secret, and the SDKs accept the event if any v1 matches the single secret you pass. So you do not need to configure two secrets: deploy the new one at any point inside the window, confirm verification succeeds, and let the old one expire.

Signing secrets are scoped per endpoint and per mode. A test-mode endpoint and a live-mode endpoint have independent whsec_ values, and the Stripe CLI issues yet another short-lived secret for stripe listen sessions. Do not mix them: using the Dashboard secret to verify CLI-forwarded events (or the reverse) is the most common cause of “No signatures found matching the expected signature for payload”. Test and CLI secrets are lower-risk than live ones, but still keep them out of Git.

How do you verify Stripe webhook signatures in Python, Node.js, Ruby and Go?

Every example below uses the official Stripe SDK rather than hand-rolled HMAC. The SDKs handle edge cases — multiple v1 values, header reordering, constant-time comparison, timestamp tolerance — that are easy to get wrong in custom code. Every handler reads the signing secret from an environment variable, validates the request before doing any other work, and returns HTTP 400 (not 500) on verification failure so Stripe does not retry a request that will never succeed.

Python: Flask, FastAPI and Django (stripe-python)

Use stripe.Webhook.construct_event, which performs HMAC verification, timestamp tolerance enforcement, and constant-time comparison internally. Read the body with request.get_data() to get the raw bytes — never use request.get_json() before verification, because Flask will parse and discard the original byte stream.

import os import stripe from flask import Flask, request, jsonify app = Flask(__name__) stripe.api_key = os.environ["STRIPE_SECRET_KEY"] ENDPOINT_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"] @app.route("/stripe/webhooks", methods=["POST"]) def stripe_webhook(): payload = request.get_data(as_text=False) # raw bytes sig_header = request.headers.get("Stripe-Signature", "") if not sig_header: return jsonify(error="missing signature"), 400 try: event = stripe.Webhook.construct_event( payload=payload, sig_header=sig_header, secret=ENDPOINT_SECRET, tolerance=300, # 5 min default; tighten for high-value endpoints ) except ValueError: # malformed JSON return jsonify(error="invalid payload"), 400 except stripe.SignatureVerificationError as e: app.logger.warning("stripe signature verification failed: %s", e) return jsonify(error="invalid signature"), 400 # Safe to dispatch: event is authenticated and fresh if event["type"] == "payment_intent.succeeded": handle_payment_succeeded(event["data"]["object"]) return jsonify(received=True), 200

The same call works in any Python framework as long as you hand it the raw bytes. In FastAPI use await request.body() (not a Pydantic body parameter); in Django use request.body and exempt the view from CSRF:

# FastAPI @app.post("/stripe/webhooks") async def stripe_webhook(request: Request): payload = await request.body() sig_header = request.headers.get("stripe-signature") try: event = stripe.Webhook.construct_event(payload, sig_header, ENDPOINT_SECRET) except (ValueError, stripe.SignatureVerificationError): raise HTTPException(status_code=400) return {"received": True} # Django @csrf_exempt @require_POST def stripe_webhook(request): try: event = stripe.Webhook.construct_event( request.body, request.headers.get("Stripe-Signature"), ENDPOINT_SECRET ) except (ValueError, stripe.SignatureVerificationError): return HttpResponse(status=400) return HttpResponse(status=200)

stripe.SignatureVerificationError is the current import path; older code uses the deprecated stripe.error.SignatureVerificationError alias.

Node.js (Express and Next.js + stripe)

The single most common mistake in Node.js is forgetting express.raw(). Express's default express.json() middleware parses the body into an object, leaving req.body as a JavaScript value that no longer matches the bytes Stripe signed. Mount express.raw({ type: 'application/json' }) specifically on the webhook route — not globally, or your other JSON endpoints will break.

import express from "express"; import Stripe from "stripe"; const app = express(); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; app.post( "/stripe/webhooks", express.raw({ type: "application/json" }), // MUST be raw, not json (req, res) => { const sig = req.headers["stripe-signature"]; if (!sig) return res.status(400).send("missing signature"); let event; try { event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret); } catch (err) { // Stripe.errors.StripeSignatureVerificationError console.warn("stripe signature verification failed:", err.message); return res.status(400).send(`Webhook Error: ${err.message}`); } switch (event.type) { case "payment_intent.succeeded": handlePaymentSucceeded(event.data.object); break; // ... } res.json({ received: true }); } );

In a Next.js App Router route handler there is no body parser to disable: read const body = await req.text(), pass it to constructEvent with req.headers.get("stripe-signature"), and only then parse. On edge runtimes use await stripe.webhooks.constructEventAsync(...). Full example in the Stripe webhooks implementation guide.

Ruby (Rails + stripe-ruby)

In Rails, use request.body.read to get the raw payload and disable CSRF protection for the webhook action — Stripe cannot present a CSRF token. Rate-limit or IP-allowlist the route at the edge instead.

# config/routes.rb # post "/stripe/webhooks", to: "stripe_webhooks#create" class StripeWebhooksController < ApplicationController skip_before_action :verify_authenticity_token ENDPOINT_SECRET = ENV.fetch("STRIPE_WEBHOOK_SECRET") def create payload = request.body.read sig_header = request.env["HTTP_STRIPE_SIGNATURE"] return head :bad_request if sig_header.blank? begin event = Stripe::Webhook.construct_event( payload, sig_header, ENDPOINT_SECRET ) rescue JSON::ParserError return head :bad_request rescue Stripe::SignatureVerificationError => e Rails.logger.warn("stripe signature verification failed: #{e.message}") return head :bad_request end case event.type when "payment_intent.succeeded" PaymentSucceededJob.perform_later(event.data.object.id) end head :ok end end

Go (net/http + stripe-go)

The webhook.ConstructEvent helper in github.com/stripe/stripe-go/v86/webhook (v86 is current as of September 2026) handles the 300-second tolerance and timing-safe comparison. It also returns an error when the event's API version does not match the version the library is pinned to; pin your endpoint to the same version, or use ConstructEventWithOptions with IgnoreAPIVersionMismatch only if you handle the payload shape yourself. Cap the request body with http.MaxBytesReader to defend against oversized payloads.

package main import ( "io" "log" "net/http" "os" "github.com/stripe/stripe-go/v86/webhook" ) const maxBodyBytes = int64(65536) func stripeWebhookHandler(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) payload, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "read error", http.StatusServiceUnavailable) return } sigHeader := r.Header.Get("Stripe-Signature") if sigHeader == "" { http.Error(w, "missing signature", http.StatusBadRequest) return } endpointSecret := os.Getenv("STRIPE_WEBHOOK_SECRET") event, err := webhook.ConstructEvent(payload, sigHeader, endpointSecret) if err != nil { log.Printf("stripe signature verification failed: %v", err) http.Error(w, "invalid signature", http.StatusBadRequest) return } switch event.Type { case "payment_intent.succeeded": // dispatch async worker } w.WriteHeader(http.StatusOK) } func main() { http.HandleFunc("/stripe/webhooks", stripeWebhookHandler) log.Fatal(http.ListenAndServe(":8080", nil)) }

How do you test Stripe signature verification locally?

Run stripe listen --forward-to, put the whsec_ secret it prints into your local environment, and fire events with stripe trigger. The Stripe CLI forwards events from your account to localhost, issues its own signing secret, and lets you synthesize any event type on demand — all without exposing a public URL or editing Dashboard configuration. If you already have a persistent Hooklistener URL set up as an ngrok alternative for other providers, you can also point a test-mode Stripe endpoint at that URL and forward to localhost without running stripe listen in the background.

# 1. Install (macOS example)
brew install stripe/stripe-cli/stripe
# 2. Authenticate (opens a browser, scoped to a single Stripe account)
stripe login
# 3. Start the forwarder — prints the signing secret ONCE at startup
stripe listen --forward-to localhost:3000/stripe/webhooks
# Output includes: "Your webhook signing secret is whsec_abc123..."
# 4. In another terminal, trigger a realistic test event
stripe trigger payment_intent.succeeded
# 5. Resend a delivered event to an endpoint (works up to 30 days after creation)
stripe events resend evt_1NXYZabc123 --webhook-endpoint=we_123

The signing secret printed by stripe listen is what you must put in STRIPE_WEBHOOK_SECRET for local development. It is different from both your test-mode Dashboard secret and your live-mode secret. It is printed when the session starts (stripe listen --print-secret prints it on its own). Do not check it into .env.example; it is tied to your personal Stripe login.

Use stripe trigger to generate canonically correct fixtures for every event type you care about. Use stripe events resend to deliver the same event (same evt_ ID, freshly signed) to an endpoint again, which exercises your idempotency store with real Stripe-signed payloads. For a side-by-side comparison of what each delivery looks like on the wire, pair the CLI with our webhook debugger.

How do you unit test Stripe webhook signature verification?

Your CI should assert two things about every webhook change: a correctly signed request is accepted, and every kind of malformed or stale request is rejected with a 400. The trick is generating a valid Stripe-Signature header without calling Stripe. Because the scheme is documented and deterministic, you can build the header in a few lines using only hmac and time — or use the SDK's signing utilities directly. For ad-hoc manual probes that don't belong in CI, our free webhook tester lets you fire tampered bodies, stale timestamps, and oversized payloads at a staging endpoint to confirm every rejection path returns 400 instead of 500.

Python pytest example

import hmac, hashlib, json, os, time SECRET = "whsec_test_fixture_secret" # The app reads its env at import time, so set it first os.environ["STRIPE_WEBHOOK_SECRET"] = SECRET os.environ.setdefault("STRIPE_SECRET_KEY", "sk_test_dummy") from app import app # Flask app from section 3 def _sign(payload: bytes, secret: str, ts: int) -> str: signed = f"{ts}.".encode() + payload v1 = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return f"t={ts},v1={v1}" def test_valid_signature_is_accepted(): body = json.dumps({"id": "evt_1", "type": "payment_intent.succeeded", "data": {"object": {"id": "pi_1"}}}).encode() ts = int(time.time()) header = _sign(body, SECRET, ts) client = app.test_client() resp = client.post("/stripe/webhooks", data=body, headers={"Stripe-Signature": header, "Content-Type": "application/json"}) assert resp.status_code == 200 def test_tampered_body_rejected(): body = b'{"id":"evt_1","type":"payment_intent.succeeded"}' header = _sign(body, SECRET, int(time.time())) tampered = body.replace(b"evt_1", b"evt_X") client = app.test_client() resp = client.post("/stripe/webhooks", data=tampered, headers={"Stripe-Signature": header}) assert resp.status_code == 400

Node.js Jest example

// jest.setup.ts (listed in setupFiles) runs before the app is imported: // process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_fixture_secret"; // process.env.STRIPE_SECRET_KEY = "sk_test_dummy"; import crypto from "crypto"; import request from "supertest"; import app from "../app"; // Express app from section 3 const SECRET = process.env.STRIPE_WEBHOOK_SECRET!; function signPayload(payload: Buffer, ts = Math.floor(Date.now() / 1000)) { const signed = `${ts}.${payload.toString("utf8")}`; const v1 = crypto.createHmac("sha256", SECRET).update(signed).digest("hex"); return `t=${ts},v1=${v1}`; } test("valid signature returns 200", async () => { const body = Buffer.from(JSON.stringify({ id: "evt_1", type: "payment_intent.succeeded", data: { object: { id: "pi_1" } }, })); const res = await request(app) .post("/stripe/webhooks") .set("Stripe-Signature", signPayload(body)) .set("Content-Type", "application/json") .send(body); expect(res.status).toBe(200); }); test("stale timestamp is rejected", async () => { const body = Buffer.from('{"id":"evt_1"}'); const stale = Math.floor(Date.now() / 1000) - 60 * 60; // 1 hour old const res = await request(app) .post("/stripe/webhooks") .set("Stripe-Signature", signPayload(body, stale)) .send(body); expect(res.status).toBe(400); });

Keep the fixture secret distinct from any real secret, and never reuse it across environments. Running these tests against every pull request catches the two failure modes that cause the most production incidents: accidentally re-introducing a JSON parser in front of the verifier, and silently loosening the timestamp tolerance.

Why does Stripe webhook signature verification fail? Common pitfalls

Using == instead of constant-time comparison

Plain equality on hex strings leaks timing information. An attacker can iterate byte-by-byte and measure response time to reconstruct a valid signature. Always use hmac.compare_digest (Python), crypto.timingSafeEqual (Node.js), or subtle.ConstantTimeCompare (Go). The Stripe SDKs already do this internally — which is why hand-rolled HMAC verification is risky.

Parsing JSON before verifying

JSON parsers normalize whitespace, reorder keys, and re-encode Unicode escapes. Re-serializing the parsed object produces bytes that no longer match what Stripe signed, and verification fails even on legitimate requests. The fix is to capture the raw body first and pass those exact bytes to the verifier. In Python Flask, that means request.get_data() before any call that touches JSON; in Express, it means express.raw() on the webhook route.

Forgetting express.raw() in Express

If express.json() is mounted globally (for example, in a createApp() helper), it runs before your Stripe route and consumes the raw stream. req.body becomes a parsed object, and constructEvent throws on every request. Mount express.raw only on the webhook path, or define the Stripe route before any global body parser.

Replay window too wide

Stripe's default tolerance is 5 minutes. For high-value endpoints (refunds, payouts, subscription cancellations) consider tightening to 2 minutes and alerting on any rejection. Never set it to 0 (Stripe warns that disables the recency check) and never widen it to “make flaky tests pass” — regenerate fixtures at test time instead of reusing stale ones.

Hardcoding the signing secret

The secret belongs in a secret manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler, 1Password) or at minimum an environment variable loaded from outside the repository. Secrets committed to Git — even in private repos — end up in CI logs, container layers, and backup snapshots, and are effectively unrecoverable once leaked.

Not rotating on leak

Rotation has zero downtime if you do it right. In Workbench, open the webhook endpoint, choose “Roll secret” from the overflow menu, and keep the current secret active for up to 24 hours. Stripe signs each delivery with every active secret, so you can deploy the new whsec_ value at any time inside that window and monitor for verification failures. Only expire the old secret immediately if it has actually leaked: every delivery that reaches your server before the new secret is deployed will then fail verification and go into Stripe's retry schedule.

Silently dropping unverified events

Returning 400 without logging and alerting turns your endpoint into a black hole during a real incident. Emit a structured log entry on every verification failure including the source IP, the t value from the header, and the reason (stale, no matching signature, malformed header). Page on-call when failures exceed a small baseline rate — in steady state, verification should effectively never fail on legitimate traffic.

Treating event.type as authorization

Signature verification proves "this event came from Stripe's infrastructure," not "this customer is allowed to perform this action." A signed event still needs to be authorized against your own domain model: verify the account field matches the expected Connect account, confirm the customer belongs to the correct tenant, and never grant entitlements based on an event type alone. Apply principle of least privilege inside your handler, exactly as you would for any authenticated API call.

Should you IP-allowlist Stripe webhooks as well as verify signatures?

Yes, if you can keep the list current: Stripe's own docs recommend both an IP allowlist and signature verification. HMAC verification is the primary control and the one you cannot skip, but it should not be the only thing standing between the internet and your payments logic. Layer additional, cheaper controls in front of it so that malformed traffic never reaches your verifier at all.

IP allow-listing at the edge (CDN, WAF, or load balancer) using the webhook IPs Stripe publishes at docs.stripe.com/ips (machine-readable at stripe.com/files/ips/ips_webhooks.json) drops blind spoofing traffic cheaply. The list changes over time, so refresh it automatically and alert when a fetch fails; a stale list turns into silently rejected, then retried, then failed deliveries.

TLS-only ingress rejects any HTTP request at the listener, not just with a redirect. Modify your load balancer or ingress controller to close plain HTTP connections on the webhook port. An HTTP redirect is fine for humans and SEO; for a webhook endpoint, it is an invitation to downgrade attacks.

Opaque endpoint path tokens — a long random segment in the URL such as /stripe/webhooks/k7f2... — add a weak but nonzero layer of URL obscurity. This is not authentication, but it does raise the cost of blind scanning. Rotate the path whenever you rotate the signing secret.

For a provider-agnostic version of this layered pattern covering payload signing, ephemeral tokens, and egress controls, see our webhook security fundamentals guide. The principles there apply to any webhook producer; this page applies them specifically to Stripe's signature scheme.

Test Stripe signature verification with Hooklistener

Unit tests prove your code with fixtures; Hooklistener lets you prove it with the exact bytes Stripe sent. Every step works from the dashboard or from an AI assistant connected to theHooklistener MCP server.

  1. Capture a sandbox event. Register a Hooklistener endpoint URL as a sandbox webhook endpoint and run stripe trigger payment_intent.succeeded. The raw body and Stripe-Signature header are stored exactly as delivered.
  2. Check the signature. Store that endpoint's whsec_ with create_secret, then run verify_request_signature (provider: "stripe", tolerance_seconds 1–3600). HMAC validity and timestamp freshness are reported separately.
  3. Replay it re-signed. With hooklistener listen --endpoint <ENDPOINT_ID> --port 3000 running, call replay_request with target: "cli", signing_provider: "stripe" and your signing_secret_id. The header is generated at delivery, so the timestamp is fresh.
  4. Replay the failure cases. Replay with an edited amount and no re-signing, then replay the original after the tolerance has passed. Both must return 400, and the delivery log shows exactly what your handler answered.

Capture and replay are on every plan, including Free; stored secrets (needed for verification and re-signing) are on paid plans.

Production Security Checklist

Ship only when you can tick every item:

  • HTTPS-only ingress; plain HTTP rejected at the listener.
  • Requests missing Stripe-Signature rejected with 400 before any parsing.
  • Signature verification done with the official Stripe SDK, never hand-rolled HMAC.
  • Signing secret loaded from environment variable or secret manager, never committed.
  • Constant-time comparison everywhere (guaranteed when you use the SDK).
  • Raw request bytes passed to the verifier — no JSON parse ahead of it.
  • Idempotency store keyed on event.id deduplicates retries and replays.
  • Timestamp tolerance ≤ 5 minutes (tighter for high-value endpoints).
  • Every received event logged with ID, type, and verification outcome to an immutable store.
  • Alerting on failed-verification rate above baseline; paged on-call if sustained.
  • Documented rotation runbook with overlap window; tested at least annually.
  • Separate signing secrets per environment (CLI, test, live) with separate Dashboard endpoints.
  • CI pipeline runs signature-verification unit tests on every PR.

Frequently Asked Questions

How do I verify a Stripe webhook signature in Python?

Read the raw request bytes (request.get_data() in Flask, await request.body() in FastAPI, request.body in Django) and call stripe.Webhook.construct_event(payload, request.headers['Stripe-Signature'], endpoint_secret). It raises ValueError for malformed JSON and stripe.SignatureVerificationError for a bad signature or a timestamp older than the default 300-second tolerance; return 400 in both cases.

How do I verify a Stripe webhook signature in Node.js?

Get the raw body, not parsed JSON: express.raw({ type: 'application/json' }) on the webhook route in Express, or await req.text() in a Next.js route handler. Then call stripe.webhooks.constructEvent(rawBody, req.headers['stripe-signature'], endpointSecret) inside try/catch and return 400 on error. On edge runtimes use constructEventAsync.

What is the Stripe-Signature header format?

Stripe-Signature is a comma-separated list such as t=1492774577,v1=5257a8...,v0=6ffbb5.... t is the Unix timestamp of the delivery attempt; v1 is the hex HMAC-SHA256 of the string '{t}.{raw body}' keyed with the endpoint's whsec_ secret. Only v1 is valid in live mode; v0 is a fake signature on test events, and during a secret roll there is one v1 per active secret.

How do I rotate a Stripe webhook signing secret without downtime?

Roll the secret from the endpoint's overflow menu in Workbench and choose to keep the old secret active for up to 24 hours. During that window Stripe signs every delivery with each active secret, so the header carries two v1 signatures and either secret verifies. Deploy the new whsec_ value within the window, confirm verification succeeds, and let the old secret expire.

How can I test Stripe signature verification with a real Stripe event?

Send a sandbox event to a Hooklistener endpoint, store your whsec_ secret, and run the verify_request_signature MCP tool to confirm the signature and see whether the timestamp was fresh at capture. Then replay the event to your local handler with replay_request, re-signed at delivery so it passes the 5-minute tolerance, or replay an edited body without re-signing to confirm your handler returns 400.

Related Resources