That’s My Mail Docs/llms.txt
← All docs

API reference

Base host: https://api.thatsmymail.com (no /api prefix). The surface has a shared, product-agnostic layer that every product reuses, plus thin product layers built on top:

There now is a product-agnostic /v1/* — the shared layer above. (Sending mail is the shared service POST /v1/services/sendEmail; there is no separate mail product.)

All requests are HMAC-signed — see Authentication (the signature covers the full path including the query string). Errors share one envelope — see Errors.

GET /health{ ok, service, time } is the one unauthenticated endpoint.


data — /v1/<table>/<verb>

The data layer: thin, generic CRUD over the backend's tables, on the shared product-agnostic /v1/* layer. Every call is row-scoped to your credential (an account token sees only its own projects' rows; a project key only its project's; staff/god see all). Reads return only an allowlist of columns — never secrets, password hashes, or private keys. Unknown table or verb → 404.

Method Path Returns
GET /v1/<table>/list[?limit] { rows: [...] } — your rows for that table.
GET /v1/<table>/get?id= { row: {...} } — one row you own (404 otherwise).
POST /v1/<table>/create { row: {...} } (201) — insert one row (secret-bearing tables mint + return the secret once).
POST /v1/<table>/update { row: {...} } — update one row you own (body = its key + changed columns).
POST /v1/<table>/upsert { row: {...} } — insert, or update on the table's natural key.
POST /v1/<table>/delete { ok: true } — delete one row you own.

Tables (all in the api schema): projects, api_keys, sender_domains, webhook_endpoints, suppressions, idempotency_keys, audit_log, messages, users (self), account_tokens (own), organizations, organization_members, organization_tokens (own orgs; reads only — org create + tier changes are service actions under /console/v1/organizations).

Full CRUD, row-scoped. Gated only by row-scoping to your credential (the project_id/id you target is validated against what you own — a row you don't own returns 404) plus secret-column redaction on reads. Bodies use snake_case column names:

POST /v1/suppressions/create
{ "project_id": "…", "address": "user@example.com", "reason": "manual" }
POST /v1/projects/update
{ "id": "…", "name": "Renamed project" }

Writable tables: projects (update/delete), api_keys, sender_domains, webhook_endpoints, suppressions (create/delete), account_tokens (create/delete), and users (self-update = email change re-sets email_verified_at; self-delete = irreversible hard-delete that cascades the user's projects/keys/sender-domains/tokens, 409 if a project has sent mail). create on a secret-bearing table mints + returns the secret once. Permanent carve-outs stay service-only: messages create (a gated send — use POST /v1/services/sendEmail), audit_log (append-only), and engine tables rate_limits · webhook_deliveries · message_events (read-only). Calling a non-enabled write → 404.


auth — /auth/v1/*

Account entry point. signup and login are public (no signature) and both return the user plus a per-session account token (tmma_) to act with. logout is signed by that token and revokes it.

Method Path Auth Purpose
POST /auth/v1/signup public Create a user. Body: { email, password }. Returns { user, accountToken: { tokenId, secret } }.
POST /auth/v1/login public Verify credentials. Body: { email, password }. Returns { user, accountToken: { tokenId, secret } }.
POST /auth/v1/logout account token Revoke the calling account token (ends the session). No body, no id.
POST /auth/v1/password/forgot public Body: { email }. Always returns { ok: true } (no account enumeration); if the account exists, emails a single-use reset link (valid 1h) — sent by TMM itself from notification@noreply.thatsmymail.com.
POST /auth/v1/password/reset public Body: { token, password }. Consume the emailed token, set the new password, and revoke the user's existing sessions. 401 on an invalid/expired/used token.
POST /auth/v1/password/change account token Body: { currentPassword, newPassword }. Verify the current password, then set the new one (logged-in change; no email). 401 if the current password is wrong.

console — /console/v1/*

Organizations, project lifecycle and per-project configuration. The UI uses these exact endpoints.

The model: a user belongs to one or more organizations; an organization owns its projects; billing/metering lives on the organization (one daily allowance shared across its projects). The onboarding flow is Create user → Create organization → Create project → Add sender domain → Verify sender domain. A fresh signup owns no org, so the first step after auth.signup is either POST /console/v1/organizations (be the first one and create it) or — if a colleague gave them a join codePOST /console/v1/organizations/join to join an existing org. A user already in an organization cannot create another (409 already_in_organization) but may still join more with a code.

Organizations

Authorized by an account token (the member) or — for :id routes — the org's own tmmo_ token, a staff token, or the god-key. Non-members get 404 (the org's existence is never leaked).

Method Path Auth Purpose
POST /console/v1/organizations account token Create an org; the caller becomes sole owner. Returns it plus its first tmmo_ organization token (secret once). 409 already_in_organization if the caller already belongs to an organization — join an existing one with a join code instead.
GET /console/v1/organizations account token List the orgs you belong to (with your role).
GET /console/v1/organizations/{id} member / org token / staff Composite detail: org + members + projects + org tokens + pending invitations + today's message usage ({ tier, dailyLimit, usedToday, remaining, overageToday }) + this month's inbox billing (inboxUsage: { activeInboxes, billedCents, storageOverageGb, storageBilledCents, totalBilledCents, monthStart }).
PATCH /console/v1/organizations/{id} owner Rename the org. Body: { name }.
POST /console/v1/organizations/{id}/plan owner Switch the billing tier. Body: { plan: "free" | "payg" }. (The payment gate lands here later.)
POST /console/v1/organizations/{id}/tokens owner Mint/rotate an org token. Body: { label? }. Returns { tokenId, secret, … } (secret once).
GET /console/v1/organizations/{id}/tokens member / org token List the org's tokens (no secrets).
DELETE /console/v1/organizations/{id}/tokens/{tokenId} owner Revoke an org token.
POST /console/v1/organizations/{id}/invitations owner Invite by email. Body: { email, role? }. Returns { invitation, acceptUrl, emailed } — the link is emailed (from notification@noreply.thatsmymail.com) and returned to copy.
GET /console/v1/organizations/{id}/invitations member Pending (unaccepted, unexpired) invitations.
DELETE /console/v1/organizations/{id}/invitations/{invitationId} owner Revoke a pending invitation.
POST /console/v1/organizations/invitations/accept account token Accept an invitation. Body: { token }. The caller's email must match the invited one (403 otherwise; 401 invalid/expired).
POST /console/v1/organizations/{id}/join-codes owner Generate a one-time join code to onboard a colleague. Body: { role? } (free text; a non-owner role joins as member until roles widen). Returns { joinCode, code, warning } — the plaintext code (TMM-XXXX-XXXX) is shown once.
GET /console/v1/organizations/{id}/join-codes member List the org's join codes with their status (active / used / expired / revoked); never the codes themselves.
DELETE /console/v1/organizations/{id}/join-codes/{codeId} owner Revoke an unused join code (409 if it was already used).
POST /console/v1/organizations/join account token Redeem a join code → become a member with the code's role. Body: { code }. 401 invalid/used/expired; 409 if already a member of that org.
POST /console/v1/organizations/{id}/members/{userId}/role members:manage Change a member's role. Body: { role }. 409 if it would demote the only owner.
DELETE /console/v1/organizations/{id}/members/{userId} members:manage Remove a member. 409 if it's the only owner.
GET /console/v1/organizations/roles — (public metadata) The static role → permissions matrix (TMM-88): { roles: [{ role, permissions }], permissions }.

Roles & permissions (TMM-88)

Every organization member has one of five fixed roles. Each role grants a fixed set of permission keys; a write to an endpoint whose required permission the role lacks returns 403 forbidden. (Owner-defined custom roles are a later addition.)

Role Grants
owner Everything (incl. billing:manage); the only role that can never be demoted away from the last owner.
admin All administration except billing:manage.
member The "do the work" role — projects, API keys, domains; no org administration.
auditor Read-only — no write permission anywhere.
billing Only billing:manage.

Permission keys gate every write. Org administration: org:manage (rename), billing:manage (plan), members:manage (invitations, join codes, member roles, removal), tokens:manage (org tokens), domains:manage (custom + sending domains). Project work: projects:manage (create/update/delete projects, webhooks, suppressions), apikeys:manage (mint/rotate/rename/revoke project API keys). Invitations and join codes carry one of the five roles; reads need no permission beyond org membership. Sending mail uses a project API key (tmm_, messages:send), not a personal account — an auditor cannot mint one (apikeys:manage), so cannot send.

The organization token (tmmo_) is the org-scoped admin credential: it authenticates AS the org and authorizes managing the org + its projects/domains/billing. It does not send mail — that stays a project key (tmm_, messages:send).

Create example

POST /console/v1/organizations
{ "name": "Acme Inc" }
{
  "organization": { "id": "…", "name": "Acme Inc", "plan": "free", "created_at": "…" },
  "organizationToken": { "id": "…", "tokenId": "tmmo_…", "secret": "…", "label": "Initial org token (auto-minted at organization creation)", "createdAt": "…", "warning": "store the secret now; it is not retrievable" }
}

Custom webmail domains — /console/v1/organizations/{id}/custom-domains (TMM-43)

Let an organization reach its webmail on its own (sub)domain (e.g. mail.acme.com) instead of the default host. The org CNAMEs the (sub)domain to mail.thatsmymail.com; once we confirm the CNAME, we serve the webmail there with automatic HTTPS. The host is platform-wide unique (like a domain claim), so two orgs can never register the same one. Authorized like the other :id org routes (member / org token / staff / god-key); non-members get 404.

Method Path Auth Purpose
POST …/custom-domains member / org token / staff Register a custom domain. Body: { domain }. Returns { customDomain, dnsRecord, nextStep }dnsRecord is the CNAME to publish ({ host, type: "CNAME", value: "mail.thatsmymail.com" }). 409 custom_domain_taken if it's already registered (any org); 400 invalid_domain if it isn't a valid hostname.
GET …/custom-domains member / org token / staff List the org's custom domains, each with its dnsRecord.
POST …/custom-domains/{domainId}/verify member / org token / staff Re-check the CNAME (an apex with no CNAME is also accepted if its A/AAAA records match ours). 200 { status: "verified", domain } on success; 400 cname_not_found until it points at us. Always bumps last_verify_attempt.
DELETE …/custom-domains/{domainId} member / org token / staff Remove a custom domain (the webmail stops being served there).

A custom domain has statuspending · verified · disabled. Only a verified domain is served (and only a verified one passes the on-demand-TLS ask-endpoint below, so a certificate is never issued for an unverified or someone else's host).

Create example

POST /console/v1/organizations/{id}/custom-domains
{ "domain": "mail.acme.com" }
{
  "customDomain": { "id": "…", "domain": "mail.acme.com", "status": "pending", "verified_at": null, "last_verify_attempt": null, "created_at": "…" },
  "dnsRecord": { "host": "mail.acme.com", "type": "CNAME", "value": "mail.thatsmymail.com", "note": "required — point mail.acme.com at mail.thatsmymail.com, then Verify. We then serve your webmail there with automatic HTTPS." },
  "nextStep": "Publish the CNAME, then POST /console/v1/organizations/{id}/custom-domains/{domainId}/verify."
}

Domains — claim, capabilities & inboxes (TMM-8)

A domain is a first-class, platform-wide-unique claim: once a project claims acme.com, no other project (in any org) can claim it or any subdomain. You claim the apex first, then add capabilities under it — outbound (sending, DKIM) and/or inbound (receiving, MX) — on the apex or any subdomain, plus inboxes under a verified inbound domain. (This replaces the old per-project sender-domains + inbound-domains surfaces; one domain now has exactly one owner, which fixes DKIM-key collisions across projects.)

Claims/console/v1/projects/{id}/domains

Method Path Purpose
POST …/domains Claim a domain. Body: { domain }. Returns the ownership TXT to publish (dnsRecords.ownership at _tmm-verify.{domain}) + nextStep. 409 domain_claim_conflict if it (or an ancestor) is owned by another project; 422 apex_not_claimed if you claim a subdomain whose apex you haven't claimed; 422 apex_not_verified if the apex is claimed but not yet verified (verify the apex first — a subdomain's trust derives from the proven apex).
GET …/domains List claims. Each carries the ownership dnsRecords + an outbound[] and inbound[] array of capabilities (each capability has its own id, domain, status, dnsRecords; outbound ones also carry defaultDisplayName).
GET …/domains/{domainId} One claim + its outbound[] / inbound[] capabilities + DNS.
POST …/domains/{domainId}/verify Re-check the ownership TXT. 200 { status: "verified" }; 400 on mismatch; 429 within 10s.
DELETE …/domains/{domainId} Release the claim (all capabilities + inboxes cascade; 409 if a subdomain claim or sent-mail history blocks it).

One claim, many capabilities. You claim the apex once, then add as many Sending/Receiving capabilities as you like — on the apex or any subdomain — via the optional subdomain field. No separate claim per subdomain. Each capability is addressed by its own id (capId).

Outbound (sending / DKIM) — many per claim

Method Path Purpose
POST …/domains/{domainId}/outbound Add a DKIM sending identity. Body: { subdomain?, returnPathSubdomain? } (blank subdomain = apex; "noreply" = noreply.{domain}). Returns the updated claim; the new capability carries dnsRecords.dkim (+ recommended spf/dmarc). 409 outbound_already_exists if that (sub)domain already has sending.
POST …/domains/{domainId}/outbound/{capId}/verify Re-check that capability's DKIM TXT and mark it verified.
POST …/domains/{domainId}/outbound/{capId}/display-name Set (or clear, with null) the default sender display name for this sending (sub)domain. Body: { displayName }. Applied to sends with a bare from; a per-message from: "Name <addr>" wins. The capability's defaultDisplayName is returned on the claim reads above.
DELETE …/domains/{domainId}/outbound/{capId} Remove that sending capability (blocked if it has sent mail).

Inbound (receiving / MX) — many per claim

Method Path Purpose
POST …/domains/{domainId}/inbound Add a receiving capability. Body: { subdomain? }. Returns the updated claim; the new capability carries dnsRecords.mx. 409 inbound_already_exists if that (sub)domain already has receiving.
POST …/domains/{domainId}/inbound/{capId}/verify Confirm that capability's MX points at That's My Mail (mx_record_mismatch otherwise).
DELETE …/domains/{domainId}/inbound/{capId} Remove that receiving capability (its inboxes cascade).

Both at once

Method Path Purpose
POST …/domains/{domainId}/capabilities Add Sending and Receiving for one (sub)domain in a single call. Body: { subdomain?, outbound?, inbound? } (both default to true).

Inboxes (under one verified inbound capability)

Method Path Purpose
POST …/domains/{domainId}/inbound/{capId}/inboxes Create an inbox on that receiving capability. Body: { localpart, type? } (type = individual | group). The initial password is generated server-side and returned once ({ inbox, initialPassword }); the user changes it on first login. 400 if the capability isn't verified; 409 on a duplicate localpart.
GET …/domains/{domainId}/inbound/{capId}/inboxes List that capability's inboxes. Each carries initialPassword until the user first logs in (then null), plus storageUsedBytes + storageLimitBytes (storage usage + limit).
DELETE …/domains/{domainId}/inbound/{capId}/inboxes/{inboxId} Remove an inbox (soft delete: its stored mail is purged and it stops receiving / can't log in, but it keeps its billed days for the month's invoice). The address frees up immediately for re-creation.
POST …/domains/{domainId}/inbound/{capId}/inboxes/{inboxId}/storage Set the inbox's storage limit, in whole GB. Body: { limitGb } (1–1024). Returns { storage: { usedBytes, limitBytes } }. 400 invalid_storage_limit if limitGb isn't a whole number in range. The first 1 GB is free; each GB above bills €0.50/month.
POST …/domains/{domainId}/inbound/{capId}/inboxes/{inboxId}/import Mail Transfer — import existing mail into the inbox. Body: { messages: [{ raw, folder?, read?, date? }] } where raw is base64 RFC822/MIME (≤200/call). Folders + read state + original dates are preserved. Idempotent: dedup by Message-ID (or a content hash), so a re-import adds 0 new. Returns { total, imported, skipped, failed }.

Inbox billing. An inbox costs €1 per month, metered per active UTC day (≈ €1⁄30 per day, capped at €1 per inbox per month). An inbox starts billing at its first activity — the earliest of (a) the first inbound mail it receives, or (b) its first login (webmail or IMAP) — and stops when it is deleted. An inbox that is never used (no mail, never logged in) is free. The current month's total is on the org detail's inboxUsage ({ activeInboxes, billedCents, storageOverageGb, storageBilledCents, totalBilledCents, monthStart }). (The payment gate lands here later — today this only computes + displays the cost.)

Inbox storage. Every inbox includes 1 GB of storage free. You can upgrade an inbox in whole-GB steps via the storage endpoint above; each GB above the free tier bills €0.50 per month (charged on the reserved limit, summed across live inboxes into inboxUsage.storageBilledCents). When an inbox is at or over its limit, inbound mail is rejected at receive time (552 mailbox is full) — so the sender gets a clear bounce and the upgrade is how a customer makes room.

The first-party Mail webmail sends as an inbox via the platform "send-on-behalf" path (POST /v1/services/sendEmail as platform admin resolves the project from the from-address domain's verified outbound capability — unambiguous, since one outbound exists per (sub)domain platform-wide).

Projects

Method Path Auth Purpose
POST /console/v1/projects account token (member) Create a project under an organization; returns it plus its first project:admin key (secret once).
GET /console/v1/projects[?organizationId=] account token List your projects across all your orgs (optionally narrowed to one).
GET /console/v1/projects/me project key The project the calling key belongs to.
GET /console/v1/projects/{id} project:admin / member / org token / staff Composite detail: project + keys + sender domain + webhooks + suppressions + recent messages.
PATCH /console/v1/projects/{id} project:admin / member / org token / staff Rename the project.
DELETE /console/v1/projects/{id} project:admin / member / org token / staff Soft-delete the project.

Create exampleorganizationId is required (the caller must be a member of that org):

POST /console/v1/projects
{ "name": "My App", "organizationId": "…" }
{
  "project": { "id": "…", "name": "My App", "status": "active", "plan": "free", "created_at": "…" },
  "apiKey": { "id": "…", "keyId": "tmm_…", "secret": "…", "scopes": ["project:admin"], "createdAt": "…", "warning": "store the secret now; it is not retrievable" }
}

API keys — /console/v1/projects/{id}/api-keys

Method Path Purpose
POST …/api-keys Mint a key. Body: { label?, scopes?: Scope[], allowedCidrs?: string[] }. Returns { id, keyId, secret, scopes, managed, allowedCidrs, … } (secret once). With allowedCidrs the key only works from those IP ranges (platform-managed keys only).
GET …/api-keys List keys (no secrets). Each row carries managed: "adrifact" | "local" (+ allowed_cidrs for platform keys).
PATCH …/api-keys/{keyId} Rename a key's label. Body: { label }. Returns { key } (the updated row, no secret). The secret + scopes are immutable — to change scopes, mint a new key. 404 if the key isn't in this project. Platform-managed (tk_) keys can't be renamed (400) — set the name when minting.
POST …/api-keys/{keyId}/rotate Rotate a platform-managed (tk_) key: mints a successor and returns its secret once ({ keyId, secret, rotatedFrom, … }). The old key keeps working for a 24h grace window, then dies. Legacy local keys 400 — mint a new key + revoke the old instead.
DELETE …/api-keys/{keyId} Revoke a key by its id (a tk_ key, or a local key's database id).

New keys are platform-managed through the Adrifact tokens service — their keyId is prefixed tk_ and list rows report managed: "adrifact". Existing keys (tmm_, managed: "local") keep working unchanged. The request-signing scheme (TMM-HMAC-SHA256, see Authentication) is identical for both, so your signing code doesn't change. SDK: client.console.projects.apiKeys.create/list/update/revoke/rotate.

Webhooks — /console/v1/projects/{id}/webhooks

Method Path Purpose
POST …/webhooks Register. Body: { url, events?: WebhookEvent[] }. Returns { id, url, events, secret, … } (secret once).
GET …/webhooks List webhooks.
DELETE …/webhooks/{webhookId} Delete a webhook.

WebhookEvent: delivered · deferred · bounced · failed · suppressed · complained.

Suppressions — /console/v1/projects/{id}/suppressions

Method Path Purpose
POST …/suppressions Add. Body: { address, reason?, detail? }.
GET …/suppressions[?address=] List (optionally filter by address).
DELETE …/suppressions/{address} Remove an address.

SuppressionReason: hard_bounce · soft_bounce_max · complaint · manual.

Project views — messages, audit, test

Owner-authorized (account token / project:admin / staff). Let the Console render a project's tabs without any platform credential.

Method Path Purpose
GET /console/v1/projects/{id}/messages[?limit&status] Recent messages for the project.
GET /console/v1/projects/{id}/audit[?limit&action] The project's audit log.
POST /console/v1/projects/{id}/test-message Send a test (body = a MailMessage) from the project's verified sender domain. Enqueues via the same pipeline as /v1/services/sendEmail.

Account tokens — /console/v1/account-tokens

Your own user-scoped credentials (tmma_) — issued at login and self-manageable here (no god-key). Authorized by the caller's own account token.

Method Path Purpose
POST /console/v1/account-tokens Mint a token for yourself. Body: { label? }. Returns { tokenId, secret, … } (secret once).
GET /console/v1/account-tokens List your own tokens.
DELETE /console/v1/account-tokens/{id} Revoke one of your own.

send service + message reads — /v1/services/*, /v1/messages/*

Sending and message status live on the shared /v1/* layer — sending is the shared service POST /v1/services/sendEmail; message reads are data-layer gets. Needs a project key with the matching scope.

Method Path Auth Purpose
POST /v1/services/sendEmail messages:send Queue a message. Returns { providerMessageId, status } (status: "queued").
GET /v1/messages/get?id={id} messages:read Current status + metadata of a message.
GET /v1/messages/events?id={id} messages:read Full delivery-attempt event history.

Send example

POST /v1/services/sendEmail
{
  "to": "alice@example.com",
  "from": "noreply@yourdomain.com",
  "subject": "Welcome!",
  "text": "Thanks for signing up.",
  "html": "<p>Thanks for signing up.</p>",
  "idempotencyKey": "signup-1234"
}
{ "providerMessageId": "01J…", "status": "queued" }

from may be a bare addr@domain or an RFC 5322 display name — "Acme <noreply@yourdomain.com>". Its domain must be a verified sender domain for the project, or you get sender_domain_not_verified. Sender display name: a per-message display name ("Name <addr>") always wins; if you send a bare address, the sending (sub)domain's default display name (set in the Console / via the outbound display-name endpoint below) is applied; otherwise the recipient just sees the bare address. idempotencyKey (8–128 chars) dedupes retries for 24h. MessageStatus: queued · sending · delivered · deferred · bounced · failed · suppressed.

Templates: instead of html/text, pass templateId (a templates module template in the project's org) + variables ({{var}} values) to POST /v1/services/sendEmail — the body is rendered + HTML-escaped at send time. subject is still required.

Daily quota (per organization). Every accepted message counts against the owning organization's daily allowance — 500 accepted messages/day on the free tier, shared across all the org's projects (UTC day). On a free org, message #501 today is rejected with quota_exceeded (429); upgrade the org to pay-as-you-go for unlimited sending (the first 500/day are free, the rest is metered as billable overage). See the org's usage on GET /console/v1/organizations/{id}.

Identity — GET /v1/services/whoami

A shared identity probe: tells you which principal your credential resolves to. Works with any TMM credential (project key, account token, organization token, or the platform admin key). The result is discriminated by principal. The key win for integrators: an organization token learns its own organizationId in one call, so you never have to probe org-scoped endpoints to discover it.

Method Path Auth Purpose
GET /v1/services/whoami any TMM credential Resolve the calling principal (and, for an org token, its own org).
// organization token (tmmo_)
{ "principal": "organization_token", "organizationId": "01J…", "organization": { "id": "01J…", "name": "Acme Inc", "plan": "free" } }
// account token (tmma_)
{ "principal": "account_token", "userId": "01J…", "role": "customer", "organizations": [ { "id": "01J…", "name": "Acme Inc", "plan": "free" } ] }
// project key (tmm_)
{ "principal": "project_api_key", "projectId": "01J…", "organizationId": "01J…", "scopes": ["messages:send"] }
// platform admin key
{ "principal": "platform_admin" }

Custom-domain allow check — GET /v1/services/custom-domain-allowed (TMM-43)

A shared, unauthenticated ask-endpoint for Caddy's on-demand-TLS: before issuing a TLS certificate for an incoming webmail host, Caddy asks "is this host allowed?". It is public on purpose (Caddy can't sign a TMM request) and reveals only a boolean for the exact host queried, so there is nothing sensitive to leak.

Method Path Auth Purpose
GET /v1/services/custom-domain-allowed?domain={host} none 200 { "allowed": true } if {host} is a verified custom webmail domain; otherwise 404 (Caddy treats any non-200 as "do not issue a certificate").
GET /v1/services/custom-domain-allowed?domain=mail.acme.com
{ "allowed": true }

modules — /modules/<module>/v1/*

Modules are embeddable feature units that run inside the Api under their own path namespace and own their own Postgres schema. The first is templates.

templates — /modules/templates/v1/*

Organization-scoped email templates usable as a message body. Authorized by an org member's account token, the org's tmmo_ token, a staff token, or the god-key (writes are owner-gated). organizationId is in the body (POST) or query (GET).

Method Path Auth Purpose
POST /modules/templates/v1/templates/create owner Body: { organizationId, name, subject, html?, text?, variables?, isPublic? }. Returns { template }. At least one of html/text. isPublic (default false) makes it usable cross-org.
GET /modules/templates/v1/templates/list?organizationId= member { templates: [...] } — the org's own templates only (public ones from other orgs are not mixed in).
GET /modules/templates/v1/templates/list-public?organizationId= member { templates: [...] } — all public templates (usable cross-org). organizationId is for auth only.
GET /modules/templates/v1/templates/get?organizationId=&id= member { template }. Succeeds if the template is in your org or is public; 404 otherwise.
POST /modules/templates/v1/templates/update owner Body: { organizationId, id, …fields, isPublic? }. Returns { template }. Only the owning org can update (incl. flipping isPublic).
POST /modules/templates/v1/templates/delete owner Body: { organizationId, id }. Only the owning org.
POST /modules/templates/v1/templates/render member Preview. Body: { organizationId, id? | {html,text,variables}, values? }. Returns { html, text }. A public template (any org) can be previewed by id.

variables is a declared list [{ name, default? }]. Each template carries is_public (bool). Use a template at send time by passing templateId + variables to POST /v1/services/sendEmail — the body is rendered from the template if it is in the sending project's organization OR public ({{name}} substitution, HTML-escaped in the html part; a private template from another org → 404). Public templates (TMM-54): a company can publish a fixed set of canonical templates that any tenant references via templateId through their own project-key — the send still goes from the caller's own project (their from-address, domain, deliverability). Visibility ≠ editability: only the owning org can edit or delete a public template.

admin — /admin/v1/*

Platform/staff operations. Authenticated with the god-key (or a staff account token) — see Authentication. Not a customer surface. Listed here for completeness; per UI/API parity these are real endpoints, not a private proxy.

Method Path Purpose
GET /admin/v1/stats Platform counters (projects, suppressions, messages by status, last 24h).
GET /admin/v1/messages[?limit&projectId&status] Cross-project recent messages.
GET /admin/v1/audit[?limit&projectId&action] Audit log.
POST /admin/v1/test-message Send a test message on behalf of a project.
GET /admin/v1/users List users.
POST /admin/v1/users Create a staff/customer user (god-key).
GET /admin/v1/users/{id}/projects A user's projects.
POST · GET /admin/v1/users/{id}/account-tokens Mint / list account tokens (tmma_…) for a user.
DELETE /admin/v1/account-tokens/{id} Revoke an account token.
POST /admin/v1/sender-domains/{id}/mark-verified Force-verify a sender domain (ops escape hatch).

Per-project configuration is not duplicated under /admin/v1/*. Staff use the same /console/v1/projects/{id}/* endpoints customers use (UI/API parity).