Back to Blog
guides18 min readApril 12, 2026

Stripe Webhook Signature Verification: Complete Guide

Stripe signs every webhook with HMAC-SHA256. Without verification, anyone who knows your endpoint URL can POST a fake payment_intent.succeeded and trigger your fulfilment. This is the version of the guide I wish I had when I shipped HMAC signing on our own sender side and made every mistake below.

A
Founder, WebhookWhisper · April 12, 2026

Stripe signs every webhook with an HMAC-SHA256 digest of the raw request body. Verifying that signature is the only thing standing between your fulfilment logic and a stranger POSTing a fake payment_intent.succeeded at your endpoint to ship themselves a product they never paid for. (For the full Stripe-side reference, see our Stripe provider page.)

I've been on both sides of this. I've watched constructEvent throw at 2am with a deadline two hours away and no idea which of seven things broke. I've also shipped HMAC signing on the sender side of WebhookWhisper, which means I had to think very carefully about all the ways receivers get this wrong. This post is the version of the guide I wish I'd had during both.

I'll cover what the signature actually is, the canonical Stripe SDK call in five languages, the seven verification failures I see most often (with the actual fix for each), how to roll your own verifier if you have to, and how to test the whole thing end-to-end without burning a real Stripe charge.

Why signature verification is non-negotiable

Without verification, your webhook endpoint is a public POST endpoint that triggers business logic. Anyone who knows the URL — and webhook URLs leak constantly: from server logs, from .env files committed to public GitHub repos, from screenshots in Slack — can craft a payload that looks identical to the real thing and trigger your fulfilment.

The threat is concrete:

  • Order fulfilment forgery. POST checkout.session.completed with a real-looking session object → your code ships a product, sends a license key, or grants account access for a payment that never happened.
  • Subscription state manipulation. POST customer.subscription.created with the attacker's email → they get whatever access tier your code grants on subscription start.
  • Refund triggering. POST charge.refunded for a real charge ID → some implementations issue a credit on their own ledger before checking with Stripe's API.
  • Replay attacks. Even legitimate Stripe events can be captured (e.g. via a logging service) and re-sent. Without timestamp tolerance, the same successful charge fulfils five times.

Stripe's signature scheme defends against all of these. It's HMAC-SHA256 over a string that includes the timestamp, signed with a per-endpoint secret you configure in the dashboard. If the math checks out and the timestamp is recent, you know two things at once: the request came from Stripe, and it isn't a replay.

Verification is also part of basic webhook security hygiene — every provider that signs (Stripe, GitHub, Shopify, Twilio, Slack) does it for the same reason, and the verification recipe shape is the same across all of them. Get it right once and the next provider is a 30-minute integration.

How the Stripe-Signature header works

Every Stripe webhook arrives with a Stripe-Signature header. The format looks like this:

Stripe-Signature: t=1712000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v0=legacy_value_ignore_me

Three comma-separated fields:

  • t — Unix timestamp (seconds) when Stripe sent the event. Used for replay protection.
  • v1 — Hex-encoded HMAC-SHA256 of the signed payload. This is the field you actually verify.
  • v0 — Legacy scheme. Ignore. Stripe still sends it for backward compatibility but no current SDK uses it.

The "signed payload" Stripe computes the HMAC over is constructed by concatenating the timestamp, a literal period, and the raw request body bytes:

signed_payload = "{t}.{raw_request_body}"
expected_v1    = HMAC-SHA256(signing_secret, signed_payload).hex()

So the verification flow your code (or the SDK) runs is:

  1. Parse the Stripe-Signature header into t and v1.
  2. Recompute expected_v1 = HMAC-SHA256(secret, "{t}.{raw_body}").
  3. Compare expected_v1 to v1 using a constant-time comparison (not == — see "Roll your own" below for why).
  4. Check that now() - t < tolerance. Stripe's SDKs default to 300 seconds.
  5. If both pass, the request is authentic and recent. Otherwise, reject with HTTP 400.

If you want to play with this interactively — paste a body and a secret, watch the signature get computed in your browser — we built a free HMAC signature playground that does exactly this for Stripe (and four other providers). The math runs entirely client-side, so your secret never reaches a server.

The single most important rule: raw body, not parsed JSON

Before any code, the one thing that causes more Stripe signature failures than every other cause combined: your verification call must be passed the exact bytes Stripe sent, not a re-serialized JSON object.

HMAC is a checksum over bytes. If your HTTP framework parses the JSON body and your handler then receives a JavaScript object, the bytes are gone. When the SDK helper re-stringifies that object to verify, the result will not match Stripe's signature — keys may be in a different order, whitespace will be normalized away, Unicode escapes may be expanded.

