Back to Blog
WebhooksAPIIntegrationEvents

Webhooks Implementation Guide

Build reliable webhook systems. From receiving webhooks to sending them to handling failures and retries.

B
Bootspring Team
Engineering
November 12, 2023
5 min read

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.

Share this article

Help spread the word about Bootspring