Verifying Stripe Webhook Signatures in Python

Stripe signs every webhook with an HMAC over the raw payload and a timestamp, delivered in the Stripe-Signature header. If you don't verify it, anyone who learns your endpoint URL can POST a fake payment_intent.succeeded and trick your app into shipping product, unlocking a paid tier, or crediting an account for money that never moved. That is not theoretical: your webhook URL leaks into browser network tabs, proxy logs, and error trackers, and an unauthenticated write endpoint that mutates billing state is the most valuable thing an attacker can find on a side-hustle SaaS. This page resolves one narrow decision — how you prove a request really came from Stripe — and shows both ways to do it, plus when each is right. It's part of Processing Webhooks with Python, which covers the receive-verify-enqueue pattern you'll wrap this verification in, and it pairs with the reasons in When to Use Webhooks Instead of Polling.

The decision is short: use stripe.Webhook.construct_event in production, and understand the manual version so you know exactly what it's checking. Both verify the same claim — that the bytes you received were signed by your endpoint secret within a recent time window — but the SDK tracks scheme changes and rotation edge cases you shouldn't own.

How Stripe signs a webhook

Before you pick an implementation, get the mechanism straight, because every bug in this area comes from misunderstanding one of its three parts. When Stripe sends an event, it takes the exact raw bytes of the JSON body, prepends the current Unix timestamp and a literal dot, and computes HMAC-SHA256(secret, f"{t}.{body}"). The result and the timestamp travel together in the Stripe-Signature header. Your job is to recompute that same HMAC with the same endpoint secret and confirm it matches, in constant time, and that the timestamp is recent.

Three properties fall out of that design. The timestamp is inside the signed message, so an attacker can't rewind it without breaking the signature. Verification needs the byte-for-byte body — reserialize the JSON and you change whitespace and key order, and the HMAC no longer matches. And because the secret never leaves your server or Stripe's, a valid signature is proof of origin, not just integrity.

Stripe-Signature verification sequence Split the Stripe-Signature header into a timestamp and v1 signatures, build the signed payload as timestamp dot raw body, HMAC it with the endpoint secret, compare in constant time, and check the timestamp tolerance. Stripe-Signature t=…, v1=… signed_payload t + "." + raw body HMAC-SHA256 key = endpoint secret compare_digest vs each v1 check |now − t| ≤ tolerance

Option A — the Stripe SDK helper

stripe.Webhook.construct_event does the whole dance: it parses the header, recomputes the HMAC over the v1 scheme, runs a constant-time compare, enforces the timestamp tolerance, and hands back a typed Event. This is the right default for anyone actually integrating Stripe. Install with pip install stripe, load the secret from the environment, and read the raw request body — never a parsed model.

Python
import os
import stripe
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
ENDPOINT_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")


@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    raw = await request.body()
    sig_header = request.headers.get("Stripe-Signature", "")

    try:
        event = stripe.Webhook.construct_event(
            payload=raw,
            sig_header=sig_header,
            secret=ENDPOINT_SECRET,
        )
    except ValueError:
        raise HTTPException(status_code=400, detail="invalid payload")
    except stripe.SignatureVerificationError:
        raise HTTPException(status_code=401, detail="invalid signature")

    # Verified. Enqueue, don't process inline — see the webhooks guide.
    match event["type"]:
        case "payment_intent.succeeded":
            ...
        case "customer.subscription.deleted":
            ...
    return {"received": True}

Notice the two exceptions doing two distinct jobs. A ValueError means the body wasn't parseable as an event Stripe could have sent — that's a 400. A SignatureVerificationError means the payload looks like an event but the HMAC or timestamp failed — that's a 401. Collapsing both into one status code throws away the signal you'll want when a spike of rejected deliveries shows up in the dashboard. The SDK defaults its tolerance to 300 seconds; pass tolerance= only if you have a documented reason to.

Option B — manual HMAC verification

Here's the identical check with no SDK, so the v1 scheme is fully in view. The header is a comma-separated list of key=value pairs: a single t= timestamp, one or more v1= signatures, and a legacy v0= you ignore. You split it, rebuild signed_payload = f"{t}.".encode() + raw_body, HMAC that with your endpoint secret, and compare the hex digest against every v1 value in constant time.

