Skip to main content
Lettr automatically retries failed webhook deliveries to ensure you receive all events. Understanding the retry behavior helps you build resilient integrations that handle temporary failures gracefully.

Retry Schedule

When a webhook delivery fails, Lettr retries with exponential backoff. This approach balances timely delivery with giving your system time to recover from outages.
AttemptDelayCumulative Time
1Immediate0
21 minute1 minute
35 minutes6 minutes
430 minutes36 minutes
52 hours2.6 hours
68 hours10.6 hours
724 hours34.6 hours
After 7 failed attempts spanning approximately 35 hours, the webhook delivery is marked as permanently failed. At this point, no further automatic retries occur.
The retry schedule provides a balance between timely delivery and avoiding overwhelming a struggling endpoint. Most transient issues resolve within the first few retry attempts.

Success Criteria

For a webhook delivery to be considered successful, your endpoint must:
  1. Return a 2xx status code (200, 201, 202, 204, etc.)
  2. Respond within 30 seconds
// Good - Returns 200 immediately
app.post('/webhooks/lettr', (req, res) => {
  res.sendStatus(200);
  // Process asynchronously after responding
  setImmediate(() => processEvent(req.body));
});

// Bad - Processes before responding (may timeout)
app.post('/webhooks/lettr', async (req, res) => {
  await processEvent(req.body); // This might take > 30 seconds
  res.sendStatus(200);
});

Failure Conditions

Webhooks are retried when any of these conditions occur:
ConditionDescriptionRetry?
HTTP 4xx (except 410)Client errors (400, 401, 403, 404, etc.)Yes
HTTP 5xxServer errors (500, 502, 503, etc.)Yes
HTTP 410 GoneIndicates endpoint is permanently goneNo - webhook disabled
Connection timeoutNo response within 30 secondsYes
Connection refusedServer not accepting connectionsYes
DNS failureDomain cannot be resolvedYes
SSL/TLS errorCertificate or handshake issuesYes
HTTP 2xxSuccess responsesNo - delivery complete

The 410 Gone Response

Returning a 410 Gone status code is a signal to permanently disable the webhook. Use this when:
  • You’re decommissioning an endpoint
  • The webhook should no longer receive events
  • You want to stop retries without deleting the webhook via API
// Permanently disable this webhook
app.post('/webhooks/lettr', (req, res) => {
  if (shouldDisableWebhook()) {
    return res.sendStatus(410); // Webhook will be disabled
  }
  // Normal processing
  res.sendStatus(200);
});

Idempotency and Duplicate Handling

Because webhooks can be retried, your endpoint may receive the same event multiple times. Always implement idempotent handling.

Why Duplicates Occur

  1. Network issues: Your server responds 200, but the response doesn’t reach Lettr
  2. Timeout at boundary: Processing completes at exactly 30 seconds
  3. Infrastructure retries: Load balancers or proxies may retry requests

Handling Duplicates

Use the event id to detect and skip duplicate deliveries:
import { Redis } from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);
const PROCESSED_EVENT_TTL = 86400; // 24 hours

app.post('/webhooks/lettr', async (req, res) => {
  const event = req.body;

  // Check if already processed
  const key = `webhook:${event.id}`;
  const alreadyProcessed = await redis.get(key);

  if (alreadyProcessed) {
    console.log(`Duplicate event ${event.id}, skipping`);
    return res.sendStatus(200); // Still return 200!
  }

  // Mark as processed (with TTL)
  await redis.set(key, '1', 'EX', PROCESSED_EVENT_TTL);

  // Process the event
  await handleEvent(event);

  res.sendStatus(200);
});
Always return a 200 status code for duplicate events. Returning an error will trigger unnecessary retries.

Database-Based Deduplication

If you don’t have Redis, use your database:
app.post('/webhooks/lettr', async (req, res) => {
  const event = req.body;

  try {
    // Insert with unique constraint on event_id
    await db.processedWebhooks.insert({
      event_id: event.id,
      event_type: event.type,
      received_at: new Date()
    });
  } catch (err) {
    if (err.code === '23505') { // PostgreSQL unique violation
      console.log(`Duplicate event ${event.id}, skipping`);
      return res.sendStatus(200);
    }
    throw err;
  }

  // Process the event
  await handleEvent(event);
  res.sendStatus(200);
});

Monitoring Webhook Health

Webhook Status

Each webhook exposes two status fields via the API:
FieldValuesDescription
enabledtrue / falseWhether the webhook is currently enabled (can be toggled manually or auto-disabled after sustained failures)
last_status"success" / "failure" / nullThe result of the most recent delivery attempt (null if no attempts yet)

Check Webhook Status via API

You can check webhook status using the read-only API:
curl -X GET "https://app.lettr.com/api/webhooks/{webhookId}" \
  -H "Authorization: Bearer lttr_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
