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.<product>.<resource>.<action>() → /<product>/v1/<resource>/<action>. The shared,
product-agnostic layer maps as client.v1.<table>.<verb>() → /v1/<table>/<verb> (data)
and client.v1.services.<action>() → /v1/services/<action> (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
clientapiaccessors are removed; the data layer + a new shared-services accessor move to the product-agnosticclient.v1.*. Migrate as follows: the old top-levelclient.v1.services.sendEmail(msg); the oldclient.v1.messages.get(id)/client.v1.messages.events(id); and the old per-tableapiaccessor becomesclient.v1.<table>.<verb>(). 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 underclient.console.*/ the shared send service (laterclient.v1.services.sendEmail).v0.7.0 (additive): adds
client.auth.*(signup/login/logout),client.console.projects.senderDomains.*(multiple sender domains per project), and makeskeyId/secretoptional for an auth-only client.v0.8.0: product-first URLs (services moved to
/<product>/v1/*) — SDK method names are unchanged, only the URLs they hit. Adds the generic data layerclient.v1.<table>.{get,list}()(/v1/<table>/*, row-scoped + secret-redacted) andclient.admin.*(/admin/v1/*).v0.9.0 (additive): the data layer gains WRITES —
client.v1.<table>.create(body)and.delete(body)(POST /v1/<table>/{create,delete}). Server-side these are allowlisted to safe single-table writes (currentlysuppressions); theproject_idin 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.<table>.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:messagescreate (usev1.services.sendEmail),audit_log(append-only), and the engine tablesrate_limits/webhook_deliveries/message_events.v0.11.0 (breaking, data layer only): the data layer is now product-first —
client.v1.<table>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
apiproduct data accessor (later superseded by the sharedclient.v1.*in v0.17.0). The backend's tables all live in one Postgres schema (the separable unit);console/authare 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}andclient.console.organizations.tokens.{create,list,revoke}, plus the data-layer readsclient.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 throwsquota_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 fromnotification@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}}), plusv1.services.sendEmailnow acceptstemplateId+variablesto 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; onceverifyconfirms the CNAME, the webmail is served there with automatic HTTPS.createreturns the CNAME record to publish.v0.38.0 (additive): organization join codes (TMM-86) —
client.console.organizations.joinCodes.{create,list,revoke}andclient.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 throws409 already_in_organizationif 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 returns403 forbidden.
Install
Private package on GitHub Packages. Your project needs a .npmrc:
@adrikesteren:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
pnpm add @thatsmymail/sdk # GITHUB_TOKEN needs read:packages
Construct a client
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.
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.<table>.list({ limit? }) / .get(id) |
GET /v1/<table>/{list,get} — data layer (row-scoped, secret-redacted) |
tmm.v1.<table>.create / .update / .upsert / .delete (body) |
POST /v1/<table>/{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):
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):
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:
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):
// 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):
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):
// 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:
// 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):
import { TmmClient, type MailMessage, type MailerResult } from "@thatsmymail/sdk";
const tmm = new TmmClient({ keyId, secret });
const mailer = { send: (m: MailMessage): Promise<MailerResult> => tmm.v1.services.sendEmail(m) };
Errors
Every failure throws a TmmError (code, status, message, details?, requestId?).
See Errors.
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;
}