# Mailery API

Base URL:

- Hosted API: `https://mailery.co`

Contract changes are recorded in the [API changelog](./CHANGELOG.md).

## Versioning And Deprecation

- All current API endpoints live under the `/api/v1` prefix; `v1` is the only
  supported version today.
- Backwards-compatible changes (new endpoints, new optional request
  parameters, new response fields) ship on `v1` without a version bump.
  Clients must ignore unknown response fields.
- Breaking changes ship under a new `/api/v2` prefix. When a `v2` exists, `v1`
  keeps working for at least 6 months after the `v2` announcement before any
  removal.
- Deprecations are announced in [`CHANGELOG.md`](./CHANGELOG.md) first, and
  deprecated endpoints additionally return a `Deprecation` response header
  before they are removed.

Machine-readable OpenAPI:

- `GET /openapi.json`
- `GET /api/v1/openapi.json`

Human-readable docs:

- `GET /docs`
- `GET /api/docs`
- `GET /api/v1/docs`

API changelog:

- `GET /changelog`
- `GET /docs/changelog`
- `GET /api/v1/changelog`

## Support And Legal

- Support: [support@mailery.co](mailto:support@mailery.co)
- Terms of Service: `GET /legal/terms`
- Privacy Policy: `GET /legal/privacy`
- Refund Policy: `GET /legal/refunds`

The legal pages are public, unauthenticated HTML documents bundled with the
server. Data-subject rights (GDPR): export your data with
`GET /api/v1/account/export` and erase it with `DELETE /api/v1/account` — see
**Account Lifecycle** below.

## Authentication

Most API routes use `Authorization: Bearer <session-token-or-mailery-api-key>`.
Interactive user sessions are created with `POST /api/v1/auth/login` or
`POST /api/v1/auth/signup`. Agent access should use scoped API keys created
with `POST /api/v1/api-keys`.

A new signup lands on the **Free plan** automatically (PIVOT D-4): once the
email is verified, the tenant can mint API keys, create its **one** mailbox on
the shared subdomain (`shared_domain` in `GET /api/v1`,
`MAILERY_SHARED_DOMAIN` server-side), and send up to **3,000 emails per
calendar month** — no subscription and no card. Free-tier limits are enforced
by the plan quotas (`402 mailbox_quota_exceeded` /
`402 free_plan_shared_domain_required` / `402 send_limit_reached`), not by the
billing gate.

**Paid** entitlement gates everything above the free allowance — the entire
custom-domain surface (availability checks, domain lifecycle/setup/status,
purchase). It requires an `active`/`trialing` subscription, or a positive
credit balance backed by a qualifying grant (credit-pack checkout, subscription
renewal, admin grant, or trusted signup grant); otherwise those routes
return `402 billing_required`. Free signup credits never qualify: untrusted
open signups get zero credits under the default
`MAILERY_SIGNUP_CREDIT_MODE=trusted`, and under
`MAILERY_SIGNUP_CREDIT_MODE=all` the untrusted grant is recorded with the
non-qualifying `free signup grant` ledger reason — only trusted signups
(`MAILERY_SIGNUP_ALLOWED_EMAILS`, `MAILERY_SIGNUP_ALLOWED_DOMAINS`, or
`MAILERY_SIGNUP_INVITE_CODES`) earn the qualifying `trusted signup grant`
reason. Invite codes and allow-listed emails/domains remain the sanctioned
demo/comp channel for PAID-surface access without paying.

Password reset is a two-step flow: `POST /api/v1/auth/password/forgot` always
returns the same `200` whether or not an account exists (no enumeration) and,
for real accounts, emails a single-use reset link that expires after 30
minutes. The uniform `200` is returned immediately: token issuance and email
delivery run asynchronously after the response (failures are logged
server-side, never surfaced), so neither the response body nor its timing
distinguishes real accounts from unknown emails.
`POST /api/v1/auth/password/reset` consumes the token, enforces the
signup password policy, revokes **every** live session for the account, and
clears any login lockout. Only a SHA-256 hash of the token is stored
server-side.

Signup also emails a single-use verification link (24-hour expiry) consumed by
`POST /api/v1/auth/verify`; signed-in users can request another with
`POST /api/v1/auth/verify/resend`. When `MAILERY_REQUIRE_EMAIL_VERIFICATION`
is enabled (the default in production; dev/test stay relaxed), billing checkout
and every billing-gated route (the paid mutations and subscription-only
surfaces marked "billing gate" below) require the acting user's email to be
verified and otherwise return `403 email_verification_required`. Basic reads
(`/auth/me`, message/mailbox reads, billing overview) stay available while
unverified. API-key creation (`POST /api/v1/api-keys`) enforces the
verified-email requirement directly at the mint route, independent of the
billing gate, so under enforcement a key can only ever be minted by a
verified owner/admin session — this covers subscription-entitled and
credit-entitled (including trusted-signup) tenants alike.

Login is throttled per IP **and** per account: repeated failed logins against
one email lock that account with exponential backoff (`429 account_locked`
with a `Retry-After` header), regardless of source IP. The counter decays
after an hour without failures and clears on successful login or password
reset.

API key scopes:

- `full`: all tenant API access (implies every scope below, including `domains:purchase`)
- `mail_read`: read mailboxes, messages, attachments, digests, and domain status
- `mail_write`: create/update/delete messages, send hosted mail, create mailboxes, generate digests, and connect/set up domains (but NOT purchase — see `domains:purchase`)
- `billing_read`: read billing overview
- `domains:purchase`: register (buy) a domain through the platform. Gates the only irreversible-spend path, so it must be granted **explicitly** — it is not part of the least-privilege default and is not implied by `mail_write`.

New keys created without an explicit `scopes` list default to **least privilege**
(`["mail_read", "mail_write"]`), not `full`. Request `full` or `domains:purchase`
explicitly when a key needs them. Previously-issued keys keep whatever scopes
they were stored with, so existing integrations are unaffected.

Tenant API keys can never carry platform-admin semantics: `admin` is not a
selectable scope, and legacy keys that stored it are aliased to `full`
(tenant-wide access only).

Admin routes require a platform admin session or `MAILERY_ADMIN_API_KEY`.
Billing webhooks verify the upstream payment signature. Mail-plane provider
webhooks are internal implementation routes and are intentionally omitted from
the public API contract.

Cross-origin (CORS) access is a config-driven allowlist: production allows the
`MAILERY_PUBLIC_URL`/`MAILERY_APP_URL` origins by default and
`MAILERY_CORS_ALLOWED_ORIGINS` (CSV, `*` to reflect any origin) overrides it.
Requests whose body exceeds `MAILERY_REQUEST_MAX_BYTES` (default 32 MiB) are
rejected with HTTP `413` — enforced on the streamed body, not just the
`Content-Length` header. Enforcement is lazy: body bytes are only read (and
buffered) when a handler consumes the body, so requests rejected earlier
(401/404) never allocate body memory.

