REST API 23 ๐ Webhooks and Event-Driven Notifications
A webhook is an HTTP callback: instead of a client repeatedly asking whether something has changed, the server sends a POST request to a URL the client registered in advance, at the moment the event occurs . This inversion of the request-response model is what makes webhooks the standard mechanism for real-time server-to-server notifications. A payment processor notifies a shop when a charge succeeds. A repository notifies a CI system when code is pushed. A document service notifies a portal when a status changes.
For the REST API series, webhooks introduce a different reliability model than synchronous endpoints. The producer does not know whether the consumer is available, how long it will take to process, or whether the same event will be delivered twice. The consumer must verify authenticity, tolerate duplicates, acknowledge quickly, and reconcile when deliveries are missed. This chapter covers the webhook contract, the handler sequence that makes receivers correct, the security model based on HMAC signatures, and the operational patternsโretries, idempotency, queue-first ingestionโthat separate a demo from a production integration.
Key point: A webhook handler must verify the signature before parsing the payload, parse the payload before acting on it, and act idempotently so that a duplicate delivery does not produce a duplicate side effect .
Why webhooks exist
The polling inefficiency problem. A client that needs to know when an order ships can ask the API every thirty seconds. Most of those requests return “no change.” Polling wastes bandwidth, consumes rate-limit budget, and introduces latency equal to the polling interval . Webhooks eliminate the wasted requests by pushing the notification at the moment the event occurs. The trade-off is that the consumer must maintain a publicly reachable endpoint and handle the failure modes that push introduces.
The real-time requirement problem. Some workflows cannot tolerate polling latency. A fraud detection system that learns about a suspicious transaction five minutes after it happens has lost five minutes. A provisioning system that waits for a payment confirmation before allocating resources needs that confirmation immediately. Webhooks exist because these workflows require the producer to tell the consumer, not the other way around .
The operational complexity problem. Webhooks are harder to operate than polling. The consumer needs a server, a public URL, signature verification, idempotency storage, and a reconciliation strategy for missed events. Asana’s documentation is explicit about this trade-off: “Use polling instead of webhooks if possible. Avoid using webhooks unless reacting to events in real time is a strict requirement” . The complexity is justified only when the latency reduction matters to the business.
The security problem. A webhook endpoint is a public URL that accepts POST requests. Without verification, anyone who discovers the URL can send fake events. The standard solution is HMAC signing: the producer signs the request body with a shared secret, and the consumer recomputes the signature to verify authenticity and integrity . Without this, webhook endpoints are an injection vector.
a. The Webhook Contract and Payload
A webhook delivery is an HTTP POST with a JSON body. The body contains the event type, a unique event identifier, a timestamp, and the event data. The exact shape varies by provider, but the structure is consistent: identify the event, then describe what happened.
{
"id": "evt_01HXJ4K2M9",
"type": "order.created",
"created_at": "2026-10-21T14:32:00Z",
"data": {
"order_id": "ord_8871",
"total": 149.99,
"currency": "USD",
"customer_id": "cus_4412"
}
}
The id field is the idempotency key. Every retry of the same event carries the same id, which allows the consumer to recognize duplicates . The type field determines which handler logic runs. The created_at timestamp enables replay protection: a request whose timestamp is too old can be rejected as stale .
The CloudEvents specification standardizes this envelope across providers. A CloudEvent carries id, source, type, specversion, and data, which means a consumer can process events from multiple producers with a single handler structure . For providers that do not use CloudEvents, the handler must be written against their specific schema.
b. The Handler Sequence: Verify, Parse, Handle Idempotently
The order of operations inside a webhook handler is not a matter of preference. It is a correctness requirement. The sequence is: verify the signature using the raw request body, parse the payload only after verification succeeds, then process the event idempotently .
The reason verification must come first is that the signature covers the raw bytes of the request body. If the framework parses and re-serializes the JSON before verification, the bytes changeโwhitespace, key ordering, encodingโand the signature no longer matches . This is why Express handlers use express.raw() instead of express.json() on the webhook route, and why FastAPI handlers call await request.body() before json.loads() .
// Express: raw body for signature verification
const crypto = require('crypto');
function verifySignature(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Constant-time comparison prevents timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
app.post('/webhooks',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-webhook-signature'];
if (!verifySignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Parse only after verification
const event = JSON.parse(req.body.toString());
// Handle idempotently
handleEvent(event);
res.status(200).json({ received: true });
}
);
The idempotency check comes after parsing. The handler extracts the event id, checks whether it has already been processed, and returns success immediately if it has. If not, it marks the event as processing, executes the side effects, and marks it as completed .
# FastAPI: idempotency with a persistent store
@app.post("/webhooks")
async def webhook(request: Request):
raw_body = await request.body()
signature = request.headers.get("x-webhook-signature")
if not verify_signature(raw_body, signature, SECRET):
raise HTTPException(status_code=401)
event = json.loads(raw_body)
event_id = event["id"]
# Check idempotency store before processing
status = await redis.get(f"webhook:{event_id}")
if status == "processed":
return {"received": True} # Duplicate, no side effect
if status == "processing":
return JSONResponse(status_code=409) # Let provider retry
await redis.set(f"webhook:{event_id}", "processing", ex=60)
await process_event(event)
await redis.set(f"webhook:{event_id}", "processed", ex=86400)
return {"received": True}
The response codes matter. A 2xx response tells the provider the event was accepted and no retry is needed. A 4xx response (other than 429) tells the provider not to retry, usually because the request is malformed or the signature is invalid. A 5xx response tells the provider to retry with backoff . Returning 200 for a duplicate is correct: the event has been handled, just not by this delivery.
c. Security and Operational Reliability
The security model for webhooks rests on three layers: HTTPS for transport, HMAC signatures for authenticity and integrity, and timestamp validation for replay protection .
HMAC signing works as follows. The producer and consumer share a secret. The producer computes HMAC-SHA256(secret, raw_body) and sends the result in a header. The consumer recomputes the HMAC and compares it in constant time. If the values match, the request came from someone who knows the secret and the body was not modified in transit . A simple equality comparison (==) is insufficient because it leaks timing information; hmac.compare_digest in Python and crypto.timingSafeEqual in Node.js perform the comparison without early exit .
Replay protection adds a timestamp check. A valid signature on an old request can be replayed by an attacker who captured it. If the request carries a timestamp and the consumer rejects requests older than a small window (two to five minutes), the replay window closes . Some providers include the timestamp inside the signed payload so it cannot be altered independently.
# Timestamp validation for replay protection
import time
def verify_with_timestamp(raw_body, signature, timestamp, secret, max_age=300):
# Reject stale requests
if abs(time.time() - int(timestamp)) > max_age:
return False
# Sign the timestamp and body together
signed_payload = f"v0:{timestamp}:{raw_body.decode()}"
expected = hmac.new(
secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
The operational model is where webhooks diverge most from synchronous APIs. The consumer must assume deliveries will fail, arrive late, arrive out of order, and arrive more than once. The producer retries on 5xx responses with exponential backoff, but retries are boundedโtypically over hours or days. Beyond that window, the consumer must reconcile by querying the API for missed events .
The queue-first pattern addresses bursts and slow processing. Instead of processing the event synchronously within the request handler, the handler verifies the signature, writes the raw event to a durable queue, and returns 2xx immediately. A separate worker consumes from the queue and performs the actual processing . This decouples the provider’s retry timeout from the consumer’s processing time and protects against retry storms during outages. The cost is additional infrastructure: a queue, a worker, and monitoring for queue depth.
Complete Example Session
# ============================================
# PART 1: WEBHOOK ENDPOINT STRUCTURE
# ============================================
# A webhook handler has three stages:
# 1. Verify signature (raw body)
# 2. Check idempotency (event ID)
# 3. Process and acknowledge
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
import hmac, hashlib, json, os, time
app = FastAPI()
SECRET = os.environ["WEBHOOK_SECRET"].encode()
# ============================================
# PART 2: SIGNATURE VERIFICATION FUNCTION
# ============================================
def verify_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
# Reject stale requests (replay protection)
if abs(time.time() - int(timestamp)) > 300:
return False
# Sign timestamp + body together
signed = f"v0:{timestamp}:{raw_body.decode()}"
expected = hmac.new(SECRET, signed.encode(), hashlib.sha256).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected, signature)
# ============================================
# PART 3: IDEMPOTENCY STORE (IN-MEMORY FOR DEMO)
# ============================================
# In production, use Redis or a database table
# with a TTL at least as long as the retry window
processed_events: dict[str, float] = {}
DEDUPE_TTL = 24 * 60 * 60 # 24 hours
def is_duplicate(event_id: str) -> bool:
now = time.time()
# Clean expired entries
for eid, ts in list(processed_events.items()):
if now - ts > DEDUPE_TTL:
del processed_events[eid]
return event_id in processed_events
def mark_processed(event_id: str):
processed_events[event_id] = time.time()
# ============================================
# PART 4: WEBHOOK HANDLER
# ============================================
@app.post("/webhooks/orders")
async def handle_order_webhook(request: Request):
# Stage 1: read raw body, verify signature
raw_body = await request.body()
signature = request.headers.get("x-signature", "")
timestamp = request.headers.get("x-timestamp", "")
if not signature or not timestamp:
raise HTTPException(status_code=400, detail="Missing headers")
if not verify_signature(raw_body, signature, timestamp):
raise HTTPException(status_code=401, detail="Invalid signature")
# Stage 2: parse and check idempotency
event = json.loads(raw_body)
event_id = event.get("id")
if not event_id:
raise HTTPException(status_code=400, detail="Missing event ID")
if is_duplicate(event_id):
# Already handled; return success to stop retries
return JSONResponse(status_code=200, content={"received": True})
# Stage 3: process the event
if event["type"] == "order.created":
# Business logic here
print(f"Order created: {event['data']['order_id']}")
elif event["type"] == "order.cancelled":
print(f"Order cancelled: {event['data']['order_id']}")
# Mark processed after successful handling
mark_processed(event_id)
return JSONResponse(status_code=200, content={"received": True})
# ============================================
# PART 5: QUEUE-FIRST VARIANT
# ============================================
# For high-throughput or slow-processing scenarios
# The handler returns 2xx immediately after enqueueing
import asyncio
queue: asyncio.Queue = asyncio.Queue()
@app.post("/webhooks/queue")
async def handle_with_queue(request: Request):
raw_body = await request.body()
signature = request.headers.get("x-signature", "")
timestamp = request.headers.get("x-timestamp", "")
if not verify_signature(raw_body, signature, timestamp):
raise HTTPException(status_code=401)
# Enqueue raw bytes; worker parses and processes
await queue.put(raw_body)
return JSONResponse(status_code=200, content={"queued": True})
# ============================================
# PART 6: WORKER CONSUMER
# ============================================
async def worker():
while True:
raw_body = await queue.get()
try:
event = json.loads(raw_body)
if not is_duplicate(event["id"]):
# Process event
print(f"Processing {event['type']}")
mark_processed(event["id"])
except Exception as e:
print(f"Worker error: {e}")
finally:
queue.task_done()
# ============================================
# PART 7: EXPRESS EQUIVALENT
# ============================================
# Express requires raw body middleware on the webhook route
# const express = require('express');
# const app = express();
# app.post('/webhooks',
# express.raw({ type: 'application/json' }),
# (req, res) => {
# const signature = req.headers['x-signature'];
# if (!verify(req.body, signature)) return res.status(401).send();
# const event = JSON.parse(req.body.toString());
# // ... idempotency and processing
# res.json({ received: true });
# }
# );
# ============================================
# PART 8: TESTING WITH CURL
# ============================================
# Generate a valid signature for local testing
# import hmac, hashlib, time
# body = '{"id":"evt_1","type":"order.created","data":{}}'
# ts = str(int(time.time()))
# sig = hmac.new(SECRET, f"v0:{ts}:{body}".encode(), hashlib.sha256).hexdigest()
# curl -X POST http://localhost:8000/webhooks/orders \
# -H "Content-Type: application/json" \
# -H "X-Signature: $sig" \
# -H "X-Timestamp: $ts" \
# -d "$body"
# ============================================
# PART 9: RESPONSE CODE TABLE
# ============================================
# 200: Accepted, no retry
# 400: Bad request (missing headers, malformed body)
# 401: Invalid signature (do not retry)
# 409: Already processing (let provider retry later)
# 429: Rate limited (retry after delay)
# 5xx: Server error (retry with backoff)
# ============================================
# PART 10: RECONCILIATION JOB
# ============================================
# When retries are exhausted, poll the API for missed events
# This runs periodically (e.g., nightly) to fill gaps
async def reconcile():
# Fetch events since last checkpoint from the provider API
# Compare against processed_events
# Process any that were missed
pass
The ten parts trace the webhook lifecycle: signature verification against the raw body, idempotency checking before processing, the queue-first variant for high throughput, the Express equivalent, local testing with curl, and the reconciliation job that closes the gap when retries fail.
Quick Reference
Handler Sequence
| Stage | Action | Failure Response |
|---|---|---|
| 1 | Verify signature on raw body | 401 |
| 2 | Parse JSON | 400 |
| 3 | Check event ID for duplicates | 200 (if duplicate) |
| 4 | Process event | 5xx (triggers retry) |
| 5 | Mark processed, acknowledge | 200 |
Response Codes
| Code | Meaning | Provider Behavior |
|---|---|---|
2xx | Success | No retry |
4xx | Client error | Usually no retry |
429 | Rate limited | Retry after delay |
5xx | Server error | Retry with backoff |
Security Layers
| Layer | Mechanism | Purpose |
|---|---|---|
| Transport | HTTPS | Encrypt in transit |
| Authenticity | HMAC-SHA256 signature | Verify sender |
| Integrity | Signature covers raw body | Detect modification |
| Replay | Timestamp window (2โ5 min) | Reject stale requests |
Best Practices
โ Do This:
# Verify signature on raw body before parsing
raw_body = await request.body()
if not verify_signature(raw_body, signature, secret): raise HTTPException(401) # โ
# Check event ID for duplicates before processing
if is_duplicate(event["id"]): return {"received": True} # โ
// Use express.raw() on webhook routes
app.post('/webhooks', express.raw({ type: 'application/json' }), handler) // โ
# Use constant-time comparison for signatures
hmac.compare_digest(expected, provided) # โ
# Return 200 for duplicates to stop retries
if is_duplicate(event_id): return JSONResponse(status_code=200) # โ
# Enqueue immediately and process asynchronously
await queue.put(raw_body); return {"queued": True} # โ
โ Don’t Do This:
// Parse JSON before verification (signature fails)
app.use(express.json()); // breaks signature verification on webhook route // โ
# Compare signatures with == (timing attack)
if expected == signature: # โ
# Process the same event twice
if not is_duplicate(event_id): await process(event) # โ
# Return 500 for a duplicate (causes unnecessary retries)
raise HTTPException(500, "Already processed") # โ
# Process synchronously in the handler (slow response, retry storms)
await heavy_processing(event); return {"ok": True} # โ
# Ignore timestamp validation (replay vulnerability)
if verify_signature(raw_body, signature): # no timestamp check # โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Signature verification always fails | Framework parses JSON before verification | Use raw body middleware |
| Duplicate side effects | No idempotency check | Store and check event ID |
| Retry storm after outage | Synchronous processing times out | Queue-first ingestion |
| Replay attack succeeds | No timestamp validation | Reject requests older than 5 min |
| Missed events never recovered | No reconciliation job | Poll API for gaps after retry exhaustion |
| Rate-limited during burst | No backpressure handling | Queue depth monitoring, worker scaling |
Real-World Examples
1. Verify HMAC Signature
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature): reject()
2. Idempotency Check
if await redis.get(f"event:{event_id}"): return {"received": True}
3. Queue-First Handler
await queue.put(raw_body)
return {"queued": True}
4. Timestamp Validation
if abs(time.time() - int(timestamp)) > 300: reject()
5. Raw Body Middleware
app.post('/webhooks', express.raw({ type: 'application/json' }), handler)
6. Constant-Time Compare
hmac.compare_digest(expected, provided)
7. Mark Processed After Success
await redis.set(f"event:{event_id}", "processed", ex=86400)
8. Return 200 for Duplicate
if is_duplicate(event_id): return JSONResponse(status_code=200)
9. Reconciliation Job
# Nightly: fetch events since checkpoint, process missing
async def reconcile(): ...
10. Test with curl
curl -X POST localhost:8000/webhooks -H "X-Signature: $sig" -d "$body"
Visual
The Webhook Handler Sequence
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ INCOMING WEBHOOK REQUEST โ
โ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ Read raw body โ Do NOT parse JSON yet โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ Verify signatureโโโโโโบ INVALID: return 401 โ
โ โ on raw bytes โ (no retry from provider) โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โ VALID โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ Parse JSON โ Now it is safe to parse โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ Check event ID โโโโโโบ DUPLICATE: return 200 โ
โ โ in idempotency โ (stop retries, no side effect) โ
โ โ store โ โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โ NEW โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ Process event โโโโโโบ FAILURE: return 5xx โ
โ โ โ (provider retries with backoff) โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โ SUCCESS โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ Mark processed โ โ
โ โ Return 200 โ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ
โ Order matters: verify โ parse โ idempotency โ process. โ
โ Parsing before verification breaks the signature. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
HMAC Signing Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ PRODUCER CONSUMER โ
โ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โ
โ โ Event occurs โ โ Receives POST โ โ
โ โโโโโโโโโโฌโโโโโโโโโ โโโโโโโโโโฌโโโโโโโโโ โ
โ โผ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โ
โ โ Serialize body โ โ Read raw body โ โ
โ โโโโโโโโโโฌโโโโโโโโโ โโโโโโโโโโฌโโโโโโโโโ โ
โ โผ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โ
โ โ HMAC-SHA256 โ โ HMAC-SHA256 โ โ
โ โ (secret, body) โ โ (secret, body) โ โ
โ โโโโโโโโโโฌโโโโโโโโโ โโโโโโโโโโฌโโโโโโโโโ โ
โ โผ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โ
โ โ Send signature โโโโโโโโโโโบโ Compare in โ โ
โ โ in header โ โ constant time โ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโฌโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโดโโโโโโโโโโ โ
โ โผ โผ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โ MATCH โ โ MISMATCH โ โ
โ โ Process โ โ Reject โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โ
โ The signature covers the exact bytes sent. โ
โ Any re-serialization breaks the comparison. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Queue-First Architecture
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ WITHOUT QUEUE WITH QUEUE โ
โ โ
โ โโโโโโโโโโโ โโโโโโโโโโโ โ
โ โ Providerโ โ Providerโ โ
โ โโโโโโฌโโโโโ โโโโโโฌโโโโโ โ
โ โ POST โ POST โ
โ โผ โผ โ
โ โโโโโโโโโโโ โโโโโโโโโโโ โ
โ โ Handler โ โ Handler โ โ
โ โ โ โ โ โ
โ โ Process โ โ Verify โ โ
โ โ (slow) โ โ Enqueue โ โ
โ โโโโโโฌโโโโโ โ Return โ โ
โ โ โโโโโโฌโโโโโ โ
โ โผ โ โ
โ โโโโโโโโโโโ โผ โ
โ โ 200/5xx โ โโโโโโโโโโโ โ
โ โ based onโ โ Queue โ โ
โ โ process โ โโโโโโฌโโโโโ โ
โ โ time โ โ โ
โ โโโโโโโโโโโ โผ โ
โ โโโโโโโโโโโ โ
โ Slow processing โ Worker โ โ
โ causes timeouts โ Process โ โ
โ and retries. โโโโโโโโโโโ โ
โ โ
โ Queue-first decouples acknowledgment from processing. โ
โ Handler returns 2xx in milliseconds, not seconds. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Polling vs Webhooks
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ POLLING WEBHOOKS โ
โ โ
โ Client asks repeatedly Server pushes on event โ
โ โ
โ โโโโโโโโโโโ โโโโโโโโโโโ โ
โ โ Client โโโGETโโโบ โ Server โโโPOSTโโโบ โ
โ โ โโโ200โโ โ โ โ
โ โ (every โ โ (on โ โ
โ โ 30s) โ โ event) โ โ
โ โโโโโโโโโโโ โโโโโโโโโโโ โ
โ โ
โ Latency: up to interval Latency: near-zero โ
โ Waste: many empty responses Waste: none โ
โ Simple to implement Requires public endpoint โ
โ No public URL needed Must verify signatures โ
โ Must handle duplicates โ
โ โ
โ Use polling when: Use webhooks when: โ
โ - Real-time not needed - Real-time required โ
โ - Simpler infrastructure - Event-driven workflow โ
โ - No public endpoint - Willing to handle complexity โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Webhook | HTTP POST sent by producer to consumer URL on event |
| Handler sequence | Verify signature โ parse โ idempotency โ process |
| Signature algorithm | HMAC-SHA256 over raw body |
| Replay protection | Timestamp window (2โ5 minutes) |
| Idempotency key | Event id from payload |
| Duplicate response | 200 (stop retries, no side effect) |
| Server error response | 5xx (provider retries with backoff) |
| Queue-first | Handler enqueues and returns immediately |
| Reconciliation | Poll API for missed events after retries exhausted |
Key takeaways:
- Verify before parsing. The signature covers the raw request body. If the framework parses and re-serializes the JSON before verification, the bytes change and the signature fails. Use raw body middleware on webhook routes .
- Idempotency is mandatory, not optional. Webhook providers retry on failure, which means the same event can arrive multiple times. Store the event ID after processing and return
200for duplicates. Without this, a retry produces a duplicate side effect . - Use constant-time comparison. A simple
==comparison on signatures leaks timing information. Usehmac.compare_digestin Python orcrypto.timingSafeEqualin Node.js . - Queue-first protects against bursts. Processing synchronously in the handler means the provider’s timeout becomes your processing deadline. Enqueue the event and return
2xximmediately, then process from a worker . - Webhooks require a reconciliation strategy. Retries are bounded. When they are exhausted, the consumer must poll the API for events it missed. A webhook integration without reconciliation is incomplete .
- Polling is often the right choice. Webhooks add operational complexity: a public endpoint, signature verification, idempotency storage, and reconciliation. Use them only when real-time notification is a genuine requirement .
Remember: A webhook is not a request your API handles; it is a request your API receives. The distinction matters because the security model, the reliability model, and the failure modes are all different. Your API does not control when the webhook arrives, whether it arrives twice, or whether it arrives at all. The handler’s job is to verify that the request is authentic, recognize whether it has been seen before, acknowledge it quickly, and process it safely. The producer’s job is to retry with backoff and to provide a way for the consumer to reconcile. Both sides must hold up their end for the integration to work. The handler sequenceโverify, parse, idempotency, processโis the consumer’s contribution to that contract.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!