Skip to content
Docs menuCurrent page: Sending

Guides

Sending

Send one message or a batch of up to 100, schedule up to 30 days ahead, and retry safely with idempotency keys.

Tested with resend@6.30.0

Last reviewed

On this page (9)

Send

mail.emails.send() calls POST /emails. Send with a sandbox or live key; sending-only keys are enough.

send.ts
const { data, error } = await mail.emails.send({  from: 'Your product <hello@notify.yourproduct.example>',  to: ['ada@customer.example'],  cc: 'team@customer.example',  replyTo: 'support@yourproduct.example',  subject: 'Your receipt',  text: 'Thanks for your order.',  html: '<p>Thanks for your order.</p>',  headers: { 'X-Entity-Ref-ID': 'order-1042' },  tags: [{ name: 'category', value: 'receipt' }],});
Parameters of emails.send
ParameterTypeDescription
fromRequiredstringThe sender, with an optional display name: Your product <hello@notify.yourproduct.example>. With a live key, the address’s domain must be a verified domain of the project, exactly as named.
toRequiredstring | string[]Recipients. At most 50 across to, cc and bcc together.
ccstring | string[]Carbon-copy recipients.
bccstring | string[]Blind carbon-copy recipients.
replyToHTTP reply_tostring | string[]Where replies go.
subjectRequiredstringOne line, at most 998 characters.
textstringThe plain-text body. Send text, html or both; each can be up to 250,000 characters.
htmlstringThe HTML body.
headersRecord<string, string>Extra headers. Names use letters, digits and hyphens; values stay on one line. Headers Letterpier sets itself, such as From, Subject, Message-ID, Content-Type and DKIM-Signature, can’t be overridden.
tagsTag[]Up to 10 tags, each { name, value }. Names and values use letters, digits, underscores and hyphens, at most 256 characters each.
attachmentsAttachment[]Files sent with the message. See Attachments.
scheduledAtHTTP scheduled_atstringSend later. See Scheduling.

Parameters of emails.send

  • fromRequired
    Type
    string
    Description
    The sender, with an optional display name: Your product <hello@notify.yourproduct.example>. With a live key, the address’s domain must be a verified domain of the project, exactly as named.
  • toRequired
    Type
    string | string[]
    Description
    Recipients. At most 50 across to, cc and bcc together.
  • cc
    Type
    string | string[]
    Description
    Carbon-copy recipients.
  • bcc
    Type
    string | string[]
    Description
    Blind carbon-copy recipients.
  • replyToHTTP reply_to
    Type
    string | string[]
    Description
    Where replies go.
  • subjectRequired
    Type
    string
    Description
    One line, at most 998 characters.
  • text
    Type
    string
    Description
    The plain-text body. Send text, html or both; each can be up to 250,000 characters.
  • html
    Type
    string
    Description
    The HTML body.
  • headers
    Type
    Record<string, string>
    Description
    Extra headers. Names use letters, digits and hyphens; values stay on one line. Headers Letterpier sets itself, such as From, Subject, Message-ID, Content-Type and DKIM-Signature, can’t be overridden.
  • tags
    Type
    Tag[]
    Description
    Up to 10 tags, each { name, value }. Names and values use letters, digits, underscores and hyphens, at most 256 characters each.
  • attachments
    Type
    Attachment[]
    Description
    Files sent with the message. See Attachments.
  • scheduledAtHTTP scheduled_at
    Type
    string
    Description
    Send later. See Scheduling.

The response is { id }, the message ID you’ll see in logs, webhooks and mail.emails.get(id).

Differs from Resend

Batch

mail.batch.send() calls POST /emails/batch with up to 100 messages. Letterpier checks every message first and stores the batch in one transaction, so either all of them are accepted or none is. The response lists one ID per message, in the order you sent them. An idempotencyKey covers the whole batch.