## Public Routes

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/health` | Health, version, mail mode, Stripe mode, release SHA, and a short-deadline DB ping (`db.ok`, `db.latency_ms`). Returns `503` with `status: degraded` when the database ping fails. |
| `GET` | `/api/v1` | Platform metadata for SDK and CLI discovery. |
| `GET` | `/openapi.json` | OpenAPI 3.1 document. |
| `GET` | `/api/v1/openapi.json` | OpenAPI 3.1 document under the API prefix. |
| `GET` | `/docs` | Human API docs. |
| `GET` | `/api/docs` | Human API docs under the API prefix. |
| `GET` | `/api/v1/docs` | Human API docs under the API version prefix. |
| `GET` | `/legal/terms` | Terms of Service (HTML). |
| `GET` | `/legal/privacy` | Privacy Policy (HTML). |
| `GET` | `/legal/refunds` | Refund Policy (HTML). |
| `POST` | `/api/v1/auth/signup` | Create an account. Accepts optional `inviteCode`. Returns a session, not an API key secret. Emails a verification link. |
| `POST` | `/api/v1/auth/login` | Create a session from email/password. Per-IP rate limit plus per-account exponential lockout (`429 account_locked`). |
| `POST` | `/api/v1/auth/logout` | Revoke a session token. |
| `POST` | `/api/v1/auth/password/forgot` | Request a password reset email. Uniform `200` whether or not the account exists; rate-limited per IP and per email. |
| `POST` | `/api/v1/auth/password/reset` | Set a new password from a reset token. Single-use, 30-minute expiry; revokes all of the user's sessions. |
| `POST` | `/api/v1/auth/verify` | Verify an email address from a verification token. Single-use, 24-hour expiry. |
| `GET` | `/api/v1/auth/providers` | Available auth providers and capabilities (`password_reset`, `email_verification`, `email_verification_required`). |
| `POST` | `/webhooks/stripe` | Stripe webhook, verified with `Stripe-Signature`. Invalid, tampered, or stale signatures are rejected with `400 invalid_signature`. Handles `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `invoice.paid`, `invoice.payment_failed`, `customer.subscription.updated`, `customer.subscription.deleted`, `charge.refunded`, and `charge.dispute.created`. The Stripe endpoint must be pinned to API version `2025-02-24.acacia` (see DEPLOY.md). |

## Account And API Keys

| Method | Path | Scope | Description |
| --- | --- | --- | --- |
| `GET` | `/api/v1/auth/me` | any valid token | Current user (including `emailVerifiedAt`), tenant, and auth context. |
| `POST` | `/api/v1/auth/verify/resend` | user session | Resend the email verification link. Rate-limited per user and per IP. |
| `GET` | `/api/v1/account/export` | owner/admin user session | GDPR Art. 20 JSON export of all tenant data. Never billing-gated. |
| `DELETE` | `/api/v1/account` | owner user session + password re-auth | Permanently delete the tenant and all its data. |
| `GET` | `/api/v1/api-keys` | owner/admin user session | List API key records without secret material. |
| `POST` | `/api/v1/api-keys` | owner/admin user session + verified email | Create a scoped API key. The secret is returned once. Available on the Free plan (no subscription needed); the verified-email check is enforced directly at this route. |
| `POST` | `/api/v1/api-keys/:id/revoke` | owner/admin user session | Revoke an API key. |
| `POST` | `/api/v1/keys/revoke-all` | session token **OR** `email` + `password` in body | Break-glass kill-switch: revoke **every** live API key for the tenant. Authenticates itself so it works with zero usable keys; the password path shares the login lockout. Returns `{ ok, revoked, via }`. |
| `POST` | `/api/v1/feedback` | any valid token | Send product feedback. Not billing-gated. Rate-limited per tenant and per IP. |
| `GET` | `/api/v1/feedback` | any valid token | List the caller's own feedback, newest first. |

## Feedback

Product feedback is persisted, tenant-scoped, and never billing-gated — any
authenticated tenant actor (a signed-in user session OR a tenant API key) may
submit and read their own notes. Feedback is stored in Postgres through the
[`@hasna/feedback`](https://www.npmjs.com/package/@hasna/feedback) SDK's
`FeedbackStore` contract, so submissions survive container recycles and
redeploys.

### POST /api/v1/feedback

Request body:

```json
{
  "message": "The digest view is great, but I'd love keyboard shortcuts.",
  "kind": "idea",
  "severity": "low",
  "url": "https://mailery.co/docs#feedback",
  "tags": ["cli"],
  "context": { "route": "docs#feedback", "url": "https://mailery.co/docs#feedback" },
  "screenshot": "data:image/png;base64,…"
}
```

- `message` is required (1–4000 characters); everything else is optional.
- `kind` (`bug`, `idea`, `question`, `praise`, `other`; defaults to `other`)
  and `severity` (`low`, `medium`, `high`, `critical`) are validated against the
  SDK's enums — an unknown value is `400 bad_request`.
- `url`/`context.url`, when supplied, must be **absolute** http(s) URLs (e.g.
  `https://mailery.co/docs`); a relative value is rejected with `400 bad_request`.
- `context.route`/`context.url` record where the note was raised. The server
  also folds in the environment and user-agent; the SDK redacts any secrets.
- `screenshot` is an optional client-captured PNG data URL. It is best-effort:
  decoded, size-capped (6 MB), and stored **by reference** (a size + checksum
  reference is folded into `context.screenshot`; the image bytes are only
  persisted to object storage when attachment storage is configured, and are
  never embedded in the feedback row). A malformed or oversized image never
  blocks the text.

Returns `201` with `{ "feedback": { id, kind, severity, message, status, url, createdAt, context } }`.

### GET /api/v1/feedback

Lists the caller's own feedback, newest first. Optional `limit` (1–200, default
50). Returns `{ "data": FeedbackEntry[] }`. Rows are tenant-scoped and further
narrowed to the requesting actor.

## MCP (Model Context Protocol)

### POST /api/v1/mcp

HTTP transport for the MCP (Model Context Protocol) JSON-RPC 2.0 interface.
Accepts a **single** JSON-RPC request, builds an in-process MaileryClient with
the bearer token, and dispatches to the shared MCP core that also powers the
CLI's stdio server.