Every framework has a different escape hatch for this:

  • Express: express.raw({ type: 'application/json' }) on the webhook route, before express.json().
  • Fastify: register a content-type parser that returns the raw buffer for that route.
  • Next.js (Pages Router): export const config = { api: { bodyParser: false } } and read with micro or buffer manually.
  • Next.js (App Router): await req.text() in the route handler — Next gives you the raw text by default.
  • Django: request.body returns raw bytes. Just don't access request.POST or json.loads first.
  • Flask: request.get_data() for raw bytes. Don't call request.get_json() before verifying.
  • Go (net/http): io.ReadAll(r.Body) — straightforward.
  • Rails: request.body.read in the controller action. Skip CSRF for this route.
  • PHP: file_get_contents('php://input').

I've seen this bug ship to production at three startups I've consulted with, and I made the same mistake myself the first time I integrated Stripe in 2019. It is universal. If your verification is failing and you can't see why, this is the first thing to check. The error-page reference is stripe-signature-mismatch (and the cross-provider version is signature-mismatch).

The Stripe Node SDK does the entire flow in one call. The trick, as above, is the raw body middleware:

import Stripe from 'stripe'
import express from 'express'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET  // whsec_...

const app = express()

// ⚠️  Route-specific raw body — must come BEFORE express.json()
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const sig = req.headers['stripe-signature']

    let event
    try {
      event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret)
    } catch (err) {
      console.error('Signature verification failed:', err.message)
      return res.status(400).send(`Webhook Error: ${err.message}`)
    }

    // Signature verified — now it's safe to act on the event
    switch (event.type) {
      case 'payment_intent.succeeded':
        await fulfillOrder(event.data.object)
        break
      case 'customer.subscription.deleted':
        await revokeAccess(event.data.object)
        break
      default:
        console.log('Unhandled event type:', event.type)
    }

    // Acknowledge fast — Stripe times out at 30s
    res.json({ received: true })
  }
)

// Global JSON middleware for everything else
app.use(express.json())

What constructEvent does for you: parses the Stripe-Signature header, recomputes the HMAC, does a constant-time comparison, and enforces the 300-second timestamp tolerance. If anything fails, it throws a StripeSignatureVerificationError with a message that tells you which check failed. Always log err.message — it's specific and useful.

Implementation — Python (Django and Flask)

import stripe
from django.conf import settings
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt

stripe.api_key = settings.STRIPE_SECRET_KEY
webhook_secret = settings.STRIPE_WEBHOOK_SECRET

@csrf_exempt
def stripe_webhook(request):
    payload = request.body          # raw bytes — Django keeps these
    sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, webhook_secret
        )
    except ValueError:
        # Invalid payload (not JSON, etc.)
        return HttpResponseBadRequest('Invalid payload')
    except stripe.error.SignatureVerificationError as e:
        return HttpResponseBadRequest(f'Signature verification failed: {e}')

    if event['type'] == 'payment_intent.succeeded':
        fulfill_order(event['data']['object'])

    return HttpResponse(status=200)

For Flask, the shape is identical — read request.get_data() and pass it to the same stripe.Webhook.construct_event call. @csrf_exempt in Django (or skipping CSRF in Flask) is essential — Stripe doesn't have a CSRF token and never will.

Implementation — Go

package main

import (
  "io"
  "log"
  "net/http"
  "os"

  "github.com/stripe/stripe-go/v76/webhook"
)

func handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
  const maxBodyBytes = int64(65536)
  r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)

  payload, err := io.ReadAll(r.Body)
  if err != nil {
    http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
    return
  }

  event, err := webhook.ConstructEvent(
    payload,
    r.Header.Get("Stripe-Signature"),
    os.Getenv("STRIPE_WEBHOOK_SECRET"),
  )
  if err != nil {
    log.Printf("Signature verification failed: %v", err)
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
  }

  switch event.Type {
  case "payment_intent.succeeded":
    // handle payment success
  case "customer.subscription.deleted":
    // handle cancellation
  }

  w.WriteHeader(http.StatusOK)
}

The http.MaxBytesReader wrap is a small but important hardening — Stripe payloads are typically under 16 KB, but without a cap any client (legitimate or malicious) can stream gigabytes at your handler before you discover the body isn't really a webhook.

Implementation — Ruby (Rails)

require 'stripe'

class StripeWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload    = request.body.read
    sig_header = request.env['HTTP_STRIPE_SIGNATURE']
    secret     = ENV['STRIPE_WEBHOOK_SECRET']

    begin
      event = Stripe::Webhook.construct_event(payload, sig_header, secret)
    rescue JSON::ParserError, Stripe::SignatureVerificationError => e
      return head :bad_request
    end

    case event.type
    when 'payment_intent.succeeded'
      FulfillmentJob.perform_later(event.data.object.id)
    end

    head :ok
  end
end

skip_before_action :verify_authenticity_token is the Rails-specific equivalent of Django's @csrf_exempt. Without it, Rails will reject the POST as a CSRF attempt before your handler runs.

Implementation — PHP

<?php
require_once 'vendor/autoload.php';

\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));
$endpoint_secret = getenv('STRIPE_WEBHOOK_SECRET');

$payload    = file_get_contents('php://input');
$sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';

try {
    $event = \Stripe\Webhook::constructEvent(
        $payload, $sig_header, $endpoint_secret
    );
} catch (\UnexpectedValueException $e) {
    http_response_code(400); exit('Invalid payload');
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    http_response_code(400); exit('Invalid signature');
}

if ($event->type === 'payment_intent.succeeded') {
    fulfill_order($event->data->object);
}

http_response_code(200);

file_get_contents('php://input') reads the raw request body. It's the only safe way — $_POST is parsed and useless for HMAC verification.

Rolling your own verifier (when you have to)

Most of the time you should use the SDK. But there are real situations where you can't — minimal serverless functions where the SDK is too heavy, an Edge Worker with no Node compatibility, a language Stripe doesn't ship a library for. Here's the canonical implementation, in Node, with the gotchas called out:

import crypto from 'crypto'

function verifyStripeSignature(rawBody, sigHeader, secret, toleranceSeconds = 300) {
  // 1. Parse the Stripe-Signature header
  const parts = Object.fromEntries(
    sigHeader.split(',').map(kv => kv.split('='))
  )
  const timestamp = parts.t
  const v1 = parts.v1
  if (!timestamp || !v1) throw new Error('Invalid signature header')

  // 2. Recompute the HMAC
  const signedPayload = `${timestamp}.${rawBody}`
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex')

  // 3. Constant-time comparison — NOT == or ===
  const expectedBuf = Buffer.from(expected, 'hex')
  const receivedBuf = Buffer.from(v1, 'hex')
  if (expectedBuf.length !== receivedBuf.length) throw new Error('Signature mismatch')
  if (!crypto.timingSafeEqual(expectedBuf, receivedBuf)) {
    throw new Error('Signature mismatch')
  }

  // 4. Replay-protection check
  const now = Math.floor(Date.now() / 1000)
  if (now - parseInt(timestamp, 10) > toleranceSeconds) {
    throw new Error('Timestamp outside tolerance zone')
  }

  return true
}

Three things that look fine but aren't, and that I see in DIY implementations all the time:

  1. === instead of constant-time compare. A naive string equality check returns false on the first differing character, and the time it takes to do that is observable on the wire. A determined attacker can fish out the secret byte-by-byte over millions of requests. Always use crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hmac.Equal in Go.
  2. JSON-stringifying the body before signing. If you pass JSON.stringify(req.body) instead of the raw bytes, your re-stringified JSON's whitespace and key order will not match what Stripe signed. Always use the raw buffer.
  3. Skipping the timestamp check. Without it, a logged or replayed request stays valid forever. The signature is correct; the event already happened. The 5-minute tolerance is a feature, not a bug.

The seven verification failures, ranked by frequency

This is what actually causes signature verification to fail in the wild. The order is mine, based on watching this bug report from teams using Stripe over the years and from supporting WebhookWhisper users.

SymptomLikely causeFix
"No signatures found matching the expected signature" Body parsed before verification (the raw-body bug) Use express.raw() on the webhook route, before express.json(). In Next.js App Router, use await req.text() not await req.json().
"Timestamp outside the tolerance zone" Replaying an old event manually, or your server clock has drifted In dev: pass a longer tolerance to constructEvent. In prod: install chrony or ntpd — server clocks drift surprisingly fast on cheap VPSes.
Verification works in test mode, fails in live You went to production with the test-mode whsec_ Stripe issues separate signing secrets per endpoint per mode. Re-register the endpoint in live mode and use the live signing secret.
Verification fails for events fired by stripe trigger but works in dashboard Using the Stripe CLI's signing secret with a Dashboard endpoint, or vice versa The CLI prints its own whsec_ when you run stripe listen — that secret only verifies CLI-forwarded events. Dashboard endpoints have their own secret.
Works locally, fails behind a proxy / CDN / WAF Cloudflare, an API gateway, or NGINX is altering the body (common with auto-decompression, JSON pretty-printing, or removing trailing whitespace) Disable any body transformation on the webhook route. In Cloudflare, turn off "Auto Minify" and "Rocket Loader" for the webhook path. In API Gateway, use Lambda proxy integration so the raw body passes through unmodified.
Works for some events, fails for others on the same endpoint Some events are larger than your body-size middleware allows. The body gets truncated and the HMAC fails. Increase your body parser limit. Stripe's largest events (invoice.payment_succeeded with many line items) can exceed 64 KB. express.raw({ limit: '1mb' }).
Verification randomly fails for ~1% of events Multi-region deployment with sticky-session middleware re-encoding the body, or a load balancer adding/stripping headers Verify on the edge (closest to ingress) or as the first thing your handler does — don't let any other middleware touch the body first.

