Documentation

Everything you need to send your first email and wire sendthen into production — REST API, TypeScript SDK, webhooks, and domains.

Getting started

sendthen is a single Next.js app backed by one SQLite file. Run it locally with pnpm, or in production with Docker:

# local
pnpm install
cp .env.example .env.local   # set ADMIN_PASSWORD
pnpm dev

# production
docker build -t sendthen .
docker run -p 3000:3000 -v sendthen-data:/data -e ADMIN_PASSWORD=... sendthen

Open the dashboard and create the first account — it becomes the instance admin (set DISABLE_SIGNUP=true afterwards to block public registration). Create an API key under API Keys, then send:

curl -X POST http://localhost:3000/api/v1/emails \
  -H "Authorization: Bearer st_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "you <hello@yourdomain.com>",
    "to": "user@example.com",
    "subject": "Hello",
    "html": "<strong>It just sends.</strong>"
  }'

In the default sandbox mode the email is DKIM-signed, captured to disk, and marked delivered — the entire pipeline (queue → send → events → webhooks) runs without touching the network.

Authentication

Every API request needs a bearer token. Keys start with st_, are shown once at creation, and are stored as SHA-256 hashes.

Authorization: Bearer st_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
PermissionCan do
fullEverything, including managing API keys
sendingEverything except creating or revoking API keys

Mail modes & providers

The instance default transport is SENDTHEN_MAIL_MODE; every user can override it in Settings with their own SES credentials or SMTP relay:

