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.
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.
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.
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_event | Manual HMAC | |
|---|---|---|
| Tolerance check | Built in (300s default) | You write it |
| Multiple v1 signatures | Handled | You handle it |
| Returns | Typed Event object | A boolean |
| Dependency | stripe package | Standard library only |
| Best for | Production Stripe endpoints | Learning, 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.
- 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
v1values. 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.
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.
Related
Same track:
- Processing Webhooks with Python — the receive-verify-enqueue shape this verification slots into.
- Handling GitHub Webhooks with FastAPI — the same HMAC pattern for a provider with no Python SDK.
- Building an Idempotent Webhook Receiver — dedupe verified events so retries don't double-charge.
Other tracks:
- How to Charge for API Access Using Stripe — turn verified events into billed usage.
- Testing Stripe Integrations with Test Clocks — simulate time to test subscription webhooks before launch.