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 exceeded the webhook's configured timeout (5–120 s, default 30 s) |
| Network issues | Connection failed during delivery |
| System recovery | Event replay after maintenance |
Choosing an Idempotency Key
Key on the X-Webhook-Event-ID header. It is the UUID of the event behind the delivery, so every attempt at that event carries the same value and two different events never share one.
Two nearby headers are not dedupe keys:
X-Webhook-IDis the webhook configuration UUID, the same value for every delivery from that webhook.X-Webhook-Timestampis the send time of the attempt in hand, so it moves between retries. A key built on it records one event once per attempt, which is the opposite of what you want.
Payload-derived keys are narrower than the event ID and worth using when you also want to collapse events that are logically the same, such as a status transition re-sent after a provider replay:
- Inbound message events (
event=message.received):payload.message_idis unique per inbound message. - Outbound message events:
payload.message_id+payload.message_statusis unique per state transition. - Template events:
payload.template_id+payload.statusis unique per transition.
Event ID Deduplication
Store the idempotency key to skip duplicates:
function idempotencyKey(eventData: any, eventId: string): string {
if (eventData.event === 'message.received') {
// Inbound: each inbound message has its own UUID
return `in:${eventData.payload.message_id}`;
}
if (eventData.field === 'message' && eventData.payload.message_id) {
// Outbound: state transition is unique
return `msg:${eventData.payload.message_id}:${eventData.payload.message_status}`;
}
if (eventData.field === 'templates') {
return `tpl:${eventData.payload.template_id}:${eventData.payload.status}`;
}
// Fallback: the event's own UUID, identical on every attempt
return `evt:${eventId}`;
}
async function handleWebhook(eventData: any, eventId: string) {
const key = idempotencyKey(eventData, eventId);
const existing = await db.webhookEvents.findUnique({ where: { idempotencyKey: key } });
if (existing) {
console.log(`Event ${key} already processed`);
return;
}
await processEvent(eventData);
await db.webhookEvents.create({
data: { idempotencyKey: key, eventType: eventData.field, processedAt: new Date() }
});
}def idempotency_key(event_data: dict, event_id: str) -> str:
if event_data.get('event') == 'message.received':
# Inbound: each inbound message has its own UUID
return f"in:{event_data['payload']['message_id']}"
if event_data.get('field') == 'message' and event_data['payload'].get('message_id'):
return f"msg:{event_data['payload']['message_id']}:{event_data['payload']['message_status']}"
if event_data.get('field') == 'templates':
return f"tpl:{event_data['payload']['template_id']}:{event_data['payload']['status']}"
# Fallback: the event's own UUID, identical on every attempt
return f"evt:{event_id}"
async def handle_webhook(event_data: dict, event_id: str):
key = idempotency_key(event_data, event_id)
existing = db.webhook_events.find_unique(where={"idempotency_key": key})
if existing:
print(f"Event {key} already processed")
return
await process_event(event_data)
db.webhook_events.create({
"idempotency_key": key,
"event_type": event_data['field'],
"processed_at": datetime.now()
})import "fmt"
func idempotencyKey(event WebhookEvent, eventID string) string {
// WebhookEvent maps the envelope: Field `json:"field"`, Event `json:"event"`, Payload `json:"payload"`
if event.Event != nil && *event.Event == "message.received" {
// Inbound: each inbound message has its own UUID
return fmt.Sprintf("in:%s", event.Payload.MessageID)
}
if event.Field == "message" && event.Payload.MessageID != "" {
return fmt.Sprintf("msg:%s:%s", event.Payload.MessageID, event.Payload.MessageStatus)
}
if event.Field == "templates" {
return fmt.Sprintf("tpl:%s:%s", event.Payload.TemplateID, event.Payload.Status)
}
// Fallback: the event's own UUID, identical on every attempt
return fmt.Sprintf("evt:%s", eventID)
}
func handleWebhook(event WebhookEvent, eventID string) error {
key := idempotencyKey(event, eventID)
var exists bool
if err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM webhook_events WHERE idempotency_key = $1)", key).Scan(&exists); err != nil {
return err
}
if exists {
log.Printf("Event %s already processed", key)
return nil
}
if err := processEvent(event); err != nil {
return err
}
_, err := db.Exec(
"INSERT INTO webhook_events (idempotency_key, event_type, processed_at) VALUES ($1, $2, $3)",
key, event.Field, time.Now(),
)
return err
}Database Transaction
Ensure atomic processing with transactions, keyed by the same idempotency key:
const key = idempotencyKey(eventData, eventId);
await db.$transaction(async (tx) => {
// Record event processing start
await tx.webhookEvents.create({
data: {
idempotencyKey: key,
eventType: eventData.field,
status: 'processing'
}
});
// Process business logic
if (eventData.field === 'message') {
await tx.messages.update({
where: { id: eventData.payload.message_id },
data: { status: eventData.payload.message_status }
});
}
// Mark as completed
await tx.webhookEvents.update({
where: { idempotencyKey: key },
data: { status: 'completed' }
});
});key = idempotency_key(event_data, event_id)
with db.transaction():
# Record event processing start
webhook_event = WebhookEvent(
idempotency_key=key,
event_type=event_data['field'],
status="processing"
)
db.add(webhook_event)
# Process business logic
if event_data['field'] == 'message':
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()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
// Delivery progression only — see the Events Reference for the full message_status list
const statusOrder = ['QUEUED', 'ROUTED', '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 Budget
Each webhook has a configurable retry budget (retry_count). When a delivery fails (non-2xx, timeout, or network error), Sent retries with exponential backoff (the first retry fires about a minute after the failure and the delay grows with each attempt) until either the endpoint returns 2xx or the budget is exhausted, at which point the event is marked FAILED. Because retries for a single event can arrive well after the original delivery, size your deduplication window generously (the 7-day window shown later on this page is a safe default). X-Webhook-Attempt carries the index of the attempt in hand, starting at 1, so you can log a redelivery without keeping state of your own.
The retry schedule, retry budget, and auto-disable threshold are stated canonically in Delivery Statuses & Retries on the Events Reference.
After the configured retry budget is exhausted the event is not retried indefinitely. It is left in the FAILED state. Inspect the failed events in the Sent Dashboard to diagnose endpoint problems and replay if needed.
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 — derive idempotency key from the payload itself
await queue.add('process-webhook', {
eventId: req.headers['x-webhook-event-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: {
idempotencyKey: key,
processedAt: { gte: oneWeekAgo }
}
});4. Log for Debugging
logger.info('Processing webhook event', {
idempotencyKey: key,
eventType: field,
messageId: eventData.payload?.message_id,
status: eventData.payload?.message_status,
timestamp: new Date().toISOString()
});