What Are Webhooks?
A webhook is an HTTP callback — your server makes a POST request to a URL registered by the consumer whenever a specified event occurs. Instead of the consumer polling your API repeatedly, your system pushes notifications the moment something happens.
Common use cases: payment confirmations (Stripe), push events (GitHub), form submissions (Typeform), and deployment notifications (Vercel).
Webhook vs Polling
| Polling | Webhooks | |
|---|---|---|
| **Latency** | Up to poll interval | Near real-time |
| **Server load** | High (many idle requests) | Low (event-driven) |
| **Client complexity** | Simple | Requires public endpoint |
| **Reliability** | Client controls retries | Provider must retry |
Use webhooks when low latency matters and the consumer can expose a public HTTPS endpoint. Fall back to polling for environments behind firewalls.
Designing Webhook Payloads
A good webhook payload is self-describing and stable:
{
"id": "evt_01HX4K9Z2M",
"type": "order.completed",
"created_at": "2024-03-15T14:22:31Z",
"api_version": "2024-01-01",
"data": {
"object": "order",
"id": "ord_9182",
"amount": 4999,
"currency": "usd",
"status": "completed"
}
}
Key design decisions:
- Include a unique event ID — enables deduplication on the consumer side
- Include
created_at— consumers can detect and discard out-of-order delivery - Include
type— consumers route to the right handler without parsing the body - Embed a snapshot of the object — consumers do not need to call back to fetch current state (thin vs fat payloads debate; fat is more reliable)
- Include
api_version— lets consumers handle schema evolution
Delivery Guarantees
Most webhook systems guarantee at-least-once delivery: events will be delivered at least once, but may be delivered more than once due to retries. Exactly-once delivery is extremely hard to guarantee across distributed systems.
Your consumer must be idempotent — processing the same event twice must produce the same result. Use the event id as an idempotency key, store processed IDs in a database, and skip duplicates.
Signature Verification (HMAC)
Never trust a webhook payload without verifying it came from your provider. The standard approach is HMAC-SHA256:
Provider side — compute a signature and send it in a header:
import hashlib, hmac
def sign_payload(payload: bytes, secret: str) -> str:
sig = hmac.new(secret.encode(), payload, hashlib.sha256)
return 'sha256=' + sig.hexdigest()
POST /webhooks HTTP/1.1
X-Signature-256: sha256=abc123...
Consumer side — verify the signature before processing:
import hashlib, hmac
def verify_signature(payload: bytes, header: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header) # constant-time compare
Always use hmac.compare_digest (or equivalent), never ==, to prevent timing attacks.
Retry and Failure Handling
When the consumer returns a non-2xx status code (or times out), the provider should retry with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 6 hours |
After exhausting retries, move the event to a dead-letter queue for manual inspection. Alert the consumer via email or dashboard.
Consumer best practice: respond with 200 immediately, then process asynchronously. A 30-second processing timeout that causes a retry is a common source of duplicate processing.
Testing Webhooks
During development, use a tunnel to expose your local server:
# ngrok
ngrok http 8000
# → https://abc123.ngrok.io → http://localhost:8000
# Stripe CLI (for Stripe webhooks)
stripe listen --forward-to localhost:8000/webhooks/stripe
For testing retry logic, return 500 deliberately and observe the retry schedule. Log all incoming webhook payloads to a persistent store during development — it is much easier to replay stored payloads than to re-trigger real events.