# That's My Mail — full documentation > The complete API + SDK reference, concatenated from docs.thatsmymail.com. > API host: api.thatsmymail.com. Endpoints: /{version}/{product}/{resource}. > SDK: @thatsmymail/sdk, mirroring the API paths 1:1. --- # Overview # Overview **That's My Mail** (TMM) is a transactional email platform: you register a sending domain, mint an API key, and `POST` a message; the platform queues it, DKIM-signs it, and delivers it over SMTP — with delivery events, suppressions, and webhooks. This site documents the **API** and the **SDK**. UI/usage guides come later. ## The API at a glance - **Own host.** The API is served from `api.thatsmymail.com`. Because it's its own host, endpoints carry **no `/api` prefix** — that would be redundant. - **A shared, product-agnostic layer `/v1/*`** that every product reuses, plus thin **product layers** on top. - **Data — `/v1//`**: thin, generic, row-scoped CRUD over the backend's tables. ✅ `/v1/suppressions/create`, `/v1/messages/list`. - **Shared services — `/v1/services/`**: cross-product business actions that compose several table ops in one transaction. ✅ `/v1/services/sendEmail`. - **Product layers — `//v1//`**: a single product's actions (`console`/`auth`/`admin`), built **on** the shared layer and never referencing each other. ✅ `/console/v1/projects`. - **JSON in, JSON out.** Request and response bodies are JSON. Auth is HMAC-signed (see [Authentication](/authentication)). ## Products (product layers) | Product | Base path | What it covers | |---|---|---| | `auth` | `/auth/v1/*` | Signup, login, logout (issues the account token). | | `console` | `/console/v1/*` | Project lifecycle + per-project config: projects, API keys, sender domains, webhooks, suppressions. | | `admin` | `/admin/v1/*` | Internal/staff + platform operations (god-key or staff token). Not for customer use. | > **Sending mail is not a separate product.** It's the shared service > `POST /v1/services/sendEmail` on the product-agnostic `/v1/*` layer, with message status > + delivery events at `GET /v1/messages/get?id=` and `GET /v1/messages/events?id=`. ## The SDK mirrors every layer 1:1 The official TypeScript SDK, [`@thatsmymail/sdk`](/sdk), is a structural replica of the API paths. **Product** layers map as `client...()` → `//v1//` — e.g. `client.console.projects.create()` → `POST /console/v1/projects`. The **shared** layer maps as `client.v1.
.()` → `/v1/
/` (data — e.g. `client.v1.suppressions.create()` → `POST /v1/suppressions/create`) and `client.v1.services.()` → `/v1/services/` (shared services — e.g. `client.v1.services.sendEmail()` → `POST /v1/services/sendEmail`). If you can read an SDK call, you know the exact URL it hits. ## Where to go next 1. [Authentication](/authentication) — sign your requests (HMAC) and pick the right credential. 2. [API reference](/api) — every endpoint, grouped by product. 3. [SDK reference](/sdk) — install, construct a client, the full surface. 4. [Errors](/errors) — the error envelope and codes. > **Reading this with an agent?** Fetch [`/llms.txt`](/llms.txt) for the index or > [`/llms-full.txt`](/llms-full.txt) for every doc in a single file. --- # Authentication # Authentication Every request (except `GET /health`) is authenticated with an **HMAC-SHA256 signature** over a canonical string. Nothing secret travels on the wire — only the signature. ## Headers | Header | Value | |---|---| | `Authorization` | `TMM-HMAC-SHA256 Credential=, Signature=` | | `X-TMM-Timestamp` | Unix time in **milliseconds** when the request was signed | | `X-TMM-Nonce` | A unique value per request (UUID), for replay protection | | `Content-Type` | `application/json` (for requests with a body) | The timestamp must be within **±5 minutes** of server time, or the request is rejected (`unauthorized`). Each nonce is single-use within that window. ## The string to sign Build the canonical string by joining these five lines with `\n`: ``` METHOD # uppercase, e.g. POST PATH # exact request target, INCLUDING the query string — e.g. /console/v1/projects/x/messages?limit=3 TIMESTAMP # same value as X-TMM-Timestamp NONCE # same value as X-TMM-Nonce sha256hex(body) # hex SHA-256 of the raw JSON body ("" if no body) ``` Then `Signature = hex(HMAC_SHA256(secret, stringToSign))`. ### Reference (Node.js) ```ts import { createHmac, createHash, randomUUID } from "node:crypto"; function sign(secret: string, method: string, path: string, body = "") { const timestamp = Date.now(); const nonce = randomUUID(); const bodyHash = createHash("sha256").update(body).digest("hex"); const stringToSign = [method.toUpperCase(), path, String(timestamp), nonce, bodyHash].join("\n"); const signature = createHmac("sha256", secret).update(stringToSign).digest("hex"); return { timestamp, nonce, signature }; } ``` You normally never write this yourself — the [SDK](/sdk) does it for you. ## Credential types There are four kinds of credential. Which one you use depends on the endpoint. ### Project key — `tmm_` The everyday credential. Belongs to **one project**, carries **scopes**, and is minted via the API (and rotatable). Used for sending (`/v1/services/sendEmail`), message reads (`/v1/messages/*`), and the project's own `console` endpoints. The `keyId` (`tmm_…`) is public; the `secret` is shown **once** at mint time. **Scopes:** | Scope | Grants | |---|---| | `messages:send` | `POST /v1/services/sendEmail` | | `messages:read` | `GET /v1/messages/*` | | `suppressions:read` | read the project's suppression list | | `suppressions:write` | add/remove suppressions | | `project:admin` | manage the project itself (keys, sender domain, webhooks) | ### Account token — `tmma_` A **user-scoped** credential that drives **organization + project lifecycle** (`/console/v1/organizations`, `/console/v1/projects`) and operates on the projects of the orgs you belong to. **Issued at `/auth/v1/login` and `/auth/v1/signup`** (returned alongside the user), and self-manageable via `/console/v1/account-tokens` (mint/list/revoke your own); rotatable. This is the credential a logged-in app (e.g. the Console) acts with — never the god-key. Access is **organization membership**: you can reach a project iff you're a member of its organization (non-members get `404`). Call `POST /auth/v1/logout` to revoke the token you're signing with (the Console does this on logout). ### Organization token — `tmmo_` An **organization-scoped** admin credential: it authenticates AS the organization and authorizes managing the org + its projects/domains/billing. **Minted by default when an org is created** (`POST /console/v1/organizations` returns it, secret once) and rotatable via `/console/v1/organizations/{id}/tokens`. It does **not** send mail — that stays a project key (`tmm_`, `messages:send`). Use it for org-scoped automation that shouldn't depend on a particular user's account token. ### God-key (platform / bootstrap) — server-side only The platform operator credential, accepted on `/admin/v1/*`. It is **not** a customer credential and must never ship in client code. It's sent as: ``` Authorization: TMM-HMAC-SHA256 Credential=admin, Signature=bootstrap X-TMM-Admin-Key: ``` Reserved for **break-glass / bootstrap** (creating the first staff user, minting account tokens). The staff Admin console authenticates with a **staff account token** (`tmma_`) via the SDK — `/admin/v1/*` accepts either — so the god-key ships in no deployed app. ## Getting a key - **First key for a new project:** `POST /console/v1/projects` (with an account token) returns the project **and** its first `project:admin` key (secret shown once). - **More keys:** `POST /console/v1/projects/{id}/api-keys`. - **Lost a secret?** You can't retrieve it — mint a new key and revoke the old one. --- # API reference # 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/
/`**: generic, row-scoped CRUD over the backend's tables. - **Shared services — `/v1/services/`**: cross-product business actions (e.g. `POST /v1/services/sendEmail`) that compose table writes in one transaction. - **Product layers — `//v1//`**: 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](/authentication) (the signature covers the full path **including the query string**). Errors share one envelope — see [Errors](/errors). `GET /health` → `{ ok, service, time }` is the one unauthenticated endpoint. --- ## data — `/v1/
/` 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/
/list[?limit]` | `{ rows: [...] }` — your rows for that table. | | `GET` | `/v1/
/get?id=` | `{ row: {...} }` — one row you own (`404` otherwise). | | `POST` | `/v1/
/create` | `{ row: {...} }` (`201`) — insert one row (secret-bearing tables mint + return the secret once). | | `POST` | `/v1/
/update` | `{ row: {...} }` — update one row you own (body = its key + changed columns). | | `POST` | `/v1/
/upsert` | `{ row: {...} }` — insert, or update on the table's natural key. | | `POST` | `/v1/
/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: ```http POST /v1/suppressions/create { "project_id": "…", "address": "user@example.com", "reason": "manual" } ``` ```http 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** ```http POST /console/v1/organizations { "name": "Acme Inc" } ``` ```json { "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](#identity--get-v1serviceswhoami) below, so a certificate is never issued for an unverified or someone else's host). **Create example** ```http POST /console/v1/organizations/{id}/custom-domains { "domain": "mail.acme.com" } ``` ```json { "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 "` 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): ```http POST /console/v1/projects { "name": "My App", "organizationId": "…" } ``` ```json { "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](/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** ```http POST /v1/services/sendEmail { "to": "alice@example.com", "from": "noreply@yourdomain.com", "subject": "Welcome!", "text": "Thanks for signing up.", "html": "

Thanks for signing up.

", "idempotencyKey": "signup-1234" } ``` ```json { "providerMessageId": "01J…", "status": "queued" } ``` `from` may be a bare `addr@domain` or an RFC 5322 display name — `"Acme "`. 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 "`) 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](#templates--modulestemplatesv1) 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). | ```json // 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](#custom-webmail-domains--consolev1organizationsidcustom-domains-tmm-43); otherwise `404` (Caddy treats any non-`200` as "do not issue a certificate"). | ```http GET /v1/services/custom-domain-allowed?domain=mail.acme.com ``` ```json { "allowed": true } ``` --- ## modules — `/modules//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](/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). --- # Inbound mail & inboxes # Inbound mail & inboxes (BYOD) **Bring Your Own Domain** for *receiving* mail. Claim a domain you own, add a **receiving** (inbound) capability, point its MX at That's My Mail, then create **inboxes** on it. An inbox user signs in to the webmail app to read and send. Inbound is one capability of a **claimed domain** (see the [API reference](/api) → *Domains*). A domain is claimed by a **project**, **platform-wide unique** (one owner across the whole platform), apex first. All endpoints require a session with access to the project (any project member) or the platform god-key. > **Receiving vs sending.** Receiving needs an **MX** record (the domain is already > proven via the claim's ownership TXT). *Sending* from the same domain is a separate > **outbound** capability that adds a DKIM record. A domain can have either or both. --- ## The flow 1. **Claim** the domain → publish the ownership TXT it returns → **verify**. 2. **Add inbound** to the claim → publish the **MX** record → **verify inbound**. 3. **Create inboxes** (e.g. `info@your-domain.com`). The initial password is generated for you and shown until the user's first login. 4. The inbox user signs in at the **webmail app** and is forced to change it. --- ## Endpoints All paths are under the customer API host, scoped to a project. Auth = a session token for a project member, or the god-key. `:projectId` is the project's id; `:domainId` is the claimed domain's id. See the [API reference](/api) for the full table; the essentials: ### Claim + verify a domain ```http POST /console/v1/projects/:projectId/domains { "domain": "acme.com" } POST /console/v1/projects/:projectId/domains/:domainId/verify ``` ### Add + verify receiving (inbound / MX) You can add receiving on the apex or any subdomain (optional `subdomain` body field) — as many as you like under the one claim. Each capability has its own `capId`, returned in the claim's `inbound[]` array. ```http POST /console/v1/projects/:projectId/domains/:domainId/inbound { "subdomain": "mail" } POST /console/v1/projects/:projectId/domains/:domainId/inbound/:capId/verify ``` ### Inboxes (under one receiving capability) ```http POST /console/v1/projects/:projectId/domains/:domainId/inbound/:capId/inboxes { "localpart": "info", "type": "individual" } GET /console/v1/projects/:projectId/domains/:domainId/inbound/:capId/inboxes DELETE /console/v1/projects/:projectId/domains/:domainId/inbound/:capId/inboxes/:inboxId ``` `POST …/inboxes` returns `{ inbox, initialPassword }` — the password is generated server-side and returned **once**; it also stays viewable via `GET …/inboxes` (`initialPassword` field) **until the user first logs in**, then becomes `null`. `type` is `individual` or `group` (group fan-out behavior is a later iteration). ### Inbox storage Each inbox includes **1 GB free**. Every `GET …/inboxes` item carries its `storageUsedBytes` + `storageLimitBytes`. Upgrade an inbox in whole-GB steps: ```http POST /console/v1/projects/:projectId/domains/:domainId/inbound/:capId/inboxes/:inboxId/storage { "limitGb": 5 } ``` Returns `{ storage: { usedBytes, limitBytes } }`. Storage **above the free 1 GB** bills **€0.50/GB/month** (rolled into the org's `inboxUsage.storageBilledCents`). When an inbox is **full**, incoming mail is **rejected** (`552 mailbox is full`) until you raise the limit — so upgrading is how you make room. ### Import existing mail (Mail Transfer) Migrating from another provider? Push the old messages into an inbox — they appear in the webmail like any other mail (folders, read/unread, original dates preserved). ```http POST /console/v1/projects/:projectId/domains/:domainId/inbound/:capId/inboxes/:inboxId/import { "messages": [ { "raw": "", "folder": "inbox", "read": true, "date": "2021-06-01T10:00:00Z" }, { "raw": "", "folder": "sent" } ] } ``` Each `raw` is a base64-encoded `.eml` (full MIME). Per message you may override `folder` (`inbox`|`sent`|`drafts`|`trash`, default `inbox`), `read`, and `date` (else the message's own `Date:` header is used). Up to **200 messages per call** — send a big mailbox in several calls. Response is per-batch counts: ```json { "total": 2, "imported": 2, "skipped": 0, "failed": 0 } ``` **Idempotent:** re-sending the same messages imports **0 new** — dedup is by the message's `Message-ID` (or a content hash when it has none), so an agent can safely retry. `skipped` = already present; `failed` = couldn't be parsed. > Today's import is **push** (you upload the MIME — get it from the old provider via > IMAP/export yourself). A future iteration will let TMM pull straight from the old > mailbox over IMAP, on the same import core. --- ## SDK ```ts const tmm = new TmmClient({ baseUrl, sessionToken }); // Claim → verify → add inbound → verify → create an inbox const claim = await tmm.console.projects.domains.claim(projectId, { domain: "acme.com" }); // publish claim.dnsRecords.ownership, then: await tmm.console.projects.domains.verify(projectId, claim.id); const updated = await tmm.console.projects.domains.addInbound(projectId, claim.id, { subdomain: "mail" }); const cap = updated.inbound.find((i) => i.domain === "mail.acme.com")!; // publish cap.dnsRecords.mx, then verify THAT capability by its id: await tmm.console.projects.domains.verifyInbound(projectId, claim.id, cap.id); const { inbox, initialPassword } = await tmm.console.projects.domains.inboxes.create( projectId, claim.id, cap.id, { localpart: "info", type: "individual" }, ); // hand `inbox.localpart@acme.com` + initialPassword to the user; they change it on first login. // Migrate existing mail into the new inbox (idempotent — safe to retry) const oldMail = [{ raw: emlBase64, folder: "inbox", read: true }]; const r = await tmm.console.projects.domains.inboxes.import(projectId, claim.id, cap.id, inbox.id, { messages: oldMail, }); // r = { total, imported, skipped, failed } ``` See also: the [API reference](/api) (the full Domains table — claims, sending, receiving, inboxes) and the [SDK reference](/sdk). --- # SDK reference # SDK reference `@thatsmymail/sdk` — the official TypeScript SDK. Zero runtime dependencies (native `fetch` + `node:crypto`), full type definitions, HMAC signing built in. **It mirrors the API paths 1:1.** **Product** layers map as `client...()` → `//v1//`. The shared, product-agnostic layer maps as `client.v1.
.()` → `/v1/
/` (data) and `client.v1.services.()` → `/v1/services/` (shared services, e.g. `client.v1.services.sendEmail()`). Path params (`:id`) are leading arguments. If you can read the call, you know the URL. > **v0.17.0 is a breaking change** (alpha — still 0.x). The old `client` `mail` and `api` accessors are > removed; the data layer + a new shared-services accessor move to the product-agnostic > `client.v1.*`. Migrate as follows: the old top-level `mail` send becomes > `client.v1.services.sendEmail(msg)`; the old `mail` message reads become > `client.v1.messages.get(id)` / `client.v1.messages.events(id)`; and the old per-table > `api` accessor becomes `client.v1.
.()`. Other products (`client.console.*`, > `client.admin.*`, `client.auth.*`, `client.modules.templates.*`) are unchanged. > > **v0.5.0 is a breaking change** from 0.4.x: the flat surface (`client.send`, > `client.project(id)`, `client.messages`, `client.projects`) is gone — everything now > lives under `client.console.*` / the shared send service (later `client.v1.services.sendEmail`). > > **v0.7.0** (additive): adds `client.auth.*` (signup/login/logout), > `client.console.projects.senderDomains.*` (multiple sender domains per project), and makes > `keyId`/`secret` optional for an auth-only client. > > **v0.8.0**: product-first URLs (services moved to `//v1/*`) — SDK **method names are > unchanged**, only the URLs they hit. Adds the generic data layer > `client.v1.
.{get,list}()` (`/v1/
/*`, row-scoped + secret-redacted) and > `client.admin.*` (`/admin/v1/*`). > > **v0.9.0** (additive): the data layer gains WRITES — `client.v1.
.create(body)` and > `.delete(body)` (`POST /v1/
/{create,delete}`). Server-side these are **allowlisted** to > safe single-table writes (currently `suppressions`); the `project_id` in the body is validated > against your credential, and all other tables' writes stay service-only. > > **v0.10.0** (additive): data layer is **full CRUD** — adds `client.v1.
.update(body)` and > `.upsert(body)`; `.delete(body)` now returns `{ ok: true }`. Writes are row-scoped to your > credential; enabled across the tenant tables (`suppressions`, `projects`, `api_keys`, `account_tokens`, > `webhook_endpoints`, `sender_domains`). Permanent > carve-outs stay service-only/read-only: `messages` create (use `v1.services.sendEmail`), `audit_log` > (append-only), and the engine tables `rate_limits`/`webhook_deliveries`/`message_events`. > > **v0.11.0** (breaking, data layer only): the data layer is now **product-first** — > `client.v1.
` moves to a product-scoped accessor; the product-agnostic `/v1/*` is retired. > > **v0.12.0** (breaking, data layer only): the data layer collapses to a **single `api` product** > data accessor (later superseded by the shared `client.v1.*` in v0.17.0). The backend's tables all > live in one Postgres schema (the separable unit); `console`/`auth` are *service* facets, not > schemas, so the per-product data split from 0.11.0 was wrong. Service-layer calls are unchanged. > Tables: `projects`, `api_keys`, `sender_domains`, `webhook_endpoints`, `suppressions`, > `idempotency_keys`, `audit_log`, `messages`, `users`, `account_tokens`. > > **v0.13.0** (additive + one small breaking change): **organizations** — the ownership + billing > layer. Adds `client.console.organizations.{create,list,get,setPlan}` and > `client.console.organizations.tokens.{create,list,revoke}`, plus the data-layer reads > `client.v1.{organizations,organization_members,organization_tokens}`. **Breaking:** > `client.console.projects.create()` now requires `{ name, organizationId }` (a project is created > under an org), and `.list()` takes an optional `{ organizationId }` filter. Quota: > sending on a free org throws `quota_exceeded` (`429`) at 500 accepted messages/day. > > **v0.14.0** (additive): password reset/change — `client.auth.password.forgot(email)` (public; always > resolves, emails a reset link), `.reset(token, password)` (public; consumes the emailed token), and > `.change(currentPassword, newPassword)` (account token; logged-in change). Reset emails are sent by > TMM itself from `notification@noreply.thatsmymail.com`. > > **v0.15.0** (additive): organization management — `client.console.organizations.update(id, { name })` > (rename), `.invitations.{create,list,revoke}` + `.acceptInvitation(token)` (invite teammates by email; > the accept link is emailed and returned to copy), and `.members.remove(id, userId)`. > > **v0.16.0** (additive): the **templates** module — `client.modules.templates.{create,list,get,update,delete,render}` > (org-scoped reusable email bodies with `{{variables}}`), plus `v1.services.sendEmail` now accepts `templateId` + > `variables` to render the body from a stored template at send time. > > **v0.29.0** (additive): **custom webmail domains** (TMM-43) — `client.console.organizations.customDomains.{create,list,verify,delete}`. > An org registers an own (sub)domain CNAME'd to the webmail; once `verify` confirms the CNAME, the webmail is > served there with automatic HTTPS. `create` returns the CNAME record to publish. > > **v0.38.0** (additive): **organization join codes** (TMM-86) — `client.console.organizations.joinCodes.{create,list,revoke}` > and `client.console.organizations.join(code)`. An owner generates a one-time code (with a role) to onboard a colleague, > who redeems it to join the org. Also (TMM-87): `client.console.organizations.create()` now throws > `409 already_in_organization` if the caller already belongs to an organization — join an existing one with a code instead. > > **v0.39.0** (additive): **organization roles & permissions** (TMM-88) — five fixed roles (owner/admin/member/ > auditor/billing). `client.console.organizations.members.setRole(id, userId, role)` changes a member's role, > `client.console.organizations.roles()` returns the static role → permissions matrix, and invitations + join codes > now accept any of the five roles. A write whose permission your role lacks returns `403 forbidden`. ## Install Private package on GitHub Packages. Your project needs a `.npmrc`: ```ini @adrikesteren:registry=https://npm.pkg.github.com //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} ``` ```bash pnpm add @thatsmymail/sdk # GITHUB_TOKEN needs read:packages ``` ## Construct a client ```ts import { TmmClient } from "@thatsmymail/sdk"; const tmm = new TmmClient({ keyId: process.env.TMM_KEY_ID!, // public id (tmm_… / tmma_…) secret: process.env.TMM_SECRET!, // HMAC secret — treat like a password // baseUrl: "http://localhost:18001", // optional; defaults to https://api.thatsmymail.com // timeoutMs: 30000, // optional }); ``` One client = one credential = one environment. The credential you pass decides what you can call (the server authorizes per endpoint) — a project key for `v1.services.sendEmail`, an account token for `console.projects.create/list`. See [Authentication](/authentication). `keyId`/`secret` are **optional**: construct `new TmmClient()` with no credentials to call the public `tmm.auth.signup()` / `tmm.auth.login()`, which return the account token you then build a full client with. ## Surface | Call | Endpoint | |---|---| | `tmm.auth.signup({ email, password })` | `POST /auth/v1/signup` (public) | | `tmm.auth.login({ email, password })` | `POST /auth/v1/login` (public) | | `tmm.auth.logout()` | `POST /auth/v1/logout` | | `tmm.auth.password.forgot(email)` | `POST /auth/v1/password/forgot` (public; always ok) | | `tmm.auth.password.reset(token, password)` | `POST /auth/v1/password/reset` (public) | | `tmm.auth.password.change(currentPassword, newPassword)` | `POST /auth/v1/password/change` (account token) | | `tmm.v1.services.sendEmail(msg)` | `POST /v1/services/sendEmail` | | `tmm.v1.services.whoami()` | `GET /v1/services/whoami` (which principal am I? org token → its own `organizationId`) | | `tmm.v1.messages.get(id)` | `GET /v1/messages/get?id=` | | `tmm.v1.messages.events(id)` | `GET /v1/messages/events?id=` | | `tmm.console.organizations.create({ name })` | `POST /console/v1/organizations` (→ org + first `tmmo_` token) | | `tmm.console.organizations.list()` | `GET /console/v1/organizations` | | `tmm.console.organizations.get(id)` | `GET /console/v1/organizations/:id` (members + projects + usage) | | `tmm.console.organizations.setPlan(id, "free" \| "payg")` | `POST /console/v1/organizations/:id/plan` | | `tmm.console.organizations.update(id, { name })` | `PATCH /console/v1/organizations/:id` | | `tmm.console.organizations.tokens.create(id, { label? }) / .list(id) / .revoke(id, tokenId)` | `/console/v1/organizations/:id/tokens*` | | `tmm.console.organizations.invitations.create(id, { email, role? }) / .list(id) / .revoke(id, invId)` | `/console/v1/organizations/:id/invitations*` | | `tmm.console.organizations.acceptInvitation(token)` | `POST /console/v1/organizations/invitations/accept` | | `tmm.console.organizations.joinCodes.create(id, { role? }) / .list(id) / .revoke(id, codeId)` | `/console/v1/organizations/:id/join-codes*` (one-time onboarding codes; the plaintext code is shown once) | | `tmm.console.organizations.join(code)` | `POST /console/v1/organizations/join` (redeem a join code → join the org) | | `tmm.console.organizations.roles()` | `GET /console/v1/organizations/roles` (static role → permissions matrix, TMM-88) | | `tmm.console.organizations.members.setRole(id, userId, role)` | `POST /console/v1/organizations/:id/members/:userId/role` (one of owner/admin/member/auditor/billing) | | `tmm.console.organizations.members.remove(id, userId)` | `DELETE /console/v1/organizations/:id/members/:userId` | | `tmm.console.organizations.customDomains.create(id, { domain })` | `POST /console/v1/organizations/:id/custom-domains` (custom webmail domain; returns the CNAME to publish) | | `tmm.console.organizations.customDomains.list(id)` | `GET /console/v1/organizations/:id/custom-domains` | | `tmm.console.organizations.customDomains.verify(id, domId)` | `POST /console/v1/organizations/:id/custom-domains/:domId/verify` (re-check the CNAME) | | `tmm.console.organizations.customDomains.delete(id, domId)` | `DELETE /console/v1/organizations/:id/custom-domains/:domId` | | `tmm.console.projects.create({ name, organizationId })` | `POST /console/v1/projects` | | `tmm.console.projects.list({ organizationId? })` | `GET /console/v1/projects` | | `tmm.console.projects.me()` | `GET /console/v1/projects/me` | | `tmm.console.projects.get(id)` | `GET /console/v1/projects/:id` | | `tmm.console.projects.update(id, name)` | `PATCH /console/v1/projects/:id` | | `tmm.console.projects.delete(id)` | `DELETE /console/v1/projects/:id` | | `tmm.console.projects.apiKeys.create(id, { label?, scopes? })` | `POST /console/v1/projects/:id/api-keys` | | `tmm.console.projects.apiKeys.list(id)` | `GET /console/v1/projects/:id/api-keys` | | `tmm.console.projects.apiKeys.update(id, keyId, { label })` | `PATCH /console/v1/projects/:id/api-keys/:keyId` (rename; secret + scopes immutable) | | `tmm.console.projects.apiKeys.revoke(id, keyId)` | `DELETE /console/v1/projects/:id/api-keys/:keyId` | | `tmm.console.projects.domains.claim(id, { domain })` | `POST /console/v1/projects/:id/domains` (claim — apex first, platform-wide unique; returns ownership TXT) | | `tmm.console.projects.domains.list(id) / .get(id, domId)` | `GET /console/v1/projects/:id/domains[/:domId]` | | `tmm.console.projects.domains.verify(id, domId)` | `POST /console/v1/projects/:id/domains/:domId/verify` | | `tmm.console.projects.domains.delete(id, domId)` | `DELETE /console/v1/projects/:id/domains/:domId` | | `tmm.console.projects.domains.addOutbound(id, domId, { subdomain? }) / .verifyOutbound(id, domId, capId) / .setOutboundDisplayName(id, domId, capId, name\|null) / .deleteOutbound(id, domId, capId)` | `…/domains/:domId/outbound[/:capId...]` (DKIM sending; **default display name**; many per claim, apex or `subdomain`) | | `tmm.console.projects.domains.addInbound(id, domId, { subdomain? }) / .verifyInbound(id, domId, capId) / .deleteInbound(id, domId, capId)` | `…/domains/:domId/inbound[/:capId...]` (MX receiving; **many per claim**) | | `tmm.console.projects.domains.addCapabilities(id, domId, { subdomain?, outbound?, inbound? })` | `POST …/domains/:domId/capabilities` (sending + receiving in one call) | | `tmm.console.projects.domains.inboxes.create(id, domId, inboundId, { localpart, type? })` | `POST …/domains/:domId/inbound/:inboundId/inboxes` (initial password generated + returned once) | | `tmm.console.projects.domains.inboxes.list(id, domId, inboundId) / .delete(id, domId, inboundId, inboxId)` | `GET\|DELETE …/domains/:domId/inbound/:inboundId/inboxes[/:inboxId]` (list items carry `storageUsedBytes` + `storageLimitBytes`) | | `tmm.console.projects.domains.inboxes.setStorage(id, domId, inboundId, inboxId, { limitGb })` | `POST …/inboxes/:inboxId/storage` — set the storage limit in whole GB (1 GB free, €0.50/GB/mo above); returns `{ usedBytes, limitBytes }` | | `tmm.console.projects.domains.inboxes.import(id, domId, inboundId, inboxId, { messages })` | `POST …/inboxes/:inboxId/import` — Mail Transfer: import existing mail (base64 MIME), deduped + idempotent | | `tmm.console.projects.webhooks.register(id, { url, events? })` | `POST /console/v1/projects/:id/webhooks` | | `tmm.console.projects.webhooks.list(id)` | `GET /console/v1/projects/:id/webhooks` | | `tmm.console.projects.webhooks.delete(id, webhookId)` | `DELETE /console/v1/projects/:id/webhooks/:webhookId` | | `tmm.console.projects.suppressions.add(id, { address, reason?, detail? })` | `POST /console/v1/projects/:id/suppressions` | | `tmm.console.projects.suppressions.list(id, address?)` | `GET /console/v1/projects/:id/suppressions` | | `tmm.console.projects.suppressions.remove(id, address)` | `DELETE /console/v1/projects/:id/suppressions/:address` | | `tmm.console.projects.messages(id, { limit?, status? })` | `GET /console/v1/projects/:id/messages` | | `tmm.console.projects.audit(id, { limit?, action? })` | `GET /console/v1/projects/:id/audit` | | `tmm.console.projects.testMessage(id, msg)` | `POST /console/v1/projects/:id/test-message` | | `tmm.console.accountTokens.create({ label? }) / .list() / .revoke(id)` | `/console/v1/account-tokens*` | | `tmm.modules.templates.create({ organizationId, name, subject, html?, text?, variables?, isPublic? })` | `POST /modules/templates/v1/templates/create` (`isPublic` = usable cross-org; owner-only) | | `tmm.modules.templates.list(orgId) / .listPublic(orgId) / .get(orgId, id) / .update(input) / .delete(orgId, id) / .render(input)` | `/modules/templates/v1/templates/*` (`get` succeeds for own **or** public; `listPublic` returns the cross-org set) | | `tmm.v1.
.list({ limit? }) / .get(id)` | `GET /v1/
/{list,get}` — data layer (row-scoped, secret-redacted) | | `tmm.v1.
.create / .update / .upsert / .delete (body)` | `POST /v1/
/{create,update,upsert,delete}` — full CRUD, row-scoped | | | e.g. `tmm.v1.suppressions.create()`, `tmm.v1.messages.get()`, `tmm.v1.users.update()` | | `tmm.admin.stats() / .users() / .userProjects(id) / .audit() / .messages()` | `GET /admin/v1/*` — god-key or staff token | ## Examples **Log in, then act as the user** (no credentials needed to log in): ```ts const auth = new TmmClient(); // no keyId/secret → auth-only const { user, accountToken } = await auth.login({ email, password }); const tmm = new TmmClient({ keyId: accountToken.tokenId, secret: accountToken.secret }); const projects = await tmm.console.projects.list(); // …end the session: await tmm.auth.logout(); // revokes accountToken ``` **Send a message** (project key, `messages:send`): ```ts const { providerMessageId, status } = await tmm.v1.services.sendEmail({ to: "alice@example.com", from: "noreply@yourdomain.com", subject: "Welcome!", text: "Thanks for signing up.", }); ``` **Who am I?** (any credential) — resolve the calling principal. An organization token gets its own `organizationId` back, so an integrator signing with a `tmmo_` token no longer has to probe org-scoped endpoints to find which org it controls: ```ts const me = await tmm.v1.services.whoami(); if (me.principal === "organization_token") { console.log(me.organizationId); // the org this tmmo_ token controls } ``` **Onboard: create an org, then a project under it** (account token): ```ts // A fresh account owns no organization yet — create one first. const { organization, organizationToken } = await tmm.console.organizations.create({ name: "Acme Inc" }); // store organizationToken.secret now — the org-scoped admin credential (tmmo_), shown once // Projects are created UNDER an organization (billing/quota live on the org): const { project, apiKey } = await tmm.console.projects.create({ name: "My App", organizationId: organization.id, }); // store apiKey.secret now — it is not retrievable again const key = await tmm.console.projects.apiKeys.create(project.id, { label: "server", scopes: ["messages:send", "messages:read"], }); ``` **Check today's usage / switch tier** (the daily allowance is per organization): ```ts const { usage, inboxUsage } = await tmm.console.organizations.get(organization.id); // usage = { tier, dailyLimit: 500, usedToday, remaining, overageToday } — messages today // inboxUsage = { activeInboxes, billedCents, storageOverageGb, storageBilledCents, totalBilledCents, monthStart } // billedCents = inbox-days (€1/inbox/mo); storageBilledCents = extra storage (€0.50/GB/mo above 1 GB free); totalBilledCents = sum await tmm.console.organizations.setPlan(organization.id, "payg"); // owner only — lifts the daily cap ``` **Claim a domain, then set up sending** (project:admin): ```ts // 1. Claim the apex ONCE (publish the ownership TXT it returns, then verify) const claim = await tmm.console.projects.domains.claim(project.id, { domain: "example.com" }); // publish claim.dnsRecords.ownership, then: await tmm.console.projects.domains.verify(project.id, claim.id); // 2. Add sending on a subdomain — noreply.example.com — under the SAME claim const updated = await tmm.console.projects.domains.addOutbound(project.id, claim.id, { subdomain: "noreply" }); const cap = updated.outbound.find((o) => o.domain === "noreply.example.com")!; // publish cap.dnsRecords.{dkim,spf,dmarc}, then verify THAT capability by its id: await tmm.console.projects.domains.verifyOutbound(project.id, claim.id, cap.id); // (or set up sending + receiving for one (sub)domain in a single call) await tmm.console.projects.domains.addCapabilities(project.id, claim.id, { subdomain: "mail" }); ``` **Custom webmail domain** (TMM-43) — let an org reach its webmail on its own host: ```ts // 1. Register the (sub)domain; publish the CNAME it returns. const { customDomain, dnsRecord } = await tmm.console.organizations.customDomains.create( organization.id, { domain: "mail.acme.com" }, ); // dnsRecord = { host: "mail.acme.com", type: "CNAME", value: "mail.thatsmymail.com", … } // 2. Once the CNAME is live, verify it. After this we serve the webmail there with auto-HTTPS. await tmm.console.organizations.customDomains.verify(organization.id, customDomain.id); // → { status: "verified", domain: "mail.acme.com" } (throws `cname_not_found` until it points at us) const domains = await tmm.console.organizations.customDomains.list(organization.id); ``` **Wrapping for a `Mailer` interface** (the SDK has no top-level `send()` — sending is the shared service): ```ts import { TmmClient, type MailMessage, type MailerResult } from "@thatsmymail/sdk"; const tmm = new TmmClient({ keyId, secret }); const mailer = { send: (m: MailMessage): Promise => tmm.v1.services.sendEmail(m) }; ``` ## Errors Every failure throws a `TmmError` (`code`, `status`, `message`, `details?`, `requestId?`). See [Errors](/errors). ```ts import { TmmError } from "@thatsmymail/sdk"; try { await tmm.v1.services.sendEmail({ /* … */ }); } catch (err) { if (err instanceof TmmError) console.error(err.code, err.status, err.requestId); else throw err; } ``` --- # Errors # Errors ## Envelope Failures return a non-2xx status and a JSON body: ```json { "error": "invalid_body", "message": "human-readable explanation", "details": { "...": "optional, e.g. a Zod flatten of field issues" }, "requestId": "..." } ``` The `requestId` also comes back as the `x-request-id` response header. Quote it when reporting an issue — it matches the API server's logs. ## SDK: `TmmError` The [SDK](/sdk) throws `TmmError` for every failure (including transport errors), so you catch one type: ```ts import { TmmError } from "@thatsmymail/sdk"; try { await tmm.v1.services.sendEmail({ /* … */ }); } catch (err) { if (err instanceof TmmError) { console.error(err.code, err.status, err.message, err.details, err.requestId); } else { throw err; } } ``` ## Codes | `code` | `status` | Meaning | |---|---|---| | `network_error` | 0 | Request never reached the server (DNS, connection refused, timeout). SDK-only. | | `unauthorized` | 401 | Bad/missing `keyId`/`secret`, or the timestamp is outside the ±5-minute window. | | `forbidden` | 403 | The key lacks the required scope, or the recipient is suppressed. | | `invalid_body` | 400 | Validation failed; `details` carries the field issues. | | `sender_domain_not_verified` | 400 | The `from` domain isn't a verified sender domain for the project. | | `not_found` | 404 | The resource doesn't exist, or belongs to another organization. | | `rate_limited` | 429 | Per-key request rate exceeded (default 100 req/min). `retryAt` says when to retry. | | `quota_exceeded` | 429 | The project's **organization** hit its free daily allowance (500 accepted messages/day, shared across the org's projects). Upgrade the org to pay-as-you-go to keep sending. | | `http_error` | 5xx | Unexpected server error — check the platform logs with the `requestId`. | ## Behavior notes - `v1.services.sendEmail()` is **fire-and-forget** at the SMTP layer: a `200` means *queued*, not *delivered*. Poll `v1.messages.get(id)` until `status` is terminal (`delivered` / `bounced` / `failed` / `suppressed`). - The SDK does **not** retry. On `network_error`, wrap calls in your own retry-with-backoff and reuse the same `idempotencyKey` so a retried send doesn't double-deliver (24h dedupe window).