Skip to content

Sending email

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 lookups
FieldTypeDescription
idstringDatabase record ID — use with emails.get() for traceability
messageId?stringRFC 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.

ParamTypeRequiredDescription
fromstringYesVerified sender. RFC 5322 display names ok: "Name <addr@domain.com>"
tostring | string[]YesOne or more recipients
subjectstringYes
htmlstringNoHTML body
textstringNoPlain-text body
ccstring | string[]NoCarbon copy
bccstring | string[]NoBlind carbon copy
replyTostringNoReply-to address
headersRecord<string, string>NoCustom headers, forwarded to SES
attachmentsEmailAttachment[]NoFiles to send with the message
scheduledAtstringNoSchedule 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.

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.

FieldTypeRequiredDescription
filenamestringYesFilename the recipient sees. Required — an unnamed attachment is unopenable.
contentstringYesFile bytes, base64-encoded (no data: prefix).
contentTypestringNoMIME type, e.g. application/pdf. Guessed from the filename when omitted.
contentIdstringNoSet 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.

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:

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 send

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

Sending fails with a 4xx otherwise:

  • from, to, subject required; at least one of html / text required (422).
  • The from domain 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 filename and valid raw base64 content, 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”).