Webhooks

Webhooks

Use webhooks to receive an HTTP POST request when a Lettermint event occurs. A webhook removes the need to poll the Team API for delivery updates.

When to use webhooks

ApproachHow it worksBest for
PollingYour server requests updates on a schedule.Scheduled reports and occasional checks.
WebhooksLettermint sends each selected event to your endpoint.Delivery handlers and event-driven workflows.

If your system processes bounce or complaint emails, use bounce and complaint forwarding. You can use forwarding and webhooks together.

Common use cases:

  • Bounce handling: Remove invalid addresses from a mailing list.
  • Delivery confirmation: Update your database after delivery.
  • Engagement tracking: Start a workflow after a supported open or click.
  • Complaint management: Stop email to recipients who report spam.
  • Suppression synchronization: Copy suppression changes to another system.

Quick start

1. Create a webhook in the dashboard

  1. Go to Dashboard → Webhooks.
An overview of your route's webhooks in the dashboard
  1. Click Create webhook
The modal to create a webhook in the dashboard
  1. Enter your webhook details:

    • Name: e.g., "Production Events"
    • URL: your HTTPS endpoint (e.g., https://api.example.com/webhooks/lettermint)
    • Basic Auth: add a username and password if your endpoint requires a login
    • Scope: select all projects, one or more projects, or one or more routes
    • Events: select the event types you want to receive
    • Include machine events: off by default; enable it only if you want security scanner, preview, and generic bot tracking observations. Supported privacy opens do not need this option.
    • Enabled: keep on to start receiving events
  2. Save. Your webhook is now active.

Webhook details in the dashboard

2. Send a test delivery

From the webhook details page, click Test Webhook. You should receive a payload like this:

JSONCode
{ "id": "test-7f9c8e2a-1b3d-4f6e-b7d2-5c9f3a7e8b0c", "event": "webhook.test", "timestamp": "2025-08-08T20:14:12.000Z", "context": { "scope": "team", "team_id": "9f4e3d2c-1b0a-4987-9654-3210fedcba98", "project_id": null, "route_id": null }, "data": { "message": "This is a test webhook from Lettermint", "webhook_id": "9f9bf19c-4a2c-45f3-a6c7-bc937224ec5a", "timestamp": 1754921294 } }

You can use three webhook scopes:

  • Team: The webhook receives events from all current and future projects and routes.
  • Project: Select one or more projects. The webhook receives events from all current and future routes in those projects.
  • Route: Select one or more routes. The routes can be in different projects.

A webhook has one URL and one signing secret. The secret does not change when you change its targets.

Basic authentication

Use Basic Auth when your HTTPS endpoint requires a username and password. This works for team, project, and route webhooks, including test deliveries.

In the dashboard, open Basic Auth (optional) below Endpoint URL. Enter your username and password, then create the webhook or save your changes. You can click Show to check the password you enter. Opening or closing this row keeps your changes.

If credentials are saved, the row shows Configured. Open it and click Change credentials to enter a new username and password. The fields start empty. Click Cancel to keep the saved credentials.

To remove Basic Auth, open the row and click Remove. The message Will be removed when you save confirms the pending change. Click Undo to keep the saved credentials, or Save changes to remove them.

You can also paste a URL such as https://staging-user:demo-password@example.com/webhook. The dashboard moves the credentials into the auth fields and shows the URL without them. Percent-encode reserved characters in URL credentials. For example, use %40 for @ and %3A for :.

Webhook setup with the optional Basic Auth row open below Endpoint URL. The username and masked password use example data.

Lettermint encrypts saved credentials. The dashboard and API never return the saved username or password. The API returns has_basic_auth to show whether Basic Auth is configured.

Configure Basic Auth through the Team API

Send basic_auth with both username and password when you create or update a webhook:

Use this request body with POST /v1/webhooks:

JSONCode
{ "name": "Staging events", "scope": "team", "url": "https://example.com/webhook", "events": ["message.created"], "basic_auth": { "username": "staging-user", "password": "demo-password" } }

The response contains the URL without credentials and "has_basic_auth": true. A webhook read response includes these fields:

JSONCode
{ "url": "https://example.com/webhook", "has_basic_auth": true }

This example shows only the fields relevant to Basic Auth. See the Team API reference for the complete request and response schemas.

Update requestResult
Omit basic_authKeep the saved credentials, including when the URL changes.
Send both username and passwordReplace both credentials.
Send "basic_auth": nullRemove Basic Auth.

To keep credentials when you change the URL, send:

JSONCode
{ "url": "https://example.com/new-webhook" }

To remove Basic Auth, send:

JSONCode
{ "basic_auth": null }

Use these bodies with PUT /v1/webhooks/{webhook_id}. You can also include credentials in the URL. Use one credential input per request. A request with credentials in both the URL and basic_auth fails validation.

The username must not be empty or contain a colon. The password field is required but can be empty. Credentials must not contain control characters. Lettermint preserves spaces and other valid characters in credentials.

Continue to verify X-Lettermint-Signature before you process a delivery. Basic Auth passes your endpoint's login check. Signature verification confirms that Lettermint sent the request.

Your endpoint must use HTTPS and a public address. Basic Auth does not permit deliveries to localhost or private network addresses. Lettermint does not follow redirects.

Implementing a webhook endpoint

Here's a minimal endpoint that receives webhooks:

const express = require('express') const app = express() app.use(express.json()) app.post('/webhooks/lettermint', (req, res) => { const event = req.body // TODO: Verify signature in production! // See: https://docs.lettermint.co/platform/webhooks/signing console.log('Received:', event.event, event.id) // Return 200 quickly - do heavy processing async res.status(200).json({ received: true }) }) app.listen(3000)

Always verify webhook signatures in production. Without verification, anyone who discovers your endpoint URL can send fake events. See Signed webhooks for implementation examples.

Best practices

Return 200 quickly Do heavy processing asynchronously. If your endpoint takes too long or returns an error, we'll retry the delivery.

Implement idempotency Use the event.id field to detect duplicate deliveries and prevent processing the same event twice:

JavascriptCode
app.post('/webhooks/lettermint', async (req, res) => { const event = req.body // Check if we've already processed this event const alreadyProcessed = await db.webhookEvents.findUnique({ where: { eventId: event.id } }) if (alreadyProcessed) { return res.status(200).json({ received: true }) // Still return 200 } // Process the event await handleEvent(event) // Mark as processed await db.webhookEvents.create({ data: { eventId: event.id } }) res.status(200).json({ received: true }) })

Use the Test Webhook button in the dashboard to verify your endpoint is working before sending real emails.

HTTP headers

Every webhook delivery includes these headers:

HeaderDescription
X-Lettermint-SignatureHMAC-SHA256 signature for verification
X-Lettermint-EventEvent type (e.g., message.delivered)
X-Lettermint-DeliveryDelivery timestamp (Unix seconds)
X-Lettermint-AttemptRetry attempt number (1, 2, 3...)

See Signed webhooks for details on verifying the signature.

Webhook fields

FieldDescription
NameDisplay name for your webhook
URLHTTPS endpoint we POST to
ScopeAll projects, selected projects, or selected routes that send events to the webhook
EventsArray of event types to receive
Delivery modeWhether the webhook receives Live events, Sandbox events, or both. New team, project, and route webhooks default to Live.
Include machine eventsWhether message.opened and message.clicked webhooks include security scanner, preview, and generic bot observations. Supported privacy opens do not need this option. Disabled by default.
EnabledWhether the webhook is active
SecretHMAC secret for signature verification (rotatable)
DeliveriesRecent delivery attempts with status, response, and timing

Create multiple webhooks when different systems need different event sets. Use one webhook with multiple targets when one system processes events from selected projects or routes.

Changing a project between Live and Sandbox does not change the webhook delivery mode filter. See Sandbox mode for simulated event sequences and payload fields.

Delivery and retries

We retry temporary failures with exponential backoff. Timeouts, HTTP 408, HTTP 429, and server errors can trigger retries. Other HTTP 4xx responses, including HTTP 401, do not trigger automatic retries. The request timeout is 30 seconds.

Retry schedule (12 total attempts: 1 initial attempt + 11 automatic retries):

AttemptDelay after previous
1Immediate
21 minute
32 minutes
45 minutes
510 minutes
610 minutes
715 minutes
830 minutes
91 hour
102 hours
114 hours
126 hours

This schedule spans roughly 14 hours from the initial delivery attempt to the final automatic retry.

After all retries are exhausted, the delivery is marked as failed. You can see all delivery attempts in the dashboard by clicking on your webhook.

Webhook delivery list showing status, response code, attempt count, and next retry time.

Retry a failed delivery

You can retry a failed delivery from the webhook's deliveries in the dashboard, with the Webhook API, or through MCP. Delivery logs are kept for 7 days, so you can retry a delivery within 7 days of its first attempt. A retry sends the original payload right away and counts as an attempt. If it fails with an error that triggers automatic retries, the schedule above continues. Each failed delivery shows when its next automatic retry is due.

You can't retry a successful delivery, a delivery whose first attempt is still in progress, or any delivery while the webhook is disabled.

Endpoints that keep failing

If 20 deliveries in a row fail with an error that triggers automatic retries, such as a timeout or a 5xx response, we pause requests to that webhook for one minute. Deliveries that come up during a pause are recorded as a skipped attempt and follow the retry schedule above, so a skipped attempt uses one of the 12 attempts.

When the pause ends we send one delivery to check the endpoint. A 2xx response, or an error that doesn't trigger retries, resumes normal delivery. If the delivery fails again, we pause for twice as long, up to five minutes. Test deliveries and manual retries are always sent.

Automatic disabling

We disable a webhook when every delivery to it has failed for 72 hours. A single successful delivery in that time keeps it enabled, and so does a short outage. Team members who can manage the webhook get an email when this happens, and the webhook page shows when it was disabled.

Events that occur while a webhook is disabled are not delivered and are not sent later. Once your endpoint responds with a 2xx status code again, turn the Enabled toggle back on in the webhook settings. Enabling the webhook or changing its URL starts a new 72 hours.

Troubleshooting

Webhook is disabled

Problem: No events are being delivered

Solution: Check that the webhook's Enabled toggle is on. Disabled webhooks won't send any deliveries. If you did not turn it off yourself, we may have disabled it automatically after 72 hours of failed deliveries.

No deliveries appear

Problem: Expected events aren't showing up

Solutions:

  • Use the Test Webhook button to verify your endpoint is reachable
  • Confirm your endpoint returns a 2xx status code
  • Check your server logs for incoming requests
  • Verify that the webhook scope includes the route and that the webhook uses the correct events

Repeated retries

Problem: The same event keeps being retried

Solutions:

  • Your endpoint must return 200-299 status quickly (within 30 seconds)
  • Move heavy processing to a background job and return 200 immediately
  • Implement idempotency using event.id to handle duplicate deliveries gracefully

Connection refused or timeout

Problem: Deliveries fail with connection errors

Solutions:

  • Ensure your endpoint is publicly accessible (not localhost)
  • Check firewall rules allow incoming HTTPS connections
  • Verify SSL/TLS certificate is valid and not expired
  • For local development, use a tunnel like ngrok

Endpoint returns HTTP 401

Check the delivery response and your endpoint logs. Your endpoint can return HTTP 401 because Basic Auth failed or because signature verification failed.

For Basic Auth failures, replace the saved credentials and send a test delivery. HTTP 401 does not trigger automatic retries. Check that your endpoint accepts the username and password sent in the Authorization: Basic header.

Signature verification fails

Problem: All webhooks are rejected as invalid

Solution: See the troubleshooting section in Signed webhooks.

Next steps