Skip to content
Docs menuCurrent page: Webhooks

Guides

Webhooks

Signed events for sending and receiving, retried up to 8 times over about 27½ hours, and at least once.

Tested with resend@6.30.0

Last reviewed

On this page (7)

Events

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 types
EventSent when
email.sentPostal, Letterpier’s mail server, accepted the message for delivery. For a sandbox message: it was captured.
email.deliveredThe recipient’s mail server accepted the message. That isn’t a promise of the inbox.
email.delivery_delayedThe receiving server deferred the message. Postal keeps retrying.
email.bouncedThe receiving side refused the message. The address joins the project’s suppression list.
email.failedDelivery 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.receivedMail arrived for an address on one of the project’s verified domains.
email.complainedOnly 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.

Payload shape

The body is JSON: the event type, when the event happened (created_at), and data about the message.

email.delivered
{  "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  }}
Fields in data
FieldMeaning
email_idThe message ID, as returned when you sent it.
created_atWhen the message was created (not the event).
fromThe sender.
toThe recipients; for delivery events, the one recipient the event is about.
subjectThe subject.
sandboxtrue for sandbox messages: nothing was delivered.
bounceOn email.bounced, and on email.failed after a permanent delivery failure: the receiving server’s reason.
attachments, message_idOn 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
    true for sandbox messages: nothing was delivered.
  • bounce
    Meaning
    On email.bounced, and on email.failed after a permanent delivery failure: the receiving server’s reason.
  • attachments, message_id
    Meaning
    On email.received only. See Receiving.
data.bounce
"bounce": {  "message": "550 5.1.1 <ada@customer.example>: mailbox unavailable",  "type": "Permanent",  "subType": "General"}
Differs from Resend

Headers and verification

Every request carries three headers:

Request 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.

Retries

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:

Retry schedule: 8 attempts, the last about 27½ hours after the first
AttemptWait before itSince the first attempt
1Immediately0 s
25 s5 s
35 min5 min
430 min35 min
52 h2 h 35 min
65 h7 h 35 min
710 h17 h 35 min
810 h27 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-id and get a fresh timestamp and signature.
  • Events can arrive out of order, for example email.delivered before a retried email.sent. Use the message’s status, not arrival order.
  • After attempt 8, about 27½ hours after the first, the delivery is Exhausted.

Replay

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.

Endpoint requirements

  • 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.

endpoints.ts
// 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);
Differs from Resend

Sandbox

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.