Anatomy of the Stripe-Signature header The header value splits into a timestamp field, one or more v1 HMAC signatures, and a legacy v0 field that is ignored, each part annotated with its role in verification. Stripe-Signature: one line, three kinds of field t=1750000000 v1=5257a8…c1f9 v1=9b40e…7a02 v0=… Timestamp signed with the body; drives the tolerance check One or more v1 HMAC-SHA256 digests Multiple appear during secret rotation — match against ANY. A plain dict keeps only the last key, so scan every pair. Compare with hmac.compare_digest, never ==.
Python
import os
import hmac
import hashlib
import time

ENDPOINT_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
TOLERANCE = int(os.getenv("STRIPE_WEBHOOK_TOLERANCE", "300"))


def verify_stripe(raw_body: bytes, sig_header: str) -> bool:
    parts = [item.split("=", 1) for item in sig_header.split(",") if "=" in item]
    timestamp = next((v for k, v in parts if k == "t"), None)
    if not timestamp:
        return False

    # Reject events outside the tolerance window (replay protection).
    if abs(time.time() - int(timestamp)) > TOLERANCE:
        return False

    signed_payload = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(
        ENDPOINT_SECRET.encode(), signed_payload, hashlib.sha256
    ).hexdigest()

    # Stripe can include several v1 signatures during secret rotation.
    provided = [v for k, v in parts if k == "v1"]
    return any(hmac.compare_digest(expected, sig) for sig in provided)

Two details trip everyone up. The timestamp is part of the signed message, so f"{timestamp}." has to prefix the exact raw bytes — build it as bytes, not by string-concatenating a decoded body, or a non-UTF-8 payload corrupts the digest. And compare_digest is what makes the comparison timing-safe: a naive == short-circuits on the first differing byte, and the microscopic timing difference leaks the digest one byte at a time to a patient attacker. Note the snippet keeps a list of pairs rather than a dict, because a dict silently drops all but the last v1 — exactly the values you need during rotation. This same raw-body-plus-HMAC shape is what you use for providers with no Python SDK, which is why Handling GitHub Webhooks with FastAPI reads almost identically.

When to use the SDK vs manual

SDK construct_eventManual HMAC
Tolerance checkBuilt in (300s default)You write it
Multiple v1 signaturesHandledYou handle it
ReturnsTyped Event objectA boolean
Dependencystripe packageStandard library only
Best forProduction Stripe endpointsLearning, audits, thin deploys

Reach for the SDK whenever you're integrating Stripe for real. It tracks scheme changes, gives you a typed event to pattern-match on, and removes an entire class of subtle mistakes from code that touches money. Reach for manual verification in three narrow cases: you're verifying a provider that ships no Python SDK, you're auditing what a library actually does before trusting it, or you're squeezing a function into a serverless deploy where every dependency adds cold-start time and you've measured that the stripe import matters. Outside those, the manual path is a footgun you're choosing to hold.

Failure modes and edge cases

Signature verification fails loudly when you get it wrong — you find out in the Stripe dashboard's delivery log, not a customer complaint. The catch is that several distinct root causes all surface as the same red "failed" row, so knowing the taxonomy saves hours.

Verification outcome decision tree and the status code each branch returns An incoming webhook is checked for a parseable body, a present timestamp within tolerance, and a matching HMAC; each failing branch returns 400 or 401 and a match returns 200 after enqueueing. POST + raw body Body parses as event? 400 malformed t present, within tolerance? 401 stale / replay Any v1 digest matches? 401 bad signature 200 — enqueue, ack fast
  • Raw body, always. If FastAPI parses the JSON into a Pydantic model first, the re-serialized bytes won't match the signature. Read await request.body() and verify those exact bytes. This is the same rule the parent webhooks guide opens with, and it's the most common cause of "it worked in curl but fails from Stripe."
  • Timestamp tolerance is replay protection, not a nicety. Drop it and a captured valid request can be replayed indefinitely. 300 seconds is Stripe's default; keep it unless your servers have proven clock skew, in which case fix the clock with NTP rather than widening the window.
  • Multiple signatures during rotation. When you roll an endpoint secret, Stripe signs each event with both the old and new secret for a window, sending two v1 values. Match against any of them, not just the first — the rotation discipline mirrors Rotating API Keys Without Downtime.
  • Per-endpoint secrets. Each Stripe webhook endpoint has its own whsec_.... Point production at a test-mode secret, or reuse one endpoint's secret for another, and every event fails verification with no obvious cause — the signature is real, it just wasn't made with the key you're checking against.
  • Verification is not deduplication. A valid signature says the event is authentic, not that you haven't already processed it. Stripe retries, so route verified events into Building an Idempotent Webhook Receiver before you act on them.

