Webhooks are the simplest form of event-driven integration: the service you are integrating with makes an HTTP POST to your server when something happens. No polling loop, no message broker in the critical path, no SDK required on the receiving end. Yet most webhook integrations in production have at least one subtle failure mode — missed events, duplicate processing, or unverified payloads. This is how to get them right.
What changed in 2026
- Webhook signatures are now universal. Stripe, GitHub, Shopify, Svix, and virtually every serious provider sign every payload. The era of accepting raw webhook bodies without verification is over.
- Svix and Hookdeck emerged as infrastructure layers for teams building outbound webhooks — they handle delivery, retries, and the management UI so you do not build it from scratch.
- OpenAPI's
webhooks object (introduced in 3.1) became widely adopted — webhook contracts are now documented alongside REST endpoints in the same spec file.
- Delivery guarantees converged on at-least-once. No mainstream provider guarantees exactly-once; idempotency on the receiver is the expected pattern.
How webhook delivery works
1. Event occurs in sender (e.g., payment completed)
2. Sender queues a delivery job
3. Delivery job POSTs JSON to your registered endpoint
4. Your endpoint returns HTTP 200-299
5. If no 200 within timeout (~5s), sender marks delivery failed
6. Sender retries with exponential backoff (e.g., 1m, 5m, 30m, 2h, 12h)
The retry window and max attempts vary by provider — Stripe retries for 3 days, GitHub retries for ~72 hours. Design for this.
Signature verification
Every provider gives you a secret when you register the endpoint. They compute HMAC-SHA256(secret, payload) and send the result in a header (e.g., Stripe-Signature, X-Hub-Signature-256).
import hmac, hashlib
def verify_stripe_signature(
payload: bytes,
header: str,
secret: str,
tolerance_seconds: int = 300,
) -> bool:
import time
parts = {k: v for k, v in (p.split("=", 1) for p in header.split(","))}
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > tolerance_seconds:
return False # replay attack protection
signed = f"{timestamp}.{payload.decode()}"
expected = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
received = parts.get("v1", "")
return hmac.compare_digest(expected, received)
Key points:
- Use
hmac.compare_digest to prevent timing attacks.
- Check the timestamp to prevent replay attacks (reject events older than 5 minutes).
- Read the raw body bytes before any JSON parsing — frameworks that parse the body first can invalidate the signature.
Respond fast, process async
# FastAPI example — acknowledge immediately, enqueue for processing
from fastapi import FastAPI, Request, BackgroundTasks
app = FastAPI()
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request, background_tasks: BackgroundTasks):
payload = await request.body()
sig = request.headers.get("Stripe-Signature", "")
if not verify_stripe_signature(payload, sig, STRIPE_WEBHOOK_SECRET):
return {"error": "invalid signature"}, 400
event = json.loads(payload)
background_tasks.add_task(process_event, event) # async, not blocking
return {"received": True} # return 200 immediately
Target under 500ms for the HTTP response. Any work beyond signature verification and enqueueing belongs in a background worker.
Idempotency — handling duplicates
Senders retry on network failures, even if your server processed the event. You will receive the same event ID twice. Make handlers idempotent:
def process_payment_event(event: dict):
event_id = event["id"] # e.g., "evt_1ABC..."
if db.webhook_events.exists(event_id):
return # already processed, skip
with db.transaction():
db.webhook_events.insert(event_id, processed_at=now())
# ... business logic
Use the event ID as a deduplication key in a webhook_events table. The insert inside the transaction ensures exactly-once processing even under concurrent retries.
Delivery guarantees comparison
| Guarantee |
What it means |
Who provides it |
Your responsibility |
| At-least-once |
Delivered one or more times |
Stripe, GitHub, most SaaS |
Idempotent handlers |
| At-most-once |
Delivered zero or one time |
Rare; fire-and-forget |
Accept possible loss |
| Exactly-once |
Delivered exactly once |
Nobody via HTTP alone |
Idempotency + dedup |
How to pick your processing architecture
- Low volume (<10/sec), low stakes? Process inline after enqueueing with a simple task queue (Celery, BullMQ, Sidekiq).
- High volume or critical events? Write to a durable queue (SQS, Kafka) first, then process. The HTTP handler only enqueues.
- Building outbound webhooks for your own platform? Use Svix or Hookdeck — do not build the retry/delivery infra yourself.
- Debugging failed deliveries? Log every raw payload with its event ID before processing so you can replay.
Common mistakes
Not verifying signatures. An unverified webhook endpoint accepts arbitrary POST requests from anyone who finds the URL.
Parsing the body before verification. Framework middleware that parses JSON before your handler runs modifies the byte stream; the HMAC will not match. Read raw bytes first.
Blocking on slow processing. If your handler calls a slow third-party API or does heavy DB work synchronously, the sender times out and retries — creating duplicate events you now must handle.
No deduplication. Most teams discover this after a retry storm or a provider bug causes duplicate deliveries.
Hardcoded endpoint URLs in test environments. Use ngrok, Cloudflare Tunnel, or a local proxy; do not expose your dev machine directly.
What to skip
- Polling as a fallback for missed webhooks in critical integrations — use the provider's event log API (e.g., Stripe's Events API) for reconciliation instead.
- Processing all event types in one handler — route by
event.type early and handle unknown types gracefully rather than erroring.
- Shared webhook secrets across environments — issue separate secrets for dev, staging, and production.
FAQ
What if my server is down during a webhook?
Providers retry for hours to days. After recovery, they catch up automatically. For events older than the retry window, use the provider's event log to backfill.
How do I test webhooks locally?
Use ngrok http 8000 or the Stripe CLI (stripe listen --forward-to localhost:8000). Both tunnel public HTTPS requests to your local server.
Should I store all webhook payloads?
Yes, at least for a rolling window (7–30 days). Raw payload logs are invaluable for debugging and replaying after handler bugs.
What is the difference between a webhook and a WebSocket?
A webhook is a one-way HTTP callback triggered by an event — stateless, fire-and-forget from the sender's perspective. A WebSocket is a persistent bidirectional connection maintained by both parties.
Where to go next