How to Verify a Stripe Webhook Signature (HMAC)

A Stripe webhook endpoint that trusts any incoming POST is an open door - anyone who knows the URL can fake a payment.succeeded event. Stripe signs every delivery with HMAC-SHA256; verifying that signature is the one line of defense between your upgrade flow and fraud.

Backend Engineerpythonfastapistripe

Why unsigned webhooks are dangerous

Stripe sends events to a public HTTPS URL you configure. Without signature verification, your endpoint has no way to tell a real Stripe delivery from a forged request. An attacker posts:

curl -X POST https://example.com/webhooks/stripe \
  -H 'Content-Type: application/json' \
  -d '{"type": "payment.succeeded", "id": "evt_fake"}'

If the handler acts on the body alone, the user gets upgraded without ever paying. Stripe signs every delivery to prevent exactly this.

The Stripe-Signature header format

Stripe sends a header of the form:

Stripe-Signature: t=1700000000,v1=<hex_hmac>

t is a Unix timestamp. v1 is HMAC-SHA256 of the string "{t}.{raw_body}" keyed with your webhook secret. There can be multiple v1= entries during secret rotation - a valid signature from any of them should be accepted.

Implementing verification with the Python stdlib

Use hmac, hashlib, and time - no Stripe SDK needed. This is exactly what the SDK does internally.

import os, time, hmac, hashlib, json
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
TOLERANCE = 300  # 5 minutes, same as Stripe's default

def _verify(body: bytes, header: str) -> None:
    t = None
    v1s = []
    for part in header.split(","):
        part = part.strip()
        if part.startswith("t="):
            t = int(part[2:])
        elif part.startswith("v1="):
            v1s.append(part[3:])

    if t is None or not v1s:
        raise HTTPException(400, "malformed Stripe-Signature")
    if abs(time.time() - t) > TOLERANCE:
        raise HTTPException(400, "timestamp outside tolerance window")

    # sign the raw bytes - NOT the parsed JSON
    signed_payload = f"{t}.{body.decode()}".encode()
    expected = hmac.new(SECRET.encode(), signed_payload, hashlib.sha256).hexdigest()

    if not any(hmac.compare_digest(expected, v1) for v1 in v1s):
        raise HTTPException(400, "signature mismatch")

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    body = await request.body()          # raw bytes before JSON parse
    _verify(body, request.headers.get("stripe-signature", ""))
    event = json.loads(body)
    # ... handle event
    return {"received": True}

The four rules that matter

Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.

Try it in a workspace →

What you'll practice

FAQ

How does Stripe webhook signature verification work?

Stripe computes HMAC-SHA256 over the string '{timestamp}.{raw_body}' using your webhook secret, then sends the result in the Stripe-Signature header as 't=<ts>,v1=<hex_hmac>'. Your server recomputes the same HMAC and compares in constant time.

Why must I use hmac.compare_digest instead of == to compare signatures?

String equality (==) short-circuits on the first mismatching character, which leaks timing information that an attacker can use to guess a valid signature byte by byte. hmac.compare_digest always runs in constant time regardless of where the strings differ.

What happens if I verify the JSON-parsed body instead of the raw bytes?

The HMAC will not match. Stripe signs the exact bytes it sent over the wire. Parsing the body into JSON and re-serializing it can alter whitespace and key ordering, producing different bytes and a different HMAC.

How do you verify a Stripe webhook signature?

Read the Stripe-Signature header, compute HMAC-SHA256 over the timestamp and the raw request body with your webhook signing secret, and compare it to the header signature in constant time. Reject the event if it does not match or the timestamp is stale.

Keep learning

Return the Correct HTTP Status CodeBackend projectRepair Broken API PaginationBackend projectAdd CORS Middleware to an Express APIBackend projectBackend roadmapStep by step to hiredBackend interview questionsSTAR answersAll Backend projectsProjects hub

Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.

Open the workspace →