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 took > 5 seconds
Network issuesConnection failed during delivery
System recoveryEvent 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:

AttemptDelay After Failure
1Immediate
25 seconds
325 seconds
42 minutes
510 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()
});

On this page