batch.ts
const { data, error } = await mail.batch.send(  [    {      from: 'Your product <hello@notify.yourproduct.example>',      to: 'ada@customer.example',      subject: 'Your weekly summary',      text: 'Here is what happened this week.',    },    {      from: 'Your product <hello@notify.yourproduct.example>',      to: 'grace@customer.example',      subject: 'Your weekly summary',      text: 'Here is what happened this week.',    },  ],  { idempotencyKey: 'weekly-summary/2026-40' },);// data?.data holds one { id } per message, in order.
Differs from Resend

Scheduling

Set scheduledAt to an ISO 8601 time with an offset, such as 2026-10-02T09:00:00+02:00, in the future and at most 30 days ahead. The message waits as Scheduled until then.

schedule.ts
// An ISO 8601 time with an offset, at most 30 days ahead.const { data } = await mail.emails.send({  from: 'Your product <hello@notify.yourproduct.example>',  to: 'ada@customer.example',  subject: 'Your trial ends tomorrow',  text: 'Your trial ends tomorrow at noon.',  scheduledAt: '2026-10-02T09:00:00+02:00',});// Move it (full-access key): only while it's still queued or scheduled.await mail.emails.update({  id: data!.id,  scheduledAt: '2026-10-03T09:00:00+02:00',});// Or cancel it (full-access key).await mail.emails.cancel(data!.id);
  • Reschedule with mail.emails.update() (PATCH /emails/:id), within the same 30-day window.
  • Cancel with mail.emails.cancel() (POST /emails/:id/cancel). The message becomes Cancelled.
  • Both need a full-access key (a sending-only key gets 403 restricted_api_key) and work only while the message is still queued or scheduled; otherwise the answer is 409.

Idempotency

Pass idempotencyKey (the Idempotency-Key header, 1 to 256 characters) whenever you might send the same request twice, for example after a timeout.

  • A key lasts 24 hours and is scoped to the project, the environment and the operation (send or batch). Every key of the project in that environment shares it.
  • The same key with the same request returns the first response. No second message is created.
  • The same key with a different request is refused with 409 invalid_idempotent_request.

Attachments

Each attachment has content as base64 text, a filename and, optionally, contentType. A message can carry up to 20 attachments, and a request up to about 10 MB of them, decoded.