The response includes status information:
{
  "message": "Webhook retrieved successfully.",
  "data": {
    "id": "webhook-abc123",
    "name": "Order Notifications",
    "url": "https://example.com/webhook",
    "enabled": true,
    "event_types": ["message.delivery", "message.bounce"],
    "auth_type": "basic",
    "has_auth_credentials": true,
    "last_successful_at": "2024-01-15T10:30:00+00:00",
    "last_failure_at": null,
    "last_status": "success"
  }
}

Dashboard Monitoring

The Lettr dashboard provides visibility into webhook delivery:
  1. Navigate to Webhooks in the sidebar
  2. Select a webhook to view its details
  3. See last attempt time, last status, and enabled state

Automatic Disabling

Webhooks are automatically disabled after sustained failures to protect both systems:
  • Prevents queue buildup of undeliverable events
  • Reduces load on your failing endpoint
  • Alerts you to investigate the issue
When a webhook is auto-disabled, you’ll see it in the dashboard and can re-enable it after fixing the issue. Navigate to Webhooks in the sidebar, select the disabled webhook, and toggle it back on.
Before re-enabling, ensure the underlying issue is resolved. Re-enabling without fixing the problem will lead to immediate failures and potentially another auto-disable.

Best Practices for Reliable Delivery

1. Respond Quickly

Return a 200 response as fast as possible. Process events asynchronously:
app.post('/webhooks/lettr', (req, res) => {
  // Acknowledge immediately
  res.sendStatus(200);

  // Process in background
  setImmediate(async () => {
    try {
      await processEvent(req.body);
    } catch (err) {
      console.error('Processing failed:', err);
      // Store for manual retry or alerting
      await storeFailedEvent(req.body, err);
    }
  });
});

2. Use a Queue

For high-volume or complex processing, use a message queue:
import { Queue } from 'bullmq';

const webhookQueue = new Queue('webhooks');

app.post('/webhooks/lettr', async (req, res) => {
  // Add to queue with deduplication
  await webhookQueue.add(req.body.type, req.body, {
    jobId: req.body.id // Prevents duplicate jobs
  });

  res.sendStatus(200);
});

3. Handle Partial Failures

If processing involves multiple steps, handle partial failures gracefully:
async function processEvent(event) {
  // Critical operation - must succeed
  await updateDatabase(event);

  // Non-critical operations - fail gracefully
  const nonCriticalTasks = [
    sendNotification(event).catch(err => {
      console.warn('Notification failed:', err);
    }),
    updateAnalytics(event).catch(err => {
      console.warn('Analytics failed:', err);
    })
  ];

  await Promise.all(nonCriticalTasks);
}

4. Monitor and Alert

Set up monitoring for webhook health:
// Track webhook processing metrics
const metrics = {
  received: 0,
  processed: 0,
  failed: 0,
  duplicates: 0
};

app.post('/webhooks/lettr', async (req, res) => {
  metrics.received++;

  const isDuplicate = await checkDuplicate(req.body.id);
  if (isDuplicate) {
    metrics.duplicates++;
    return res.sendStatus(200);
  }

  try {
    await processEvent(req.body);
    metrics.processed++;
  } catch (err) {
    metrics.failed++;
    console.error('Webhook processing failed:', err);
    // Alert if failure rate is high
    if (metrics.failed / metrics.received > 0.1) {
      await alertOps('High webhook failure rate');
    }
  }

  res.sendStatus(200);
});

5. Implement Health Checks

Ensure your webhook endpoint is monitored:
// Health check endpoint
app.get('/webhooks/health', (req, res) => {
  const healthy = checkDatabaseConnection() && checkQueueConnection();
  res.status(healthy ? 200 : 503).json({ healthy });
});

Troubleshooting

Common Issues

ProblemPossible CauseSolution
All webhooks timing outSlow processing before responseReturn 200 immediately, process async
Intermittent failuresResource exhaustionAdd queue, increase capacity
SSL errorsCertificate issuesVerify certificate chain, check expiry
4xx errorsAuthentication/authorizationCheck auth config, verify endpoint path
No webhooks receivedWebhook disabledCheck status in dashboard, re-enable

Debugging Failed Deliveries

  1. Check delivery history in dashboard or via API
  2. Review response codes and error messages
  3. Check your server logs for the corresponding requests
  4. Verify endpoint URL is correct and accessible
  5. Test with manual retry after fixing issues

Event Ordering

Webhooks are delivered in approximate order, but strict ordering is not guaranteed. Events may arrive out of order due to:
  • Retry delays
  • Network latency variations
  • Parallel processing
Design your handlers to be order-independent when possible:
async function handleEmailStatus(event) {
  const { emailId, status, timestamp } = event.data;

  // Use timestamp to handle out-of-order updates
  await db.emails.update(
    { id: emailId },
    {
      status,
      status_updated_at: timestamp
    },
    {
      // Only update if this event is newer
      where: {
        OR: [
          { status_updated_at: null },
          { status_updated_at: { lt: timestamp } }
        ]
      }
    }
  );
}