Webhooks are HTTP callbacks — your service notifies others when events happen. Done wrong: lost events, duplicate processing, no way to verify authenticity, no replay. Done right: signed payloads, at-least-once delivery with exponential retry, idempotency keys, a delivery log for debugging, and an event catalog that documents what gets sent. Claude Code writes the webhook delivery engine, HMAC signature utilities, idempotent consumer patterns, and the fanout architecture that broadcasts one event to many subscribers reliably.
CLAUDE.md for Webhook Systems
## Webhook Stack
- Delivery: queue-backed (Celery/BullMQ) with exponential backoff retry
- Signatures: HMAC-SHA256 with Stripe-style timestamp prefix
- Idempotency: event_id in payload; consumers store processed IDs in Redis/DB
- Retry policy: 5 attempts at 1min, 5min, 1hr, 6hr, 24hr
- Timeout: 10s per delivery attempt
- Delivery log: persist every attempt (status, response code, duration)
- Event versioning: event_type includes version (order.created.v1)
Webhook Delivery Engine
// webhooks/delivery.ts
import { Queue } from 'bullmq';
import crypto from 'crypto';
import { setTimeout as delay } from 'timers/promises';
interface WebhookEvent {
id: string;
type: string;
version: string;
created_at: string;
data: Record<string, unknown>;
}
interface Subscription {
id: string;
url: string;
secret: string;
events: string[]; // e.g. ['order.created.v1', 'order.status.updated.v1']
}
// Job queue for async delivery
const webhookQueue = new Queue('webhooks', {
defaultJobOptions: {
attempts: 5,
backoff: {
type: 'custom',
delay: (attempt: number) => [60_000, 300_000, 3_600_000, 21_600_000, 86_400_000][attempt - 1],
},
},
});
// Dispatch: called after any state change
export async function dispatchEvent(event: WebhookEvent): Promise<void> {
// Find all subscriptions that want this event type
const subscriptions = await db.getSubscriptions({ eventType: event.type });
// Enqueue one job per subscription (fan-out)
await webhookQueue.addBulk(
subscriptions.map(sub => ({
name: 'deliver',
data: { event, subscription: sub },
}))
);
}
// Worker: deliver one event to one endpoint
export async function deliverWebhook(
event: WebhookEvent,
subscription: Subscription,
): Promise<void> {
const body = JSON.stringify(event);
const signature = signPayload(body, subscription.secret);
const startTime = Date.now();
let statusCode: number;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10_000);
const response = await fetch(subscription.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-ID': event.id,
'X-Webhook-Timestamp': Math.floor(Date.now() / 1000).toString(),
'X-Webhook-Signature': signature,
},
body,
signal: controller.signal,
});
clearTimeout(timeoutId);
statusCode = response.status;
await db.logDeliveryAttempt({
eventId: event.id,
subscriptionId: subscription.id,
statusCode,
duration: Date.now() - startTime,
success: response.ok,
});
if (!response.ok) {
throw new Error(`HTTP ${statusCode}: endpoint returned error`);
}
} catch (err) {
if (err.name === 'AbortError') {
throw new Error('Webhook delivery timed out after 10s');
}
throw err; // BullMQ will retry based on job config
}
}
function signPayload(body: string, secret: string): string {
const timestamp = Math.floor(Date.now() / 1000);
const toSign = `${timestamp}.${body}`;
const hmac = crypto.createHmac('sha256', secret).update(toSign).digest('hex');
return `t=${timestamp},v1=${hmac}`;
}
Signature Verification (Consumer Side)
// webhooks/verify.ts — used by webhook consumers to verify incoming payloads
export function verifyWebhookSignature(
body: string,
signatureHeader: string,
secret: string,
toleranceSeconds = 300, // Reject webhooks older than 5 minutes
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(',').map(part => part.split('='))
);
const timestamp = parseInt(parts['t'] ?? '0', 10);
const signatures = Object.entries(parts)
.filter(([k]) => k.startsWith('v'))
.map(([, v]) => v);
// Reject stale webhooks (prevents replay attacks)
const age = Math.abs(Date.now() / 1000 - timestamp);
if (age > toleranceSeconds) return false;
const toSign = `${timestamp}.${body}`;
const expected = crypto.createHmac('sha256', secret).update(toSign).digest('hex');
return signatures.some(sig => crypto.timingSafeEqual(
Buffer.from(sig, 'hex'),
Buffer.from(expected, 'hex'),
));
}
// Express middleware
export function requireValidWebhook(secret: string) {
return (req: Request, res: Response, next: NextFunction) => {
const rawBody = req.rawBody; // Must be configured before JSON parsing
const signature = req.headers['x-webhook-signature'] as string;
if (!signature || !verifyWebhookSignature(rawBody, signature, secret)) {
return res.status(401).json({ error: 'Invalid webhook signature' });
}
next();
};
}
Idempotent Consumer
// webhooks/consumer.ts — safe to receive duplicate events
export async function handleOrderCreated(event: WebhookEvent): Promise<void> {
// Check if already processed (idempotency)
const alreadyProcessed = await redis.set(
`webhook:processed:${event.id}`,
'1',
'EX', 86400, // Dedup window: 24 hours
'NX', // Only set if not exists
);
if (!alreadyProcessed) {
console.log(`Webhook ${event.id} already processed, skipping`);
return;
}
// Safe to process
const order = event.data as Order;
await fulfillmentService.processNewOrder(order);
}
// FastAPI receiver
@app.post("/webhooks/orders")
async def receive_order_webhook(
request: Request,
x_webhook_signature: str = Header(...),
):
body = await request.body()
if not verify_signature(body.decode(), x_webhook_signature, WEBHOOK_SECRET):
raise HTTPException(status_code=401, detail="Invalid signature")
event = json.loads(body)
# Process based on event type
match event["type"]:
case "order.created.v1":
await handle_order_created(event)
case "order.status.updated.v1":
await handle_order_status_updated(event)
case _:
pass # Unknown event type — ignore gracefully
return {"ok": True}
Webhook Testing
// Test: mock delivery and verify signatures
describe('webhook delivery', () => {
it('sends correct HMAC signature', async () => {
const mockServer = setupMockServer();
const subscription = { url: mockServer.url, secret: 'test-secret', events: ['order.created.v1'] };
await deliverWebhook(
{ id: 'evt-1', type: 'order.created.v1', version: '1', created_at: new Date().toISOString(), data: {} },
subscription,
);
const [receivedRequest] = mockServer.requests;
const isValid = verifyWebhookSignature(
receivedRequest.body,
receivedRequest.headers['x-webhook-signature'],
'test-secret',
);
expect(isValid).toBe(true);
});
});
For the event-driven architecture that dispatches these webhooks on state changes, the event-driven architecture guide covers domain event emission and handler registration. For the Celery task queue that backs webhook delivery in Python stacks, the Celery guide covers retry policies and task routing. The Claude Skills 360 bundle includes webhook skill sets covering HMAC signing, delivery retry engines, idempotent consumers, and fanout architecture. Start with the free tier to try webhook delivery system generation.