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
Receiving when inbound port 25 is blocked
Most cloud hosts (Hetzner, AWS, GCP, …) drop inbound connections on port 25, so pointing MX straight at the instance often can't work — the SMTP handshake never reaches the server. Route mail through Cloudflare Email Routing instead: Cloudflare accepts the SMTP on its own infrastructure and an Email Worker forwards the raw message to the HTTP ingest above. No open port on your server.
| Step | Action |
|---|---|
| 1 | Enable Email Routing on the Cloudflare zone (adds route*.mx.cloudflare.net MX). |
| 2 | Settings → Subdomains → add your sending subdomain (e.g. email). Delete any conflicting non-Cloudflare MX for it first, or the add is rejected. |
| 3 | Deploy the Email Worker below and bind INGEST_KEY = the instance ingest key as a Worker secret. |
| 4 | Routing rules → catch-all (or a specific address) → Send to a Worker → this worker. |
export default {
async email(message, env) {
const raw = await new Response(message.raw).arrayBuffer();
const url =
"https://send.yourdomain.com/api/inbound/raw?to=" +
encodeURIComponent(message.to);
await fetch(url, {
method: "POST",
headers: {
authorization: "Bearer " + env.INGEST_KEY,
"content-type": "message/rfc822",
},
body: raw,
});
},
};The recipient domain must match a domain registered in sendthen so the message is stored against the right team. Only the MX changes — outbound sending (DKIM / stmail._domainkey) is unaffected, and DMARC still passes via DKIM alignment.
Verifying receiving
A domain's detail page has a Verify receivingcheck — it resolves the domain's MX and confirms it points at this instance, for the built-in SMTP route — and a Test receiving panel that surfaces the most recent inbound message for the domain. With the SES or Cloudflare relay routes the MX points at the provider, so confirm delivery via Test receiving or Emails → Receiving rather than the MX check.
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 |