Guides
Webhooks
Signed events for sending and receiving, retried up to 8 times over about 27½ hours, and at least once.
Last reviewed
On this page (7)
Subscribe an endpoint to any of the 7 event types. Each event is about one message, and it is sent to every enabled endpoint of the project that subscribed to its type.
| Event | Sent when |
|---|---|
email. | Postal, Letterpier’s mail server, accepted the message for delivery. For a sandbox message: it was captured. |
email. | The recipient’s mail server accepted the message. That isn’t a promise of the inbox. |
email. | The receiving server deferred the message. Postal keeps retrying. |
email. | The receiving side refused the message. The address joins the project’s suppression list. |
email. | Delivery failed permanently and the address is suppressed. Also sent when a message is held, by Letterpier’s checks or by Postal, or suppressed before it was sent. |
email. | Mail arrived for an address on one of the project’s verified domains. |
email. | Only simulated today, for sandbox messages. No complaint feed is connected: complaint feedback loops are not yet available. |
Event types
email.sent - Sent when
- Postal, Letterpier’s mail server, accepted the message for delivery. For a sandbox message: it was captured.
email.delivered - Sent when
- The recipient’s mail server accepted the message. That isn’t a promise of the inbox.
email.delivery_delayed - Sent when
- The receiving server deferred the message. Postal keeps retrying.
email.bounced - Sent when
- The receiving side refused the message. The address joins the project’s suppression list.
email.failed - Sent when
- Delivery failed permanently and the address is suppressed. Also sent when a message is held, by Letterpier’s checks or by Postal, or suppressed before it was sent.
email.received - Sent when
- Mail arrived for an address on one of the project’s verified domains.
email.complained - Sent when
- Only simulated today, for sandbox messages. No complaint feed is connected: complaint feedback loops are not yet available.
When a message has several recipients, delivery events arrive per recipient, and data.to names the one they are about. The dashboard combines them into the message’s status.
The body is JSON: the event type, when the event happened (created_at), and data about the message.
{ "type": "email.delivered", "created_at": "2026-09-29T09:14:05.118Z", "data": { "email_id": "3a8f5c2d-1b4e-4d7a-9c6f-0e2b8d1a7f45", "created_at": "2026-09-29T09:13:58.021Z", "from": "Your product <hello@notify.yourproduct.example>", "to": ["ada@customer.example"], "subject": "Welcome", "sandbox": false }}| Field | Meaning |
|---|---|
email_id | The message ID, as returned when you sent it. |
created_at | When the message was created (not the event). |
from | The sender. |
to | The recipients; for delivery events, the one recipient the event is about. |
subject | The subject. |
sandbox | true for sandbox messages: nothing was delivered. |
bounce | On email.bounced, and on email.failed after a permanent delivery failure: the receiving server’s reason. |
attachments, message_id | On email.received only. See Receiving. |
Fields in data
email_id- Meaning
- The message ID, as returned when you sent it.
created_at- Meaning
- When the message was created (not the event).
from- Meaning
- The sender.
to- Meaning
- The recipients; for delivery events, the one recipient the event is about.
subject- Meaning
- The subject.
sandbox- Meaning
truefor sandbox messages: nothing was delivered.
bounce- Meaning
- On
email.bounced, and onemail.failedafter a permanent delivery failure: the receiving server’s reason.
attachments,message_id- Meaning
- On
email.receivedonly. See Receiving.
"bounce": { "message": "550 5.1.1 <ada@customer.example>: mailbox unavailable", "type": "Permanent", "subType": "General"}cc, bcc, received_for, tags or failed, aren’t sent.Every request carries three headers:
svix-id: 9b1f0c7e-2d4a-4e8b-a6f3-51c0d9e27b84svix-timestamp: 1790673245svix-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=svix-id: the event’s ID. It stays the same on every retry and replay.svix-timestamp: when this attempt was signed, in Unix seconds. Every attempt gets a fresh one.svix-signature:v1,followed by a base64 HMAC-SHA256 of{svix-id}.{svix-timestamp}.{body}, keyed with the endpoint’s signing secret.
Verify with the SDK’s mail.webhooks.verify(), passing the raw body and the endpoint’s signing secret (whsec_…, shown once when you create the endpoint). It throws on a bad signature, and on a timestamp more than 5 minutes from your clock.
app/api/letterpier/route.ts
import type { WebhookEventPayload } from 'resend';import { mail } from '@/lib/mail';import { markProcessed, wasProcessed } from '@/lib/events';export async function POST(request: Request) { const payload = await request.text(); // The raw body, unchanged const id = request.headers.get('svix-id') ?? ''; let event: WebhookEventPayload; try { event = mail.webhooks.verify({ payload, headers: { id, timestamp: request.headers.get('svix-timestamp') ?? '', signature: request.headers.get('svix-signature') ?? '', }, webhookSecret: process.env.LETTERPIER_WEBHOOK_SECRET!, }); } catch { return new Response('Invalid signature', { status: 400 }); } // Retries repeat svix-id: skip events you've already processed. if (await wasProcessed(id)) return new Response(null, { status: 204 }); if (event.type === 'email.received') { const emailId = event.data.email_id; const email = await mail.emails.receiving.get(emailId); const files = await mail.emails.receiving.attachments.list({ emailId, }); // Anything but a 2xx makes Letterpier try again later. if (email.error || files.error) { return new Response('Try again', { status: 503 }); } // Received mail is untrusted: validate it before you act on it. console.log(email.data.subject, files.data.data.length); } // Record the event only once it's handled, then answer 2xx. await markProcessed(id); return new Response(null, { status: 204 });}.env.local
LETTERPIER_API_KEY=lp_live_...LETTERPIER_WEBHOOK_SECRET=whsec_...Record svix-id once you’ve handled the event, and answer with a 2xx only then. If handling fails, answer with anything else and the event comes back on the retry schedule.
An attempt succeeds when your endpoint answers with a 2xx status within 5 seconds. Anything else, including a redirect, a timeout or a refused connection, is retried on this schedule:
| Attempt | Wait before it | Since the first attempt |
|---|---|---|
| 1 | Immediately | 0 s |
| 2 | 5 s | 5 s |
| 3 | 5 min | 5 min |
| 4 | 30 min | 35 min |
| 5 | 2 h | 2 h 35 min |
| 6 | 5 h | 7 h 35 min |
| 7 | 10 h | 17 h 35 min |
| 8 | 10 h | 27 h 35 min |
Retry schedule: 8 attempts, the last about 27½ hours after the first
- Attempt
- 1
- Wait before it
- Immediately
- Since the first attempt
- 0 s
- Attempt
- 2
- Wait before it
- 5 s
- Since the first attempt
- 5 s
- Attempt
- 3
- Wait before it
- 5 min
- Since the first attempt
- 5 min
- Attempt
- 4
- Wait before it
- 30 min
- Since the first attempt
- 35 min
- Attempt
- 5
- Wait before it
- 2 h
- Since the first attempt
- 2 h 35 min
- Attempt
- 6
- Wait before it
- 5 h
- Since the first attempt
- 7 h 35 min
- Attempt
- 7
- Wait before it
- 10 h
- Since the first attempt
- 17 h 35 min
- Attempt
- 8
- Wait before it
- 10 h
- Since the first attempt
- 27 h 35 min
- Delivery is at least once. Deduplicate on
svix-id. - Retries keep the
svix-idand get a fresh timestamp and signature. - Events can arrive out of order, for example
email.deliveredbefore a retriedemail.sent. Use the message’s status, not arrival order. - After attempt 8, about 27½ hours after the first, the delivery is Exhausted.
Replay an exhausted delivery from the dashboard while its message is retained. It starts a fresh set of 8 attempts with the same event, so the svix-id doesn’t change: if your handler already accepted it, deduplication skips it.
- Public HTTPS on port 443, without a user name, password or fragment in the address.
- The host name must resolve only to public internet addresses. Letterpier checks this when you add the endpoint and again before every attempt, and connects to the address it checked.
- A valid TLS certificate for the host name.
- No redirects: a 3xx answer counts as a failed attempt.
- Letterpier doesn’t read the response body.
Manage endpoints in the dashboard or with a full-access key. The signing secret is returned once, when the endpoint is created.
// Full-access key. The signing secret is returned once: store it now.const { data: endpoint } = await mail.webhooks.create({ endpoint: 'https://yourproduct.example/api/letterpier', events: [ 'email.delivered', 'email.bounced', 'email.failed', 'email.received', ],});endpoint?.signing_secret; // whsec_...const { data: endpoints } = await mail.webhooks.list();await mail.webhooks.remove(endpoint!.id);webhooks.get, webhooks.update) isn’t supported yet. Delete the endpoint and create it again to change it.Sandbox messages never leave by SMTP, but their events still go to your subscribed endpoints, signed like any other, with data.sandbox: true. A sandbox message sends email.sent when it is captured, and from the dashboard you can simulate the other events for it. Check data.sandbox before you act on an event.