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:
- Shared layer —
/v1/*(product-agnostic): two kinds of endpoint —- Data —
/v1/<table>/<verb>: generic, row-scoped CRUD over the backend's tables. - Shared services —
/v1/services/<action>: cross-product business actions (e.g.POST /v1/services/sendEmail) that compose table writes in one transaction.
- Data —
- Product layers —
/<product>/v1/<resource>/<action>: business actions for a single product (console/auth/admin) that build on the shared layer and never reference each other.
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 code — POST /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 status ∈ pending · 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
subdomainfield. No separate claim per subdomain. Each capability is addressed by its ownid(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 example — organizationId 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).