Sending
sendsprite.emails.send() — POST /api/v1/emails — takes one message and
answers 201 { "id": "em_…" } once it is accepted. Delivery itself is
asynchronous: the row is written, a job is queued, and the timeline fills in
as SES reports back.
import { Sendsprite } from "sendsprite";
const sendsprite = new Sendsprite();
const { id } = await sendsprite.emails.send({
from: "Acme <hello@mail.example.com>",
to: ["ada@example.com", "grace@example.com"],
replyTo: "support@example.com",
subject: "Your invoice",
html: "<p>Attached.</p>",
text: "Attached.",
attachments: [
{
filename: "invoice.pdf",
content: base64,
contentType: "application/pdf",
},
],
tags: { env: "production", kind: "invoice" },
idempotencyKey: "invoice-2026-08-0042",
});
Over REST:
curl https://sendsprite.com/api/v1/emails \
-H "Authorization: Bearer ss_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@mail.example.com>",
"to": ["ada@example.com", "grace@example.com"],
"replyTo": "support@example.com",
"subject": "Your invoice",
"html": "<p>Attached.</p>",
"text": "Attached.",
"tags": { "env": "production", "kind": "invoice" },
"idempotencyKey": "invoice-2026-08-0042"
}'
An email's status moves through queued (or scheduled), sending,
sent, then delivered, bounced or complained; failed and cancelled
are terminal and never count against a cap. emails.get(id) /
GET /api/v1/emails/:id returns the object plus its events timeline;
emails.list() / GET /api/v1/emails pages through them with limit,
cursor, status, to, domainId and tag filters, and the SDK's
emails.iterate() walks every page. Reading needs a full key — a
sending_only key can send but not look.
Fields
| Field | Type | Notes |
|---|---|---|
from | string | Required. a@b.com or Name <a@b.com>. Must be on a verified domain of this team. |
to | string or string[] | Required, at least one address. |
cc, bcc, replyTo | string or string[] | Optional; a bare string is accepted and wrapped. |
subject | string | 1–998 characters, no control characters. Required unless template supplies one. |
html | string | Up to 5 MB. |
text | string | Up to 5 MB. |
template | string | Slug or tpl_… id of a stored template, rendered server-side. Not with html/text. |
variables | object | Values for the template's {{placeholders}}; only valid with template. |
headers | object | Custom headers, [A-Za-z0-9-]{1,80} names, values ≤ 1000 chars, no control chars. |
attachments | object[] | Up to 20; see below. |
tags | object | Up to 20 key/value pairs. |
scheduledAt | ISO 8601 with offset | Send later. |
idempotencyKey | string | 1–256 characters. |
trackOpens, trackClicks | boolean | Override the team defaults for this message. |
overrideSuppression | boolean | Ignore manual suppression entries. |
The SDK's send() and batch() additionally take react: a React element
rendered to html/text on your side before the request goes out (explicit
html/text win). It needs the optional peers react and
@react-email/render; see SDK.
One content source must be present: html, text (or both), or a
template. A template together with html or text is refused rather
than silently preferring one of them.
A send naming a template renders it server-side with the supplied
variables — the same renderer the dashboard preview uses, so what you
previewed is what is sent — and stores the rendered subject and body. The
template's subject is used unless the request carries its own. A missing
template, or a variable with no value and no declared default, is a
validation_error naming what is wrong. See Templates for
the syntax, the escaping rules and how versions work.
to, cc and bcc together may not exceed 50 recipients.
No control characters, anywhere that reaches a header. subject, every
address field, an attachment's filename and contentType, custom header
values and tag values all refuse every C0 control character and DEL — not just
CR and LF. That includes the tab, which RFC 5322 permits as whitespace in
an unstructured header: it is the folding-continuation character and therefore
the second half of a header-injection primitive. NUL truncates the value in
whatever C-string-based agent handles the message downstream, and ESC is
RFC 2047's charset-switching lead-in. A value carrying one is a
validation_error saying it "must not contain line breaks or control
characters"; strip it, or send it in the body.
subject is trimmed before it is stored, so " Hello " is stored and sent
as "Hello", and a subject of nothing but whitespace is a validation_error
rather than a blank header. When a template supplies a subject, a
whitespace-only request subject counts as absent and the template's wins.
Headers that Sendsprite or SES set themselves are refused: to, cc,
bcc, from, subject, reply-to, content-type, mime-version, date,
message-id, return-path, sender, dkim-signature, received,
content-transfer-encoding and authentication-results.
list-unsubscribe is deliberately allowed.
Batch
const { data } = await sendsprite.emails.batch([first, second, third]);
data; // [{ id: "em_…" }, …], in request order
POST /api/v1/emails/batch takes an array of 1–100 of the same objects
and answers 201 { "data": [{ "id" }, …] }. There is no outer transaction —
the items are created in order and the first failure stops the batch, with
details.index naming where it stopped; everything before it is already
queued. The SDK retries a batch on 429/5xx only when every item carries an
idempotencyKey.
Attachments
{
"filename": "invoice.pdf",
"content": "<base64>",
"contentType": "application/pdf"
}
filename is 1–255 characters with no path separators and no control characters.
content is base64 (whitespace is stripped before validation).
contentType defaults to application/octet-stream. At most 20 attachments
and 10 MB of decoded bytes across all of them. The request body as a
whole is capped at 25 MB (payload_too_large, 413).
Tags
Up to 20 tags. Keys are 1–64 characters of [A-Za-z0-9_-]; values are up
to 256 characters, with no control characters. Tags are stored with the email
and are filterable: emails.list({ tag: "env:production" }) —
GET /api/v1/emails?tag=env:production — matches one key:value pair (the
value may itself contain :).
Scheduling
Pass scheduledAt as an ISO 8601 timestamp with an offset. Anything more
than 5 seconds in the future gets status scheduled and a delayed job;
anything closer is treated as an immediate queued send.
const { id } = await sendsprite.emails.send({
...input,
scheduledAt: "2026-09-01T09:00:00+01:00",
});
await sendsprite.emails.reschedule(id, {
scheduledAt: "2026-09-02T09:00:00+01:00",
});
await sendsprite.emails.cancel(id);
POST /api/v1/emails/:id/cancelcancels aqueuedorscheduledemail and returns it; any other status is aconflict(409).PATCH /api/v1/emails/:idwith{ "scheduledAt": … }moves ascheduledemail to a new — still future — time. Anything butscheduledis a 409.
Both need a full key.
Idempotency
Set idempotencyKey and a repeat of the same send returns the original email
instead of creating a second one (200 rather than 201, same id). Keys
never expire.
The key is scoped to your team. A replay is compared on subject, the
recipient set (order-insensitive) and hashes of the html and text bodies;
cc/bcc and the tracking flags are deliberately not part of that
fingerprint. If the key comes back with a different payload the call fails
with idempotency_conflict (409). The comparison happens before the domain
and suppression checks, so a retry still gets its email back after the domain
was un-verified in between.
The SDK uses this to decide whether a send may be retried automatically: with
a key, a 429, 5xx or network failure is retried (maxRetries, default 2);
without one, a failed POST /emails is never repeated.
Tracking
trackOpens and trackClicks default to the team's settings (both on out of
the box) and can be overridden per message. When on, links in the html are
rewritten to /t/c/<email id> on your instance and a 1×1 pixel pointing at
/t/o/<email id>.gif is appended.
Click links carry an HMAC of the email id and the destination, keyed with
APP_SECRET: the redirect takes the target URL as a query parameter, and the
signature is what stops anyone using it as an open redirect. The open pixel
carries the email id and nothing else. Only http(s) hrefs inside <a> tags
are rewritten — mailto:, tel:, anchors and <link href> are left alone.
Treat both counts as advisory rather than an audit trail. Opens and clicks
are deduplicated once per (user agent[, url]) per UTC day, so a mail client
refetching the pixel counts once — and an image proxy or a prefetching
client can register an open the recipient never made. SES's own open and
click events are ignored; your instance is the single source for those.
Suppressions
Permanent bounces and complaints (any feedback type but not-spam) add the
recipient to the team's suppression list automatically, and a send to a
suppressed address is refused with suppressed_recipient (422) before
anything is queued; details lists the blocking entries.
await sendsprite.suppressions.add({
email: "ada@example.com",
reason: "unsubscribe",
note: "footer link",
});
await sendsprite.suppressions.list();
await sendsprite.suppressions.remove("ada@example.com");
# add
curl https://sendsprite.com/api/v1/suppressions -X POST \
-H "Authorization: Bearer ss_live_..." \
-H "Content-Type: application/json" \
-d '{ "email": "ada@example.com", "reason": "unsubscribe", "note": "footer link" }'
# remove
curl https://sendsprite.com/api/v1/suppressions/ada%40example.com -X DELETE \
-H "Authorization: Bearer ss_live_..."
Reasons are bounce, complaint, manual and unsubscribe; the API can
only add manual (the default) and unsubscribe — the other two come from
SES events. Adding answers 201 with the entry, also for an address already
listed; removing answers 204. overrideSuppression: true bypasses manual
entries only, and on a sending_only key it is silently ignored — such a key
can never mail an address someone put on the list by hand. Managing the list
needs a full key.
Limits and rate headers
Before the row is written, a send is checked against the team's daily and
monthly caps — set per team, else by the hosted plan, else by the instance
default — and against the SES 24-hour quota of the connected AWS account.
Both refusals are typed and map to 429: daily_quota_exceeded (the team's
daily cap, or the SES 24-hour quota) and monthly_quota_exceeded. The SDK
surfaces them as a SendspriteError with that code.
Your SES account's MaxSendRate is not a request-time refusal. Accepted
messages are paced through a per-team token bucket shared across workers;
when the bucket is empty the send job is deferred and retried, so the API
call still answers 201 and the email waits in queued a little longer.
An AWS account still in the SES sandbox can only deliver to verified
addresses. That refusal arrives from SES at delivery time, not at the API
call: the email ends failed, its lastError starts with
sandbox_restricted: and the failed event carries
code: "sandbox_restricted".
Every response from the emails endpoints carries x-ratelimit-limit and
x-ratelimit-remaining, reporting whichever cap binds first. With a team
daily cap they also carry x-ratelimit-reset (next UTC midnight, epoch
seconds); with only a monthly cap the reset is the end of that month's
window; against the SES 24-hour quota there is no fixed reset, so the header
is omitted, and with no cap at all both values read unlimited.
SMTP relay
Legacy apps that only speak SMTP go through the same pipeline — same validation, same suppression list, same event log.
| Setting | Value |
|---|---|
| Host | your instance |
| Port | SMTP_PORT, default 587 |
| Encryption | STARTTLS |
| Username | anything (ignored) |
| Password | an API key (ss_live_…) |
That port is the one clients connect to. Under Docker it is the host port:
inside the container the relay listens on 2587, because a non-root container
cannot bind below 1024, and Compose publishes it as SMTP_PORT. Set
SMTP_ENABLED=false to run an instance without the relay.
AUTH PLAIN and AUTH LOGIN are offered, and only after STARTTLS unless
SMTP_ALLOW_INSECURE_AUTH is on (development only). Five bad passwords from
one address within 10 minutes lock that address out for 10 minutes.
The envelope (RCPT TO) decides who is delivered to; the To: and Cc:
headers only decide which of those recipients are visible as To/Cc — the
rest become Bcc. Only X-* headers are forwarded; the standard ones are
carried by the parsed fields. Messages need a From: header and an html or
text body. Refusals map onto SMTP codes: unverified domain, suppressed
recipient and forbidden are 550, validation problems 501, quota and rate
limits 452, a bad key 535, and a message over SMTP_MAX_SIZE is 552.