Webhooks enable real-time communication between services. When events happen, you notify interested parties immediately instead of waiting for them to poll. Here's how to build reliable webhook systems.
Receiving Webhooks#
Basic Endpoint#
1import crypto from 'crypto';
2
3// Verify webhook signature
4function verifyWebhookSignature(
5 payload: string,
6 signature: string,
7 secret: string
8): boolean {
9 const expected = crypto
10 .createHmac('sha256', secret)
11 .update(payload)
12 .digest('hex');
13
14 return crypto.timingSafeEqual(
15 Buffer.from(signature),
16 Buffer.from(`sha256=${expected}`)
17 );
18}
19
20// Webhook endpoint
21app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
22 const signature = req.headers['stripe-signature'] as string;
23 const payload = req.body.toString();
24
25 // Verify signature
26 if (!verifyWebhookSignature(payload, signature, STRIPE_WEBHOOK_SECRET)) {
27 return res.status(401).json({ error: 'Invalid signature' });
28 }
29
30 const event = JSON.parse(payload);
31
32 // Acknowledge immediately
33 res.status(200).json({ received: true });
34
35 // Process asynchronously
36 await processWebhookEvent(event);
37});
38
39async function processWebhookEvent(event: StripeEvent) {
40 // Idempotency check
41 const processed = await redis.get(`webhook:${event.id}`);
42 if (processed) {
43 logger.info('Webhook already processed', { eventId: event.id });
44 return;
45 }
46
47 try {
48 switch (event.type) {
49 case 'payment_intent.succeeded':
50 await handlePaymentSuccess(event.data.object);
51 break;
52 case 'customer.subscription.deleted':
53 await handleSubscriptionCanceled(event.data.object);
54 break;
55 default:
56 logger.info('Unhandled webhook event', { type: event.type });
57 }
58
59 // Mark as processed
60 await redis.set(`webhook:${event.id}`, '1', 'EX', 86400 * 7);
61 } catch (error) {
62 logger.error('Webhook processing failed', {
63 eventId: event.id,
64 error: error.message,
65 });
66 throw error;
67 }
68}Queue-Based Processing#
1// Better: Queue webhooks for processing
2app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
3 const signature = req.headers['stripe-signature'] as string;
4
5 if (!verifySignature(req.body, signature)) {
6 return res.status(401).json({ error: 'Invalid signature' });
7 }
8
9 const event = JSON.parse(req.body.toString());
10
11 // Store and acknowledge
12 await prisma.webhookEvent.create({
13 data: {
14 eventId: event.id,
15 type: event.type,
16 payload: event,
17 status: 'pending',
18 },
19 });
20
21 // Queue for processing
22 await queue.add('process-webhook', { eventId: event.id });
23
24 res.status(200).json({ received: true });
25});
26
27// Worker processes webhooks
28queue.process('process-webhook', async (job) => {
29 const { eventId } = job.data;
30
31 const webhook = await prisma.webhookEvent.findUnique({
32 where: { eventId },
33 });
34
35 if (!webhook || webhook.status === 'processed') {
36 return;
37 }
38
39 try {
40 await processWebhookEvent(webhook.payload);
41
42 await prisma.webhookEvent.update({
43 where: { eventId },
44 data: { status: 'processed', processedAt: new Date() },
45 });
46 } catch (error) {
47 await prisma.webhookEvent.update({
48 where: { eventId },
49 data: {
50 status: 'failed',
51 error: error.message,
52 attempts: { increment: 1 },
53 },
54 });
55
56 throw error; // Retry
57 }
58});Sending Webhooks#
Webhook Delivery Service#
1interface WebhookSubscription {
2 id: string;
3 url: string;
4 secret: string;
5 events: string[];
6 active: boolean;
7}
8
9class WebhookService {
10 async send(
11 subscription: WebhookSubscription,
12 event: string,
13 payload: object
14 ): Promise<WebhookDelivery> {
15 const delivery = await prisma.webhookDelivery.create({
16 data: {
17 subscriptionId: subscription.id,
18 event,
19 payload,
20 status: 'pending',
21 },
22 });
23
24 try {
25 const body = JSON.stringify({
26 id: delivery.id,
27 event,
28 timestamp: new Date().toISOString(),
29 data: payload,
30 });
31
32 const signature = this.sign(body, subscription.secret);
33
34 const response = await fetch(subscription.url, {
35 method: 'POST',
36 headers: {
37 'Content-Type': 'application/json',
38 'X-Webhook-Signature': signature,
39 'X-Webhook-Event': event,
40 'X-Webhook-Delivery': delivery.id,
41 },
42 body,
43 signal: AbortSignal.timeout(30000),
44 });
45
46 await prisma.webhookDelivery.update({
47 where: { id: delivery.id },
48 data: {
49 status: response.ok ? 'delivered' : 'failed',
50 statusCode: response.status,
51 response: await response.text().catch(() => null),
52 deliveredAt: new Date(),
53 },
54 });
55
56 return delivery;
57 } catch (error) {
58 await prisma.webhookDelivery.update({
59 where: { id: delivery.id },
60 data: {
61 status: 'failed',
62 error: error.message,
63 attempts: { increment: 1 },
64 },
65 });
66
67 throw error;
68 }
69 }
70
71 private sign(payload: string, secret: string): string {
72 const timestamp = Math.floor(Date.now() / 1000);
73 const signature = crypto
74 .createHmac('sha256', secret)
75 .update(`${timestamp}.${payload}`)
76 .digest('hex');
77
78 return `t=${timestamp},v1=${signature}`;
79 }
80}Retry with Exponential Backoff#
1class WebhookRetryService {
2 private readonly maxRetries = 5;
3 private readonly baseDelay = 60; // seconds
4
5 async scheduleRetry(deliveryId: string, attempt: number): Promise<void> {
6 if (attempt >= this.maxRetries) {
7 await this.markAsFailed(deliveryId);
8 return;
9 }
10
11 // Exponential backoff: 1m, 5m, 25m, 2h, 10h
12 const delay = this.baseDelay * Math.pow(5, attempt);
13
14 await queue.add(
15 'retry-webhook',
16 { deliveryId, attempt: attempt + 1 },
17 { delay: delay * 1000 }
18 );
19 }
20
21 async retry(deliveryId: string, attempt: number): Promise<void> {
22 const delivery = await prisma.webhookDelivery.findUnique({
23 where: { id: deliveryId },
24 include: { subscription: true },
25 });
26
27 if (!delivery || delivery.status === 'delivered') {
28 return;
29 }
30
31 try {
32 await webhookService.send(
33 delivery.subscription,
34 delivery.event,
35 delivery.payload
36 );
37 } catch (error) {
38 await this.scheduleRetry(deliveryId, attempt);
39 }
40 }
41
42 private async markAsFailed(deliveryId: string): Promise<void> {
43 await prisma.webhookDelivery.update({
44 where: { id: deliveryId },
45 data: { status: 'permanently_failed' },
46 });
47
48 // Notify subscription owner
49 await notificationService.send({
50 type: 'webhook_failure',
51 deliveryId,
52 });
53 }
54}Event Fan-Out#
1// Dispatch events to all subscribers
2async function dispatchEvent(
3 event: string,
4 payload: object
5): Promise<void> {
6 const subscriptions = await prisma.webhookSubscription.findMany({
7 where: {
8 active: true,
9 events: { has: event },
10 },
11 });
12
13 const deliveries = subscriptions.map((sub) =>
14 queue.add('send-webhook', {
15 subscriptionId: sub.id,
16 event,
17 payload,
18 })
19 );
20
21 await Promise.all(deliveries);
22
23 logger.info('Webhook event dispatched', {
24 event,
25 subscriberCount: subscriptions.length,
26 });
27}
28
29// Usage
30await dispatchEvent('order.created', {
31 orderId: order.id,
32 customerId: order.customerId,
33 total: order.total,
34});Subscription Management#
1// API for managing webhook subscriptions
2app.post('/api/webhooks', authenticate, async (req, res) => {
3 const { url, events } = req.body;
4
5 // Validate URL
6 if (!isValidUrl(url)) {
7 return res.status(400).json({ error: 'Invalid URL' });
8 }
9
10 // Generate secret
11 const secret = crypto.randomBytes(32).toString('hex');
12
13 const subscription = await prisma.webhookSubscription.create({
14 data: {
15 userId: req.user.id,
16 url,
17 events,
18 secret,
19 active: true,
20 },
21 });
22
23 // Return secret only on creation
24 res.json({
25 id: subscription.id,
26 url: subscription.url,
27 events: subscription.events,
28 secret, // Only shown once!
29 });
30});
31
32// Test endpoint
33app.post('/api/webhooks/:id/test', authenticate, async (req, res) => {
34 const subscription = await prisma.webhookSubscription.findFirst({
35 where: {
36 id: req.params.id,
37 userId: req.user.id,
38 },
39 });
40
41 if (!subscription) {
42 return res.status(404).json({ error: 'Not found' });
43 }
44
45 const delivery = await webhookService.send(
46 subscription,
47 'webhook.test',
48 { message: 'This is a test webhook' }
49 );
50
51 res.json({
52 success: delivery.status === 'delivered',
53 statusCode: delivery.statusCode,
54 });
55});Best Practices#
Receiving:
✓ Always verify signatures
✓ Respond quickly (200 OK)
✓ Process asynchronously
✓ Implement idempotency
✓ Handle duplicates gracefully
Sending:
✓ Sign all webhooks
✓ Include event type in headers
✓ Retry with exponential backoff
✓ Set reasonable timeouts
✓ Provide delivery logs
✓ Allow re-sending failed webhooks
Conclusion#
Webhooks enable real-time integrations without polling. Verify signatures for security, process asynchronously for reliability, and implement retries for resilience.
Both sending and receiving require careful handling of failures—plan for them from the start.