# 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).