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
Sent's webhook delivery does not include a per-event unique ID in headers or payload. The X-Webhook-ID header is the webhook configuration UUID (the same value for every delivery from that webhook), so it cannot be used as a dedupe key.
Use one of these instead:
- 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. - Generic fallback: hash the canonical JSON body together with
X-Webhook-Timestamp.
Event ID Deduplication
Once you've derived an idempotency key from the payload, store it to skip duplicates:
import crypto from 'crypto';
function idempotencyKey(eventData: any, timestamp: 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: timestamp + canonical body hash
const hash = crypto.createHash('sha256').update(JSON.stringify(eventData)).digest('hex');
return `raw:${timestamp}:${hash}`;
}
async function handleWebhook(eventData: any, timestamp: string) {
const key = idempotencyKey(eventData, timestamp);
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() }
});
}import hashlib, json
def idempotency_key(event_data: dict, timestamp: 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']}"
body_hash = hashlib.sha256(json.dumps(event_data, sort_keys=True).encode()).hexdigest()
return f"raw:{timestamp}:{body_hash}"
async def handle_webhook(event_data: dict, timestamp: str):
key = idempotency_key(event_data, timestamp)
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 (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
)
func idempotencyKey(event WebhookEvent, timestamp 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)
}
body, _ := json.Marshal(event)
sum := sha256.Sum256(body)
return fmt.Sprintf("raw:%s:%s", timestamp, hex.EncodeToString(sum[:]))
}
func handleWebhook(event WebhookEvent, timestamp string) error {
key := idempotencyKey(event, timestamp)
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 payload-derived idempotency key:
const key = idempotencyKey(eventData, timestamp);
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, timestamp)
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).
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', {
timestamp: req.headers['x-webhook-timestamp'],
...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()
});