Whichever symptom you have, the debugging move is the same: log the raw bytes you received, log the timestamp from the header, log the secret prefix (first 8 chars only — never the full secret), and recompute the HMAC by hand using the playground or a one-liner. If your manual computation matches Stripe's v1 but the SDK call fails, your raw body isn't actually raw. If your manual computation doesn't match, your secret is wrong or your timestamp parsing is.

A short note from the other side of the signature

When we shipped HMAC signing on the WebhookWhisper sender side this year (the receiver-facing equivalent of what Stripe does — we sign every event we forward to your endpoint so you can verify it came from us), the first failure mode I hit was, embarrassingly, the same raw-body bug I'm warning you about above. Our test harness was JSON-stringifying the body before signing, then the receiver was JSON-stringifying it again before verifying, and the two stringifications were not identical. Different Node versions, different key ordering. The signatures matched on my laptop and failed on the deployed staging server.

The fix on our side was to sign over the exact bytes that go on the wire, computed once, and to document it loudly in our signing docs: pass the raw body bytes, in that order, no transformation. Stripe documents the same constraint in their docs and almost everyone misses it on first read. I missed it on first implementation, with the docs open in another tab.

The second thing we hit was constant-time comparison. The first version of our verifier helper used ===. The second version, after I read it back two days later, used crypto.timingSafeEqual. The math was right both times; one version was a textbook timing-attack vulnerability. This is the kind of thing you can write correct on paper, lint-clean, and still ship wrong because the easy-and-wrong version compiles. The SDK call exists exactly so you don't have to think about this.

Testing signature verification locally — without burning a real charge

You have three honest options, in increasing order of fidelity.

1. Use the Stripe CLI's stripe trigger. Run stripe listen --forward-to localhost:3000/webhooks/stripe in one terminal — it prints a CLI-specific whsec_. Set that as your STRIPE_WEBHOOK_SECRET. In another terminal, run stripe trigger payment_intent.succeeded. The CLI sends a real signed payload to your handler. The signature is genuine. The downside: you can only test the canonical event shapes Stripe ships with the CLI.

2. Use a real Stripe Dashboard endpoint pointed at a forwarder. Create a free WebhookWhisper endpoint, paste it into the Stripe Dashboard as a webhook URL, copy the signing secret Stripe shows you, and add a forwarding rule from the WebhookWhisper endpoint to http://localhost:3000/webhooks/stripe. Then use the Dashboard's "Send test webhook" button or trigger real test-mode events from your test code. The signature is real, the bytes are unmodified through forwarding (we explicitly preserve the body — that's the whole point), and your local handler verifies the actual Stripe signature.

3. Compute signatures yourself for unit tests. For tests where you want to assert handler behavior on synthetic payloads, use the signature playground to generate a valid Stripe-Signature header for any payload + secret combo, or use the Stripe SDK's test helper:

// Node.js — generate a valid signature for tests
import stripe from 'stripe'

const payload   = JSON.stringify({ id: 'evt_test', type: 'payment_intent.succeeded' })
const secret    = 'whsec_test_secret'
const timestamp = Math.floor(Date.now() / 1000)
const signature = stripe.webhooks.generateTestHeaderString({ payload, secret, timestamp })

// Now POST `payload` with `Stripe-Signature: ${signature}` to your handler
// and assert it returns 200.

The first option is the fastest for ad-hoc local work. The second is what you want when you're integrating with the real dashboard, want to see actual events, and want a permanent URL you don't have to update every time your laptop sleeps. The third is what your CI pipeline should use.

The production checklist