Cost and performance

Verification is effectively free, and it's worth knowing the numbers so you never treat it as a bottleneck to optimise away. An HMAC-SHA256 over a typical 2 KB Stripe payload runs in the low tens of microseconds on any modern vCPU — call it 0.03 ms. That is one to two orders of magnitude cheaper than the JSON parse, and far cheaper than the single Postgres insert you do to record the event. The security check is the smallest line item in the whole request; dropping it buys you a rounding error in exchange for your billing integrity.

Relative time cost of each step in a verified webhook request A log-scaled bar chart showing HMAC verification at about 0.03 milliseconds, JSON parse near 0.2 milliseconds, a Postgres insert near 3 milliseconds, and a downstream API call near 120 milliseconds. Time per step in a verified webhook (log scale) HMAC verify ≈ 0.03 ms JSON parse ≈ 0.2 ms Postgres insert ≈ 3 ms Downstream API ≈ 120 ms Each bar is ~one order of magnitude — verify then enqueue, keep the slow call off the request path.

The performance rule that does matter is what happens after verification: acknowledge fast, then do the work off the request path. Stripe redelivers if it doesn't see a 2xx within its timeout, so verify, write the event row, return 200, and let a background worker handle the downstream API call that dominates the chart above. Testing this end to end — including simulating the passage of time for subscription events — is worth doing before launch, which is where Testing Stripe Integrations with Test Clocks earns its keep.

Builder verdict

Use stripe.Webhook.construct_event and move on. It's a single call that handles the v1 scheme, the tolerance window, and secret rotation, and it returns a typed event you can pattern-match on. Reimplementing the HMAC by hand is a great way to understand the mechanism and a poor way to run it in production — the surface area for a subtle mistake (forgetting the timestamp prefix, comparing with ==, dropping a v1, reserializing the body) is exactly where a payment-handling bug becomes expensive. Verify with the SDK, enqueue the verified event for a background worker so your endpoint acks fast, and dedupe before you act. When you're ready to turn these events into revenue, the billing flow lives in How to Charge for API Access Using Stripe.

FAQ

Where do I get the endpoint secret, and how much does a leak cost me? In the Stripe Dashboard under Developers → Webhooks, each endpoint shows a signing secret starting with whsec_. Load it from STRIPE_WEBHOOK_SECRET and never hardcode it. If it leaks, an attacker can forge events that pass verification, so treat it like a production credential: rotate it immediately, and because Stripe signs with both secrets during the overlap, you can rotate with zero dropped events. Test-mode and live-mode endpoints have separate secrets.

Why does the timestamp go into the signature instead of alongside it? So the signature is only valid for a short window. Stripe signs t.payload, and you reject anything where now − t exceeds your tolerance. Because the timestamp is inside the signed message, an attacker can't rewind it to make a captured request look fresh without invalidating the HMAC — that's what stops one legitimate request from being replayed later.

Can I verify without the stripe package to cut cold-start cost? Yes — the manual option uses only hmac, hashlib, and time from the standard library, which shaves the stripe import off a cold serverless function. But measure before you commit: on most platforms the import costs a few milliseconds you'll never notice, and hand-rolled verification is where the expensive bugs live. For a real Stripe integration the SDK is worth its weight; keep the manual path for SDK-less providers.

What status code should a bad signature return, and does it affect retries? Return 400 for a payload Stripe couldn't have sent (malformed) and 401 for an authentic-looking request that fails verification. Either way it's non-2xx, so Stripe records the failure and retries on its schedule — which is correct, because a transient bug on your side shouldn't silently drop a real event. Never return 200 on an unverified event; that tells Stripe to stop retrying something you never actually processed.

Does verifying the signature mean I can process the event once and trust it? No. Verification proves origin, not uniqueness. Stripe delivers at least once and retries on timeouts, so a valid, verified event will arrive more than once. Combine signature verification with an idempotency ledger keyed on the event id so a duplicate is a no-op rather than a double charge.

Same track:

Other tracks: