Webhook Best Practices
Guide to implementing reliable and performant webhooks.
1. Idempotence
Webhooks may be delivered multiple times. Your code must be idempotent: processing the same event multiple times should produce the same result.
Solution: Track Processed Events
async function processWebhook(event) {
// Check if already processed
const exists = await db.processedEvents.findOne({
eventId: event.id
});
if (exists) {
console.log(`Event ${event.id} already processed`);
return { status: 'already_processed' };
}
// Process in transaction
const session = await db.startSession();
await session.withTransaction(async () => {
await processEvent(event);
// Mark as processed
await db.processedEvents.create({
eventId: event.id,
processedAt: new Date(),
eventType: event.type
});
});
return { status: 'processed' };
}
2. Asynchronous Processing
Respond quickly (< 5s) and process in background.
❌ Bad: Synchronous Processing
app.post('/webhooks', async (req, res) => {
const event = req.body;
await sendEmail(event); // 2s
await updateDatabase(event); // 3s
await callExternalAPI(event); // 4s
// Total: 9s → TIMEOUT!
res.status(200).send('OK');
});
✅ Good: Async Queue
const Bull = require('bull');
const webhookQueue = new Bull('webhooks');
app.post('/webhooks', async (req, res) => {
const event = req.body;
// Respond immediately
res.status(200).send('OK');
// Add to queue
await webhookQueue.add(event, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000
}
});
});
// Separate worker
webhookQueue.process(async (job) => {
const event = job.data;
await sendEmail(event);
await updateDatabase(event);
await callExternalAPI(event);
});
3. Error Handling
Retry with Exponential Backoff
async function processWebhookWithRetry(event, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await processEvent(event);
console.log(`✓ Event ${event.id} processed`);
return;
} catch (error) {
console.error(`✗ Attempt ${attempt}/${maxAttempts}:`, error.message);
if (attempt < maxAttempts) {
const delay = Math.pow(2, attempt) * 1000;
await sleep(delay);
} else {
await logFailedWebhook(event, error);
throw error;
}
}
}
}
4. Payload Validation
Always validate payload format before processing:
const Joi = require('joi');
const webhookSchema = Joi.object({
id: Joi.string().required(),
type: Joi.string().valid(
'appointment.created',
'appointment.updated',
'appointment.cancelled',
'appointment.validated',
'appointment.completed'
).required(),
created: Joi.date().iso().required(),
data: Joi.object({
object: Joi.object().required()
}).required()
});
app.post('/webhooks', async (req, res) => {
const event = req.body;
const { error } = webhookSchema.validate(event);
if (error) {
console.error('Invalid payload:', error.details);
return res.status(400).send('Invalid payload');
}
if (!verifySignature(req)) {
return res.status(401).send('Invalid signature');
}
res.status(200).send('OK');
processWebhookAsync(event);
});
5. Monitoring and Alerts
Metrics to Track
const metrics = {
webhooksReceived: new Counter('webhooks_received_total'),
webhooksProcessed: new Counter('webhooks_processed_total'),
webhooksFailed: new Counter('webhooks_failed_total'),
processingTime: new Histogram('webhook_processing_seconds'),
};
app.post('/webhooks', async (req, res) => {
const start = Date.now();
metrics.webhooksReceived.inc();
try {
await processWebhook(req.body);
metrics.webhooksProcessed.inc();
} catch (error) {
metrics.webhooksFailed.inc();
throw error;
} finally {
const duration = (Date.now() - start) / 1000;
metrics.processingTime.observe(duration);
}
res.status(200).send('OK');
});
6. Testing
Unit Tests
const request = require('supertest');
const crypto = require('crypto');
describe('Webhook Endpoint', () => {
it('should accept valid webhook', async () => {
const payload = JSON.stringify({
id: 'evt_test',
type: 'appointment.created',
data: { object: { id: 123 } }
});
const signature = 'sha256=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(payload)
.digest('hex');
const response = await request(app)
.post('/webhooks/moncreneau')
.set('Content-Type', 'application/json')
.set('X-Moncreneau-Signature', signature)
.send(payload);
expect(response.status).toBe(200);
});
it('should reject invalid signature', async () => {
const response = await request(app)
.post('/webhooks/moncreneau')
.set('X-Moncreneau-Signature', 'sha256=invalid')
.send({ id: 'evt_test' });
expect(response.status).toBe(401);
});
});
7. Security
Rate Limiting
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 100,
message: 'Too many webhook requests',
});
app.post('/webhooks/moncreneau', webhookLimiter, handleWebhook);
Checklist
Before going to production:
- HMAC signature verified
- Asynchronous processing implemented
- Idempotence guaranteed (deduplication)
- Error handling + retry logic
- Payload validation
- Structured logging
- Metrics and alerts
- Unit and integration tests
- HTTPS in production
- Documentation up to date