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
| Permission | Can do |
|---|---|
| full | Everything, including managing API keys |
| sending | Everything 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:
| Mode | Behavior |
|---|---|
| sandbox (default) | Full MIME built + DKIM-signed, captured to data/outbox/<id>.eml, auto-marked delivered. No network. |
| smtp | Relayed through an SMTP URL (e.g. smtp://user:pass@host:587). Any provider or your own MTA. |
| ses | Amazon SES v2 SendRawEmail with your IAM access key / secret / region. No AWS SDK involved. |
| direct | Delivered 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
/api/v1/emails— Send an email| Field | Type | Notes |
|---|---|---|
| from | string | Required. "Name <a@b.com>" or bare address |
| to | string | string[] | Required, max 50 recipients |
| subject | string | Required unless template_id is set |
| html / text | string | One required (or template_id) |
| cc, bcc, reply_to | string | string[] | Optional |
| headers | object | Optional extra headers |
| tags | object | Optional key/value metadata |
| attachments | [{filename, content, content_type?}] | content is base64; 7 MB total |
| scheduled_at | ISO 8601 | Optional — queue now, send at this time |
| track_opens / track_clicks | boolean | Override your Settings defaults |
| template_id + variables | string + object | Render 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..." }/api/v1/emails/batch— Send up to 100 emails in one callBody is a JSON array of send objects (same fields as above, minus the idempotency header). Returns { data: [{ id }, …] } in input order.
/api/v1/emails— List emailsReturns the latest emails (?limit=, max 100).
/api/v1/emails/:id— Get one emailFull record including status (queued · sending · sent · delivered · bounced · failed · canceled), message_id, and last_error.
/api/v1/emails/:id/cancel— Cancel a queued emailOnly 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| Record | Host | Value |
|---|---|---|
| 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 revokeThe 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.
| Scope | Grants |
|---|---|
| emails.send | POST /emails, /emails/batch, cancel |
| emails.read | GET /emails, GET /emails/:id |
| domains.manage | Domains CRUD + verify |
| templates.manage | Templates CRUD |
| audiences.manage | Audiences + contacts CRUD |
| broadcasts.manage | Broadcasts CRUD + send |
| webhooks.manage | Webhooks CRUD |
| keys.manage | API 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:
| Route | How |
|---|---|
| Built-in SMTP | The 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 receiving | Create an SES receipt rule that publishes to an SNS topic pointed at POST /api/inbound/ses. |
| HTTP ingest | POST 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"] }| Event | Fires when |
|---|---|
| email.queued | Accepted by the API |
| email.sent | Handed to the transport (SMTP 250 / captured) |
| email.delivered | Delivery confirmed (sandbox: immediately) |
| email.bounced | Recipient server rejected the message |
| email.complained | Recipient marked it as spam (SES feedback) |
| email.failed | All send attempts exhausted |
| email.canceled | Canceled while queued |
| email.opened | Tracking pixel loaded |
| email.clicked | Tracked 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
| Method | Returns | Notes |
|---|---|---|
| send(options, { idempotencyKey? }) | { id } | options mirrors POST /emails fields |
| get(id) | email object | status, 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 → deliveredst.domains
| Method | Returns | Notes |
|---|---|---|
| create(name) | domain + records[] | generates the DKIM keypair |
| get(id) | domain | records[] include per-record status |
| list() | { data: [...] } | |
| verify(id) | domain | re-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
| Method | Returns | Notes |
|---|---|---|
| create(name, permission = "full") | { id, token } | token is shown only here |
| list() | { data: [...] } | prefixes only |
| remove(id) | { id } | revoke |
st.webhooks
| Method | Returns | Notes |
|---|---|---|
| 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." }| Status | Name | When |
|---|---|---|
| 400 | invalid_json | Body is not valid JSON |
| 401 | missing_api_key / invalid_api_key | Bad bearer token |
| 403 | domain_not_verified / forbidden | Unverified sender domain, or key lacks permission |
| 404 | not_found | Resource does not exist |
| 409 | already_exists | Duplicate domain |
| 422 | validation_error / not_cancelable | Bad fields, or email already sent |