The minimum bar for a Stripe webhook handler that won't bite you:

  • ☑ Verify Stripe-Signature on every request — no flag, no toggle, no "skip in dev".
  • ☑ Pass the raw request body to constructEvent, never a parsed object.
  • ☑ Store STRIPE_WEBHOOK_SECRET in environment variables. Rotate after any teammate offboarding or laptop loss.
  • ☑ Use separate signing secrets for test mode and live mode.
  • ☑ Use separate signing secrets for each webhook endpoint (one for the dashboard endpoint, one for stripe listen in dev, one per environment).
  • ☑ Return 200 only after the signature is verified and your idempotency check has either accepted or rejected the event. See webhook best practices for the idempotency pattern.
  • ☑ Acknowledge within 30 seconds. Stripe retries on timeout. Enqueue heavy work; don't run it inline.
  • ☑ Log err.message from constructEvent failures with a request ID. The message is specific enough to debug from.
  • ☑ Cap your body parser at a reasonable size (1 MB is more than enough). Reject larger requests early.
  • ☑ If you're behind Cloudflare / API Gateway / a WAF, verify on the edge or disable any body-transforming features on the webhook route.
  • ☑ Alert on signature-verification failure rate. A sudden spike means either an attacker probing or a deploy that broke the handler.

Frequently asked questions

What's the difference between STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET?

STRIPE_SECRET_KEY (starts with sk_) is your API key — used to make outbound calls to Stripe (creating customers, charging cards, listing payments). STRIPE_WEBHOOK_SECRET (starts with whsec_) is your webhook signing secret — used to verify incoming webhooks from Stripe. They're different values, scoped to different operations. Don't reuse one for the other; the API will reject the secret key as a webhook secret because the prefix is wrong.

Can I verify the signature without using the Stripe SDK?

Yes — see the "Roll your own verifier" section above for the canonical implementation. The math is straightforward: HMAC-SHA256 over {timestamp}.{raw_body}, hex-encoded, constant-time-compared to the v1 field in the header, plus a 5-minute timestamp tolerance check. The SDK exists to handle the constant-time comparison and the header parsing for you, not because the algorithm is exotic.

Why does my signature verify in development but fail in production?

Three possibilities, in order of likelihood. (1) You used the test-mode whsec_ in production by mistake — these are separate values per mode, and a test-mode secret will not verify live events. (2) A production-only proxy (Cloudflare, an API gateway, or an NGINX with body buffering) is mutating the request body. (3) Your production handler uses different middleware ordering than dev — e.g. express.json() registered globally before the webhook route. Check the secret first; it's the most common.

Should I disable timestamp tolerance during local testing?

Only if you're replaying captured events more than 5 minutes after they were originally sent. For everything else, keep the default — it's the only thing protecting you from someone who managed to capture a real signed webhook (e.g. via a leaked log file) from re-firing it weeks later. If you do need to disable it for a specific test, pass a larger tolerance to constructEvent: stripe.webhooks.constructEvent(body, sig, secret, 86400) for 24 hours. Never push that to production.

What's the right way to test signature verification in a CI pipeline?

Generate a valid signature in the test itself, using stripe.webhooks.generateTestHeaderString({ payload, secret }) (Node) or its equivalent in your language. Set a fixed test secret in your CI environment, generate the header for each test payload, POST it at your handler, and assert the handler returned 200. This avoids any dependency on a live Stripe account or network calls — the signature is real HMAC math, just with a secret you control.

Closing

The summary: use the SDK, pass the raw body, use a separate whsec_ per endpoint per mode, keep the 300-second timestamp tolerance on, and constant-time-compare. Ninety-five percent of the bugs come from the raw-body issue or from mismatched secrets. The remaining five percent come from middleware between Stripe and your verifier mutating the body — which is why we built WebhookWhisper's forwarding to be byte-transparent: the bytes Stripe sends to us are the bytes we send to your localhost.

If you're stuck on a verification failure right now, the fastest debugging path is to use the signature playground to compute what the signature should be for the exact payload you're receiving, then compare to the v1 in the header. If they don't match, you have a body or secret problem. If they do match but your code rejects it anyway, you have a comparison or parsing problem in your code path.

And if you want to skip having to write any of this — receive real signed Stripe webhooks at a permanent URL, forward them to your localhost without rotating ngrok URLs, replay them when your handler crashes — that's the wedge WebhookWhisper exists for. Free endpoint, no card required.

#stripe#webhooks#security#signatures#hmac#node

Ready to test your webhooks?

Get a free HTTPS endpoint in under 5 seconds — no signup required.

Create Free Account
Stripe Webhook Signature Verification: 2026 Guide (Node, Python, Go, Ruby, PHP) | WebhookWhisper