name: webhooks
description: Webhook implementation and consumption patterns. Use when implementing webhook endpoints, sending webhooks, handling retries, or ensuring reliable delivery. Keywords: webhooks, callbacks, HMAC, signature verification, retry, exponential backoff, idempotency, event delivery, webhook security.
Webhooks
Overview
Webhooks are HTTP callbacks that notify external systems when events occur. They enable real-time communication between services without polling. This skill covers webhook design patterns, security, reliability, and implementation best practices.
Key Concepts
Webhook Design Patterns
Event-Driven Architecture:
interface WebhookEvent {
id: string;
type: string;
created: number;
apiVersion: string;
data: {
object: Record<string, any>;
previousAttributes?: Record<string, any>;
};
}
const orderCreatedEvent: WebhookEvent = {
id: "evt_1234567890",
type: "order.created",
created: 1702987200,
apiVersion: "2024-01-01",
data: {
object: {
id: "ord_abc123",
status: "pending",
total: 9999,
currency: "usd",
customer: "cus_xyz789",
},
},
};
const orderUpdatedEvent: WebhookEvent = {
id: "evt_1234567891",
type: "order.updated",
created: 1702987260,
apiVersion: "2024-01-01",
data: {
object: {
id: "ord_abc123",
status: "shipped",
total: 9999,
currency: "usd",
},
previousAttributes: {
status: "pending",
},
},
};
Webhook Subscription Model:
interface WebhookEndpoint {
id: string;
url: string;
secret: string;
events: string[];
status: "active" | "disabled";
metadata?: Record<string, string>;
createdAt: Date;
updatedAt: Date;
}
interface WebhookDelivery {
id: string;
endpointId: string;
eventId: string;
url: string;
requestHeaders: Record<string, string>;
requestBody: string;
responseStatus?: number;
responseHeaders?: Record<string, string>;
responseBody?: string;
duration?: number;
attempts: number;
nextRetryAt?: Date;
status: "pending" | | | ;
: ;
?: ;
}
Signature Verification (HMAC)
Generating Signatures:
import crypto from "crypto";
class WebhookSigner {
constructor(private secret: string) {}
sign(payload: string, timestamp: number): string {
const signedPayload = `${timestamp}.${payload}`;
return crypto
.createHmac("sha256", this.secret)
.update(signedPayload)
.digest("hex");
}
generateHeaders(payload: string): Record<string, string> {
const timestamp = Math.floor(Date.now() / 1000);
const signature = this.sign(payload, timestamp);
return {
"X-Webhook-Timestamp": timestamp.toString(),
"X-Webhook-Signature": `v1=${signature}`,
"Content-Type": "application/json",
};
}
}
Verifying Signatures:
class WebhookVerifier {
constructor(
private secret: string,
private tolerance: number = 300,
) {}
verify(payload: string, signature: string, timestamp: string): boolean {
const ts = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > this.tolerance) {
throw new WebhookError(
"Timestamp outside tolerance",
"TIMESTAMP_EXPIRED",
);
}
const sigParts = signature.split(",");
const v1Sig = sigParts
.find((part) => part.startsWith("v1="))
?.replace(, );
(!v1Sig) {
(, );
}
signedPayload = ;
expectedSig = crypto
.(, .)
.(signedPayload)
.();
isValid = crypto.(
.(v1Sig),
.(expectedSig),
);
(!isValid) {
(, );
}
;
}
}
{
() {
(message);
. = ;
}
}
Express Middleware for Verification:
import express from "express";
function webhookVerificationMiddleware(secret: string) {
const verifier = new WebhookVerifier(secret);
return (
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
const signature = req.headers["x-webhook-signature"] as string;
const timestamp = req.headers["x-webhook-timestamp"] as string;
if (!signature || !timestamp) {
return res.status(401).json({ error: "Missing signature headers" });
}
let rawBody = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
rawBody += chunk;
});
req.on("end", () => {
try {
verifier.verify(rawBody, signature, timestamp);
req. = .(rawBody);
();
} (error) {
(error ) {
res
.()
.({ : error., : error. });
}
res.().({ : });
}
});
};
}
app.(
,
express.({ : }),
{
verifier = (process..!);
{
verifier.(
req..(),
req.[] ,
req.[] ,
);
req. = .(req..());
();
} (error) {
res.().({ : });
}
},
);
Retry Logic with Exponential Backoff
Retry Configuration:
interface RetryConfig {
maxAttempts: number;
initialDelay: number;
maxDelay: number;
backoffMultiplier: number;
retryableStatuses: number[];
}
const defaultRetryConfig: RetryConfig = {
maxAttempts: 5,
initialDelay: 1000,
maxDelay: 3600000,
backoffMultiplier: 2,
retryableStatuses: [408, 429, 500, 502, 503, 504],
};
function calculateNextRetry(attempt: number, config: RetryConfig): number {
const delay = Math.min(
config.initialDelay * Math.pow(config.backoffMultiplier, attempt),
config.maxDelay,
);
jitter = delay * .() * ;
delay + jitter;
}
Webhook Delivery Service:
import fetch from "node-fetch";
class WebhookDeliveryService {
constructor(
private db: Database,
private retryConfig: RetryConfig = defaultRetryConfig,
) {}
async deliver(endpoint: WebhookEndpoint, event: WebhookEvent): Promise<void> {
const delivery = await this.createDelivery(endpoint, event);
await this.attemptDelivery(delivery);
}
private async createDelivery(
endpoint: WebhookEndpoint,
event: WebhookEvent,
): Promise<WebhookDelivery> {
const payload = JSON.stringify(event);
const signer = new WebhookSigner(endpoint.secret);
const headers = signer.generateHeaders(payload);
return this.db.deliveries.({
: (),
: endpoint.,
: event.,
: endpoint.,
: headers,
: payload,
: ,
: ,
: (),
});
}
(: ): <> {
delivery.++;
startTime = .();
{
response = (delivery., {
: ,
: delivery.,
: delivery.,
: ,
});
delivery. = response.;
delivery. = .(response.);
delivery. = response.();
delivery. = .() - startTime;
(response.) {
delivery. = ;
delivery. = ();
} (.(delivery)) {
.(delivery);
} {
delivery. = ;
delivery. = ();
}
} (error) {
delivery. = .() - startTime;
(.(delivery)) {
.(delivery);
} {
delivery. = ;
delivery. = ();
}
}
...(delivery);
}
(: ): {
(delivery. >= ..) {
;
}
(!delivery.) {
;
}
...(delivery.);
}
(: ): <> {
delay = (delivery., .);
delivery. = (.() + delay);
delivery. = ;
..(
,
{
: delivery.,
},
{
delay,
},
);
}
}
Idempotency Keys
Idempotency Implementation:
class IdempotencyManager {
constructor(private redis: Redis) {}
async checkAndStore(
key: string,
ttl: number = 86400,
): Promise<{ isNew: boolean; existingResult?: any }> {
const existing = await this.redis.get(`idempotency:${key}`);
if (existing) {
return {
isNew: false,
existingResult: JSON.parse(existing),
};
}
const acquired = await this.redis.set(
`idempotency:${key}`,
JSON.stringify({ status: "processing" }),
"EX",
ttl,
"NX",
);
return { isNew: acquired === "OK" };
}
async (
: ,
: ,
: = ,
): <> {
..(
,
.({ : , result }),
,
ttl,
);
}
(: ): <> {
..();
}
}
Webhook Handler with Idempotency:
class WebhookHandler {
constructor(
private idempotency: IdempotencyManager,
private handlers: Map<string, (data: any) => Promise<any>>,
) {}
async handleEvent(event: WebhookEvent): Promise<any> {
const check = await this.idempotency.checkAndStore(event.id);
if (!check.isNew) {
console.log(`Event ${event.id} already processed`);
return check.existingResult?.result;
}
try {
const handler = this.handlers.get(event.type);
if (!handler) {
console.log(`No handler for event type: ${event.type}`);
return null;
}
const result = (event.);
..(event., result);
result;
} (error) {
..(event.);
error;
}
}
}
Webhook Payload Design
Payload Structure Best Practices:
interface GoodWebhookPayload {
id: string;
type: "invoice.paid";
apiVersion: string;
created: number;
data: {
object: {
id: string;
customerId: string;
customerEmail: string;
amount: number;
currency: string;
status: string;
lineItems: Array<{
description: string;
amount: number;
quantity: number;
}>;
paidAt: string;
};
};
relatedObjects?: {
customer: {
id: string;
name: string;
email: string;
};
};
}
interface BadWebhookPayload {
type: "invoice.paid";
invoiceId: string;
}
Versioning Strategy:
class WebhookPayloadTransformer {
private transformers: Map<string, (data: any) => any> = new Map();
constructor() {
this.transformers.set("2023-01-01", this.transformV20230101);
this.transformers.set("2024-01-01", this.transformV20240101);
}
transform(event: WebhookEvent, targetVersion: string): WebhookEvent {
const transformer = this.transformers.get(targetVersion);
if (!transformer) {
throw new Error(`Unknown API version: ${targetVersion}`);
}
return {
...event,
apiVersion: targetVersion,
data: {
...event.data,
object: transformer(event..),
},
};
}
(: ): {
{
...data,
: data.,
};
}
(: ): {
data;
}
}
Delivery Guarantees
At-Least-Once Delivery:
class WebhookDispatcher {
private queue: Queue;
private deliveryService: WebhookDeliveryService;
async dispatch(
event: WebhookEvent,
endpoints: WebhookEndpoint[],
): Promise<void> {
await this.db.events.create(event);
for (const endpoint of endpoints) {
if (endpoint.status !== "active") continue;
if (!this.matchesEventFilter(event.type, endpoint.events)) continue;
await this.queue.add(
"webhook-delivery",
{
eventId: event.id,
endpointId: endpoint.id,
},
{
attempts: 5,
backoff: {
type: "exponential",
: ,
},
: ,
: ,
},
);
}
}
(: , : []): {
filters.( {
(filter === ) ;
(filter.()) {
prefix = filter.(, -);
eventType.(prefix);
}
eventType === filter;
});
}
}
Dead Letter Queue:
class DeadLetterHandler {
constructor(
private db: Database,
private alertService: AlertService,
) {}
async handleFailedDelivery(delivery: WebhookDelivery): Promise<void> {
await this.db.deadLetterQueue.create({
id: generateId(),
deliveryId: delivery.id,
eventId: delivery.eventId,
endpointId: delivery.endpointId,
lastAttempt: new Date(),
totalAttempts: delivery.attempts,
lastError: delivery.responseBody,
lastStatus: delivery.responseStatus,
createdAt: new Date(),
});
const recentFailures = await this.db.deadLetterQueue.count({
endpointId: delivery.,
: { : (.() - ) },
});
(recentFailures >= ) {
..({
: ,
: ,
: ,
: {
: delivery.,
: delivery.,
},
});
.(delivery.);
}
}
(: ): <> {
failures24h = ...({
endpointId,
: { : (.() - ) },
});
(failures24h >= ) {
...(endpointId, {
: ,
: ,
});
}
}
}
Webhook Monitoring and Debugging
Delivery Dashboard Data:
interface WebhookMetrics {
endpointId: string;
period: "hour" | "day" | "week";
totalDeliveries: number;
successfulDeliveries: number;
failedDeliveries: number;
avgResponseTime: number;
p95ResponseTime: number;
successRate: number;
errorBreakdown: Record<number, number>;
}
class WebhookMetricsService {
constructor(private db: Database) {}
async getMetrics(
endpointId: string,
period: "hour" | "day" | "week",
): Promise<WebhookMetrics> {
const since = this.getPeriodStart(period);
const deliveries = await this.db.deliveries.aggregate([
{
$match: {
endpointId,
: { : since },
},
},
{
: {
: ,
: { : },
: {
: { : [{ : [, ] }, , ] },
},
: {
: { : [{ : [, ] }, , ] },
},
: { : },
: { : },
},
},
]);
errorBreakdown = ...([
{
: {
endpointId,
: { : since },
: ,
},
},
{
: {
: ,
: { : },
},
},
]);
data = deliveries[] || { : , : , : };
{
endpointId,
period,
: data.,
: data.,
: data.,
: data. || ,
: .(data. || []),
: data. > ? data. / data. : ,
: .(
errorBreakdown.( [e., e.]),
),
};
}
(: ): {
now = ();
(period) {
:
(now.() - );
:
(now.() - );
:
(now.() - );
:
now;
}
}
(: []): {
(values. === ) ;
sorted = values.( a - b);
index = .(sorted. * ) - ;
sorted[index];
}
}
Event Replay:
class WebhookReplayService {
constructor(
private db: Database,
private deliveryService: WebhookDeliveryService,
) {}
async replayEvent(eventId: string, endpointId?: string): Promise<void> {
const event = await this.db.events.findById(eventId);
if (!event) {
throw new Error(`Event not found: ${eventId}`);
}
let endpoints: WebhookEndpoint[];
if (endpointId) {
const endpoint = await this.db.webhookEndpoints.findById(endpointId);
if (!endpoint) {
throw new Error(`Endpoint not found: ${endpointId}`);
}
endpoints = [endpoint];
} else {
endpoints = await this.db.webhookEndpoints.(event.);
}
( endpoint endpoints) {
..(endpoint, event);
}
}
(
: ,
: ,
): <> {
failedDeliveries = ...({
endpointId,
: ,
: { : since },
});
( delivery failedDeliveries) {
event = ...(delivery.);
endpoint = ...(endpointId);
(event && endpoint) {
..(endpoint, event);
}
}
failedDeliveries.;
}
}
Best Practices
Security
- Always use HTTPS for webhook URLs
- Implement HMAC signature verification
- Include timestamp in signatures to prevent replay attacks
- Use constant-time comparison for signatures
- Rotate webhook secrets periodically
Reliability
- Implement exponential backoff with jitter
- Use idempotency keys to handle duplicates
- Provide at-least-once delivery guarantees
- Queue webhook deliveries asynchronously
- Implement dead letter queues for persistent failures
Payload Design
- Include all necessary data in the payload
- Version your webhook payloads
- Keep payloads reasonably sized (< 256KB)
- Use consistent event naming conventions
- Include event IDs for deduplication
Receiver Implementation
- Respond quickly (< 5 seconds)
- Process webhooks asynchronously
- Store raw payloads before processing
- Implement proper error handling
- Return appropriate status codes
Monitoring
- Track delivery success rates per endpoint
- Alert on endpoint failures
- Log all delivery attempts
- Provide webhook event logs to customers
- Implement replay functionality
Examples
Complete Webhook System
import express from "express";
import { Queue, Worker } from "bullmq";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
const webhookQueue = new Queue("webhooks", { connection: redis });
async function emitEvent(type: string, data: any): Promise<void> {
const event: WebhookEvent = {
id: `evt_${generateId()}`,
type,
created: Math.floor(Date.now() / 1000),
apiVersion: "2024-01-01",
data: { object: data },
};
await db.events.(event);
endpoints = db..({
: ,
: { : [, , ] },
});
( endpoint endpoints) {
webhookQueue.(, {
: event.,
: endpoint.,
});
}
}
worker = (
,
(job) => {
{ eventId, endpointId } = job.;
event = db..(eventId);
endpoint = db..(endpointId);
(!event || !endpoint) ;
payload = .(event);
signer = (endpoint.);
headers = signer.(payload);
response = (endpoint., {
: ,
headers,
: payload,
: ,
});
(!response.) {
();
}
},
{
: redis,
: { : , : },
},
);
app = ();
app.(, express.({ : }), {
verifier = (process..!);
{
verifier.(
req..(),
req.[] ,
req.[] ,
);
} (error) {
res.().({ : });
}
event = .(req..()) ;
res.().({ : });
(event).(.);
});
(): <> {
processed = redis.();
(processed) ;
(event.) {
:
(event..);
;
:
(event..);
;
}
redis.(, , , );
}