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–120s, default 30s) |
| 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:
- Outbound message events —
payload.message_id+payload.message_statusis unique per state transition. - Inbound message events (
message.received) — combinepayload.account_id,payload.from,payload.received_at, and a hash ofpayload.text. - 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.field === 'message' && eventData.payload.message_id) {
// Outbound: state transition is unique
return `msg:${eventData.payload.message_id}:${eventData.payload.message_status}`;
}
if (eventData.sub_type === 'message.received') {
// Inbound: from + received_at + text hash
const { from, received_at, text } = eventData.payload;
const textHash = crypto.createHash('sha256').update(text ?? '').digest('hex').slice(0, 16);
return `in:${from}:${received_at}:${textHash}`;
}
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('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('sub_type') == 'message.received':
p = event_data['payload']
text_hash = hashlib.sha256((p.get('text') or '').encode()).hexdigest()[:16]
return f"in:{p['from']}:{p['received_at']}:{text_hash}"
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 {
if event.Field == "message" && event.Payload.MessageID != "" {
return fmt.Sprintf("msg:%s:%s", event.Payload.MessageID, event.Payload.MessageStatus)
}
if event.SubType != nil && *event.SubType == "message.received" {
sum := sha256.Sum256([]byte(event.Payload.Text))
return fmt.Sprintf("in:%s:%s:%s", event.Payload.From, event.Payload.ReceivedAt, hex.EncodeToString(sum[:])[:16])
}
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:
await db.$transaction(async (tx) => {
// Record event processing start
await tx.webhookEvents.create({
data: {
eventId,
eventType: field,
status: 'processing'
}
});
// Process business logic
if (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: { 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 == '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
// Webhook message_status values: QUEUED, ROUTED, SENT, DELIVERED, READ, FAILED
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_count (1–5 attempts, default 3). When a delivery fails (non-2xx, timeout, or network error), Sent retries with backoff between attempts until either the endpoint returns 2xx or the budget is exhausted — at which point the event is marked FAILED.
| Property | Value |
|---|---|
| Retries per event | retry_count (1–5, default 3) |
| Backoff between attempts | Yes, applied automatically |
| Final state on success | DELIVERED |
| Final state after budget exhausted | FAILED |
| Auto-disable | After 10 consecutive failed events |
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()
});