Skip to main content

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

Next Steps