ModeBehavior
sandbox (default)Full MIME built + DKIM-signed, captured to data/outbox/<id>.eml, auto-marked delivered. No network.
smtpRelayed through an SMTP URL (e.g. smtp://user:pass@host:587). Any provider or your own MTA.
sesAmazon SES v2 SendRawEmail with your IAM access key / secret / region. No AWS SDK involved.
directDelivered straight to each recipient's MX on port 25. Needs clean IP, PTR record, and port-25 egress.

Outside sandbox mode, only verified domains may send. Locally you can set SENDTHEN_DNS_MOCK=verified to make every DNS check pass.

Amazon SES bounce feedback

Point an SNS topic (bounces + complaints) at POST /api/ses/feedback. Subscription confirmation is automatic; hard bounces and complaints update email status and auto-populate your suppression list.

Emails API

POST/api/v1/emailsSend an email
FieldTypeNotes
fromstringRequired. "Name <a@b.com>" or bare address
tostring | string[]Required, max 50 recipients
subjectstringRequired unless template_id is set
html / textstringOne required (or template_id)
cc, bcc, reply_tostring | string[]Optional
headersobjectOptional extra headers
tagsobjectOptional key/value metadata
attachments[{filename, content, content_type?}]content is base64; 7 MB total
scheduled_atISO 8601Optional — queue now, send at this time
track_opens / track_clicksbooleanOverride your Settings defaults
template_id + variablesstring + objectRender a stored template with {{variables}}

Pass an Idempotency-Key header to make retries safe: the same key always returns the original email id.

{ "id": "em_4kq0w2xr..." }
POST/api/v1/emails/batchSend up to 100 emails in one call

Body is a JSON array of send objects (same fields as above, minus the idempotency header). Returns { data: [{ id }, …] } in input order.

GET/api/v1/emailsList emails

Returns the latest emails (?limit=, max 100).

GET/api/v1/emails/:idGet one email

Full record including status (queued · sending · sent · delivered · bounced · failed · canceled), message_id, and last_error.

POST/api/v1/emails/:id/cancelCancel a queued email

Only emails still in the queue can be canceled.

Templates

Store reusable emails with {{variables}} in subject and body, then send by id. The dashboard includes a no-code visual builder (Templates → Open builder): compose from blocks — logo, headings, buttons, images, columns, OTP code, social links, footer with unsubscribe — and it compiles to table-based, inline-styled, email-client-safe HTML. Built templates stay re-editable.

POST /api/v1/templates        { "name": "welcome", "subject": "Hi {{name}}", "html": "<h1>Hey {{name}}</h1>" }
GET  /api/v1/templates        list
GET/PATCH/DELETE /api/v1/templates/:id

POST /api/v1/emails           { "from": "...", "to": "...", "template_id": "tpl_...", "variables": { "name": "Ada" } }

Unknown variables are left as-is; explicit html/text/subject in the send call win over the template.

Audiences & broadcasts

Audiences hold contacts; broadcasts fan out one personalized email per subscribed contact with {{first_name}} {{last_name}} {{email}} {{unsubscribe_url}} substitution and RFC 8058 one-click unsubscribe headers.

POST /api/v1/audiences                  { "name": "newsletter" }
POST /api/v1/audiences/:id/contacts     { "email": "a@b.com", "first_name": "Ada" }
GET  /api/v1/audiences/:id/contacts     list

POST /api/v1/broadcasts                 { "audience_id": "aud_...", "from": "...", "subject": "Hey {{first_name}}", "html": "..." }
POST /api/v1/broadcasts/:id/send        → { "queued": 120, "skipped": 3 }

Unsubscribed and suppressed contacts are skipped automatically. Unsubscribe links are HMAC-signed and work without login.

Open & click tracking

Enable per-account in Settings or per-send with track_opens / track_clicks. Requires SENDTHEN_PUBLIC_URL. Opens use a signed 1×1 pixel; clicks rewrite links through a signed redirect. Both emit email.opened / email.clicked events to your webhooks and show up in Overview analytics.

Suppressions

Addresses on your suppression list never receive email: they are dropped at send time, and an email whose every recipient is suppressed fails with recipients_suppressed. Hard bounces and complaints (via SES feedback) are added automatically; manual entries via the dashboard.

Domains API

Adding a domain generates a 2048-bit DKIM keypair and returns the DNS records to publish:

POST /api/v1/domains          { "name": "mail.yourdomain.com" }
GET  /api/v1/domains          list
GET  /api/v1/domains/:id      detail incl. records[]
POST /api/v1/domains/:id/verify   re-check DNS
DELETE /api/v1/domains/:id    remove
RecordHostValue
TXT (DKIM)stmail._domainkey.<domain>v=DKIM1; k=rsa; p=<public key>
TXT (SPF)<domain>v=spf1 a mx ~all

status becomes verified once both records resolve. Verification also runs from the dashboard with one click.

API Keys & scopes

POST   /api/v1/api-keys      { "name": "production", "scopes": ["emails.send", "emails.read"] }
GET    /api/v1/api-keys      list (prefixes + scopes)
DELETE /api/v1/api-keys/:id  revoke

The full token is returned only once, in the create response. Omit scopesfor full access. A request outside a key's scopes fails with 403 missing_scope.

ScopeGrants
emails.sendPOST /emails, /emails/batch, cancel
emails.readGET /emails, GET /emails/:id
domains.manageDomains CRUD + verify
templates.manageTemplates CRUD
audiences.manageAudiences + contacts CRUD
broadcasts.manageBroadcasts CRUD + send
webhooks.manageWebhooks CRUD
keys.manageAPI keys CRUD

Inbound email

sendthen can receive mail for your verified domains and show it under Emails → Receiving, with one-click forwarding through your configured transport. Three ways in:

RouteHow
Built-in SMTPThe operator sets an SMTP port for the instance; point your domain's MX records at the server. Unknown recipients are rejected at RCPT time.
Amazon SES receivingCreate an SES receipt rule that publishes to an SNS topic pointed at POST /api/inbound/ses.
HTTP ingestPOST raw MIME to /api/inbound/raw with the instance ingest key as a bearer token — works behind Cloudflare Email Workers or any relay.
curl -X POST https://send.yourdomain.com/api/inbound/raw \
  -H "Authorization: Bearer <ingest key>" \
  --data-binary @message.eml

Webhooks

Subscribe to lifecycle events. Deliveries are retried 5× with backoff (5s → 5m → 30m → 2h) and logged in the dashboard.

POST /api/v1/webhooks   { "url": "https://you.com/hooks", "events": ["email.delivered"] }
EventFires when
email.queuedAccepted by the API
email.sentHanded to the transport (SMTP 250 / captured)
email.deliveredDelivery confirmed (sandbox: immediately)
email.bouncedRecipient server rejected the message
email.complainedRecipient marked it as spam (SES feedback)
email.failedAll send attempts exhausted
email.canceledCanceled while queued
email.openedTracking pixel loaded
email.clickedTracked link followed

Payload

{
  "type": "email.delivered",
  "created_at": "2026-07-02T09:31:04.220Z",
  "data": {
    "email_id": "em_4kq0w2xr...",
    "from": "you <hello@yourdomain.com>",
    "to": ["user@example.com"],
    "subject": "Hello",
    "message_id": "<...>"
  }
}

Verifying signatures

Headers are svix-compatible: webhook-id, webhook-timestamp, webhook-signature. The signed content is `${id}.${timestamp}.${body}`, HMAC-SHA256 with your endpoint's whsec_ secret:

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(req: Request, rawBody: string, secret: string) {
  const id = req.headers.get("webhook-id")!;
  const ts = req.headers.get("webhook-timestamp")!;
  const sig = req.headers.get("webhook-signature")!;

  const mac = createHmac("sha256", Buffer.from(secret.replace(/^whsec_/, "")))
    .update(`${id}.${ts}.${rawBody}`)
    .digest("base64");

  const expected = Buffer.from(`v1,${mac}`);
  const received = Buffer.from(sig);
  return (
    expected.length === received.length &&
    timingSafeEqual(expected, received)
  );
}

SDK reference

The TypeScript SDK ships as the sendthen npm package (npm install sendthen) — zero dependencies, works in Node 18+, Bun, Deno, and edge runtimes (anything with fetch). The package also includes a CLI: npx sendthen login.

Constructor

import { SendThen } from "sendthen";

const st = new SendThen("st_xxxxxxxx", {
  baseUrl: "https://send.yourdomain.com", // default: SENDTHEN_BASE_URL env or http://localhost:3000
  fetch: customFetch,                     // optional fetch override
});

Every method returns a promise. Non-2xx responses throw SendThenError with statusCode, name, and message.

st.emails

MethodReturnsNotes
send(options, { idempotencyKey? }){ id }options mirrors POST /emails fields
get(id)email objectstatus, message_id, last_error…
list(){ data: [...] }latest emails
cancel(id){ id }queued emails only
const { id } = await st.emails.send(
  {
    from: "you <hello@yourdomain.com>",
    to: ["user@example.com"],
    subject: "Welcome aboard",
    html: "<strong>It just sends.</strong>",
    scheduled_at: "2026-07-03T09:00:00+03:00", // optional
  },
  { idempotencyKey: "welcome-user-42" },       // optional, retry-safe
);

const email = await st.emails.get(id);
console.log(email.status); // queued → sent → delivered

st.domains

MethodReturnsNotes
create(name)domain + records[]generates the DKIM keypair
get(id)domainrecords[] include per-record status
list(){ data: [...] }
verify(id)domainre-resolves DKIM + SPF TXT records
remove(id){ id }
const domain = await st.domains.create("mail.yourdomain.com");
// publish domain.records, then:
const verified = await st.domains.verify(domain.id);
console.log(verified.status); // "verified"

st.apiKeys

MethodReturnsNotes
create(name, permission = "full"){ id, token }token is shown only here
list(){ data: [...] }prefixes only
remove(id){ id }revoke

st.webhooks

MethodReturnsNotes
create(url, events){ id, secret }secret is your whsec_ signing key
list(){ data: [...] }
remove(id){ id }
const hook = await st.webhooks.create(
  "https://you.com/hooks/sendthen",
  ["email.delivered", "email.bounced"],
);
// store hook.secret for signature verification

Errors

All errors share one JSON shape:

{ "statusCode": 422, "name": "validation_error", "message": "Either html or text must be provided." }
StatusNameWhen
400invalid_jsonBody is not valid JSON
401missing_api_key / invalid_api_keyBad bearer token
403domain_not_verified / forbiddenUnverified sender domain, or key lacks permission
404not_foundResource does not exist
409already_existsDuplicate domain
422validation_error / not_cancelableBad fields, or email already sent