Verify a Stripe webhook signature

v1 = hex(HMAC-SHA256(signing secret, t + "." + raw body))

Stripe’s v1 signature is the HMAC-SHA256 of the t= timestamp, a dot and the raw request body, keyed with the endpoint’s signing secret and written as hex. Both travel in one header, Stripe-Signature: t=1792036800,v1=c5cdf494…. To verify, recompute it, compare in constant time, and reject a timestamp older than your tolerance. Stripe’s libraries default to five minutes.

const crypto = require('node:crypto');

const secret = 'whsec_quackyard_demo_secret';
const body = '{"id":"evt_test_1","object":"event","type":"invoice.paid"}';
crypto.createHmac('sha256', secret).update(`1792036800.${body}`).digest('hex');
// 'c5cdf49421bd98413dda875366e39931c1106ce3b64eb4c5da6327151ee7b0b4'

The tool below signs the same 1792036800.{"id":…} and shows the same value. The key is the whole secret as text, whsec_ included: without the prefix the HMAC comes out accd4205….

With Stripe’s library

constructEvent checks the signature and the age together, and throws on either. It needs the body as Stripe sent it, so give the route express.raw and register express.json() after it:

const express = require('express');
const Stripe = require('stripe');

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, req.get('Stripe-Signature'), process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
  res.json({ received: event.type });
});

app.use(express.json());

What goes wrong, with stripe 23 for Node:

  • app.use(express.json()) above the route parses the body first, and constructEvent throws Webhook payload must be provided as a string or a Buffer […] Payload was provided as a parsed JavaScript object instead.
  • Passing JSON.stringify(req.body) signs new bytes. Stripe’s docs show event bodies spread over lines with indentation, which JSON.stringify drops, and an indented body fails with No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?
  • Replaying this page’s example today: Timestamp outside the tolerance zone. Its t is fixed, so the five minutes are long gone.

In Python (stripe 16), the call is stripe.Webhook.construct_event. Flask’s raw body is request.get_data(), FastAPI’s is await request.body():

import os

import stripe
from flask import Flask, abort, request

app = Flask(__name__)

@app.post("/webhooks/stripe")
def stripe_webhook():
    try:
        event = stripe.Webhook.construct_event(
            request.get_data(), request.headers.get("Stripe-Signature"), os.environ["STRIPE_WEBHOOK_SECRET"]
        )
    except (ValueError, stripe.SignatureVerificationError):
        abort(400)
    return {"received": event["type"]}

Without the library

Split the header on , and each item on =. Keep t and every v1: while a rolled secret is still active, Stripe signs once per secret. Ignore v0 and any other scheme, as Stripe’s docs say, so nobody can downgrade you to one. This version rejects a timestamp more than five minutes from your clock either way; the libraries only reject old ones.

const crypto = require('node:crypto');

function verifyStripe(secret, rawBody, header, tolerance = 300) {
  const pairs = (header ?? '').split(',').map((item) => item.split('='));
  const t = pairs.find(([key]) => key === 't')?.[1] ?? '';
  const signatures = pairs.filter(([key]) => key === 'v1').map(([, value]) => Buffer.from(value ?? ''));
  if (!/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;
  const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'));
  // timingSafeEqual throws on different lengths, so check them first.
  return signatures.some((got) => got.length === expected.length && crypto.timingSafeEqual(got, expected));
}
import hashlib
import hmac
import time

def verify_stripe(secret: str, raw_body: bytes, header: str | None, tolerance: int = 300) -> bool:
    pairs = [item.partition("=")[::2] for item in (header or "").split(",")]
    timestamp = next((value for key, value in pairs if key == "t"), "")
    signatures = [value.encode() for key, value in pairs if key == "v1"]
    if not timestamp.isdecimal() or abs(time.time() - int(timestamp)) > tolerance:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode()
    return any(hmac.compare_digest(expected, signature) for signature in signatures)

hmac.compare_digest handles different lengths on its own. Use isdecimal() rather than isdigit(): "²".isdigit() is True, and int("²") raises.

The timestamp

The timestamp is inside the signed payload, so changing it breaks the signature, and an old one is a replay. Keep the server’s clock on NTP. Don’t set the tolerance to 0: Stripe’s docs warn that it turns the check off. A retry isn’t rejected as stale: Stripe gives each delivery attempt a new timestamp and signature.

Try it in the HMAC Generator

Signed as the UTF-8 bytes of what's pasted, spaces included, with every line break written as the one chosen. This box keeps line breaks as LF whatever was pasted, so pick CRLF for a body sent with CRLF line endings.

The key never leaves your browser: the signature is worked out here in the page, and nothing is sent anywhere.

Algorithm
Written as
Signature · SHA-256

c5cdf49421bd98413dda875366e39931c1106ce3b64eb4c5da6327151ee7b0b4

69 bytes of message, 27 of key.

Matches — this message, signed with this key.

How webhook signatures are checked

Both services below sign exactly the way this tool does, so the value after the prefix is an HMAC you can check here.

ServiceWhat it sendsWhat the signature is made of
GitHubX-Hub-Signature-256: sha256=<hex>HMAC-SHA256 of the raw request body, keyed with the webhook secret.
StripeStripe-Signature: t=<time>,v1=<hex>HMAC-SHA256 of <time>.<raw body>, keyed with the signing secret, whsec_… and all.

GitHub: paste the raw body as the message, the secret as the key, and what follows sha256= into Check a signature. Stripe: sign t.body — the timestamp from the header, a dot, then the raw body — with the whole signing secret as the key, and compare against v1=.

Runs in your browser. Nothing you enter is sent anywhere.