Contents

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

FieldTypeNotes
fromstringRequired. a@b.com or Name <a@b.com>. Must be on a verified domain of this team.
tostring or string[]Required, at least one address.
cc, bcc, replyTostring or string[]Optional; a bare string is accepted and wrapped.
subjectstring1–998 characters, no control characters. Required unless template supplies one.
htmlstringUp to 5 MB.
textstringUp to 5 MB.
templatestringSlug or tpl_… id of a stored template, rendered server-side. Not with html/text.
variablesobjectValues for the template's {{placeholders}}; only valid with template.
headersobjectCustom headers, [A-Za-z0-9-]{1,80} names, values ≤ 1000 chars, no control chars.
attachmentsobject[]Up to 20; see below.
tagsobjectUp to 20 key/value pairs.
scheduledAtISO 8601 with offsetSend later.
idempotencyKeystring1–256 characters.
trackOpens, trackClicksbooleanOverride the team defaults for this message.
overrideSuppressionbooleanIgnore 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/cancel cancels a queued or scheduled email and returns it; any other status is a conflict (409).
  • PATCH /api/v1/emails/:id with { "scheduledAt": … } moves a scheduled email to a new — still future — time. Anything but scheduled is 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.

SettingValue
Hostyour instance
PortSMTP_PORT, default 587
EncryptionSTARTTLS
Usernameanything (ignored)
Passwordan 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.