Overview
Treat every inbound webhook as untrusted until its HMAC signature verifies; a public endpoint that acts on unsigned payloads is a remote command interface for anyone who finds the URL. This page is the house standard for receiving webhooks from any provider. Stripe’s header, SDK helpers, and delivery quirks are in payments-stripe.
Verify the HMAC over the raw request body
Compute the HMAC over the exact bytes received, before any parsing. Providers sign the raw payload, so re-serializing JSON changes whitespace, key order, or Unicode and breaks verification. Capture the raw body before middleware deserializes it, compare with a constant-time function, and reject on mismatch. A normal string comparison leaks the correct signature byte by byte.
Providers differ in what they sign: Stripe signs timestamp.body and sends a hex digest in Stripe-Signature; Standard Webhooks signs id.timestamp.body and sends base64 with a v1, prefix, space-separated for multiple secrets; GitHub sends X-Hub-Signature-256 over the body alone. Follow the provider’s scheme; this example implements Standard Webhooks.
import base64, hashlib, hmac, time
def verify(raw_body: bytes, msg_id: str, timestamp: str, signature_header: str,
secret: str, tolerance: int = 300) -> bool:
if abs(time.time() - int(timestamp)) > tolerance: # replay window
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
hmac.compare_digest(expected, sig.split(",", 1)[1]) # constant time; never ==
for sig in signature_header.split() if sig.startswith("v1,")
)In Node, use crypto.timingSafeEqual over equal-length buffers. See python-security for the broader rules.
Enforce a timestamp tolerance window
Reject deliveries whose signed timestamp is outside the tolerance. Stripe’s libraries default to 5 minutes, and Standard Webhooks requires a check of webhook-timestamp against an acceptable window. The timestamp is inside the signed payload, so an attacker cannot change it and a captured request expires. A tolerance of 0 disables the check in Stripe’s libraries. Keep server clocks on NTP.
Dedupe by event id and make handlers safe to re-run
Providers deliver at least once, so the same event arrives again after retries or manual redelivery. Store each processed ID (webhook-id, X-GitHub-Delivery, or the Stripe event id) under a unique constraint or in Redis and ignore repeats; a GitHub redelivery keeps the original delivery ID, which makes this work. Record the ID in the same transaction as the side effect so a crash between the two cannot double-charge or double-send. Prefer upserts and conditional writes to blind inserts, and do not depend on delivery order; fetch current state from the provider’s API when order matters.
Return 2xx fast and process asynchronously
Verify the signature synchronously, enqueue the validated payload, and return a 2xx. Providers time out quickly (GitHub expects a 2xx within 10 seconds), and a slow handler causes retries, duplicates, and eventually a disabled endpoint. Return 5xx only for transient failures you want retried, and 2xx for events you accepted but cannot act on. GitHub does not redeliver failed deliveries automatically, so redeliver missed ones after an outage. Queue patterns are in fastapi-background-tasks.
Support two signing secrets during rotation
Keep two active secrets during a rollover and accept a payload that verifies against either; Standard Webhooks puts one signature per secret in the header. Verify against the new secret first, then the old, and retire the old one once deliveries stop matching it. Store secrets in a secret manager (secrets-and-env) and log verification failures to error-tracking to catch a botched rotation. Where a provider publishes source IP ranges (GitHub’s GET /meta), allowlist them in addition to verifying signatures, not instead.
Related
- payments-stripe: Stripe-specific signature header and SDK verification.
- secrets-and-env: storing and rotating signing secrets.
- auth-sessions: authenticating the rest of your API surface.
- fastapi: framework baseline for the receiving route.
- error-tracking: alerting on verification and processing failures.
- python-security: constant-time compares and input handling.