Receiving Webhooks
Handle incoming webhooks from third-party services like Stripe, GitHub, and more.
6 min read
What are Webhooks?#
Webhooks are HTTP callbacks - when something happens in a third-party service, they send a POST request to your server with event data.
[Third-Party Service] --POST--> [Your Server] --Process--> [Database/Actions]
Common Webhook Sources#
| Service | Use Case |
|---|---|
| Stripe | Payment events, subscription changes |
| GitHub | Push, PR, issue events |
| Clerk/Auth0 | User created, updated, deleted |
| Twilio | SMS delivered, calls completed |
| SendGrid | Email opened, bounced |
Basic Webhook Handler#
javascript
// src/routes/webhooks.js
import express from 'express';
import crypto from 'crypto';
const router = express.Router();
// IMPORTANT: Use raw body for signature verification
router.post('/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
// Verify signature
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Return 200 quickly to acknowledge receipt
res.json({ received: true });
}
);
Signature Verification#
Always verify webhook signatures to prevent spoofed requests:
Stripe#
javascript
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
function verifyStripeWebhook(payload, signature, secret) {
return stripe.webhooks.constructEvent(payload, signature, secret);
}
GitHub#
javascript
import crypto from 'crypto';
function verifyGitHubWebhook(payload, signature, secret) {
const hmac = crypto.createHmac('sha256', secret);
const digest = 'sha256=' + hmac.update(payload).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest))) {
throw new Error('Invalid signature');
}
return JSON.parse(payload);
}
router.post('/github',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-hub-signature-256'];
try {
const event = verifyGitHubWebhook(
req.body,
signature,
process.env.GITHUB_WEBHOOK_SECRET
);
console.log(`GitHub event: ${req.headers['x-github-event']}`);
// Process event...
res.status(200).send('OK');
} catch (err) {
res.status(401).send('Invalid signature');
}
}
);
Generic HMAC Verification#
javascript
function verifyHmacSignature(payload, signature, secret, algorithm = 'sha256') {
const hmac = crypto.createHmac(algorithm, secret);
const expectedSignature = hmac.update(payload).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
Idempotency#
Webhooks may be sent multiple times. Handle duplicates:
javascript
// src/services/webhookService.js
import { prisma } from '../lib/prisma.js';
export async function processWebhook(eventId, eventType, handler) {
// Check if already processed
const existing = await prisma.webhookEvent.findUnique({
where: { eventId },
});
if (existing) {
console.log(`Webhook ${eventId} already processed`);
return { duplicate: true };
}
// Process the event
try {
await handler();
// Mark as processed
await prisma.webhookEvent.create({
data: {
eventId,
eventType,
processedAt: new Date(),
},
});
return { success: true };
} catch (error) {
// Log failure for retry
await prisma.webhookEvent.create({
data: {
eventId,
eventType,
error: error.message,
processedAt: new Date(),
},
});
throw error;
}
}
// Usage
router.post('/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const event = stripe.webhooks.constructEvent(/* ... */);
const result = await processWebhook(event.id, event.type, async () => {
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
}
});
res.json({ received: true, duplicate: result.duplicate });
});
Queue Webhook Processing#
For reliability, queue webhooks instead of processing synchronously:
javascript
// src/routes/webhooks.js
import { webhookQueue } from '../queues/webhook.js';
router.post('/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
try {
// Verify immediately
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
// Queue for processing
await webhookQueue.add('stripe', {
eventId: event.id,
eventType: event.type,
data: event.data.object,
}, {
attempts: 5,
backoff: { type: 'exponential', delay: 5000 },
});
// Return 200 immediately
res.json({ received: true });
} catch (err) {
res.status(400).send(`Webhook Error: ${err.message}`);
}
}
);
// src/queues/webhook.js
import { Queue, Worker } from 'bullmq';
export const webhookQueue = new Queue('webhooks', {
connection: { host: 'localhost', port: 6379 },
});
const worker = new Worker('webhooks', async (job) => {
const { eventId, eventType, data } = job.data;
switch (eventType) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(data);
break;
case 'customer.subscription.created':
await handleSubscriptionCreated(data);
break;
// ... more handlers
}
}, {
connection: { host: 'localhost', port: 6379 },
});
Stripe Webhook Example#
Complete Stripe webhook handler:
javascript
// src/routes/webhooks/stripe.js
import express from 'express';
import Stripe from 'stripe';
import { UserService } from '../../services/userService.js';
import { OrderService } from '../../services/orderService.js';
const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookHandlers = {
'checkout.session.completed': async (session) => {
const userId = session.metadata.userId;
const orderId = session.metadata.orderId;
await OrderService.markPaid(orderId, {
stripePaymentId: session.payment_intent,
amount: session.amount_total / 100,
});
},
'customer.subscription.created': async (subscription) => {
const userId = subscription.metadata.userId;
await UserService.updateSubscription(userId, {
stripeSubscriptionId: subscription.id,
plan: subscription.items.data[0].price.lookup_key,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
});
},
'customer.subscription.updated': async (subscription) => {
const userId = subscription.metadata.userId;
await UserService.updateSubscription(userId, {
plan: subscription.items.data[0].price.lookup_key,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
});
},
'customer.subscription.deleted': async (subscription) => {
const userId = subscription.metadata.userId;
await UserService.cancelSubscription(userId);
},
'invoice.payment_failed': async (invoice) => {
const userId = invoice.subscription_details?.metadata?.userId;
if (userId) {
await UserService.notifyPaymentFailed(userId, {
invoiceId: invoice.id,
amount: invoice.amount_due / 100,
});
}
},
};
router.post('/',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Stripe webhook error:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
const handler = webhookHandlers[event.type];
if (handler) {
try {
await handler(event.data.object);
console.log(`Processed ${event.type}`);
} catch (err) {
console.error(`Error processing ${event.type}:`, err);
// Return 200 anyway - Stripe will retry on 4xx/5xx
// Log for manual investigation
}
} else {
console.log(`Unhandled event: ${event.type}`);
}
res.json({ received: true });
}
);
export default router;
Testing Webhooks Locally#
Stripe CLI#
bash
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/webhooks/stripe
# Trigger test events
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.created
ngrok#
bash
# Install ngrok
npm install -g ngrok
# Expose local server
ngrok http 3000
# Use the ngrok URL in service webhook settings
# https://abc123.ngrok.io/webhooks/stripe
LocalTunnel#
bash
npx localtunnel --port 3000
Security Best Practices#
- Always verify signatures - Never trust unverified webhooks
- Use HTTPS - Encrypt webhook traffic
- Return 200 quickly - Process async to avoid timeouts
- Handle duplicates - Webhooks may retry
- Log everything - Debug failed webhooks
- Set timeouts - Don't hang on slow handlers
- Rate limit - Protect against webhook floods
javascript
// Webhook security middleware
import rateLimit from 'express-rate-limit';
const webhookLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: 'Too many webhook requests',
});
router.use('/webhooks', webhookLimiter);
Key Takeaways#
- Verify signatures - Always authenticate webhook sources
- Return 200 fast - Acknowledge receipt immediately
- Process async - Queue webhooks for reliability
- Handle idempotency - Webhooks may retry
- Test locally - Use Stripe CLI or ngrok
Continue Learning
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.