Handling Retries
Sent may deliver the same webhook event multiple times due to retries, network issues, or system recovery. Your handler must be idempotent to prevent duplicate processing.
Why Retries Happen
| Scenario | Behavior |
|---|---|
| No acknowledgment | Your endpoint didn't return 2xx |
| Timeout | Your endpoint took > 5 seconds |
| Network issues | Connection failed during delivery |
| System recovery | Event replay after maintenance |
Implementing Idempotency
1. Event ID Deduplication
Store processed event IDs to skip duplicates:
// Pass the X-Webhook-ID header as eventId — it's the unique delivery identifier
async function handleWebhook(eventData: any, eventId: string) {
const { field } = eventData;
// Check if already processed
const existing = await db.webhookEvents.findUnique({
where: { eventId }
});
if (existing) {
console.log(`Event ${eventId} already processed`);
return; // Skip duplicate
}
// Process event
await processEvent(eventData);
// Record as processed
await db.webhookEvents.create({
data: {
eventId,
eventType: field,
processedAt: new Date()
}
});
}
// In your handler, pass the header:
app.post('/webhooks/sent', async (req, res) => {
res.sendStatus(200);
const eventId = req.headers['x-webhook-id'] as string;
await handleWebhook(req.body, eventId);
});async def handle_webhook(event_data: dict, event_id: str):
field = event_data['field']
# Check if already processed
existing = db.webhook_events.find_unique(
where={"event_id": event_id}
)
if existing:
print(f"Event {event_id} already processed")
return
# Process event
await process_event(event_data)
# Record as processed
db.webhook_events.create({
"event_id": event_id,
"event_type": field,
"processed_at": datetime.now()
})
# In your handler, pass the header:
@app.route('/webhooks/sent', methods=['POST'])
def webhook():
event_id = request.headers.get('X-Webhook-ID')
# Enqueue for background processing, then return 200 immediately
queue.enqueue(handle_webhook, request.json, event_id)
return '', 200// Pass the X-Webhook-ID header as eventID
func handleWebhook(eventData WebhookEvent, eventID string) error {
// Check if already processed
var exists bool
err := db.QueryRow(
"SELECT EXISTS(SELECT 1 FROM webhook_events WHERE event_id = $1)",
eventID,
).Scan(&exists)
if err != nil {
return err
}
if exists {
log.Printf("Event %s already processed", eventID)
return nil
}
// Process event
if err := processEvent(eventData); err != nil {
return err
}
// Record as processed
_, err = db.Exec(
"INSERT INTO webhook_events (event_id, event_type, processed_at) VALUES ($1, $2, $3)",
eventID, eventData.Field, time.Now(),
)
return err
}2. Database Transaction
Ensure atomic processing with transactions:
await db.$transaction(async (tx) => {
// Record event processing start
await tx.webhookEvents.create({
data: {
eventId,
eventType: field,
status: 'processing'
}
});
// Process business logic
if (field === 'messages') {
await tx.messages.update({
where: { id: eventData.payload.message_id },
data: { status: eventData.payload.message_status }
});
}
// Mark as completed
await tx.webhookEvents.update({
where: { eventId },
data: { status: 'completed' }
});
});with db.transaction():
# Record event processing start
webhook_event = WebhookEvent(
event_id=event_id,
event_type=field,
status="processing"
)
db.add(webhook_event)
# Process business logic
if field == 'messages':
message = db.messages.find_by_id(event_data['payload']['message_id'])
message.status = event_data['payload']['message_status']
# Mark as completed
webhook_event.status = "completed"
db.commit()3. Message ID Deduplication
For message events, use message ID and status to skip stale updates:
async function handleMessageEvent(eventData: any) {
const { message_id, message_status } = eventData.payload;
const { timestamp } = eventData;
// Get current status from database
const message = await db.messages.findById(message_id);
// Only update if this is a newer status
// Webhook message_status values: SENT, DELIVERED, READ, FAILED
const statusOrder = ['SENT', 'DELIVERED', 'READ', 'FAILED'];
const currentIndex = statusOrder.indexOf(message.status?.toUpperCase());
const newIndex = statusOrder.indexOf(message_status.toUpperCase());
if (newIndex > currentIndex) {
await db.messages.update(message_id, { status: message_status });
}
}Retry Schedule
Sent uses exponential backoff for retries:
| Attempt | Delay After Failure |
|---|---|
| 1 | Immediate |
| 2 | 5 seconds |
| 3 | 25 seconds |
| 4 | 2 minutes |
| 5 | 10 minutes |
| 6+ | 1 hour (max) |
Retries continue for up to 24 hours. After that, the event is discarded.
Handling Out-of-Order Events
Events may arrive out of order. Use timestamps to ensure correct state:
async function handleMessageEvent(eventData: any) {
const { timestamp } = eventData;
const { message_id, message_status } = eventData.payload;
// Get existing record
const message = await db.messages.findById(message_id);
// Only update if this event is newer
if (new Date(timestamp) > new Date(message.lastUpdatedAt)) {
await db.messages.update(message_id, {
status: message_status,
lastUpdatedAt: timestamp
});
}
}Cleanup Strategy
Clean up old event records periodically:
// Run daily
async function cleanupOldEvents() {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
await db.webhookEvents.deleteMany({
where: {
processedAt: { lt: thirtyDaysAgo },
status: 'completed'
}
});
}Best Practices
1. Always Acknowledge Quickly
app.post('/webhooks/sent', async (req, res) => {
// Acknowledge immediately
res.sendStatus(200);
// Process asynchronously — include X-Webhook-ID for idempotency
await queue.add('process-webhook', {
eventId: req.headers['x-webhook-id'],
...req.body,
});
});2. Handle Duplicate Events Gracefully
// Don't throw errors for duplicates
if (existing) {
console.log('Duplicate event, skipping');
return; // Not an error
}3. Use Appropriate Deduplication Window
// Check last 7 days for duplicates
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const existing = await db.webhookEvents.findFirst({
where: {
eventId,
processedAt: { gte: oneWeekAgo }
}
});4. Log for Debugging
logger.info('Processing webhook event', {
eventId,
eventType: field,
messageId: eventData.payload?.message_id,
status: eventData.payload?.message_status,
timestamp: new Date().toISOString()
});