| name | webhooks |
| description | Use when designing a webhook delivery system, implementing HMAC signature verification on a receiver, handling retries and failures on the sender side, building idempotent webhook consumers, or testing webhook integrations locally. |
Webhooks
Patterns for reliable webhook delivery, signature verification, idempotent consumption, and local testing.
When to Activate
- Designing a system that pushes events to external URLs
- Implementing HMAC signature verification on a webhook receiver
- Building retry and failure handling on the webhook sender side
- Making a webhook consumer idempotent (safe to receive twice)
- Exposing and documenting a webhook API for third-party integrations
- Testing webhook integrations locally without a public URL
- Auditing an existing webhook implementation for security or reliability gaps
Sender vs. Receiver Responsibilities
| Concern | Sender (you push) | Receiver (you consume) |
|---|
| Signing | Sign every payload with HMAC | Verify signature before processing |
| Delivery | Retry with backoff on non-2xx | Return 2xx immediately, process async |
| Ordering | Add sequence or timestamp | Don't assume ordered delivery |
| Idempotency | Include a stable event ID | Deduplicate on event ID |
| Schema | Version the event type | Handle unknown fields gracefully |
Event Envelope Design
Every webhook payload should include a consistent envelope regardless of event type.
{
"id": "evt_01HX4K9MZQR8FVNJ2Y3BCD5",
"type": "order.completed",
"version": "2024-01",
"created_at": "2024-01-15T10:30:00Z",
"data": {
"order_id": "ord_789",
"amount": 4999,
"currency": "usd"
}
}
| Field | Purpose |
|---|
id | Stable, unique event ID — used for deduplication |
type | Namespaced event name (resource.action) |
version | Schema version — allows evolving payloads without breaking receivers |
created_at | When the event occurred (not when it was delivered) |
data | Event-specific payload |
HMAC Signature Verification
Never trust a webhook payload without verifying its signature. Use a constant-time comparison to prevent timing attacks.
Signing (Sender)
import hmac, hashlib, json, time
def sign_payload(payload: dict, secret: str) -> tuple[str, str]:
body = json.dumps(payload, separators=(",", ":")).encode()
timestamp = str(int(time.time()))
signed = f"{timestamp}.{body.decode()}"
sig = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
return f"t={timestamp},v1={sig}", body
import { createHmac } from "crypto";
function signPayload(payload: object, secret: string): { signature: string; body: string } {
const body = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000).toString();
const signed = `${timestamp}.${body}`;
const sig = createHmac("sha256", secret).update(signed).digest("hex");
return { signature: `t=${timestamp},v1=${sig}`, body };
}
func SignPayload(payload []byte, secret string, now time.Time) string {
timestamp := strconv.FormatInt(now.Unix(), 10)
signed := timestamp + "." + string(payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signed))
sig := hex.EncodeToString(mac.Sum(nil))
return fmt.Sprintf("t=%s,v1=%s", timestamp, sig)
}
Verifying (Receiver)
import hmac, hashlib, time
from fastapi import Request, HTTPException
TOLERANCE_SECONDS = 300
async def verify_webhook(request: Request, secret: str) -> bytes:
sig_header = request.headers.get("X-Webhook-Signature", "")
body = await request.body()
parts = dict(p.split("=", 1) for p in sig_header.split(","))
timestamp = parts.get("t", "")
received_sig = parts.get("v1", "")
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
raise HTTPException(400, "Webhook timestamp too old")
expected = hmac.new(
secret.encode(),
f"{timestamp}.{body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, received_sig):
raise HTTPException(400, "Invalid webhook signature")
return body
import { createHmac, timingSafeEqual } from "crypto";
import { Request, Response, NextFunction } from "express";
const TOLERANCE_SECONDS = 300;
export function verifyWebhook(secret: string) {
return (req: Request, res: Response, next: NextFunction) => {
const header = req.headers["x-webhook-signature"] as string ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const { t: timestamp, v1: receivedSig } = parts;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return res.status(400).json({ error: "Webhook timestamp too old" });
}
const body = (req as any).rawBody as Buffer;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(receivedSig))) {
return res.status(400).json({ error: "Invalid webhook signature" });
}
next();
};
}
func VerifyWebhook(r *http.Request, body []byte, secret string) error {
header := r.Header.Get("X-Webhook-Signature")
parts := parseHeader(header)
timestamp, received := parts["t"], parts["v1"]
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return errors.New("webhook timestamp too old")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "." + string(body)))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(received)) {
return errors.New("invalid webhook signature")
}
return nil
}
Reliable Delivery (Sender)
Delivery Pipeline
Event occurs → persist to outbox → worker picks up → HTTP POST → record result
↓
success: mark delivered
failure: schedule retry
Use the transactional outbox pattern — write the event to a DB table in the same transaction as the business change. A background worker reads and delivers it. This prevents events being lost if the app crashes between the DB write and the HTTP call.
@dataclass
class WebhookDelivery:
id: str
endpoint_url: str
payload: dict
attempt: int = 0
max_attempts: int = 5
next_attempt_at: datetime = field(default_factory=datetime.utcnow)
delivered_at: datetime | None = None
last_error: str | None = None
Retry Schedule
| Attempt | Delay |
|---|
| 1st retry | 30 seconds |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 8 hours |
| After max | Move to dead-letter queue |
def next_retry_delay(attempt: int) -> int:
schedule = [30, 300, 1800, 7200, 28800]
return schedule[min(attempt, len(schedule) - 1)]
Delivery Worker
import httpx
async def deliver_webhook(delivery: WebhookDelivery, secret: str) -> None:
sig, body = sign_payload(delivery.payload, secret)
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
delivery.endpoint_url,
content=body,
headers={
"Content-Type": "application/json",
"X-Webhook-Signature": sig,
"X-Webhook-ID": delivery.id,
},
)
if r.status_code < 200 or r.status_code >= 300:
raise ValueError(f"non-2xx: {r.status_code}")
await mark_delivered(delivery.id)
except Exception as exc:
await schedule_retry(delivery, error=str(exc))
Idempotent Consumers
Webhooks can be delivered more than once. Always deduplicate on event.id.
import redis.asyncio as redis
async def handle_webhook(event: dict, r: redis.Redis) -> None:
event_id = event["id"]
key = f"webhook:seen:{event_id}"
already_seen = not await r.set(key, "1", nx=True, ex=86400)
if already_seen:
return
await process_event(event)
async function handleWebhook(event: WebhookEvent): Promise<void> {
const inserted = await db.webhookEvent.upsert({
where: { id: event.id },
create: { id: event.id, type: event.type, processedAt: null },
update: {},
});
if (inserted.processedAt) return;
await processEvent(event);
await db.webhookEvent.update({ where: { id: event.id }, data: { processedAt: new Date() } });
}
Receiver Response Contract
| Scenario | Response | Notes |
|---|
| Accepted for processing | 200 OK | Body ignored by sender |
| Duplicate (already processed) | 200 OK | Not an error — idempotent |
| Invalid signature | 400 Bad Request | Do not retry |
| Unknown event type | 200 OK | Accept and ignore unknown types |
| Downstream not ready | 503 Service Unavailable | Sender will retry |
Always return 2xx immediately. Do the actual work asynchronously (queue it). A slow receiver causes the sender to timeout and retry unnecessarily.
@router.post("/webhooks/stripe")
async def stripe_webhook(request: Request, background_tasks: BackgroundTasks):
body = await verify_webhook(request, secret=STRIPE_SECRET)
event = json.loads(body)
background_tasks.add_task(process_stripe_event, event)
return {"received": True}
Local Testing
npx localtunnel --port 3000 --subdomain my-app
ngrok http 3000
curl -X POST http://localhost:3000/webhooks \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: t=1234567890,v1=abc..." \
-d '{"id":"evt_test","type":"order.completed","data":{}}'
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger payment_intent.succeeded
Red Flags
- No signature verification — any caller can send a fake payload; always verify HMAC before touching the body.
- String equality for signature comparison — use
hmac.compare_digest / timingSafeEqual / hmac.Equal; plain == leaks timing information.
- No timestamp validation — without a replay window check, an attacker can resend a captured payload indefinitely.
- Synchronous processing in the handler — a slow downstream makes the receiver timeout; the sender retries, creating a storm; always return 2xx immediately and queue the work.
- Not idempotent — at-least-once delivery is the norm; without deduplication a retry charges a customer twice or sends a duplicate email.
- Retrying 4xx responses — a 400 or 401 will never succeed; only retry 5xx and network errors.
- No dead-letter queue — events that exhaust retries silently disappear; always persist to a DLQ for inspection and manual replay.
- Delivering directly from the request handler — if the app crashes mid-delivery, the event is lost; use a transactional outbox instead.
Checklist