Skip to main content
Lettr sends webhook events to your server when email events occur (delivery, bounce, open, click, complaint, etc.). When your endpoint fails to receive or process these events, you lose visibility into your email delivery pipeline. This guide covers how to diagnose and fix webhook delivery failures.

How Lettr Webhook Delivery Works

When an email event occurs, Lettr sends an HTTP POST request to your configured webhook endpoint. The delivery follows this sequence:
  1. Event occurs (e.g., email is delivered)
  2. Lettr queues the webhook payload
  3. Lettr sends an HTTP POST to your endpoint URL
  4. Your server must respond with a 2xx status code within 30 seconds
  5. If the request fails, Lettr retries with exponential backoff

What Counts as a Failure

ResponseResult
2xx status codeSuccess — event is marked as delivered
3xx redirectFailure — Lettr does not follow redirects
4xx status codeFailure — retried according to retry schedule
5xx status codeFailure — retried according to retry schedule
Connection timeout (30s)Failure — retried according to retry schedule
DNS resolution failureFailure — retried according to retry schedule
TLS handshake failureFailure — retried according to retry schedule
Lettr does not follow HTTP redirects (301, 302, 307, 308). Your endpoint must respond directly with a 2xx status. If your URL has changed, update it in the Webhooks settings.

Retry Logic and Schedule

When a webhook delivery fails, Lettr retries the request with exponential backoff:
AttemptDelay After Previous Attempt
1st retry1 minute
2nd retry5 minutes
3rd retry30 minutes
4th retry2 hours
5th retry8 hours
After 5 failed retries (6 total attempts), the webhook event is marked as permanently failed and no further delivery attempts are made.
Failed webhook events are visible in the Lettr dashboard under Webhooks > Event Log. You can manually retry individual failed events from this page.

Diagnosing Failures

Check the Webhook Event Log

The fastest way to diagnose webhook issues is the event log in the Lettr dashboard:
  1. Go to Webhooks in the dashboard
  2. Click on the webhook endpoint that is failing
  3. Review the Event Log tab
  4. Click on a failed event to see the full request and response details, including:
    • Request headers and body sent by Lettr
    • Response status code and body returned by your server
    • Error message (for connection-level failures)

Common Failure Patterns

Your server is taking longer than 30 seconds to respond.Fixes:
  • Process webhook events asynchronously. Accept the request immediately (return 200), then process the event in a background job.
  • Check if your server is overloaded or if the webhook handler has a performance bottleneck.
  • Verify your firewall allows inbound connections from Lettr’s IP ranges.
Lettr cannot resolve your webhook endpoint hostname.Fixes:
  • Verify the URL in your webhook settings is correct.
  • Check that your DNS records are properly configured and your domain is resolving.
  • If you recently changed DNS providers, wait for propagation to complete.
The TLS handshake to your server is failing.Fixes:
  • Verify your SSL certificate is valid and not expired.
  • Ensure your server supports TLS 1.2 or higher.
  • Check that the certificate chain is complete (intermediate certificates included).
# Test your endpoint's TLS configuration
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com
Your server is sometimes returning 500-series errors.Fixes:
  • Check your server logs around the time of the failed deliveries.
  • Look for out-of-memory errors, database connection pool exhaustion, or unhandled exceptions in your webhook handler.
  • Ensure your webhook handler is idempotent — it should handle receiving the same event more than once.
Your server is rejecting the request due to authentication or authorization rules.Fixes:
  • If you have IP-based access controls, ensure Lettr’s sending IPs are allowlisted.
  • If you require authentication headers, note that Lettr sends a lettr-signature header — make sure your middleware isn’t blocking the request before signature verification.
  • Check for CSRF protection middleware intercepting the POST request.

Handling Timeouts

The 30-second timeout is the most common cause of webhook failures. Your webhook handler should return a response as quickly as possible.
const express = require("express");
const app = express();

// Good: Accept immediately, process in background
app.post(
  "/webhooks/lettr",
  express.raw({ type: "application/json" }),
  (req, res) => {
    // Verify signature first
    const isValid = verifySignature(req);
    if (!isValid) {
      return res.status(401).send("Invalid signature");
    }

    // Parse and queue for background processing
    const event = JSON.parse(req.body);
    queue.add("process-webhook", event);

    // Respond immediately
    res.status(200).send("OK");
  }
);

// Bad: Processing synchronously blocks the response
app.post("/webhooks/lettr-slow", express.json(), (req, res) => {
  // This might take 30+ seconds and cause a timeout
  updateDatabase(req.body);
  sendSlackNotification(req.body);
  updateAnalytics(req.body);

  res.status(200).send("OK");
});
Use a background job queue (Bull, Sidekiq, Laravel Queues, etc.) to process webhook events. This ensures you respond within the timeout and can handle high event volumes without blocking.

Signature Verification

Every webhook request from Lettr includes a lettr-signature header containing an HMAC-SHA256 signature of the request body. Always verify this signature to confirm the request is from Lettr.

Verification Steps

  1. Get the raw request body (unparsed)
  2. Compute an HMAC-SHA256 hash using your webhook secret
  3. Compare the computed hash with the lettr-signature header value
const crypto = require("crypto");

function verifySignature(req) {
  const signature = req.headers["lettr-signature"];
  const webhookSecret = process.env.LETTR_WEBHOOK_SECRET;

  const expectedSignature = crypto
    .createHmac("sha256", webhookSecret)
    .update(req.body) // Must be the raw body (Buffer), not parsed JSON
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

Common Signature Verification Failures

ProblemCauseFix
Signature always failsBody parsed as JSON before verificationUse express.raw() on the webhook route
Wrong signature header nameLooking for x-signature instead of lettr-signatureUse the correct header: lettr-signature
Wrong secretUsing a secret from a different webhook endpointEach webhook endpoint has its own secret — check the dashboard
Timing-based failuresUsing === instead of timingSafeEqualUse crypto.timingSafeEqual() to prevent timing attacks
Never skip signature verification in production. Without it, anyone who discovers your webhook URL can send forged events to your server.

Idempotency

Lettr may deliver the same webhook event more than once (due to retries or network issues). Your handler must be idempotent — processing the same event twice should not cause duplicate side effects.

Implementing Idempotency

Use the event ID included in every webhook payload to deduplicate:
async function processWebhook(event) {
  // Check if we've already processed this event
  const exists = await db.webhookEvents.findOne({ eventId: event.id });
  if (exists) {
    console.log(`Event ${event.id} already processed, skipping`);
    return;
  }

  // Process the event
  await handleEvent(event);

  // Record that we've processed it
  await db.webhookEvents.insertOne({
    eventId: event.id,
    processedAt: new Date(),
  });
}

Testing Webhooks

Before deploying to production, test your webhook endpoint to verify it handles events correctly.

Using Lettr’s Test Events

Send test events from the Lettr dashboard:
  1. Go to Webhooks and select your endpoint
  2. Click Send Test Event
  3. Select the event type to test
  4. Check your server logs and the event log to verify delivery

Using a Tunnel for Local Development

To test webhooks against a local development server, use a tunnel service:
# Using ngrok
ngrok http 3000

# Using cloudflared
cloudflared tunnel --url http://localhost:3000
Copy the generated public URL and use it as your webhook endpoint in the Lettr dashboard. See Webhook Testing for more details.