Webhooks
Event types, payload, signature verification, retries, pausing, test events and transport security.
letr.dev posts an event to your endpoints whenever an email is sent, delivered, deferred, bounced, drew a complaint or failed, and when your account is paused or reactivated. An account has up to 5 endpoints, each with its own event selection and signing secret. You create endpoints in the dashboard; the API can list, change, delete and test them.
Under /v1/webhooks, read_only and full_access keys list the endpoints, never their secrets. Only full_access may change url, events, enabled or paused, delete an endpoint or send a test event. Creating endpoints, rotating secrets and setting certificates stay in the dashboard. A sending_access key gets 403 insufficient_permission, and an endpoint of another account answers 404 not_found.
curl -X PATCH https://api.letr.dev/v1/webhooks/$ID \
-H "Authorization: Bearer $LETR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled": false}'
Fields you leave out stay as they are. Deleting an endpoint drops the deliveries still pending for it. The API reference lists every field.
Event types
| Type | Meaning |
|---|---|
email.sent |
letr.dev's mail server accepted the email for delivery. |
email.delivered |
The recipient's server accepted it. |
email.deferred |
Delivery failed for now and is retried. |
email.bounced |
The recipient's server refused it. |
email.complained |
The recipient marked it as spam. |
email.failed |
letr.dev gave up on it. |
account.paused |
Your account was paused and cannot send. |
account.reactivated |
Your account was resumed and can send again. |
webhook.test |
Sent by the test action only. |
Payload
{
"id": "evt_4xud2qy0r8lm1kc7tz5vb9wh",
"type": "email.delivered",
"createdAt": "2026-11-02T08:15:30Z",
"data": {
"emailId": "em_gk3lx0aqw2rz9vb7nc1ep4ty",
"recipient": "[email protected]",
"tags": { "type": "invoice" },
"detail": "250 2.0.0 OK"
}
}
detail holds the SMTP response or the bounce classification, or null. The event id is the same on every endpoint and every retry: use it to drop duplicates. Events can also arrive out of order: order them by createdAt and never move a status back.
account.paused and account.reactivated carry the account instead of an email:
{
"id": "evt_8rk1vz3qm7lp0xc2wb6hd2ma",
"type": "account.paused",
"createdAt": "2026-11-02T09:40:12Z",
"data": { "accountId": "acc_3fp9kd2wq7ys1lb4mx8tz5qe", "reason": "bounce_rate", "detail": null }
}
reason is bounce_rate, complaint_rate, payment_failed or operator for a pause, and resumed for account.reactivated. For operator, detail holds the reason the person at letr.dev gave; otherwise it is null. With every pause the account owner also gets an email.
Verify the signature
letr.dev signs every request as Standard Webhooks do, so the official Standard Webhooks libraries verify it. Each request carries:
webhook-id: the event ID.webhook-timestamp: the time of sending, in Unix seconds.webhook-signature:v1,<signature>, the base64 HMAC-SHA256 of<webhook-id>.<webhook-timestamp>.<raw body>.letr-endpoint-id: the endpoint the request is for.
The key is the endpoint's signing secret, which starts with whsec_: base64-decode the part after whsec_. Accept the request when one signature matches, and reject it when the timestamp is more than 5 minutes off. Verify the raw body, before any JSON parsing.
// Node.js: npm install standardwebhooks
import { Webhook } from 'standardwebhooks';
const webhook = new Webhook(process.env.LETR_WEBHOOK_SECRET);
// rawBody: the request body as received, a string or a Buffer
const event = webhook.verify(rawBody, request.headers);
verify throws a WebhookVerificationError when the signature does not match or the timestamp is too old or too new, and returns the parsed event otherwise.
# Python, without a library
import base64, hashlib, hmac, time
def verify_letr_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in headers["webhook-signature"].split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False
Rotating the secret
Rotate a secret in the dashboard with a grace period of now, 1 hour, 24 hours or 7 days. Until the grace period ends, webhook-signature carries two signatures separated by a space, one with the new secret and one with the old: v1,<new> v1,<old>. Both examples above accept either, so you can deploy the new secret at your pace.
Retries
letr.dev waits 10 seconds for an answer. Any answer other than 2xx, or none within that time, is retried with exponential backoff, starting at 30 seconds and growing to at most 2 hours between attempts, for 24 hours. After that the delivery counts as failed. Answer fast and do the work after acknowledging.
Failing endpoints
failingSince on an endpoint is the time of the first failed attempt since its last success, and null while deliveries succeed. After 24 hours of failures, the account owner gets an email that names the endpoint, the last error and how to replay; it comes once per endpoint until a delivery succeeds again.
Once the endpoint works, replay it in the dashboard: letr.dev queues again every delivery to that endpoint from the last 30 days that failed after 24 hours of retries. A disabled endpoint must be enabled first (409 webhook_disabled).
Pausing an endpoint
Pause an endpoint in the dashboard, or with "paused": true on PATCH /v1/webhooks/{id} (full_access), while your service is down for maintenance or a deployment. letr.dev keeps recording a delivery for each subscribed event but does not send it; a delivery already being sent when you pause still completes. The endpoint shows paused, pausedAt and heldDeliveries, the number of deliveries waiting. Test events and replays to a paused endpoint are held too, and the failing endpoint email does not go out while it is paused.
Resume with "paused": false. letr.dev queues the held deliveries oldest first, signed as usual and with fresh attempts, and the 24 hours of retries count from the resume. As with any delivery, order them by createdAt on your side. Pausing a paused endpoint or resuming a running one changes nothing. A disabled endpoint can be neither paused nor resumed (409 webhook_disabled): disabled means letr.dev records no deliveries for new events; a test event still goes out, or is held while the endpoint is paused. Disabling a paused endpoint keeps its deliveries held; enable it before you resume it.
Like every delivery, held deliveries follow the webhook delivery retention of 32 days: a delivery held for longer is deleted without being sent.
Test events
POST /v1/webhooks/{id}/test (full_access) or the dashboard sends one webhook.test event to that endpoint, even when it is disabled; a paused endpoint holds it until it resumes. Its data.emailId and data.recipient are empty strings. The result shows up on the endpoint as the last success or failure.
Transport
letr.dev posts over https only, with TLS 1.2 or later, always verifies the hostname, does not follow redirects, and refuses private and loopback addresses when it connects.
Pin your server CA
In the dashboard you can give an endpoint the CA certificates (PEM) that issued its server certificate. letr.dev then trusts only those for that endpoint instead of the public CAs; the hostname is still checked.
Mutual TLS
Per endpoint, letr.dev can present a client certificate:
| Mode | How it works |
|---|---|
none |
No client certificate. |
letr_ca |
letr.dev creates a key and a certificate signed by the letr.dev webhook client CA, valid for 1 year and renewed 30 days before it expires. Trust the CA from /meta/webhook-client-ca.pem. |
customer_ca |
letr.dev creates a key and a CSR; your CA signs it and you upload the certificate in the dashboard. |
The private key never leaves letr.dev, is encrypted at rest and is never returned.
IP allowlist
GET /meta/webhook-ips lists the addresses webhooks leave from; letr.dev uses IPv6 only when an IPv6 address is listed. Changes are announced to account owners by email at least 14 days ahead and appear in the list before they are used.
An allowlist tells you a request came from letr.dev's network, not that it is genuine or meant for your account. Verify the signature in any case; treat the allowlist as an extra layer.