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.
Quick Response Pattern
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.
Idempotency
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.
Using Event IDs
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);
}
Database-Based Idempotency
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;
}
}
Idempotency with Business Logic
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
});
}
Queue-Based Processing
For high-volume applications or complex processing, use a message queue to decouple webhook receipt from processing:
import { Queue } from 'bullmq';
const webhookQueue = new Queue('webhooks', {
connection: { host: 'localhost', port: 6379 }
});
// Webhook endpoint - just enqueue and respond
app.post('/webhooks/lettr', express.json(), async (req, res) => {
try {
const events = req.body;
// Add each event to queue
for (const event of events) {
await webhookQueue.add('webhook-event', event, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000
}
});
}
res.sendStatus(200);
} catch (err) {
console.error('Webhook error:', err.message);
res.sendStatus(400);
}
});
// Worker - processes events from the queue
import { Worker } from 'bullmq';
const worker = new Worker('webhooks', async (job) => {
const event = job.data;
const eventType = event.msys ? Object.keys(event.msys)[0] : null;
const data = eventType ? event.msys[eventType] : null;
if (!data) return;
switch (data.type) {
case 'delivery':
await handleDelivery(data);
break;
case 'bounce':
await handleBounce(data);
break;
case 'relay_delivery':
await handleInboundEmail(data);
break;
// ... handle other events
}
}, {
connection: { host: 'localhost', port: 6379 },
concurrency: 10
});
Error Handling
Proper error handling ensures you don’t lose events and can debug issues effectively.
Categorizing Errors
async function processEvent(event) {
try {
await handleEvent(event);
} catch (err) {
// Determine if error is retryable
if (isRetryableError(err)) {
// Log and let the webhook retry mechanism handle it
console.error(`Retryable error for event ${event.id}:`, err.message);
throw err; // Will trigger webhook retry
}
// Non-retryable errors - log and store for manual review
console.error(`Non-retryable error for event ${event.id}:`, err);
await storeFailedEvent(event, err);
}
}
function isRetryableError(err) {
// Network errors, timeouts, temporary database issues
const retryableCodes = ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', '503', '429'];
return retryableCodes.some(code =>
err.code === code || err.message?.includes(code)
);
}
async function storeFailedEvent(event, error) {
await db.failedWebhooks.insert({
event_id: event.id,
event_type: event.type,
payload: JSON.stringify(event),
error_message: error.message,
error_stack: error.stack,
failed_at: new Date()
});
// Optionally alert your team
await slack.send({
channel: '#webhook-failures',
text: `Webhook failed: ${event.type} (${event.id})\nError: ${error.message}`
});
}
Graceful Degradation
When external dependencies fail, don’t block the entire webhook:
async function handleEmailDelivered(data) {
const { emailId, to, metadata } = data;
// Critical operation - must succeed
await db.emails.update(emailId, {
status: 'delivered',
delivered_at: new Date()
});
// Non-critical operations - fail gracefully
try {
await analytics.track('email_delivered', { emailId, to });
} catch (err) {
console.warn('Analytics tracking failed:', err.message);
// Don't throw - the critical operation succeeded
}
try {
if (metadata?.notifyOnDelivery) {
await notifications.send(metadata.userId, 'Your email was delivered');
}
} catch (err) {
console.warn('Notification failed:', err.message);
// Don't throw - still a success
}
}
Handling Inbound Emails
Inbound email webhooks (relay.relay_delivery) require special consideration for attachments, threading, and routing.
Processing Attachments
Attachment URLs are temporary. Download them promptly:
async function handleInboundEmail(data) {
const { id, from, subject, text, html, attachments } = data;
// Download attachments in parallel
const downloadedAttachments = await Promise.all(
attachments.map(async (att) => {
try {
const response = await fetch(att.url);
if (!response.ok) {
throw new Error(`Failed to download: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
// Store in your preferred storage (S3, local, etc.)
const storedUrl = await storage.upload(
`inbound/${id}/${att.filename}`,
buffer,
att.contentType
);
return {
filename: att.filename,
contentType: att.contentType,
size: att.size,
url: storedUrl
};
} catch (err) {
console.error(`Failed to download attachment ${att.filename}:`, err);
return {
filename: att.filename,
error: err.message
};
}
})
);
// Create ticket with stored attachments
await createTicket({
externalId: id,
from,
subject,
body: html || text,
attachments: downloadedAttachments.filter(a => !a.error)
});
}
Routing Inbound Emails
Route emails to different handlers based on recipient address:
async function handleInboundEmail(data) {
const { to, from, subject, text, html } = data;
const recipient = to[0].toLowerCase();
// Extract the local part (before @)
const [localPart, domain] = recipient.split('@');
// Variable address routing (e.g., reply+ticket123@mail.example.com)
const variableMatch = localPart.match(/^(\w+)\+(.+)$/);
if (variableMatch) {
const [, prefix, identifier] = variableMatch;
return await routeVariableAddress(prefix, identifier, data);
}
// Standard address routing
const routes = {
'support': handleSupportEmail,
'sales': handleSalesEmail,
'feedback': handleFeedbackEmail,
'billing': handleBillingEmail
};
const handler = routes[localPart] || handleUnknownRecipient;
await handler(data);
}
async function routeVariableAddress(prefix, identifier, data) {
switch (prefix) {
case 'reply':
// identifier is a ticket ID
await addReplyToTicket(identifier, data);
break;
case 'unsubscribe':
// identifier is a user ID or token
await processUnsubscribe(identifier, data);
break;
case 'confirm':
// identifier is a confirmation token
await processConfirmation(identifier, data);
break;
default:
console.log(`Unknown variable address prefix: ${prefix}`);
}
}
Threading Conversations
Use email headers to thread conversations:
async function handleInboundEmail(data) {
const { headers, from, subject, text, html } = data;
// Check if this is a reply to an existing thread
const inReplyTo = headers['in-reply-to'];
const references = headers['references'];
if (inReplyTo) {
// Find the original message
const originalMessage = await db.messages.findOne({
message_id: inReplyTo
});
if (originalMessage) {
// Add to existing conversation
await db.messages.insert({
conversation_id: originalMessage.conversation_id,
message_id: headers['message-id'],
in_reply_to: inReplyTo,
from,
subject,
body: text || html,
received_at: new Date()
});
// Notify relevant parties
await notifyConversationParticipants(originalMessage.conversation_id);
return;
}
}
// No thread found - create new conversation
const conversation = await db.conversations.insert({
subject,
started_by: from,
created_at: new Date()
});
await db.messages.insert({
conversation_id: conversation.id,
message_id: headers['message-id'],
from,
subject,
body: text || html,
received_at: new Date()
});
}
Handling Multiple Recipients
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
});
}
}
}
Monitoring and Observability
Track webhook processing health with metrics and logging:
import { Counter, Histogram } from 'prom-client';
const webhooksReceived = new Counter({
name: 'webhooks_received_total',
help: 'Total webhooks received',
labelNames: ['event_type', 'status']
});
const webhookProcessingTime = new Histogram({
name: 'webhook_processing_seconds',
help: 'Webhook processing time',
labelNames: ['event_type']
});
async function processEvent(event) {
const timer = webhookProcessingTime.startTimer({ event_type: event.type });
try {
await handleEvent(event);
webhooksReceived.inc({ event_type: event.type, status: 'success' });
} catch (err) {
webhooksReceived.inc({ event_type: event.type, status: 'error' });
throw err;
} finally {
timer();
}
}
Framework-Specific Examples
Next.js App Router
// app/api/webhooks/lettr/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const events = await request.json();
// Queue for async processing (e.g., using Vercel's waitUntil)
// @ts-ignore - waitUntil is available in edge runtime
const ctx = (globalThis as any).__nextHandlerContext;
ctx?.waitUntil?.(processEvents(events));
return NextResponse.json({ received: true });
}
async function processEvents(events: any[]) {
for (const event of events) {
const eventType = event.msys ? Object.keys(event.msys)[0] : null;
const data = eventType ? event.msys[eventType] : null;
if (data) {
await handleEvent(data);
}
}
}
Express with TypeScript
import express, { Request, Response } from 'express';
const app = express();
app.post(
'/webhooks/lettr',
express.json(),
async (req: Request, res: Response) => {
res.sendStatus(200);
// Process async
setImmediate(() => {
const events = req.body;
processEvents(events).catch(console.error);
});
}
);