Processing Inbound Webhooks with Python and FastAPI
Webhooks are how the rest of the internet tells your automation that something happened — a payment cleared, a repo got pushed, an order shipped. Get them wrong and you either drop events silently or process the same one three times, and both failures cost real money: a missed order.created never syncs, a double-processed invoice.paid double-charges. This guide builds a production-grade FastAPI receiver that verifies signatures, acknowledges fast, and hands real work to a background worker. It's part of the Automating Side-Hustle Operations with APIs guide, it pairs naturally with Building Zapier Alternatives with Python — most homegrown automations are just a webhook endpoint plus a queue — and if you are still deciding whether to receive webhooks at all, read When to Use Webhooks Instead of Polling first.
The trap most builders fall into is treating a webhook like any other POST: parse the JSON, do the work inline, return. That design fails the moment a provider re-sends an event (they all do) or your handler takes longer than the provider's timeout (usually 5–30 seconds). The reliable pattern is narrow and boring: verify the signature on the raw bytes, store the event id, return 200 immediately, and process asynchronously. Everything below is that pattern in code, plus the failure modes that only show up once real traffic hits the endpoint.
Read that diagram left to right and notice where the boundary sits: the endpoint's only job is the leftmost three boxes — verify, dedupe, acknowledge. The instant it pushes the raw payload onto Redis it is done. Every slow, flaky, or expensive operation lives to the right of the queue, in a worker you can restart, scale, or pause without the provider ever knowing. That split is the entire design. If you internalize nothing else from this guide, internalize the vertical line between "Endpoint" and "Queue."
Why fast acknowledgement is a business constraint, not a nicety
Providers do not wait politely. Stripe gives your endpoint a hard budget in the tens of seconds before it records the delivery as failed; GitHub is tighter; smaller SaaS vendors are often stricter still and undocumented. When you exceed that budget, three things happen at once, and none of them are good. The provider marks the delivery failed even though your code may have succeeded. It schedules a retry, which adds load precisely when you are already slow. And after enough consecutive failures, many providers automatically disable the endpoint entirely, so you stop receiving events until a human logs into a dashboard and re-enables it. A slow handler doesn't just delay one event — it can silence your whole integration.
This is why the acknowledgement is a contract, not a courtesy. The moment you return 200, you are promising the provider "I have durably taken responsibility for this event." You are not promising you have processed it. That distinction is what lets you push the payload to a queue and answer in single-digit milliseconds. It also means the queue must be durable: if you return 200 and then lose the event because it only lived in a Python variable when the process restarted, you have lied to the provider and there is no retry coming, because from their side the delivery succeeded. Redis with append-only persistence, or a real broker, is the price of making that promise honestly.
Prerequisites
You'll need Python 3.11+ and the following. Install with pip install fastapi "uvicorn[standard]" redis. hmac, hashlib, and json are standard library.
- FastAPI + Uvicorn for the HTTP endpoint. If you're new to it, start with Setting Up FastAPI and pick worker counts using Uvicorn vs Gunicorn worker configuration.
- A queue — Redis here, because it doubles as the dedupe store and you may already run it for caching API responses. For heavier pipelines with retries, scheduling, and result tracking, graduate to Running Background Jobs with Celery — and weigh the alternatives in Celery vs RQ vs arq.
- Signing secrets in environment variables. Never hardcode them. Read more on the pattern in Handling API Authentication in Python.
Set your config in the environment before running anything:
export WEBHOOK_SIGNING_SECRET="whsec_replace_me"
export REDIS_URL="redis://localhost:6379/0"
export WEBHOOK_DEDUPE_TTL="86400"
Step 1 — Read the raw body before anything else
The single most common webhook bug is letting FastAPI parse the JSON before you verify the signature. HMAC is computed over the exact bytes the provider sent. If you re-serialize a parsed dict, key ordering, unicode escaping, and whitespace all change, and the signature will never match. This is why you must never declare a Pydantic model as the endpoint's body parameter for the verification step — doing so tells FastAPI to consume, parse, and re-encode the stream before your code runs. Always read await request.body() first, verify those bytes, and only then call json.loads on the same bytes you verified.
import os
import hmac
import hashlib
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SIGNING_SECRET = os.getenv("WEBHOOK_SIGNING_SECRET", "")
def verify_signature(raw_body: bytes, signature: str) -> bool:
"""Constant-time compare of an HMAC-SHA256 hex digest."""
if not SIGNING_SECRET:
raise RuntimeError("WEBHOOK_SIGNING_SECRET is not set")
expected = hmac.new(
SIGNING_SECRET.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
hmac.compare_digest is non-negotiable — a naive == short-circuits on the first differing byte, and that timing difference is measurable across enough requests, letting a patient attacker recover the expected digest one byte at a time. The compare-digest routine runs in time that does not depend on where the mismatch is, which closes that channel. One more subtlety worth budgeting for: providers disagree on how they encode the signature header. Stripe sends a timestamp and a v1= hex digest joined with commas; GitHub sends sha256= prefixed hex; others send raw base64. Strip the scheme prefix before comparing, and if you receive multiple candidate signatures in one header (during a secret rotation the provider may sign with both the old and new secret), accept the request if any candidate verifies. That is exactly the mechanism that lets you rotate signing keys without downtime.
Step 2 — Build the endpoint: verify, dedupe, ack fast
The endpoint does four things and nothing else: grab raw bytes, verify, dedupe by event id, enqueue. It returns 200 in single-digit milliseconds because it never touches your business logic.
import json
import redis.asyncio as redis
r = redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0"))
DEDUPE_TTL = int(os.getenv("WEBHOOK_DEDUPE_TTL", "86400"))
@app.post("/webhooks/inbound")
async def receive_webhook(request: Request):
raw = await request.body()
signature = request.headers.get("X-Signature-256", "")
if not verify_signature(raw, signature):
raise HTTPException(status_code=401, detail="bad signature")
event = json.loads(raw)
event_id = event.get("id")
if not event_id:
raise HTTPException(status_code=400, detail="missing event id")
# SET NX returns False if the key already exists -> duplicate delivery.
is_new = await r.set(f"webhook:seen:{event_id}", "1", nx=True, ex=DEDUPE_TTL)
if not is_new:
return {"status": "duplicate", "id": event_id}
await r.lpush("webhook:queue", raw)
return {"status": "accepted", "id": event_id}
The SET ... NX EX is an atomic "claim this id if nobody else has." It's your idempotency guard and it survives concurrent deliveries because Redis serializes the operation on a single thread — two simultaneous copies of the same event race for the key, exactly one wins, and the loser gets duplicate. There is one honest weakness in the naive version above: if the process crashes in the microseconds between claiming the key and pushing to the queue, the event is marked seen but never enqueued, and the retry will be deduped away. In practice that window is vanishingly small, but if you are moving money you should close it by enqueuing first and claiming inside the worker, or by using a Redis transaction that does both atomically. The design trade-off — claim-then-enqueue versus enqueue-then-claim — is worked through in full in Building an Idempotent Webhook Receiver.
Step 3 — Drain the queue in a background worker
The worker is a separate process so a slow downstream API can never delay an acknowledgement. It pops jobs off the list and runs the real automation — updating a CRM, syncing a spreadsheet, kicking off the kind of cross-platform fan-out described in Connecting CRM & Email APIs.
import asyncio
import json
import logging
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("webhook-worker")
async def handle_event(event: dict) -> None:
match event.get("type"):
case "order.created":
log.info("syncing order %s", event["id"])
# await sync_order_to_sheet(event)
case "customer.updated":
log.info("updating CRM record %s", event["id"])
case _:
log.info("ignoring %s", event.get("type"))
async def worker() -> None:
while True:
_, raw = await r.brpop("webhook:queue")
try:
await handle_event(json.loads(raw))
except Exception:
log.exception("processing failed; sending to dead-letter")
await r.lpush("webhook:dead", raw)
if __name__ == "__main__":
asyncio.run(worker())
A failed job lands on a dead-letter list instead of vanishing. You can inspect webhook:dead and replay it once the downstream bug is fixed. Two production refinements belong here as your volume grows. First, cap retries rather than dead-lettering on the first exception — a 502 from a downstream API is usually transient, so retry the same job a bounded number of times with backoff before giving up. The tenacity library wraps handle_event in a decorator that does exactly this without cluttering the loop. Second, when downstream calls go over the network, use httpx inside the worker, never the blocking requests library — a synchronous call inside an async worker blocks the whole event loop and quietly serializes every other job behind it.
Step 4 — Handle provider retries and replays
Providers retry on any non-2xx and on timeouts, usually with exponential backoff that can stretch across hours or days. Because Step 2 deduplicates on event id, those retries are harmless — the second delivery returns duplicate and the worker never sees it twice. The only thing you must guarantee is that you return 200 before doing slow work, which Step 2 already does. The timeline below is a typical backoff schedule: your endpoint is briefly down during a deploy, the provider keeps retrying with widening gaps, and the first 2xx it receives ends the sequence.
For deliberate replays — you re-send an old event to test or backfill, or an attacker captures a valid request off the wire — keep a short timestamp check so a genuine payload can't be resent weeks later and re-executed:
import time
REPLAY_TOLERANCE = 300 # seconds
def within_tolerance(event_ts: int) -> bool:
return abs(time.time() - event_ts) <= REPLAY_TOLERANCE
Call within_tolerance(event["created"]) right after signature verification and reject anything outside the window with a 400. The signature proves the payload is authentic; the timestamp proves it is fresh. You need both, because a signature alone never expires. Note that this reads the timestamp from inside the signed body (or a signed header), never from an unsigned header an attacker could edit — a freshness check on attacker-controlled data is theater.
The event lifecycle: where each delivery can end
It helps to hold the whole state machine in your head. Every inbound request travels one of a few short paths and terminates in exactly one place. Verification failures die immediately with a 401 and never touch Redis. Duplicates die at the claim step with a fast 200 so the provider stops retrying. Only genuinely new, authentic events reach the queue, and from there they either finish cleanly or land in the dead-letter list for a human. Mapping these terminal states explicitly is what stops a subtle class of bug where an event silently disappears with no 200, no error, and no dead-letter entry.
The commercial payoff of drawing this out is that each terminal state maps to a metric you should be graphing. A rising 401 rate means a secret is misconfigured or someone is probing you. A rising duplicate rate is usually benign but a sudden spike can mean your 2xx is slow enough that providers are retrying aggressively. And any growth in the dead-letter list is direct evidence of a downstream bug that is silently costing you fulfilled orders or synced records. Provider-specific quirks — Stripe's signed timestamp scheme, GitHub's delivery id header — layer on top of this same skeleton, covered in Verifying Stripe Webhook Signatures and Handling GitHub Webhooks with FastAPI.
Scaling the receiver and seeing inside it
The receiver scales almost embarrassingly well because it does so little. A single Uvicorn worker verifying an HMAC and doing one Redis round-trip comfortably handles a few thousand requests per second, and because the work per request is bounded and uniform, latency stays flat as volume climbs — there is no slow query or external call hiding in the hot path to blow out your p99. When you do need more headroom, add Uvicorn workers up to your vCPU count, not beyond; webhook receiving is CPU-light and network-bound, so oversubscribing workers buys you nothing but context-switch overhead. The worker side scales independently: if fulfillment falls behind, run more worker processes draining the same Redis list, and because BRPOP hands each job to exactly one consumer, they parallelize without you writing any coordination code.
What you cannot skip is observability. A webhook endpoint fails silently by nature — the provider sees a 200, you see nothing, and the missing side effect only surfaces when a customer complains. Emit a structured log line at every terminal state (accepted, duplicate, rejected, dead-lettered) with the event id and type as fields, then alert on the ratios. Ship those logs the way Monitoring and Logging Python APIs lays out, and prefer machine-parseable output using structured logging with structlog so "how many deliveries did we reject in the last hour" is a query, not a grep. The single most valuable dashboard panel is queue depth: if LLEN webhook:queue is climbing, your workers are losing the race against inbound volume and you need capacity before the backlog turns into stale data.
Configuration reference
| Env var | Default | Production recommendation |
|---|---|---|
WEBHOOK_SIGNING_SECRET | (none — required) | Unique per provider; rotate quarterly; store in a secret manager |
REDIS_URL | redis://localhost:6379/0 | Managed Redis with TLS, a password, and append-only persistence |
WEBHOOK_DEDUPE_TTL | 86400 (24h) | Match or exceed the provider's max retry window |
REPLAY_TOLERANCE | 300 (5 min) | 300s is the common default; tighten if clocks are reliable |
WEBHOOK_QUEUE_KEY | webhook:queue | Namespace per environment (prod:webhook:queue) |
The one setting builders get wrong most often is WEBHOOK_DEDUPE_TTL. It is tempting to keep it short to save memory, but the value has a correctness meaning: it is the window during which you promise to recognize a repeat. Set it below the provider's maximum retry span and a late retry — the kind that arrives after a multi-hour outage on their side — sails past your dedupe check and gets processed a second time. Memory is cheap; a seen key is a few dozen bytes, so a million events a day at a 24-hour TTL is single-digit megabytes. Err long.
Gotchas & failure modes
- Parsing the body before verifying. FastAPI's automatic Pydantic parsing consumes and re-encodes the body. Read
await request.body()yourself and verify the bytes, then parse. - Slow inline handlers. If your handler calls three external APIs before returning, you'll blow the provider's timeout, get a non-2xx logged on their side, and trigger a retry storm — or an auto-disabled endpoint. Always enqueue and return.
- No idempotency. Without the dedupe step, every provider retry re-runs your logic — double-charged customers, double-posted content, duplicated CRM rows. Dedupe on the provider's event id, not on your own request hash.
- Replay attacks. A captured-but-valid payload can be resent forever if you only check the signature. Pair signature verification with a timestamp tolerance read from the signed body.
- Returning 200 on a bad signature. Some teams swallow errors to "stop the retries." Don't — a
401on an unverifiable request is correct, and a verified request should never fail at the ack stage. - Losing events on restart. An in-memory queue drops everything the moment the process dies, after you already promised the provider a
200. Use a durable store with persistence enabled, and treat "acknowledged" as "durably enqueued," never "held in a variable."
Verification
Compute a signature locally and POST to the running endpoint. This mirrors exactly what a provider does:
BODY='{"id":"evt_123","type":"order.created","created":1750000000}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SIGNING_SECRET" | awk '{print $2}')
curl -s -X POST http://localhost:8000/webhooks/inbound \
-H "Content-Type: application/json" \
-H "X-Signature-256: $SIG" \
-d "$BODY"
# {"status":"accepted","id":"evt_123"}
# Send the same request again -> deduped:
curl -s -X POST http://localhost:8000/webhooks/inbound \
-H "X-Signature-256: $SIG" -d "$BODY"
# {"status":"duplicate","id":"evt_123"}
The worker log should show syncing order evt_123 exactly once. Send a request with a tampered body and you'll get 401 bad signature. Before you ship, promote this from a manual curl into an automated test — a small pytest that posts a correctly signed payload, a duplicate, and a tampered one, asserting accepted, duplicate, and 401 respectively. The patterns for that live in Testing Python APIs with pytest, and it is the cheapest insurance you will ever buy against a signature-handling regression that silently rejects real revenue events.
Cost & performance note
A verify-and-enqueue endpoint does microseconds of CPU work plus one Redis round-trip, so a single $5–$7 VPS handles thousands of webhooks per second and a month of realistic side-hustle volume — say a million events — costs a rounding error in compute. Concretely: at a million events a month you are looking at two to four dollars of compute if you keep the worker count matched to vCPUs, plus a few megabytes of Redis for dedupe keys. Your real cost lives in the worker, where the outbound API calls happen — meter those the way the parent automation guide describes, because a single fulfillment step that calls three paid APIs dwarfs the entire cost of receiving. Returning 200 in under 50ms also keeps you well inside every provider's timeout, which eliminates the retry traffic that would otherwise multiply your worker load two- or three-fold during a slow patch. And if you later monetize the automation, the same fast-ack pattern feeds straight into billing events — see how to wire that up in Integrating Stripe with Python APIs.
FAQ
How much does this cost to run at a million webhooks a month? Two to four dollars of compute if you keep the worker count matched to vCPUs, plus a few megabytes of Redis for dedupe keys — the receiver itself is nearly free. Your spend concentrates in the worker's outbound API calls, so the honest budgeting move is to price one fulfillment (how many paid API calls each event triggers) and multiply by volume, not to worry about the endpoint.
Why return 200 before processing instead of after? Providers enforce a delivery timeout in the tens of seconds. If your handler does real work inline and exceeds that, the provider records a failure, retries, and after enough failures may auto-disable your endpoint — even though your code actually succeeded. Acknowledging immediately and processing in a worker decouples your business logic from the provider's clock and protects the whole integration.
What should I dedupe on, and what happens if I rotate secrets?
Dedupe on the provider's own event id (Stripe's evt_..., GitHub's X-GitHub-Delivery) — it is stable across retries of the same event. Secret rotation is independent of dedupe: during a rotation, accept a request if it verifies against either the old or new secret, which lets you roll keys with zero dropped deliveries. Hashing the body works for dedupe too but breaks if the provider re-serializes between retries.
Do I really need a queue for a small side hustle? If your handler is genuinely instant — write one row and return — you can skip it and save yourself a moving part. The moment a handler makes an outbound API call, add the queue, because that is the call that will eventually be slow or flaky and blow your acknowledgement budget. Migrating from inline to queued later is cheap; recovering from a retry storm in production is not.
What happens to events while my worker is down, and is there migration risk? They stay in the durable Redis list until the worker returns and drains them, so the endpoint keeps accepting and acknowledging even when downstream processing is offline. The one migration risk to plan for is changing your event schema: an old payload sitting in the queue or dead-letter list may not match a new handler, so version your handlers or drain the queue before deploying a breaking change.
Related
Same track:
- Verifying Stripe Webhook Signatures — the provider-specific signing scheme, timestamps, and rolling secrets.
- Handling GitHub Webhooks with FastAPI — the same pattern applied to GitHub's delivery headers and events.
- Building an Idempotent Webhook Receiver — closing the claim-then-enqueue race for money-moving events.
- Building Zapier Alternatives with Python — where the webhook endpoint becomes the trigger for a whole automation graph.
Other tracks:
- Running Background Jobs with Celery — graduating the worker from a raw Redis loop to a real task system.
- Monitoring and Logging Python APIs — the dashboards and alerts that stop silent webhook failures.
- Integrating Stripe with Python APIs — turning inbound events into billing when you monetize the automation.