Building Webhooks
Time to build both sides: sending webhooks to your users, and receiving webhooks from external services.
Part 1: Building a Webhook Sender
When your platform needs to notify users of events, you become the webhook sender.
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ YOUR PLATFORM │
│ │
│ ┌──────────┐ ┌─────────────┐ ┌──────────────────┐ │
│ │ Event │───>│ Webhook │───>│ Delivery │ │
│ │ Source │ │ Queue │ │ Worker │ │
│ └──────────┘ └─────────────┘ └────────┬─────────┘ │
│ │ │
└──────────────────────────────────────────────│──────────────────┘
│
▼
┌────────────────────────────────┐
│ User's Webhook Endpoint │
│ https://userapp.com/webhooks │
└────────────────────────────────┘
Database Schema
-- Webhook configurations per customer
CREATE TABLE webhook_endpoints (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id),
url VARCHAR(2048) NOT NULL,
secret VARCHAR(255) NOT NULL, -- For signing
events TEXT[] NOT NULL, -- ['order.created', 'order.updated']
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
-- Webhook deliveries (for retry and audit)
CREATE TABLE webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
endpoint_id UUID NOT NULL REFERENCES webhook_endpoints(id),
event_id VARCHAR(255) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) DEFAULT 'pending', -- pending, success, failed
attempts INT DEFAULT 0,
last_attempt_at TIMESTAMP,
response_status INT,
response_body TEXT,
next_retry_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_status_retry (status, next_retry_at)
);
Webhook Sender Service
const crypto = require('crypto');
const Queue = require('bull');
class WebhookService {
constructor() {
this.queue = new Queue('webhooks', process.env.REDIS_URL);
this.setupWorker();
}
// Sign payload using HMAC-SHA256
signPayload(payload, secret, timestamp) {
const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
return crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
}
// Queue webhook for delivery
async send(customerId, eventType, payload) {
// Find all endpoints subscribed to this event
const endpoints = await db.query(
`SELECT * FROM webhook_endpoints
WHERE customer_id = $1 AND enabled = true AND $2 = ANY(events)`,
[customerId, eventType]
);
for (const endpoint of endpoints) {
const eventId = `evt_${crypto.randomBytes(16).toString('hex')}`;
const timestamp = Math.floor(Date.now() / 1000);
const webhookPayload = {
id: eventId,
type: eventType,
created: timestamp,
data: payload
};
// Store delivery record
await db.query(
`INSERT INTO webhook_deliveries
(endpoint_id, event_id, event_type, payload, status)
VALUES ($1, $2, $3, $4, 'pending')`,
[endpoint.id, eventId, eventType, webhookPayload]
);
// Queue for delivery
await this.queue.add('deliver', {
endpointId: endpoint.id,
eventId,
url: endpoint.url,
secret: endpoint.secret,
payload: webhookPayload,
timestamp
}, {
attempts: 5,
backoff: { type: 'exponential', delay: 60000 }
});
}
}
// Worker processes the queue
setupWorker() {
this.queue.process('deliver', async (job) => {
const { endpointId, eventId, url, secret, payload, timestamp } = job.data;
// Generate signature
const signature = this.signPayload(payload, secret, timestamp);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': `t=${timestamp},v1=${signature}`,
'X-Webhook-ID': eventId,
'User-Agent': 'YourPlatform-Webhooks/1.0'
},
body: JSON.stringify(payload),
timeout: 30000 // 30 second timeout
});
// Update delivery record
await db.query(
`UPDATE webhook_deliveries
SET status = $1, attempts = attempts + 1,
last_attempt_at = NOW(), response_status = $2
WHERE event_id = $3`,
[response.ok ? 'success' : 'failed', response.status, eventId]
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return { success: true };
} catch (error) {
// Update with failure
await db.query(
`UPDATE webhook_deliveries
SET attempts = attempts + 1, last_attempt_at = NOW(),
next_retry_at = NOW() + INTERVAL '1 minute' * POW(2, attempts)
WHERE event_id = $1`,
[eventId]
);
throw error; // Bull will retry
}
});
}
}
// Usage
const webhooks = new WebhookService();
// When an order is created
app.post('/orders', async (req, res) => {
const order = await createOrder(req.body);
// Send webhook
await webhooks.send(order.customerId, 'order.created', {
order_id: order.id,
total: order.total,
items: order.items
});
res.json(order);
});
Webhook Management API
Let your users configure their webhooks:
// List webhook endpoints
app.get('/api/webhooks', async (req, res) => {
const endpoints = await db.query(
`SELECT id, url, events, enabled, created_at FROM webhook_endpoints
WHERE customer_id = $1`,
[req.user.customerId]
);
res.json(endpoints);
});
// Create webhook endpoint
app.post('/api/webhooks', async (req, res) => {
const { url, events } = req.body;
// Validate URL
try {
new URL(url);
} catch {
return res.status(400).json({ error: 'Invalid URL' });
}
// Generate secret
const secret = `whsec_${crypto.randomBytes(24).toString('hex')}`;
const endpoint = await db.query(
`INSERT INTO webhook_endpoints (customer_id, url, secret, events)
VALUES ($1, $2, $3, $4)
RETURNING id, url, secret, events, created_at`,
[req.user.customerId, url, secret, events]
);
res.status(201).json(endpoint);
});
// Test webhook endpoint
app.post('/api/webhooks/:id/test', async (req, res) => {
const endpoint = await db.query(
`SELECT * FROM webhook_endpoints WHERE id = $1 AND customer_id = $2`,
[req.params.id, req.user.customerId]
);
if (!endpoint) {
return res.status(404).json({ error: 'Endpoint not found' });
}
// Send test event
await webhooks.send(req.user.customerId, 'webhook.test', {
message: 'This is a test webhook'
});
res.json({ success: true, message: 'Test webhook sent' });
});
// Get delivery history
app.get('/api/webhooks/:id/deliveries', async (req, res) => {
const deliveries = await db.query(
`SELECT id, event_type, status, attempts, response_status, created_at
FROM webhook_deliveries
WHERE endpoint_id = $1
ORDER BY created_at DESC
LIMIT 100`,
[req.params.id]
);
res.json(deliveries);
});
// Retry failed delivery
app.post('/api/webhooks/deliveries/:id/retry', async (req, res) => {
const delivery = await db.query(
`SELECT d.*, e.url, e.secret FROM webhook_deliveries d
JOIN webhook_endpoints e ON d.endpoint_id = e.id
WHERE d.id = $1 AND e.customer_id = $2`,
[req.params.id, req.user.customerId]
);
if (!delivery || delivery.status !== 'failed') {
return res.status(400).json({ error: 'Cannot retry' });
}
// Re-queue
await webhooks.queue.add('deliver', {
endpointId: delivery.endpoint_id,
eventId: delivery.event_id,
url: delivery.url,
secret: delivery.secret,
payload: delivery.payload,
timestamp: Math.floor(Date.now() / 1000)
});
res.json({ success: true });
});
Part 2: Building a Webhook Receiver
Now let's build a robust receiver for incoming webhooks.
Full-Featured Receiver
const express = require('express');
const crypto = require('crypto');
const Queue = require('bull');
const app = express();
const webhookQueue = new Queue('incoming-webhooks', process.env.REDIS_URL);
// Webhook receiver for Stripe
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['stripe-signature'];
// 1. Verify signature
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Signature verification failed:', err.message);
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Check idempotency (quick check before queueing)
const exists = await redis.get(`webhook:stripe:${event.id}`);
if (exists) {
console.log(`Duplicate event ${event.id}, acknowledging`);
return res.status(200).json({ received: true });
}
// 3. Queue for async processing
await webhookQueue.add('stripe', {
eventId: event.id,
eventType: event.type,
payload: event.data.object,
receivedAt: Date.now()
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
jobId: event.id // Prevents duplicate jobs
});
// 4. Respond immediately
res.status(200).json({ received: true });
}
);
// Webhook receiver for GitHub
app.post('/webhooks/github',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['x-hub-signature-256'];
const eventType = req.headers['x-github-event'];
const deliveryId = req.headers['x-github-delivery'];
// Verify signature
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Queue for processing
await webhookQueue.add('github', {
eventId: deliveryId,
eventType,
payload: JSON.parse(req.body),
receivedAt: Date.now()
}, {
attempts: 3,
jobId: deliveryId
});
res.status(200).json({ received: true });
}
);
// Background worker
webhookQueue.process('stripe', async (job) => {
const { eventId, eventType, payload } = job.data;
// Idempotency check
const processed = await redis.get(`webhook:stripe:${eventId}`);
if (processed) {
return { skipped: true, reason: 'duplicate' };
}
// Process based on event type
switch (eventType) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(payload);
break;
case 'customer.subscription.created':
await handleSubscriptionCreated(payload);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(payload);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(payload);
break;
default:
console.log(`Unhandled event type: ${eventType}`);
}
// Mark as processed (with TTL)
await redis.setex(`webhook:stripe:${eventId}`, 86400 * 7, 'processed');
return { success: true };
});
webhookQueue.process('github', async (job) => {
const { eventId, eventType, payload } = job.data;
switch (eventType) {
case 'push':
await handlePush(payload);
break;
case 'pull_request':
await handlePullRequest(payload);
break;
case 'issues':
await handleIssue(payload);
break;
}
return { success: true };
});
Event Handlers
async function handlePaymentSuccess(paymentIntent) {
const { id, customer, amount, metadata } = paymentIntent;
// Find or create order
const order = await db.query(
`UPDATE orders SET status = 'paid', paid_at = NOW()
WHERE payment_intent_id = $1
RETURNING *`,
[id]
);
if (order) {
// Send confirmation email
await emailService.send({
to: order.customerEmail,
template: 'order-confirmation',
data: { order }
});
// Notify fulfillment
await fulfillmentService.queue(order);
}
}
async function handleSubscriptionCreated(subscription) {
const { id, customer, status, items } = subscription;
// Provision access
await db.query(
`INSERT INTO subscriptions (stripe_id, customer_id, status, plan_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (stripe_id) DO UPDATE SET status = $3`,
[id, customer, status, items.data[0].price.id]
);
// Send welcome email
const user = await db.query(
`SELECT email FROM users WHERE stripe_customer_id = $1`,
[customer]
);
await emailService.send({
to: user.email,
template: 'subscription-welcome',
data: { planName: items.data[0].price.nickname }
});
}
async function handlePush(payload) {
const { repository, ref, commits, pusher } = payload;
// Trigger CI/CD pipeline
if (ref === 'refs/heads/main') {
await ciService.triggerBuild({
repo: repository.full_name,
branch: 'main',
commit: commits[0]?.id,
author: pusher.name
});
}
}
Webhook Dashboard UI
Build a dashboard for users to manage webhooks.
Dashboard Features
- List all webhook endpoints
- Create/edit/delete endpoints
- View delivery history and status
- Retry failed deliveries
- Send test webhooks
- View request/response details for debugging
// API for delivery details (for debugging)
app.get('/api/webhooks/deliveries/:id/details', async (req, res) => {
const delivery = await db.query(
`SELECT d.*, e.url FROM webhook_deliveries d
JOIN webhook_endpoints e ON d.endpoint_id = e.id
WHERE d.id = $1 AND e.customer_id = $2`,
[req.params.id, req.user.customerId]
);
if (!delivery) {
return res.status(404).json({ error: 'Not found' });
}
res.json({
id: delivery.id,
eventType: delivery.event_type,
status: delivery.status,
attempts: delivery.attempts,
request: {
url: delivery.url,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-ID': delivery.event_id
},
body: delivery.payload
},
response: {
status: delivery.response_status,
body: delivery.response_body
},
timestamps: {
created: delivery.created_at,
lastAttempt: delivery.last_attempt_at
}
});
});
Key Takeaways
- Sender: Sign + Retry + Log - HMAC signatures, exponential backoff, delivery records
- Receiver: Verify + Queue + Idempotent - Signature check, async processing, deduplication
- Provide management APIs - Let users configure and debug their webhooks
- Build observability - Delivery history, retry controls, debugging details
Next up: Webhook Assessment - Test your knowledge with real-world scenarios.