Guides
Sending
Send one message or a batch of up to 100, schedule up to 30 days ahead, and retry safely with idempotency keys.
Last reviewed
On this page (9)
mail.emails.send() calls POST /emails. Send with a sandbox or live key; sending-only keys are enough.
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' }],});| Parameter | Type | Description |
|---|---|---|
fromRequired | string | 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 | string | string[] | Recipients. At most 50 across to, cc and bcc together. |
cc | string | string[] | Carbon-copy recipients. |
bcc | string | string[] | Blind carbon-copy recipients. |
replyTo | string | string[] | Where replies go. |
subjectRequired | string | One line, at most 998 characters. |
text | string | The plain-text body. Send text, html or both; each can be up to 250,000 characters. |
html | string | The HTML body. |
headers | Record<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. |
tags | Tag[] | Up to 10 tags, each { name, value }. Names and values use letters, digits, underscores and hyphens, at most 256 characters each. |
attachments | Attachment[] | Files sent with the message. See Attachments. |
scheduledAt | string | Send 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.
replyTo- 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,htmlor 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-TypeandDKIM-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.
scheduledAt- 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).
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.
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.errors list. The SDK’s batchValidation: 'permissive' option has no effect.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.
// 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.
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.
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.
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', }, ],});path and inline images with a contentId are rejected with 422. Pass the file itself as base64 text; a Buffer isn’t converted for you.| What | Limit |
|---|---|
| Recipients per message (to, cc and bcc together) | 50 |
| Characters in text, and in html | 250,000 each |
| Characters in the subject | 998 |
| Tags per message | 10 |
| Attachments per message | 20 |
| Attachments per request, decoded | about 10 MB |
| Messages per batch | 100 |
| Scheduling ahead | 30 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
| What | Sandbox key | Live key |
|---|---|---|
| Key prefix | lp_test_ | lp_live_ |
| Delivery | Never. The message is captured and nothing is sent to the recipient. | Handed to Postal, Letterpier’s mail server, which delivers it. |
| Checks when you send | The 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. |
| Status | Sandbox | From Queued to Delivered, or another final status |
| Webhooks | email. with data. | Every 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.withsent 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.
A 200 means Letterpier stored the message and queued it. From there, a live message moves on by itself:
- Queued (or Scheduled): waiting for the worker.
- Submitting: the worker hands it to Postal.
- Sent: Postal accepted it and is delivering it.
- Delivered: the recipient’s mail server accepted it. Or Delayed, Bounced or Failed.
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.
{ "statusCode": 409, "name": "invalid_idempotent_request", "message": "This idempotency key was already used with a different request."}| Status | Name | When |
|---|---|---|
| 400 | validation_error | The body isn’t JSON sent as application/json, or the Idempotency-Key isn’t 1 to 256 characters. |
| 401 | missing_api_key | No Authorization: Bearer header. |
| 403 | invalid_api_key | The key doesn’t exist or was revoked. |
| 403 | restricted_api_key | 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. |
| 404 | not_found | No such message (or list cursor) in this project and environment. |
| 409 | invalid_idempotent_request | The idempotency key was used in the last 24 hours for a different request. |
| 409 | validation_error | Rescheduling or cancelling a message that is no longer queued or scheduled. |
| 413 | validation_error | The request is too large: attachments are limited to about 10 MB per request. |
| 422 | validation_error | 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. |
| 429 | rate_limit_exceeded | More than 100 requests in the current minute for this project and environment. |
| 500 | application_error | Something failed on Letterpier’s side. Retry with the same idempotency key. |
| 501 | validation_error | The operation isn’t implemented. |
Error names and statuses
- Status
- 400
- Name
validation_error- When
- The body isn’t JSON sent as
application/json, or theIdempotency-Keyisn’t 1 to 256 characters.
- Status
- 401
- Name
missing_api_key- When
- No
Authorization: Bearerheader.
- 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.