Sending email
client.send(params)
Section titled “client.send(params)”Send an email. Never throws — returns { data, error }.
const { data, error } = await client.send({ from: "Notifications <noreply@yourdomain.com>", // RFC 5322 display name optional to: "user@example.com", // or string[] subject: "Welcome", html: "<p>Hello</p>", text: "Hello", // include both when you can — deliverability scheduledAt: "2026-07-01T09:00:00Z", // optional — defer the send (ISO 8601)});// data.id is the DB record id — store it for delivery-status lookupsResult
Section titled “Result”| Field | Type | Description |
|---|---|---|
id | string | Database record ID — use with emails.get() for traceability |
messageId? | string | RFC 5322 Message-ID the mail went out with — match replies against it to thread them. |
Store id if you’ll need delivery status later.
Store messageId if replies matter to you. It’s what a recipient’s mail client quotes back in
In-Reply-To, so matching incoming replies against it is what makes them thread onto the message
you sent — without it, every reply reads as the start of a new conversation. It’s absent on
scheduled sends, which haven’t been given one yet when the call returns, and whenever the
transport won’t disclose one. Absent rather than empty, deliberately: there’s no placeholder you
could store and safely mistake for a real id.
Parameters
Section titled “Parameters”| Param | Type | Required | Description |
|---|---|---|---|
from | string | Yes | Verified sender. RFC 5322 display names ok: "Name <addr@domain.com>" |
to | string | string[] | Yes | One or more recipients |
subject | string | Yes | |
html | string | No | HTML body |
text | string | No | Plain-text body |
cc | string | string[] | No | Carbon copy |
bcc | string | string[] | No | Blind carbon copy |
replyTo | string | No | Reply-to address |
headers | Record<string, string> | No | Custom headers, forwarded to SES |
attachments | EmailAttachment[] | No | Files to send with the message |
scheduledAt | string | No | Schedule the send for a future time — ISO 8601 (e.g. 2026-07-01T09:00:00Z). Must be in the future and ≤30 days out. Omit to send immediately. |
At least one of html / text is required.
Attachments
Section titled “Attachments”Pass attachments to send files along with the message. Content is base64, and it’s raw base64 —
if you’ve got a data:application/pdf;base64, prefix on there, strip it. That prefix is the single
most common reason a send gets rejected.
| Field | Type | Required | Description |
|---|---|---|---|
filename | string | Yes | Filename the recipient sees. Required — an unnamed attachment is unopenable. |
content | string | Yes | File bytes, base64-encoded (no data: prefix). |
contentType | string | No | MIME type, e.g. application/pdf. Guessed from the filename when omitted. |
contentId | string | No | Set to embed the file in the HTML body rather than list it as a download: reference it as <img src="cid:THIS_VALUE">. Omit for a normal attachment. |
import { readFileSync } from "node:fs";
await client.send({ from: "Billing <billing@yourdomain.com>", to: "customer@example.com", subject: "Your invoice", html: "<p>Invoice attached.</p>", attachments: [ { filename: "invoice.pdf", content: readFileSync("./invoice.pdf").toString("base64"), contentType: "application/pdf", }, ],});Give an attachment a contentId and it gets embedded in the HTML body instead of listed as a
download — reference it from your markup as <img src="cid:THE_VALUE">. That’s how you put a logo
in a template without hotlinking to an image that may not load.
Attachments ride inside the JSON request body, base64-encoded, which is what sets the size limit:
3MB total across all files on one send, measured decoded. Base64 inflates by about a third, so
3MB of files is already a 4MB request, against a 4.5MB platform cap on the body — the remaining
headroom is what your html, text and headers have to fit in. Nothing here comes from SES, which
would take 40MB; it’s the cost of carrying files inside JSON. This is for ordinary documents, not
video.
Attachments don’t work on scheduled sends yet. A scheduled email is stored as a row and rebuilt
when its timer fires, and that row has nowhere to keep the bytes — so pairing attachments with
scheduledAt is refused outright rather than sending later with the files quietly missing.
The SDK checks all of this before it sends anything, so a malformed attachment — or one bound for
a scheduled send — fails immediately rather than after you’ve spent a round trip uploading it. The
API re-checks everything regardless; it can’t take a client’s word for it.
MAX_TOTAL_ATTACHMENT_BYTES and validateAttachments are exported if you want to budget files or
show the problem in your own UI first.
Scheduling
Section titled “Scheduling”Pass scheduledAt (ISO 8601) to send() to defer delivery — it must be in the future and ≤30 days out. The email sits in scheduled status until the sender dispatches it. Manage a pending send with:
client.cancel(id)
Section titled “client.cancel(id)”Cancel a scheduled email before it sends. Only valid while the email is scheduled (409 otherwise). Canceling is terminal — a canceled email cannot be rescheduled. Never throws — returns { data, error }.
const { error } = await client.cancel(emailId); // stops a pending scheduled sendclient.reschedule(id, scheduledAt)
Section titled “client.reschedule(id, scheduledAt)”Reschedule a scheduled email to a new time (ISO 8601, future, ≤30 days). Only valid while the email is scheduled (409 otherwise). Never throws — returns { data, error }.
const { data, error } = await client.reschedule(emailId, "2026-07-01T09:00:00Z");Both only work while the email is still scheduled (otherwise a 409).
Rules the sender API enforces
Section titled “Rules the sender API enforces”Sending fails with a 4xx otherwise:
from,to,subjectrequired; at least one ofhtml/textrequired (422).- The
fromdomain must be owned by the API key’s team and have sending enabled (403: “Sending domain not authorized for this team”). - Domain-scoped keys can only send from their scoped domains; only system keys are unrestricted (403: “API key not authorized for this domain”).
- Every attachment needs a
filenameand valid raw base64content, and the decoded total must be ≤3MB (422). The SDK runs the same check locally first. - Attachments can’t be combined with
scheduledAt(422: “Attachments are not supported on scheduled sends yet”).