Request body is a single JSON-RPC 2.0 request object:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list"
}
```

```json
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "get_billing_status",
    "arguments": {}
  }
}
```

**Responses:**

- JSON-RPC responses (requests with an `id`): `200` with `{ jsonrpc, id, result }` or `{ jsonrpc, id, error }`.
- JSON-RPC notifications (no `id`): `202 Accepted` with an empty body.
- Invalid JSON: `400` with `{ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }`.

**Supported methods:**

- `initialize` — returns protocol version, capabilities, and server info.
- `tools/list` — returns the MCP tool manifest (8 agent-facing tools).
- `tools/call` — executes one tool (send_email, list_inbox, read_message, search_domains, buy_domain, create_mailbox, get_billing_status, submit_feedback).
- `ping` — returns `{}`.
- `notifications/initialized`, `notifications/cancelled` — accepted (no response).

The route requires **authentication** (bearer token: session or API key) and
respects the same scopes and entitlements as direct `/api/v1` calls. Admin/operator
tools are not exposed; only the eight agent-facing SDK tools are available.

Rate limit: 100 requests per tenant per minute.

## Account Lifecycle (Export And Deletion)

### GET /api/v1/account/export (data export, GDPR Art. 20)

Returns a JSON export of the tenant's data. Requires an interactive owner/admin
user session (API keys cannot export the whole tenant) and works with or
without an active subscription — a data-subject right is never billing-gated.

**Size strategy:** the small collections — `tenant`, `users` (no password
hashes), `mailboxes`, `labels`, `api_keys` (metadata only, no secrets),
`domains`, `subscriptions`, and `credits` (balance + full ledger) — are returned
in full on **every** page. Messages, the unbounded collection, are
keyset-paginated on `id` ASC and include full bodies (`raw_email`, `text_body`,
`html_body`, `clean_markdown`) plus attachment **metadata** with a
`download_url` per attachment (binary attachment content stays behind the
authenticated `GET /api/v1/attachments/:id/download` route). Walk
`messages.next_cursor` until it is `null` and concatenate `messages.data`.

| Param | Notes |
| --- | --- |
| `limit` | Messages per page, 1–1000, default 200. |
| `cursor` | A message id from a prior `next_cursor`. Anything else is `400`. |

**Rate limit:** 600 export requests per tenant per hour. The CLI/API export
flow should page at `limit=1000`, assembling up to ~600k messages inside one
window; for larger tenants walk `messages.next_cursor` across windows (cursors
stay valid between requests and across hours) or contact
support@mailery.co for an out-of-band export.

```json
{
  "export_version": 1,
  "generated_at": "ISO-8601",
  "tenant": { "id": "uuid", "name": "…", "slug": "…", "plan": "…" },
  "users": [{ "id": "uuid", "email": "…", "role": "owner" }],
  "mailboxes": [{ "id": "uuid", "email": "…" }],
  "labels": [{ "id": "uuid", "name": "…", "kind": "system | custom" }],
  "api_keys": [{ "id": "uuid", "name": "…", "prefix": "…", "scopes": ["…"] }],
  "domains": [{ "id": "uuid", "domain": "…" }],
  "subscriptions": [{ "id": "uuid", "status": "…", "plan": "…" }],
  "credits": { "balance": 0, "monthly_grant": 0, "ledger": [{ "delta": 1, "reason": "…" }] },
  "messages": {
    "data": [{ "id": "uuid", "subject": "…", "rawEmail": "…", "textBody": "…", "htmlBody": "…", "attachments": [{ "id": "uuid", "filename": "…", "download_url": "/api/v1/attachments/…/download" }] }],
    "next_cursor": "message-id | null"
  }
}
```

### DELETE /api/v1/account (account deletion, GDPR Art. 17)

Body: `{ "password": "current password" }`.

- Requires an interactive **owner** user session **and** password
  re-authentication in the body. A missing password is `400`; a wrong password
  is `403 invalid_password`. The route is rate limited.
- Refuses with `409 subscription_active` while any non-canceled subscription
  exists (`active`, `trialing`, `past_due`, `incomplete`, …). The error details
  list the blocking subscriptions and point at `POST /api/v1/billing/portal` —
  cancel in the Stripe customer portal first, then retry.
- On success, deletes the tenant and everything that belongs to it in one
  cascading transaction — users, sessions (all session tokens are revoked), API
  keys, mailboxes, messages, attachments, labels, domains, credit balance and
  ledger — and writes an `account.delete` audit row that survives the delete
  with the tenant reference severed. In object-storage mode the stored
  attachment binaries are removed best-effort immediately after the
  cascade commits. The blocking-subscription check re-runs inside the deleting
  transaction, so a subscription created concurrently (e.g. by a checkout
  webhook) still aborts the delete with `409`. Deletion is irreversible.

## Security Audit Log

Security-relevant events are recorded in the `admin_audit` table (tenant-scoped
rows; readable by platform admins):

| Action | Recorded on |
| --- | --- |
| `auth.login` | Successful password login. |
| `auth.login_failed` | Failed login attempt (target is the normalized email; the client IP is stored in the event payload). |
| `auth.logout` | Session logout. |
| `auth.password_change` | Password change/reset (recorded by the password lifecycle surfaces). |
| `api_key.create` | API key creation (name, prefix, and scopes — never the secret). |
| `api_key.revoke` | API key revocation. |
| `api_key.revoke_all` | Break-glass revoke-all of every live key for the tenant (records the count and whether the session or password path was used). |
| `credit.adjust` | Admin credit adjustment. |
| `audit.purge` | Platform admin purged security audit rows past the retention window. |
| `account.delete` | Account deletion (written atomically with the delete). |
| `billing.dispute_created` | Chargeback dispute recorded; the tenant is suspended. |
| `tenant.freeze` | Operator (or dispute) froze the tenant: sends + all spend blocked, renewals stopped. |
| `tenant.unfreeze` | Operator lifted a tenant freeze. |

**Retention:** audit rows are kept for up to 24 months, then removed by the
`POST /api/v1/admin/audit/purge` admin API endpoint. The private
`platform-mailery audit-purge` CLI command is only a thin authenticated API
client for that endpoint; operators must schedule it (e.g. daily cron against
the deployed API) before serving real customers. Rows survive account
deletion with `tenant_id` nulled; as disclosed in the privacy policy, the
`account.delete` row keeps the workspace name and account email in `before` so
the deletion remains provable, and `auth.login_failed` rows keep the attempted
email and client IP for abuse prevention.

## Billing

| Method | Path | Scope | Description |
| --- | --- | --- | --- |
| `GET` | `/api/v1/billing/overview` | `billing_read` | Credit balance, plans, packs, subscriptions, and ledger. |
| `POST` | `/api/v1/billing/checkout` | owner/admin user session | Create a Stripe checkout URL for a paid subscription tier or a credit pack. Rate limited to 10 attempts per tenant/actor per hour. |
| `POST` | `/api/v1/billing/portal` | owner/admin user session | Create a Stripe billing portal URL. |

### Identity tiers (pricing)

Mailery tiers are centered on the managed agent identity, not per-email credits. Each tier is
an entitlement bundle (custom domains, mailboxes, monthly sends); the credits
ledger meters sends underneath (1 credit = 1 send).

| Plan | Price | Custom domains | Mailboxes | Monthly sends | Notes |
| --- | --- | --- | --- | --- | --- |
| `free` | `$0/mo` | 0 (shared subdomain) | 1 | 3,000 | Default tenant plan; no checkout. |
| `pro` | `$20/mo` | 10 | 50 | 10,000 | Warmup + deliverability monitoring. |
| `scale` | `$99/mo` | 100 | 500 | 100,000 | Priority support. |

The Free plan is live at signup: its single mailbox lives on the shared
subdomain (`shared_domain` in `GET /api/v1`; server env
`MAILERY_SHARED_DOMAIN` — when a deployment has none configured, free-tier
mailbox creation returns `503 shared_domain_not_configured`). Free-plan
addresses are unique across ALL tenants (`409 mailbox_address_taken`), and a
free-plan tenant's mailbox must be on the shared subdomain
(`402 free_plan_shared_domain_required`). Free sends are metered per UTC
calendar month (3,000/mo; the allowance never rolls over): at the cap the send
returns `402 send_limit_reached` with an upgrade hint, and any purchased
credit-pack balance is spent only after the month's free allowance is used.
Paid plans meter sends through the credits ledger (monthly grants + packs).

`POST /api/v1/billing/checkout` with `kind: "subscription"` accepts `plan`
(`pro` or `scale`; defaults to `pro` when omitted). An explicit plan that is not
a purchasable tier — including the retired `starter` or the free plan — is
rejected with `400 invalid_plan` (details carry `valid_plans`), never silently
charged as `pro`. The `free` plan has no checkout. Mailbox
and custom-domain **count** caps are enforced at creation (`402
mailbox_quota_exceeded` / `402 domain_quota_exceeded`).

**Domain purchases** are billed at the all-in price shown by
`GET /api/v1/domains/availability` before any charge (see “Domain quote”
below for the exact response fields).

Credit packs (one-time top-ups; a positive purchased balance also unlocks
Mailery, and purchased credits never expire):

| Credits | Price |
| --- | --- |
| `1000` | `$9` |
| `5000` | `$35` |
| `20000` | `$99` |

`402 billing_required` fires on the PAID surface (the custom-domain routes)
when the tenant has neither an active subscription nor a qualifying credit
balance (`details.required` is `active_subscription_or_credits`;
`details.hint` names the upgrade command). The free-tier surface — key
minting, the shared-domain mailbox, metered sending, message operations —
never returns it for a verified tenant. Use `/api/v1/billing/checkout` to
create a Stripe checkout URL for the subscription or a credit pack, or apply a
trusted invite/allow-list policy before signup if the deployment intentionally
grants launch credits.

`past_due` (a failed renewal) is deliberately **not** an entitling
subscription status: the tenant is demoted to the credits path and keeps
access exactly as long as a positive qualifying credit balance remains — every
gated action keeps debiting credits — and re-gates at balance `0`. When the
newest subscription is `past_due`, the `402` `details` additionally carry a
`past_due_recovery` object naming the two recovery paths: fix the payment
method via `POST /api/v1/billing/portal` (`fix_payment`), or cancel the
subscription in the same portal and continue spending the remaining credit
balance (`spend_credits`) — a positive qualifying balance unlocks Mailery
access on its own.

Breaking change note: `details.required` was previously
`active_subscription`, and the 402 message named only the subscription.
Clients pinned to the old `details.required` value must switch to
`active_subscription_or_credits`; keying on `status === 402` plus
`error.code === "billing_required"` is unaffected.

`POST /api/v1/billing/checkout` with `kind: "subscription"` returns
`409 subscription_exists` when the tenant already has an `active`, `trialing`,
or `past_due` subscription; manage the existing subscription through
`POST /api/v1/billing/portal` instead. Credit-pack checkouts stay available to
subscribed tenants as top-ups.

Two documented limits of that 409 guard:

- It is a pre-checkout check only. A checkout URL created before the
  subscription existed (Stripe sessions remain completable for roughly 24
  hours) or two concurrent checkouts can still both complete; webhook-side
  dedupe/auto-cancel of the duplicate subscription is tracked in the Stripe
  webhook robustness stream.
- The guard matches any subscription row in those statuses, while the
  entitlement check inspects only the newest row and requires an unexpired
  period. A tenant can therefore get `402 billing_required` on gated endpoints
  and `409 subscription_exists` on subscription checkout at the same time
  (for example after webhook lag leaves the latest `active` row period-lapsed).
  The billing portal — or a credit-pack purchase — is the recovery path for
  that corner state.

Credit policy: credits never expire. Purchased packs, subscription grants, and
admin grants remain spendable indefinitely; a failed-operation refund restores
the spendable amount of its exact debit. The tenant's balance only decreases
through usage or a payment clawback. This is an explicit product commitment —
do not add expiry without a contract change.

Entitlement accounting note: the credit side of the gate requires both a
positive current balance and a positive transactionally-maintained qualifying
subtotal. Every debit records the exact qualifying portion consumed, including
its exact subscription invoice-grant origin and invalidation generation and its
exact fulfilled credit-pack Checkout Session origin. A failure refund restores
only that recorded origin's remaining capacity; it cannot borrow capacity from
a later invoice, convert free credits into paid entitlement, or resurrect a
grant invalidated by payment refund or subscription quarantine after the debit.

Billing robustness policies (enforced by the Stripe webhook handlers):

- **Refunds** (`charge.refunded`): granted credits are clawed back with a
  negative ledger entry (idempotency key `stripe:refund:{refundId}`),
  proportional to the refunded fraction of the charge. Balances are **not**
  floored at zero — a tenant that already spent refunded credits goes negative,
  which blocks further spending. Subscription and credit-pack qualifying
  entitlement is removed only from the exact refunded origin. A credit-pack
  charge must resolve a recorded fulfilled Checkout grant by PaymentIntent;
  refund-before-fulfillment returns 503 so Stripe retries after fulfillment,
  and never debits unrelated credits. The tenant is flagged
  (`billing_flag=refund`).
- **Disputes** (`charge.dispute.created`): recorded in the audit log and the
  tenant is flagged (`billing_flag=dispute`) for operator review. A transient
  charge-lookup failure (network error, Stripe 5xx/429) makes the webhook
  answer 5xx so Stripe retries; only permanent lookup failures (Stripe 4xx) or
  an unresolvable tenant are ACKed as `dispute_unresolved_tenant` and surfaced
  by the `billing-anomalies` sweep.
- **Failed/expired checkouts** (`checkout.session.async_payment_failed` /
  `checkout.session.expired`): the pending checkout row is closed so it never
  sits `pending` forever. A declined async payment records the event outcome
  `checkout_failed` — distinct from `failed`, which is reserved for crashed
  handlers and alerts via `billing-anomalies`.
- **Subscription invoices**: monthly credits are granted only for a fully paid
  USD `subscription_create` or `subscription_cycle` invoice whose single
  non-proration monthly line proves the exact configured plan price and billing
  period. `$0` invoices, partial/underpayments, prorations, plan-change
  (`subscription_update`), manual, and unknown billing reasons never grant an
  allowance. Payment evidence must cover the full published plan price; a
  positive `amount_paid` alone is not sufficient.
- **Stripe Tax**: when `MAILERY_STRIPE_TAX_ENABLED` is on (default in live
  Stripe mode) checkout sessions enable `automatic_tax`, require a billing
  address, and collect tax IDs. Stripe Tax must also be enabled in Stripe
  account settings. Checkout rows store the pre-tax amount, so fulfillment matches the
  session's pre-tax `amount_subtotal` as well as `amount_total` — taxed
  purchases (tax-inclusive totals) still grant credits.
- **Receipts**: one-time credit-pack payments set `receipt_email` so Stripe
  emails a receipt when "Successful payments" emails are enabled in the Stripe
  account; subscription invoices use Stripe's built-in invoice emails.

After checkout, Stripe redirects the browser to `/?checkout=success` or
`/?checkout=cancel`. Fulfillment is asynchronous via the Stripe webhook, so
a successful redirect means "payment is processing" — poll
`GET /api/v1/billing/overview` until the balance or subscription reflects the
purchase.

## Mailboxes And Messages

| Method | Path | Scope | Description |
| --- | --- | --- | --- |
| `GET` | `/api/v1/mailboxes` | `mail_read` | List tenant mailboxes. |
| `POST` | `/api/v1/mailboxes` | `mail_write` | Create or connect a mailbox (plan mailbox-count cap applies). Free-plan tenants: one mailbox, on the shared subdomain only, address unique across tenants. |
| `GET` | `/api/v1/labels` | `mail_read` | List tenant labels with per-label live-message counts. |
| `GET` | `/api/v1/messages` | `mail_read` | List/search messages with optional `mailboxId`, `folder`/`group`, `direction`, `threadId`, `domain` (repeatable), `updatedSince`, `q`, `limit`, and `cursor`. Bodyless projection. |
| `GET` | `/api/v1/messages/groups` | `mail_read` | Mailbox group counts (incl. `starred` and `sent`); optional `mailboxId` and repeatable `domain` scope. |
| `GET` | `/api/v1/messages/changes` | `mail_read` | Delta / changesSince feed: created-or-changed messages since a watermark, ordered by `updated_at` ASC. |
| `POST` | `/api/v1/messages/bulk` | `mail_write` + billing gate | Bounded, counter-correct bulk mutation over an id list or a `{ mailboxId, folder }` filter. |
| `POST` | `/api/v1/messages` | `mail_write` + billing gate | Upload a hosted message. |
| `POST` | `/api/v1/messages/send` | `mail_write` + domain/metering gates | Send hosted mail from an enabled, outbound-ready tenant domain, or from the shared subdomain. Free plan: 3,000 sends per calendar month (`402 send_limit_reached` at the cap). |
| `GET` | `/api/v1/messages/tombstones` | `mail_read` | Deleted-message tombstones for source-of-truth sync; optional `mailboxId` scope. |
| `GET` | `/api/v1/messages/:id` | `mail_read` | Read one message. |
| `DELETE` | `/api/v1/messages/:id` | `mail_write` + billing gate | Delete a message and return a tombstone. |
| `PATCH` | `/api/v1/messages/:id` | `mail_write` + billing gate | Update `isRead`/`isImportant`/`isArchived`/`isSpam`/`isTrash`/`isStarred` flags. |
| `POST` | `/api/v1/messages/:id/labels` | `mail_write` + billing gate | Add a label to a message (bumps `updated_at`). |
| `DELETE` | `/api/v1/messages/:id/labels/:label` | `mail_write` + billing gate | Remove a label from a message (idempotent). |
| `POST` | `/api/v1/messages/:id/parse` | `mail_write` + billing gate | Parse/classify one message, spending credits. |
| `GET` | `/api/v1/messages/:id/attachments` | `mail_read` | List attachment metadata for one message. |
| `GET` | `/api/v1/attachments/:id/download` | `mail_read` | Download attachment content. |

Message responses redact attachment bodies. Attachment content is available only
through the authenticated download route.

### GET /api/v1/messages (list / search)

Query parameters:

| Param | Notes |
| --- | --- |
| `mailboxId` (alias `mailbox_id`) | Scope to one mailbox. Must be a UUID or the request is `400`. |
| `folder` (alias `group`) | One of `inbox`, `important`, `unread`, `spam`, `trash`, `archive`, `starred`, `sent` (`archived` is accepted as an alias for `archive`). `starred` lists the star flag; `sent` lists outbound. Any other value is `400` — an unknown folder never silently returns the entire mailbox (incl. spam/trash). |
| `direction` | `inbound` or `outbound`. Any other value is `400`. |
| `threadId` (alias `thread_id`) | List one thread. A blank/whitespace value is `400` (never silently lists the whole mailbox). |
| `domain` (repeatable) | Mailbox-scope domain filter, shared self-hosted/cloud contract: only messages in the tenant's mailboxes whose **address** is at one of these domains (e.g. `?domain=corp-a.test&domain=corp-b.test`). Case-insensitive; a leading `@` is tolerated; max 16 distinct values. IDN domains match under **either** spelling (`?domain=bücher.example` ≡ `?domain=xn--bcher-kva.example`). A blank or whitespace-only value collapses to **no filter** (like `?folder=`), NOT to "match nothing"; any malformed non-blank value is `400` — the filter never silently mismatches. Combines with `mailboxId` as an intersection (UUID case-insensitive). Index-backed at scale: the filter resolves to mailbox ids via an expression index, then runs one bounded keyset descent per mailbox (`unnest` + `LATERAL` top-N merge, O(mailboxes × page) — never an offset scan, never a full-domain sort, never a full-tenant walk). |
| `updatedSince` (alias `updated_since`) | ISO timestamp. Transparently routes the list to the delta feed (created-or-changed since the watermark, ordered by `updated_at` ASC) — see **GET /messages/changes**. Cannot be combined with `q`/`group`/`direction`/`threadId`/`domain` (that is a `400`); use those on the plain list instead. |
| `q` | Hybrid full-text search. Ranked whole-word/stemmed matching via a GIN `tsvector`, plus index-backed `pg_trgm` substring and partial-email matching over `subject`, `from_address`, the stored `snippet`, and **recipients** (`to_addresses` + `cc_addresses`, via the generated `recipients_text` column). Recipient/verification-code lookups (e.g. searching a recipient address) resolve server-side and stay index-backed at scale. LIKE metacharacters are matched literally. |
| `limit` | 1–200, default 50. |
| `cursor` | Opaque keyset cursor from a prior `next_cursor`. An **absent** cursor is page 1; an explicitly-provided but undecodable/old-version cursor is `400` (so stale clients detect the break instead of silently restarting at page 1). |

The list is a deliberately lightweight, **bodyless** projection — it never returns
`text_body`, `html_body`, `clean_markdown`, `raw_email`, `metadata`, or
`classification` (fetch those from `GET /messages/:id`). Each item carries a
maintained ~200-char `snippet` and a `has_attachments` flag instead. Response
shape:

```json
{
  "data": [
    {
      "id": "uuid",
      "tenant_id": "uuid",
      "mailbox_id": "uuid",
      "thread_id": "string | null",
      "direction": "inbound | outbound",
      "subject": "string",
      "from_address": "string",
      "to_addresses": ["string"],
      "snippet": "string (<= 200 chars, no body detoast)",
      "is_read": false,
      "is_important": false,
      "is_spam": false,
      "is_trash": false,
      "is_archived": false,
      "is_starred": false,
      "importance_score": 0,
      "received_at": "ISO-8601 | null",
      "sort_at": "ISO-8601",
      "created_at": "ISO-8601",
      "updated_at": "ISO-8601",
      "has_attachments": false,
      "labels": ["string"],
      "label_records": [{ "id": "uuid", "name": "string", "color": "#hex", "kind": "system | custom" }]
    }
  ],
  "next_cursor": "opaque string | null"
}
```

Pagination is a `(sort_at, id)` keyset carried at full microsecond precision, so no
sub-millisecond rows are skipped across a page boundary. Each item also carries its
exact `updated_at` so a delta client can advance a precise per-row watermark. Fields
are returned in both `snake_case` and `camelCase` for client compatibility.

### GET /api/v1/messages/changes (delta / changesSince)

Returns live messages **created or changed** since a watermark, ordered by
`updated_at` ASC and keyset-paginated on `(updated_at, id)`. Deletions come from
**GET /messages/tombstones**; together they let a bounded-cache client sync without
re-fetching pages.

| Param | Notes |
| --- | --- |
| `updatedSince` (aliases `updated_since`, `since`) | ISO timestamp watermark (inclusive/`gte`). |
| `mailboxId` (alias `mailbox_id`) | Scope to one mailbox (UUID). |
| `limit` | 1–500, default 100. |
| `cursor` | Opaque `(updated_at, id)` keyset cursor; an undecodable/old-version cursor is `400`. |

Each row carries its exact `updated_at` — advance the watermark from those, not from a
local clock. The response also returns a server-issued watermark as the
`X-Mailery-Server-Time` response header and as `server_time`/`serverTime` plus an
opaque base64url `state` token in the body; once a client fully drains the feed
(`next_cursor` is `null`) it can adopt `server_time` as its next `updatedSince`. The
same feed is reachable via `GET /messages?updatedSince=…` (which rejects combining
`updatedSince` with `q`/`group`/`direction`/`threadId`).

### POST /api/v1/messages/bulk

Bounded, tenant-scoped, counter-correct bulk mutation. Body: an `action` plus either an
explicit `ids` array (≤ 1000) **or** a `{ mailboxId, folder }` filter.

```json
{ "action": "markRead | markUnread | important | unimportant | star | unstar | archive | unarchive | spam | unspam | trash | untrash | addLabel | removeLabel | delete",
  "ids": ["uuid"],
  "mailboxId": "uuid", "folder": "inbox | unread | …",
  "label": "required for addLabel/removeLabel",
  "cursor": "resume token for draining a large folder (filter path)" }
