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

ScenarioBehavior
No acknowledgmentYour endpoint didn't return 2xx
TimeoutYour endpoint exceeded the webhook's configured timeout (5–120 s, default 30 s)
Network issuesConnection failed during delivery
System recoveryEvent 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_id is unique per inbound message.
  • Outbound message events: payload.message_id + payload.message_status is unique per state transition.
  • Template events: payload.template_id + payload.status is 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()
});

On this page