invoice.ts
import { readFile } from 'node:fs/promises';const invoice = await readFile('invoice-1042.pdf');await mail.emails.send({  from: 'Your product <billing@notify.yourproduct.example>',  to: 'ada@customer.example',  subject: 'Invoice 1042',  text: 'Your invoice is attached.',  attachments: [    {      filename: 'invoice-1042.pdf',      content: invoice.toString('base64'), // base64 text, not a Buffer      contentType: 'application/pdf',    },  ],});
Differs from Resend

Limits

Limits per message and request
WhatLimit
Recipients per message (to, cc and bcc together)50
Characters in text, and in html250,000 each
Characters in the subject998
Tags per message10
Attachments per message20
Attachments per request, decodedabout 10 MB
Messages per batch100
Scheduling ahead30 days

Limits per message and request

  • Recipients per message (to, cc and bcc together)
    Limit
    50
  • Characters in text, and in html
    Limit
    250,000 each
  • Characters in the subject
    Limit
    998
  • Tags per message
    Limit
    10
  • Attachments per message
    Limit
    20
  • Attachments per request, decoded
    Limit
    about 10 MB
  • Messages per batch
    Limit
    100
  • Scheduling ahead
    Limit
    30 days

Every limit and window

Sandbox versus live

What each environment does with a message
WhatSandbox keyLive key
Key prefixlp_test_lp_live_
DeliveryNever. The message is captured and nothing is sent to the recipient.Handed to Postal, Letterpier’s mail server, which delivers it.
Checks when you sendThe request is validated; the sender’s domain isn’t checked.Live sending is on, the sender’s domain is verified with a check under 24 hours old, and no recipient is suppressed.
StatusSandboxFrom Queued to Delivered, or another final status
Webhooksemail.sent with data.sandbox: trueEvery event, as it happens

What each environment does with a message

  • Key prefix
    Sandbox key
    lp_test_
    Live key
    lp_live_
  • Delivery
    Sandbox key
    Never. The message is captured and nothing is sent to the recipient.
    Live key
    Handed to Postal, Letterpier’s mail server, which delivers it.
  • Checks when you send
    Sandbox key
    The request is validated; the sender’s domain isn’t checked.
    Live key
    Live sending is on, the sender’s domain is verified with a check under 24 hours old, and no recipient is suppressed.
  • Status
    Sandbox key
    Sandbox
    Live key
    From Queued to Delivered, or another final status
  • Webhooks
    Sandbox key
    email.sent with data.sandbox: true
    Live key
    Every event, as it happens

The live checks run again just before the worker hands a message to Postal. If one fails then, the message is Held (or Suppressed) and your webhook receives email.failed.

What happens after the 200

A 200 means Letterpier stored the message and queued it. From there, a live message moves on by itself:

  1. Queued (or Scheduled): waiting for the worker.
  2. Submitting: the worker hands it to Postal.
  3. Sent: Postal accepted it and is delivering it.
  4. Delivered: the recipient’s mail server accepted it. Or Delayed, Bounced or Failed.

All statuses and the webhook for each

Errors

Errors are JSON with the HTTP status, a name and a readable message. The SDK returns them as error with data: null; it doesn’t throw.

409 response
{  "statusCode": 409,  "name": "invalid_idempotent_request",  "message": "This idempotency key was already used with a different request."}
Error names and statuses
StatusNameWhen
400validation_errorThe body isn’t JSON sent as application/json, or the Idempotency-Key isn’t 1 to 256 characters.
401missing_api_keyNo Authorization: Bearer header.
403invalid_api_keyThe key doesn’t exist or was revoked.
403restricted_api_keyA sending-only key called an operation that needs full access. Or a live message can’t go out: live sending is off, the sender’s domain isn’t verified or its last check is 24 hours old or more, or a recipient is suppressed. The message says which.
404not_foundNo such message (or list cursor) in this project and environment.
409invalid_idempotent_requestThe idempotency key was used in the last 24 hours for a different request.
409validation_errorRescheduling or cancelling a message that is no longer queued or scheduled.
413validation_errorThe request is too large: attachments are limited to about 10 MB per request.
422validation_errorA field is missing, unknown or invalid (the message names each one, such as to: …), or the send time is outside the next 30 days.
429rate_limit_exceededMore than 100 requests in the current minute for this project and environment.
500application_errorSomething failed on Letterpier’s side. Retry with the same idempotency key.
501validation_errorThe operation isn’t implemented.

Error names and statuses

  • Status
    400
    Name
    validation_error
    When
    The body isn’t JSON sent as application/json, or the Idempotency-Key isn’t 1 to 256 characters.
  • Status
    401
    Name
    missing_api_key
    When
    No Authorization: Bearer header.
  • Status
    403
    Name
    invalid_api_key
    When
    The key doesn’t exist or was revoked.
  • Status
    403
    Name
    restricted_api_key
    When
    A sending-only key called an operation that needs full access. Or a live message can’t go out: live sending is off, the sender’s domain isn’t verified or its last check is 24 hours old or more, or a recipient is suppressed. The message says which.
  • Status
    404
    Name
    not_found
    When
    No such message (or list cursor) in this project and environment.
  • Status
    409
    Name
    invalid_idempotent_request
    When
    The idempotency key was used in the last 24 hours for a different request.
  • Status
    409
    Name
    validation_error
    When
    Rescheduling or cancelling a message that is no longer queued or scheduled.
  • Status
    413
    Name
    validation_error
    When
    The request is too large: attachments are limited to about 10 MB per request.
  • Status
    422
    Name
    validation_error
    When
    A field is missing, unknown or invalid (the message names each one, such as to: …), or the send time is outside the next 30 days.
  • Status
    429
    Name
    rate_limit_exceeded
    When
    More than 100 requests in the current minute for this project and environment.
  • Status
    500
    Name
    application_error
    When
    Something failed on Letterpier’s side. Retry with the same idempotency key.
  • Status
    501
    Name
    validation_error
    When
    The operation isn’t implemented.