```

Returns `{ ok, action, affected, matched, has_more, next_cursor }`. `affected` counts
rows that actually transitioned. A folder larger than one call drains in keyset batches;
when `has_more` is `true`, pass `next_cursor` back as `cursor` to continue. An unknown
action, > 1000 ids, no target selector, or an undecodable resume cursor is `400`.

### Labels

`GET /api/v1/labels` returns the tenant's label set with a per-label live-message
count, so a client renders a labels sidebar without deriving labels from a sampled page.
System-label counts (Important, Unread, Spam, Trash, Archive) read the O(1) folder
counters (`message_counters`); custom-label counts are computed from `message_labels`
joined to live messages:

```json
{ "data": [ { "id": "uuid", "name": "Priority", "color": "#hex", "kind": "system | custom",
              "count": 12, "message_count": 12, "messageCount": 12 } ] }
```

`POST /api/v1/messages/:id/labels` (`{ "label": "Priority" }`) adds a label and bumps the
message's `updated_at` so the delta feed observes it; `DELETE
/api/v1/messages/:id/labels/:label` (URL-encoded name) removes it idempotently. Both
return the message's full label set (`label_records` + `label_names`).

Hosted send from a tenant-owned domain spends `MAILERY_COST_SEND_MESSAGE`
credits, default `1`. It fails closed before charging if the sender domain is
the platform system domain, belongs to another tenant, is not configured, is
restricted, or is missing `ownership_ready`, `dns_ready`, `outbound_ready`,
`send_enabled`, or `billing_ready`.

Hosted send from the **shared subdomain** (`MAILERY_SHARED_DOMAIN`) skips
the per-tenant domain-readiness gates — Mailery owns that domain's mail
identity and DMARC posture — and is bounded by the sender plan instead:
free-plan tenants draw from the free monthly allowance (3,000/calendar month,
race-safe counter, no rollover) and then from any purchased credit balance;
when both are exhausted the send returns `402 send_limit_reached` with
`details.hint` naming the upgrade command.

## Digests

| Method | Path | Scope | Description |
| --- | --- | --- | --- |
| `GET` | `/api/v1/digests` | `mail_read` | List generated digests. |
| `POST` | `/api/v1/digests/generate` | `mail_write` + credits | Generate a digest for `today`, `yesterday`, `last_7_days`, or `month`. Spends `MAILERY_COST_DIGEST` credits, idempotent per (tenant, window, UTC day): a same-day repeat returns the stored digest without re-running the model or re-billing. Rate-limited per tenant (60/hour). |

## Domains

Mailery uses three domain kinds:

- `system_domain`: platform-owned identity for Mailery itself. In
  production this is `mailery.co`; it is not assignable to a tenant and tenant
  setup requests for it must be rejected.
- `tenant_domain`: tenant-owned SaaS domain. Each tenant domain has independent
  ownership, DNS, inbound, outbound, DMARC, event, and billing readiness.
- `self_hosted_domain`: external domain outside the hosted tenant workflow.
  Mailery does not control it through tenant billing or hosted-domain APIs.

The shared free-tier domain (`MAILERY_SHARED_DOMAIN`) is platform-owned like
the system domain: no tenant may configure it — or any subdomain of it — as a
tenant domain (`409 shared_domain_reserved`), because a tenant-owned record
with catch-all inbound would capture other tenants' shared-domain mail. Inbound
mail addressed to a shared-domain mailbox routes by the (globally
unique) mailbox address alone; no per-tenant domain readiness row is required.

DMARC is a per-domain outbound ramp and reputation gate. It must not block
non-sending aggregation, read-only mailbox sync, or inbound-only setup. Tenant
domains start with `p=none` and `rua=mailto:postmaster@<domain>`. DMARC
aggregate observations update `dmarc_policy`; quarantine requires at least three
clean reports and ten aligned messages, while reject requires additional clean
history. Applying enforcement updates the required DMARC DNS record but still
does not mutate external DNS without separate DNS authority.
In live mode, outbound readiness also requires the tenant mail identity and
custom bounce domain to be verified. Mailery fails closed for bounce-domain MX
failures instead of silently falling back to the platform domain.

### Domain-purchase containment

A domain **purchase** (`buy: true` / `purchase: true` on `/api/v1/domains/setup`)
is a paid custom-domain action, so it is gated before Mailery starts it.
Purchases are rejected when any of these fail:

- **Suspension** (`403 tenant_suspended`): the tenant is frozen by a chargeback
  dispute or the operator kill-switch. Frozen tenants also cannot send, and
  subscription renewals stop granting credits until an operator unfreezes them.
- **`domains:purchase` scope** (`403 forbidden`): the key must carry the
  `domains:purchase` scope (or `full`, or be an interactive owner session).
- **First-purchase trust gate** (`403 settled_funds_required`): the tenant must
  have an established billing relationship and account age before the first
  custom-domain purchase.
- **Account safety limits** (`402 domain_spend_cap_exceeded`): purchase volume
  is bounded by account-level safety controls.

Auto-renew is **off by default** on purchased domains — an agent must opt in
explicitly (`autoRenew: true`).

### Quote-confirm purchase lifecycle (`GET /api/v1/domains/availability` → `POST /api/v1/domains/purchase`)

Paid domain purchase is quote-confirmed. First call
`GET /api/v1/domains/availability?domain=example.com`; when the domain is
available and purchasable, the response includes `purchase_quote.token`, a
short-lived signed quote bound to tenant, domain, customer total, currency, and
expiry. Then call `POST /api/v1/domains/purchase` with:

- `Idempotency-Key` header (required, caller-supplied)
- `domain`
- `quoteToken` (required non-empty string)
- `maxPriceCents` (required positive safe integer; caller-authorized all-in ceiling)
- `currency` (required three-letter currency matching the signed quote)
- optional `autoRenew`, default false

The purchase endpoint fails closed without the idempotency key, matching quote
token, price ceiling, and currency. Quote-only CLI/MCP responses surface a
suggested stable key; use that exact key for the first execution and every
retry. A refreshed equivalent quote may reuse the key, while changed purchase
options or authorization bounds conflict. It then runs the identity-readiness workflow toward
`ownershipStatus: ready`, applying every containment gate above plus the plan's
custom-domain count cap. It is **idempotent and resumable**:

- A purchase attempt runs the paid ownership step at most once for the same
  identity. Re-calling the endpoint resumes the existing lifecycle (`resumed:
  true`) instead of double-charging or creating duplicate ownership.
- Verification is idempotent. A verification failure is reported as the failed
  lifecycle step and can be retried by calling the endpoint again.
- If ownership is still in flight, Mailery keeps the lifecycle pending and a
  later call resumes from the current state.

The response carries per-step progress in `lifecycle_steps` (each with a
`status` of `completed`/`in_progress`/`pending`/`blocked`/`failed`/`skipped`),
an `ownership_ready` boolean (the P5 acceptance), and `resumed`. The same
`lifecycle_steps` are returned by `GET /api/v1/domains/:id`, so a CLI can poll
that endpoint to stream progress.

### Domain availability and purchase display

Public domain responses are deliberately product-level. They show whether the
identity can be registered, the lifecycle state, and the DNS records Mailery
needs to verify. Availability responses may include a customer-facing
`purchase_quote` for confirmable purchase; internal provider costs and
implementation details are not part of the public API contract.

Purchases remain gated by plan, scope, purchase safeguards, and tenant status.
The CLI fetches a quote, asks for explicit confirmation before any command that
can spend money, and sends the quote token plus an idempotency key to the API.

### Registrant contact (required for live registration)

Live domain registration needs registrant contact details. If no valid contact
is available, the purchase **fails closed** with `registrant_contact_required`
(400). A contact supplied in the request can fill missing details. Missing
required contact fields are reported as `domain purchase contact is missing:
…`.

Domain setup uses Mailery's managed identity workflow. The public response
returns lifecycle state and DNS records that must be published or verified.

| Method | Path | Scope | Description |
| --- | --- | --- | --- |
| `GET` | `/api/v1/domains` | `mail_read` + billing gate | List tenant domain lifecycle records. |
| `POST` | `/api/v1/domains` | `mail_write` + billing gate | Add a tenant domain record without mutating DNS providers. |
| `GET` | `/api/v1/domains/availability?domain=example.com` | `mail_read` + billing gate | Check whether a domain can be used for a Mailery identity. |
| `POST` | `/api/v1/domains/setup` | `mail_write` + billing gate (+ `domains:purchase` and purchase safeguards when `buy`/`purchase` is set) | Buy/connect a domain and return DNS/mailbox setup. |
| `POST` | `/api/v1/domains/purchase` | `mail_write` + `domains:purchase` + purchase-containment + count-cap gates | **One-command, idempotent, resumable** domain purchase (PIVOT P5). Creates a paid custom-domain identity and drives it toward `ownershipStatus: ready`. Re-calling continues from the current lifecycle state and never double-charges or creates duplicate ownership. Returns per-step `lifecycle_steps`, `ownership_ready`, and `resumed`. |
| `GET` | `/api/v1/domains/status?domain=example.com` | `mail_read` + billing gate | Domain registration, DNS delegation, mail identity, and bounce-domain status. |
| `GET` | `/api/v1/domains/:id` | `mail_read` + billing gate | Read one tenant domain lifecycle record with DNS records. |
| `GET` | `/api/v1/domains/:id/dns` | `mail_read` + billing gate | List DNS records required or verified for one tenant domain. |
| `GET` | `/api/v1/domains/:id/events` | `mail_read` + billing gate | List mail-plane readiness events, audit state transitions, and automatic outbound pauses for one tenant domain. |
| `POST` | `/api/v1/domains/:id/dmarc/report` | `mail_write` + billing gate | Record DMARC aggregate alignment evidence without affecting inbound readiness. |
| `POST` | `/api/v1/domains/:id/dmarc/policy` | `mail_write` + billing gate | Recommend or apply DMARC quarantine/reject only after clean alignment evidence. |
| `POST` | `/api/v1/domains/:id/verify` | `mail_write` + billing gate | Refresh provider/DNS readiness and record verification attempts. |
| `POST` | `/api/v1/domains/:id/enable-inbound` | `mail_write` + billing gate | Enable inbound only after inbound readiness is `ready`. |
| `POST` | `/api/v1/domains/:id/enable-outbound` | `mail_write` + billing gate | Enable outbound only after outbound readiness is `ready`. |
| `POST` | `/api/v1/domains/:id/disable` | `mail_write` + billing gate | Pause inbound/outbound for a tenant domain and record a restriction. |
| `DELETE` | `/api/v1/domains/:id` | `mail_write` + billing gate | Soft-delete a tenant domain while preserving audit history. |

The public `mailery` CLI and MCP server wrap these endpoints for agent use.
They use the same hosted API contract documented here and hide internal
implementation details.


## Webhooks And Admin

| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| `GET` | `/api/v1/admin/overview` | admin | Platform counts, recent credit events, and billing-flagged tenants (refunds/disputes). |
| `POST` | `/api/v1/admin/credits/adjust` | admin | Adjust tenant credits with an audit row. |
| `POST` | `/api/v1/admin/audit/purge` | admin | Delete `admin_audit` rows older than 24 months and record a fresh `audit.purge` row. |
| `GET`/`POST` | `/api/v1/admin/billing/anomalies` | admin | Run the billing reconciliation sweep. Stripe subscription checks use the server-side Stripe configuration only; clients never send Stripe keys. |
| `POST` | `/api/v1/admin/tenants/:id/freeze` | admin | Freeze one tenant: blocks sends, domain purchases, and renewal credit grants. Body: `{ "reason": "..." }`. Idempotent. |
| `POST` | `/api/v1/admin/tenants/:id/unfreeze` | admin | Unfreeze one tenant. Body: `{ "clearBillingFlag": true }` defaults to clearing the billing flag; set `false` to keep it. Idempotent. |

Private operator CLI commands (`audit-purge`, `billing-anomalies`, `tenant
freeze`, `tenant unfreeze`) call these admin endpoints using
`MAILERY_OPERATOR_API_URL` plus `MAILERY_OPERATOR_TOKEN` or
`MAILERY_ADMIN_API_KEY`. They do not import database, Stripe, or containment
modules locally. Local configuration checks, migrations, and SES worker
processes run through the separately named `platform-mailery-runtime`
deployment executable (or the runtime-scoped `platform-mailery-migrate`
compatibility alias), not through the operator client.

Mail-plane provider webhooks are not part of the public tenant contract. They
use dedicated provider authentication and are separated from platform admin
credentials.

## Landing, Auth Pages, And Unknown Paths

Mailery has no web app login surface (PIVOT §1) — the product is the
`mailery` CLI, MCP server, and SDK. The web surface is:

- `GET /` — the marketing landing page (a standalone Vite + React + Tailwind +
  shadcn/ui build; source under `web/`, emitted to `web/dist`).
- `GET /verify` and `GET /reset` — tiny noindex pages that one-time email
  links (email verification, password reset) land on. They call the public
  auth endpoints and point the human straight back to the terminal.

The landing's hashed, same-origin static bundles are served at
`GET /assets/<file>` with a long immutable cache. The build is CSP-safe for
the server policy (`script-src 'self'`; `style-src 'self' 'unsafe-inline'`):
every script is an external hashed file (no inline bootstrap script), and no
`data:` URIs are emitted. If the web build is absent, `GET /` falls back to a
minimal inline placeholder so the server still responds `200`.

The former app SPA and the retired `/login` page were removed:
`GET /app`, `GET /app/`, `GET /styles.css`, `GET /app.js`, and
`GET /app-lib.js` all issue a permanent `308` redirect to `/`. `GET /login`
`308`-redirects home, translating legacy `?verify_token=` / `?reset_token=`
query params onto `/verify` and `/reset` so old email links keep working. Any
other unknown path returns HTTP `404` — an HTML 404 page for non-API paths,
and the JSON error shape below for unknown `/api/*` and `/webhooks/*` paths.

## Error Shape

Errors use JSON:

```json
{
  "error": {
    "code": "unauthorized",
    "message": "valid session or Mailery API key required"
  }
}
```

## Rate Limits

Rate-limited responses return HTTP `429` and a `Retry-After` header with the
number of seconds to wait. A locked account (too many failed logins) returns
`429 account_locked` with the same `Retry-After` header. An unverified user
hitting a verification-gated route in enforced deployments gets
`403 email_verification_required`. Current limits:

| Surface | Keyed by | Limit | Window |
| --- | --- | --- | --- |
| `POST /api/v1/auth/signup` | client IP | 5 | 1 hour |
| `POST /api/v1/auth/signup` | global (all clients) | 100 | 1 hour |
| `POST /api/v1/auth/signup` | normalized email | 2 | 24 hours |
| `POST /api/v1/auth/login` | client IP | 10 | 15 minutes |
| `POST /api/v1/auth/login` | normalized email (lockout) | 5 free failures, then exponential backoff (1 min → 1 h cap) | 1 hour decay |
| `POST /api/v1/auth/password/forgot` | client IP | 5 | 15 minutes |
| `POST /api/v1/auth/password/forgot` | global (all clients) | 200 | 1 hour |
| `POST /api/v1/auth/password/forgot` | normalized email | 3 | 1 hour |
| `POST /api/v1/auth/password/reset` | client IP | 10 | 15 minutes |
| `POST /api/v1/auth/verify` | client IP | 20 | 15 minutes |
| `POST /api/v1/auth/verify/resend` | user | 3 | 1 hour |
| `POST /api/v1/auth/verify/resend` | client IP | 10 | 1 hour |
| Failed `/api/v1/*` authentication | client IP | 30 failures | 15 minutes |
| `POST /api/v1/api-keys` | tenant + actor | 20 | 1 hour |
| `POST /api/v1/digests/generate` | tenant | 60 | 1 hour |
| `POST /api/v1/billing/checkout` | tenant + actor | 10 | 1 hour |
| `GET /api/v1/account/export` | tenant | 600 | 1 hour |
| `DELETE /api/v1/account` | tenant | 5 | 1 hour |
| `POST /api/v1/feedback` | tenant | 30 | 1 hour |
| `POST /api/v1/feedback` | client IP | 60 | 1 hour |
| `POST /api/v1/mcp` | tenant | 100 | 1 minute |
| `POST /api/v1/admin/audit/purge` | client IP | 10 | 1 hour |
| `GET/POST /api/v1/admin/billing/anomalies` | client IP | 60 | 1 hour |
| `POST /api/v1/admin/tenants/:id/freeze` | tenant + client IP | 30 | 1 hour |
| `POST /api/v1/admin/tenants/:id/unfreeze` | tenant + client IP | 30 | 1 hour |

Free-plan hosted sending is additionally metered per UTC **calendar month**
(3,000 sends; `402 send_limit_reached` at the cap — a quota, not a 429 rate
limit). Other authenticated routes are not currently rate-limited beyond the
authentication-failure limiter and the request body size cap.
