Back to Blog
guides9 min readApril 12, 2026

Stripe Webhook Signature Verification: Complete Guide

Stripe signs every webhook with an HMAC-SHA256 signature. Verifying it is not optional — without it, anyone can POST a fake payment_intent.succeeded to your endpoint and trigger order fulfilment. This guide covers the full verification flow, the most common failure modes, and how to test it locally.

A
Abinash B
April 12, 2026

Why Signature Verification Is Non-Negotiable

Without signature verification, your webhook endpoint is an open door. Any attacker who knows your endpoint URL can POST a crafted payment_intent.succeeded payload and trigger your fulfilment logic — shipping products, granting access, or sending receipts — for payments that never happened.

Stripe signs every webhook with an HMAC-SHA256 digest of the raw request body, using a signing secret unique to each webhook endpoint. Verifying the signature proves the request came from Stripe's servers and hasn't been tampered with in transit.

Before diving in, make sure you can receive and inspect real Stripe payloads. If you haven't set that up yet, start with how to test Stripe webhooks locally first.


How the Stripe Signature Works

Stripe's signature scheme is slightly more complex than a simple HMAC. Each request includes a Stripe-Signature header in this format:

Stripe-Signature: t=1712000000,v1=abc123def456...,v0=legacy...

The fields:

  • t — Unix timestamp of when Stripe sent the event
  • v1 — HMAC-SHA256 signature (current scheme)
  • v0 — legacy signature (ignore this)

The signed payload that Stripe computes the HMAC over is:

{timestamp}.{raw_request_body}

So the full verification flow is:

  1. Extract t and v1 from the Stripe-Signature header
  2. Concatenate: {t}.{raw_body}
  3. Compute HMAC-SHA256 of that string using your signing secret
  4. Compare to v1 — if they match, the request is authentic
  5. Check that t is within 300 seconds of now — replay attack protection

Stripe's SDK does all of this in one call. See the full Stripe webhook events reference for a list of events and their payload shapes.


Implementation — Node.js (Recommended)

The Stripe Node.js SDK handles verification natively. The one critical requirement: you must pass the raw request body as a Buffer, not a parsed JSON object.

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()

// ⚠️  This route must use raw body middleware — NOT express.json()
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (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 — process 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)
    }

    res.json({ received: true })
  }
)

Important: If you use express.json() globally, it will parse the body before this route can see the raw bytes. Either place the webhook route before your global middleware, or scope express.raw() specifically to this path.


Implementation — Python (Django / Flask)

import stripe
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 stripe.error.SignatureVerificationError as e:
        return HttpResponseBadRequest(str(e))

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

    return HttpResponse(status=200)

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...
  }

  w.WriteHeader(http.StatusOK)
}

The Most Common Verification Failures

1. "No signatures found matching the expected signature"

Almost always caused by body parsing before verification. The HMAC is computed over the raw bytes. If any middleware has already parsed the body (JSON, urlencoded, etc.), the bytes have changed.

Fix: Use express.raw({ type: 'application/json' }) specifically on the webhook route. In Express, route-level middleware runs before global middleware if you order them correctly:

// Route-specific raw body — before global json()
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler)

// Global JSON for all other routes
app.use(express.json())

2. "Timestamp outside the tolerance zone"

The t timestamp in the signature header is more than 300 seconds old. Stripe rejects deliveries that take too long (server clock drift, extremely delayed processing). By default, constructEvent enforces this 5-minute tolerance.

Fix in development: Pass { tolerance: null } (or a larger value) to skip the timestamp check during local testing:

// In dev only — don't disable tolerance in production
event = stripe.webhooks.constructEvent(payload, sig, secret, 600) // 10 min tolerance

3. Wrong signing secret

Stripe creates a separate signing secret for each webhook endpoint — it starts with whsec_. This is different from your API secret key (sk_test_...) and different for each endpoint you create.

Fix: In the Stripe Dashboard → Developers → Webhooks → [your endpoint] → Signing secret. Reveal and copy it. Make sure STRIPE_WEBHOOK_SECRET in your environment matches the endpoint you're testing.

4. Using the Stripe CLI secret with a Dashboard endpoint

When you run stripe listen, it prints its own webhook signing secret (whsec_...). This secret only works for payloads forwarded by the CLI — not for events sent to a Dashboard-registered endpoint. They're not interchangeable.

5. Webhook secret from test mode used in live mode

Stripe test mode and live mode have separate webhook secrets. When you go to production, re-register the endpoint in live mode and use the live signing secret.


Testing Signature Verification Locally

The cleanest way to test end-to-end signature verification without a real Stripe account is:

  1. Create a free WebhookWhisper endpoint and register it in the Stripe Dashboard (test mode)
  2. Copy the signing secret Stripe shows you (whsec_...)
  3. Set STRIPE_WEBHOOK_SECRET=whsec_... in your local environment
  4. Add a forwarding rule from your WebhookWhisper endpoint to http://localhost:3000/webhooks/stripe
  5. In the Stripe Dashboard, click Send test event — pick payment_intent.succeeded
  6. The event arrives at WebhookWhisper, gets forwarded to your local handler, and your constructEvent call verifies the real Stripe signature

This is the only way to test real signature verification end-to-end — WebhookWhisper's built-in Test Sender fires payloads without Stripe signatures, which is fine for testing handler logic but not signature verification.


Security Checklist

  • ☑ Verify signature on every incoming webhook — no exceptions
  • ☑ Use the raw body (Buffer/bytes), never a parsed object
  • ☑ Store STRIPE_WEBHOOK_SECRET in environment variables, never in code
  • ☑ Use separate secrets for test and live mode endpoints
  • ☑ Return 200 only after successful verification
  • ☑ Implement idempotency — same event can arrive more than once
  • ☑ Respond within 30 seconds — enqueue heavy work, don't do it synchronously

Related Guides

#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 Guide (2026) | WebhookWhisper