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.
Install ngrok :
# macOS
brew install ngrok
# Or download from https://ngrok.com/download
Start your local server :
npm run dev # Your app running on port 3000
Create a tunnel :
Copy the HTTPS URL :
Forwarding https://abc123.ngrok.io -> http://localhost:3000
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
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
Verify SSL certificate - Ensure your staging environment has a valid SSL certificate
Test all event types - Send test events for each type you handle
Test retry handling - Temporarily return errors to verify retry behavior
Test authentication - Confirm your staging environment uses the correct webhook credentials
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
});
Use tools like Webhook.site or RequestBin to inspect webhook payloads:
Get a temporary URL from Webhook.site
Configure a test webhook to that URL
Send test events and inspect the raw requests
Copy the payload structure for your tests
Dashboard Webhook Details
The Lettr dashboard shows webhook status information:
Go to Webhooks in the sidebar
Select your webhook
View the webhook details including last attempt time, last status, and enabled state
Common Testing Pitfalls
Hardcoded credentials in production
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 );
});
Not testing error scenarios
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 ();
});