Building a reliable webhook handler requires careful attention to response timing, idempotency, error handling, and asynchronous processing. This guide covers best practices to ensure you never miss an event and process each one exactly once.
The most critical rule for webhook handling: respond immediately, process asynchronously. Lettr waits up to 30 seconds for your endpoint to respond. If processing takes longer, the webhook is considered failed and will be retried.
import express from 'express';const app = express();app.post('/webhooks/lettr', express.json(), (req, res) => { // Acknowledge receipt immediately res.sendStatus(200); // Process asynchronously (after response is sent) setImmediate(() => { const events = req.body; processEvents(events).catch(err => { console.error('Failed to process events:', err); }); });});
Never perform long-running operations before responding. Database writes, external API calls, and complex processing should happen after you’ve acknowledged the webhook.
Webhooks may be delivered more than once due to retries, network issues, or edge cases. Your handler must be idempotent—processing the same event twice should have the same effect as processing it once.
Every webhook event has a unique id. Store processed event IDs to detect and skip duplicates:
import { Redis } from 'ioredis';const redis = new Redis(process.env.REDIS_URL);async function processEventIdempotently(event) { const eventKey = `webhook:processed:${event.id}`; // Try to set the key with NX (only if not exists) and EX (expiry) const isNew = await redis.set(eventKey, '1', 'NX', 'EX', 86400); // 24 hour expiry if (!isNew) { console.log(`Event ${event.id} already processed, skipping`); return; } // Process the event await processEvent(event);}
For applications without Redis, use your database:
async function processEventIdempotently(event) { // Use a transaction with a unique constraint try { await db.transaction(async (trx) => { // This will fail if event already exists await trx('processed_webhooks').insert({ event_id: event.id, event_type: event.type, processed_at: new Date() }); // Process the event within the transaction await processEvent(event, trx); }); } catch (err) { if (err.code === '23505') { // PostgreSQL unique violation console.log(`Event ${event.id} already processed`); return; } throw err; }}
Sometimes you need idempotency at the business level, not just event level:
async function handleEmailBounced(data) { const { to, bounceType, emailId } = data; if (bounceType !== 'hard') { return; // Only suppress hard bounces } // Idempotent operation: adding to suppression list // If already suppressed, this is a no-op await db.suppressions.upsert({ email: to, reason: 'hard_bounce', source_email_id: emailId, suppressed_at: new Date() }, { conflictFields: ['email'], updateFields: [] // Don't update if exists });}
When an email is sent to multiple addresses on your domain, handle each appropriately:
async function handleInboundEmail(data) { const { to, cc, from, subject, text } = data; // Process each direct recipient for (const recipient of to) { await routeToRecipient(recipient, { from, subject, body: text, isDirectRecipient: true }); } // Optionally process CC recipients differently for (const recipient of cc || []) { // Only process if it's one of our domains if (isOurDomain(recipient)) { await routeToRecipient(recipient, { from, subject, body: text, isDirectRecipient: false, isCc: true }); } }}