> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lettr.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Testing Webhooks

> Test Lettr webhooks locally with ngrok tunnels and verify event handling before deploying your endpoint to production.

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](https://ngrok.com) 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**:

```bash theme={null}
# macOS
brew install ngrok

# Or download from https://ngrok.com/download
```

2. **Start your local server**:

```bash theme={null}
npm run dev  # Your app running on port 3000
```

3. **Create a tunnel**:

```bash theme={null}
ngrok http 3000
```

4. **Copy the HTTPS URL**:

```
Forwarding  https://abc123.ngrok.io -> http://localhost:3000
```

5. **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`).

<Tip>
  Use ngrok's paid plan to get a stable subdomain. Free URLs change each time you restart ngrok.
</Tip>

### Using Cloudflare Tunnel

[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/) provides a similar capability:

```bash theme={null}
# Install cloudflared
brew install cloudflared

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

### Using localtunnel

[localtunnel](https://github.com/localtunnel/localtunnel) is a free, open-source alternative:

```bash theme={null}
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:

```bash theme={null}
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

```javascript theme={null}
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:

```javascript theme={null}
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

```javascript theme={null}
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:

```javascript theme={null}
// 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:

```javascript theme={null}
// 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:

```javascript theme={null}
// 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:

```javascript theme={null}
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](https://webhook.site) or [RequestBin](https://requestbin.com) 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

<AccordionGroup>
  <Accordion title="Hardcoded credentials in production">
    Never commit webhook authentication credentials to source control. Use environment variables.

    ```javascript theme={null}
    // Wrong
    const username = 'webhook';
    const password = 'my-secret-password';

    // Right
    const username = process.env.WEBHOOK_USERNAME;
    const password = process.env.WEBHOOK_PASSWORD;
    ```
  </Accordion>

  <Accordion title="Not testing idempotency">
    Always test that your handler correctly handles duplicate events.

    ```javascript theme={null}
    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);
    });
    ```
  </Accordion>

  <Accordion title="Not testing error scenarios">
    Test what happens when downstream services fail.

    ```javascript theme={null}
    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();
    });
    ```
  </Accordion>
</AccordionGroup>

## Related Topics

<CardGroup cols={2}>
  <Card title="Handling Webhooks" icon="code" href="/learn/webhooks/handling">
    Best practices for processing webhooks reliably
  </Card>

  <Card title="Authorization" icon="lock" href="/learn/webhooks/authorization">
    Secure your endpoints with authentication
  </Card>

  <Card title="Retries" icon="rotate" href="/learn/webhooks/retries">
    Understand retry behavior and failure handling
  </Card>

  <Card title="Event Types" icon="list" href="/learn/webhooks/event-types">
    Complete reference of all webhook events
  </Card>
</CardGroup>
