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

POST   /api/v1/api-keys      { "name": "production", "permission": "full" | "sending" }
GET    /api/v1/api-keys      list (prefixes only)
DELETE /api/v1/api-keys/:id  revoke

The full token is returned only once, in the create response. Requires a full-permission key.

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 lives in sdk/index.ts — zero dependencies, works in Node 18+, Bun, Deno, and edge runtimes (anything with fetch).

Constructor

import { SendThen } from "./sdk";

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