Skip to main content
Testing webhooks requires a different approach than testing typical API integrations. Since webhooks are push-based, your local development server isn’t directly accessible from Lettr’s servers. This guide covers strategies for testing webhooks at every stage of development.

Local Development

During local development, you need a way to receive webhooks on your machine. There are several approaches.

Using ngrok

ngrok creates a secure tunnel from a public URL to your local server, allowing Lettr to send webhooks directly to your development machine.
  1. Install ngrok:
# macOS
brew install ngrok

# Or download from https://ngrok.com/download
  1. Start your local server:
npm run dev  # Your app running on port 3000
  1. Create a tunnel:
ngrok http 3000
  1. Copy the HTTPS URL:
Forwarding  https://abc123.ngrok.io -> http://localhost:3000
  1. Configure webhook in Lettr: Go to Webhooks in the sidebar and create a new webhook pointing to your ngrok URL (e.g., https://abc123.ngrok.io/webhooks/lettr).
Use ngrok’s paid plan to get a stable subdomain. Free URLs change each time you restart ngrok.

Using Cloudflare Tunnel

Cloudflare Tunnel provides a similar capability:
# Install cloudflared
brew install cloudflared

# Create tunnel
cloudflared tunnel --url http://localhost:3000

Using localtunnel

localtunnel is a free, open-source alternative:
npx localtunnel --port 3000

Triggering Test Events

The simplest way to trigger real webhook events during testing is to send an actual email through the API. This generates authentic message.injection and message.delivery (or message.bounce) events that are delivered to your webhook endpoint:
curl -X POST "https://app.lettr.com/api/emails" \
  -H "Authorization: Bearer lttr_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "test@yourdomain.com",
    "to": ["verified-test@example.com"],
    "subject": "Webhook Test",
    "text": "Testing webhook delivery"
  }'

Unit Testing Your Handler

Test your webhook handler logic without making real HTTP requests.

Basic Unit Test

import { describe, it, expect, vi } from 'vitest';
import { handleWebhook } from './webhookHandler';

describe('Webhook Handler', () => {
  it('handles delivery event', async () => {
    const mockDb = {
      emails: {
        update: vi.fn()
      }
    };

    const event = {
      msys: {
        message_event: {
          type: 'delivery',
          message_id: 'msg_456',
          rcpt_to: 'recipient@example.com',
          timestamp: '2024-01-15T10:30:05Z'
        }
      }
    };

    await handleWebhook(event, { db: mockDb });

    expect(mockDb.emails.update).toHaveBeenCalledWith('msg_456', {
      status: 'delivered',
      delivered_at: expect.any(Date)
    });
  });

  it('handles bounce event', async () => {
    const mockDb = {
      emails: { update: vi.fn() },
      suppressions: { add: vi.fn() }
    };

    const event = {
      msys: {
        message_event: {
          type: 'bounce',
          message_id: 'msg_456',
          rcpt_to: 'invalid@example.com',
          bounce_class: '10',
          raw_reason: '550 User unknown'
        }
      }
    };

    await handleWebhook(event, { db: mockDb });

    expect(mockDb.suppressions.add).toHaveBeenCalledWith(
      'invalid@example.com',
      'hard_bounce'
    );
  });

  it('handles relay delivery event', async () => {
    const mockTicketService = {
      create: vi.fn().mockResolvedValue({ id: 'ticket_123' })
    };

    const event = {
      msys: {
        relay_event: {
          type: 'relay_delivery',
          rcpt_to: 'support@mail.example.com',
          friendly_from: 'customer@example.com',
          subject: 'Help needed'
        }
      }
    };

    await handleWebhook(event, { ticketService: mockTicketService });

    expect(mockTicketService.create).toHaveBeenCalledWith({
      sender: 'customer@example.com',
      subject: 'Help needed'
    });
  });
});

Testing Authentication Verification

If your webhook uses basic authentication, test that your handler correctly verifies credentials:
import { verifyBasicAuth } from './webhookHandler';

describe('Basic Auth Verification', () => {
  const username = 'webhook';
  const password = 'test-secret';

  function encodeBasicAuth(user, pass) {
    return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
  }

  it('accepts valid credentials', () => {
    const authHeader = encodeBasicAuth(username, password);
    expect(verifyBasicAuth(authHeader, username, password)).toBe(true);
  });

  it('rejects invalid credentials', () => {
    const authHeader = encodeBasicAuth('wrong', 'credentials');
    expect(verifyBasicAuth(authHeader, username, password)).toBe(false);
  });

  it('rejects missing auth header', () => {
    expect(verifyBasicAuth(undefined, username, password)).toBe(false);
  });
});

Integration Testing

Test the full webhook flow including HTTP handling.

Using Supertest

import request from 'supertest';
import { app } from './app';

describe('Webhook Endpoint', () => {
  const username = process.env.WEBHOOK_USERNAME;
  const password = process.env.WEBHOOK_PASSWORD;

  function basicAuth(user, pass) {
    return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
  }

  it('returns 200 for valid webhook with correct auth', async () => {
    const payload = [{
      msys: {
        message_event: {
          type: 'delivery',
          message_id: 'msg_456',
          rcpt_to: 'recipient@example.com'
        }
      }
    }];

    const response = await request(app)
      .post('/webhooks/lettr')
      .set('Content-Type', 'application/json')
      .set('Authorization', basicAuth(username, password))
      .send(payload);

    expect(response.status).toBe(200);
  });

  it('returns 401 for invalid credentials', async () => {
    const response = await request(app)
      .post('/webhooks/lettr')
      .set('Content-Type', 'application/json')
      .set('Authorization', basicAuth('wrong', 'credentials'))
      .send([]);

    expect(response.status).toBe(401);
  });

  it('returns 401 for missing auth', async () => {
    const response = await request(app)
      .post('/webhooks/lettr')
      .set('Content-Type', 'application/json')
      .send([]);

    expect(response.status).toBe(401);
  });
});

End-to-End Testing

Test the complete flow from sending an email to receiving webhooks.

Trigger Real Events

Send actual emails to trigger genuine webhook events:
// test/e2e/webhooks.test.js
import { lettr } from './client';
import { waitForWebhook } from './helpers';

describe('Webhook E2E', () => {
  it('receives delivery webhook after sending email', async () => {
    // Send an email
    const { data: email } = await lettr.emails.send({
      from: 'test@yourdomain.com',
      to: ['verified-test@example.com'], // Use a verified test address
      subject: 'E2E Test',
      text: 'Testing webhook delivery'
    });

    // Wait for webhook (with timeout)
    const webhook = await waitForWebhook({
      type: 'delivery',
      timeout: 60000 // 60 seconds
    });

    expect(webhook.msys.message_event.type).toBe('delivery');
  }, 120000); // Jest timeout
});

Webhook Collector Helper

Create a helper to capture webhooks during tests:
// test/helpers/webhookCollector.js
import express from 'express';

export function createWebhookCollector(port = 4000) {
  const app = express();
  const events = [];
  const waiters = [];

  app.use(express.json());

  app.post('/webhooks', (req, res) => {
    events.push(req.body);

    // Check if any waiters match this event
    waiters.forEach((waiter, index) => {
      if (waiter.matches(req.body)) {
        waiter.resolve(req.body);
        waiters.splice(index, 1);
      }
    });

    res.sendStatus(200);
  });

  const server = app.listen(port);

  return {
    url: `http://localhost:${port}/webhooks`,
    events,

    waitFor(predicate, timeout = 30000) {
      return new Promise((resolve, reject) => {
        // Check existing events
        const existing = events.find(predicate);
        if (existing) {
          return resolve(existing);
        }

        // Wait for matching event
        const timer = setTimeout(() => {
          reject(new Error('Webhook wait timeout'));
        }, timeout);

        waiters.push({
          matches: predicate,
          resolve: (event) => {
            clearTimeout(timer);
            resolve(event);
          }
        });
      });
    },

    clear() {
      events.length = 0;
    },

    close() {
      server.close();
    }
  };
}

// Usage in tests
const collector = createWebhookCollector();

// Configure webhook in the dashboard to point to collector.url
// Then send an email to trigger delivery events

const event = await collector.waitFor(
  (e) => e.msys?.message_event?.type === 'delivery'
);

collector.close();

Staging Environment Testing

Before deploying to production, test webhooks in a staging environment.

Checklist

  1. Configure staging webhook endpoint in the Lettr dashboard, pointing to your staging URL (e.g., https://staging.example.com/webhooks/lettr) and subscribing to all events
  2. Verify SSL certificate - Ensure your staging environment has a valid SSL certificate
  3. Test all event types - Send test events for each type you handle
  4. Test retry handling - Temporarily return errors to verify retry behavior
  5. Test authentication - Confirm your staging environment uses the correct webhook credentials
  6. Load testing - Send multiple webhooks to test concurrent handling

Simulating Failures

Test how your system handles webhook failures:
// Temporarily fail webhooks to test retry behavior
let failCount = 0;

app.post('/webhooks/lettr', (req, res) => {
  if (failCount < 3) {
    failCount++;
    console.log(`Simulating failure ${failCount}`);
    return res.sendStatus(500);
  }

  // Process normally after 3 failures
  processWebhook(req.body);
  res.sendStatus(200);
});

Debugging Webhooks

Logging Incoming Webhooks

Add comprehensive logging during development:
app.post('/webhooks/lettr', express.json(), (req, res) => {
  console.log('=== Webhook Received ===');
  console.log('Headers:', JSON.stringify(req.headers, null, 2));
  console.log('Body:', JSON.stringify(req.body, null, 2));
  console.log('========================');

  // ... rest of handler
});

Request Inspection Tools

Use tools like Webhook.site or RequestBin to inspect webhook payloads:
  1. Get a temporary URL from Webhook.site
  2. Configure a test webhook to that URL
  3. Send test events and inspect the raw requests
  4. Copy the payload structure for your tests

Dashboard Webhook Details

The Lettr dashboard shows webhook status information:
  1. Go to Webhooks in the sidebar
  2. Select your webhook
  3. View the webhook details including last attempt time, last status, and enabled state

Common Testing Pitfalls

Never commit webhook authentication credentials to source control. Use environment variables.
// Wrong
const username = 'webhook';
const password = 'my-secret-password';

// Right
const username = process.env.WEBHOOK_USERNAME;
const password = process.env.WEBHOOK_PASSWORD;
Always test that your handler correctly handles duplicate events.
it('handles duplicate events idempotently', async () => {
  const event = createTestEvent('delivery');

  // Process twice
  await handleWebhook(event);
  await handleWebhook(event);

  // Should only have one record
  const records = await db.deliveries.count({ eventId: event.id });
  expect(records).toBe(1);
});
Test what happens when downstream services fail.
it('handles database errors gracefully', async () => {
  mockDb.update.mockRejectedValue(new Error('Connection lost'));

  const event = createTestEvent('delivery');

  // Should not throw, but should store for retry
  await handleWebhook(event);

  expect(mockFailedEventStore.add).toHaveBeenCalled();
});