# Account API reference

Operations for managing an account itself: bootstrapping one programmatically,
checking your send budget and usage, managing link-scanning activation, exporting your
data, and deleting the account. Dashboard-only account settings (billing checkout,
inbox counts) are managed from the web dashboard and are not part of this API
surface.

For the machine-readable contract, see the generated OpenAPI spec at
[`/docs/openapi.json`](/docs/openapi.json). Error codes and their remedies are
catalogued once at [/agents/errors](/agents/errors) — this page names the codes
each operation can return and links there instead of restating remediation.

**Auth** is either a Bearer API key (`admin` or `agent` role) or a dashboard
session cookie; where an operation is `admin`-only, an `agent`-role key is
rejected with `403 INSUFFICIENT_SCOPE`. Each operation below carries a
**Classification** tag drawn from `read-only`, `mutating`, and `secret-revealing`.

## POST /v1/accounts/signup

Create a new account with an API key and no password — the programmatic/CLI
signup path. Every new account must verify both its email address and a mobile
phone number before protected product routes unlock.

**Auth:** none (public endpoint). **Classification:** `mutating`,
`secret-revealing`.

**Request:**

```json
{
  "email": "user@example.com",
  "phone_number": "+13125550123",
  "accept_terms": true,
  "invite_code": "...",
  "cli_signup_code": "...",
  "accept_web_risk_version": "v2"
}
```

- `phone_number` is required and must include its country code. Common spaces,
  parentheses, periods, and hyphens are normalized before validation and storage
  in E.164 form. ReplyLayer sends a 6-digit SMS one-time password; signup
  responses expose only the masked destination.
- `accept_terms` must be `true`; it records the single signup legal-assent
  action: agreement to the Terms of Service — which incorporate the
  Acceptable Use Policy and, where applicable, the Data Processing Agreement —
  and acknowledgement of the Privacy Policy. The field name is retained for
  API compatibility.
- Depending on how signup is gated for your deployment, the request must carry
  **either** an `invite_code` **or** a single-use `cli_signup_code` (issued to you
  from the dashboard by an existing customer). A missing code returns
  `403 CLI_SIGNUP_CODE_REQUIRED`; an invalid, expired, or already-consumed code
  returns `403 CLI_SIGNUP_CODE_INVALID`.
- `accept_web_risk_version` is **optional** and records an explicit
  acknowledgement of the malicious-link-scanning (URL reputation) disclosure.
  **Its absence enables link scanning by default** under the signup disclosure
  (Privacy Policy §7a). A mismatched (stale) explicit value creates the account
  normally with link scanning disabled — enable it later via
  [`POST /v1/accounts/url-reputation`](#post-v1accountsurl-reputation). You can
  turn scanning off per mailbox via the mailbox scanner policy
  (`disabled_scanners: ["url-reputation"]`).

**Response (201):**

```json
{
  "account_id": "uuid",
  "api_key": "rly_live_<public_id>.<secret>",
  "verification_required": true,
  "email_verification_required": true,
  "phone_verification_required": true,
  "sms_delivery_status": "after_email_verification",
  "phone_number_masked": "•••• 0123",
  "message": "Check your email for a 6-digit verification code. After you verify it, we'll text a code to your phone."
}
```

The `api_key` is shown in plaintext **only** on this response — store it
immediately. It can call the verification/recovery endpoints, but protected
product routes return `403 EMAIL_NOT_VERIFIED` while email is pending and then
`403 PHONE_NOT_VERIFIED` while phone verification is pending. `sms_delivery_status`
is normally `after_email_verification`: no SMS is sent at signup, and the first code
is texted when you verify the email with `POST /v1/auth/verify-email` (whose response
reports the send outcome). Other values are `sent`, `pending`, `failed`, or
`not_required`; if a send did not go out, retry it with
`POST /v1/auth/resend-phone-verification` once the email is verified (it returns
`403 EMAIL_NOT_VERIFIED` before that). See
[/docs/authentication](/docs/authentication) for both verification steps.

**Errors you'll care about:** `403 CLI_SIGNUP_CODE_REQUIRED` /
`403 CLI_SIGNUP_CODE_INVALID` (signup gate), and `429 SIGNUP_RATE_LIMITED` — a
per-IP limiter caps this endpoint at **10 requests / 60s** and returns a
`Retry-After` header plus `details.retry_after`, distinct from the generic
`RATE_LIMITED`.

## GET /v1/accounts

Minimal identity ("whoami") for the authenticated caller.

**Auth:** any account-scoped key (`admin` or `agent`) or session.
**Classification:** `read-only`.

**Response (200):**

```json
{ "account_id": "uuid", "email": "user@example.com", "status": "active", "tier": "sandbox" }
```

Useful for confirming which account a key belongs to — for example, echoing the
account's own email into the `confirm_email` field required by account deletion,
without persisting the email locally.

## GET /v1/accounts/quota

Send-budget preflight — the effective daily send limit and how much of it remains
today. This is the endpoint an agent should call before a send.

**Auth:** `admin`, `agent`, or session (all receive a `200` — unlike
[`GET /v1/accounts/usage`](#get-v1accountsusage), which is admin-only).
**Classification:** `read-only`.

**Response (200):**

```json
{
  "today": { "count": 5, "limit": 200, "day": "2026-05-30" },
  "sends_remaining": 195,
  "reset_at": "2026-05-31T00:00:00.000Z",
  "scope": "agent",
  "bound_mailbox_ids": ["uuid1", "uuid2"]
}
```

- `today.limit` is the **effective** daily limit — it accounts for trust level and
  may be lower than the raw tier cap. A sandbox account reports `15` here. This is
  the same value the send-time budget gate enforces, so
  `sends_remaining = max(0, limit - count)` matches what a send will actually
  allow. See [/docs/limits](/docs/limits) for the full tier and sandbox table.
- `reset_at` is an ISO-8601 UTC instant at midnight UTC of the next UTC day, when
  the budget resets.
- `scope` disambiguates the two empty-`bound_mailbox_ids` cases: `"admin"` (an
  unrestricted key/session — `[]` means **all** mailboxes) vs `"agent"` (a scoped
  key — an empty list means the key has **no** bound mailboxes and cannot send).
  Key off `scope`, not `bound_mailbox_ids.length`, to tell "all" from "none".
- An optional `warmup` object appears only while a new paid account is inside its
  shared-domain new-sender warm-up window; when present, `today.limit` reflects the
  warm-up cap so the preflight never advertises headroom a send would refuse.

When the budget is exhausted, send endpoints return `429 RATE_LIMITED` with
enriched `details: { daily_limit, sends_remaining: 0, reset_at }` — the same
numbers reported here. See [/agents/send-gates](/agents/send-gates).

## GET /v1/accounts/usage

Full account usage: send budget, 30-day history, mailbox count, deliverability
rates, health status bands, and trust-promotion progress.

**Auth:** `admin` or session only (`agent` keys → `403 INSUFFICIENT_SCOPE`).
**Classification:** `read-only`.

**Response (200):**

```json
{
  "today": { "count": 5, "limit": 100, "day": "2026-04-08" },
  "history": [{ "day": "2026-04-07", "count": 12 }],
  "mailbox_count": 2,
  "mailbox_limit": 5,
  "rates": { "bounce_7d": 0.012, "complaint_7d": 0.0003, "delivery_7d": 0.987 },
  "health": {
    "bounce": {
      "value": 0.012,
      "status": "healthy",
      "basis_count": 84,
      "window": "7d",
      "summary": "Bounce rate is comfortably within acceptable range.",
      "action_hint": null
    }
  },
  "trust": {
    "level": 1,
    "next_promotion": {
      "target_level": 2,
      "eligible": false,
      "requirements": {
        "min_age_days": { "required": 7, "current": 3, "met": false },
        "min_emails": { "required": 20, "current": 12, "met": false },
        "max_bounce_rate": { "required": 0.02, "current": 0.012, "met": true },
        "max_complaint_rate": { "required": 0.0005, "current": 0.0003, "met": true }
      }
    }
  }
}
```

- `rates` are cohort-based, windowed on provider-accepted sends.
- `health` gives server-derived display bands: `status` is one of `healthy`,
  `watch`, `at_risk`, or `insufficient_data`. Enforcement thresholds are not
  exposed.
- `next_promotion` is `null` at the lowest trust level (which requires payment to
  advance) and at the maximum level.

## POST /v1/accounts/url-reputation

Enable malicious link scanning (URL reputation) for the account, recording
acknowledgement of the current disclosure. New accounts have link scanning
enabled by default at signup, so this route mostly serves accounts that predate
default-on or a disclaimer version bump. It turns on an account-wide scanning
data flow, so it is an admin action.

**Auth:** `admin` Bearer or session only (`agent` keys → `403 INSUFFICIENT_SCOPE`,
before any work). **Classification:** `mutating`.

**Request:**

```json
{ "accept_web_risk_version": "v2" }
```

`accept_web_risk_version` is required and must equal the current disclosure
version. Read that version from the account-info endpoint
([`GET /v1/auth/me`](/docs/authentication)), which reports a `url_reputation`
block (`active`, `accepted_version`, `current_version`, `privacy_ok`). A stale
value returns `400 DISCLAIMER_VERSION_MISMATCH`; if the account's acknowledged
privacy version predates the disclosed sub-processor, the call returns
`409 PRIVACY_VERSION_TOO_OLD` — review and acknowledge the current Privacy Policy
first.

**Response (200):**

```json
{
  "url_reputation": {
    "active": true,
    "accepted_version": "v2",
    "current_version": "v2",
    "privacy_ok": true
  },
  "disclosure": { "notice": "<disclosure text>", "advisory_url": "<advisory link>" }
}
```

The `disclosure` object echoes the notice text and an advisory link for the
underlying reputation-data provider named in the Privacy Policy.

## GET /v1/accounts/export

Data portability export (GDPR Art. 20): all account data as structured JSON.

**Auth:** `admin` or session only (`agent` keys → `403 INSUFFICIENT_SCOPE`).
**Classification:** `read-only`.

**Response (200):**

```json
{
  "account": { "id": "uuid", "email": "...", "phone_number": "+13125550123", "phone_verification_required_at": "...", "phone_verified_at": "...", "tier": "sandbox", "status": "active", "data_region": "us" },
  "mailboxes": [],
  "messages": { "items": [], "total": 89, "truncated": false },
  "message_events": { "items": [], "truncated": false },
  "recipients": [],
  "domains": [],
  "api_keys": [{ "id": "uuid", "prefix": "rly_live_<public_id>.****", "status": "active" }],
  "send_budgets": [],
  "trust_history": [],
  "suppressed_addresses": [],
  "exported_at": "2026-04-09T00:00:00.000Z"
}
```

Messages and events are capped at 10,000 items, with `truncated: true` when more
exist. API keys are masked — the key hash is never exposed. Each successful export
writes one audit entry with counts only (no PII).

## DELETE /v1/accounts · DELETE /v1/accounts/:id

Soft-delete the account with a 30-day grace period. Both forms have identical
semantics; the `:id` variant exists for REST-idiomatic callers (the `:id` must
equal the caller's own account id).

**Auth:** `admin` Bearer or session (`agent` keys → `403 INSUFFICIENT_SCOPE`).
**Classification:** `mutating` (destructive).

**Two layers of confirmation:**

1. **Intent gate (all paths, Bearer included).** The body must include
   `confirm_email` equal to the account's own email (case-insensitive). Missing →
   `400 DELETE_CONFIRMATION_REQUIRED`; wrong email → `403 DELETE_CONFIRMATION_MISMATCH`.
2. **Auth factor.** A Bearer admin key skips step-up (possession of the key is the
   confirmation). A dashboard session runs a method-aware step-up ladder: for an
   **SMS-based MFA** account, an SMS re-auth code — mint a challenge with
   `POST /v1/auth/reauth/sms/send` (`action_scope: "account_delete"`), then pass
   `reauth_challenge` + `mfa_code`; for a **TOTP** account, a `totp_code`; else the
   account `password`; else — for a provider-only account with none —
   `400 DELETE_REQUIRES_TOTP` (with `details.required_method: "totp_or_sms"`).
   `mfa_code` is the method-blind field for the SMS code; `totp_code` is still
   accepted as its alias. Exception: an account that never completed signup phone
   verification (and has no step-up factor) deletes with `confirm_email` alone,
   since MFA enrollment is blocked until the phone is verified and such an account
   cannot hold resources.

**Request (Bearer path):**

```json
{ "confirm_email": "owner@example.com" }
```

**Request (session path — TOTP account):**

```json
{ "confirm_email": "owner@example.com", "password": "..." }
```

**Request (session path — SMS-MFA account):**

```json
{ "confirm_email": "owner@example.com", "reauth_challenge": "uuid", "mfa_code": "123456" }
```

**Response (200):**

```json
{
  "status": "deleted",
  "message": "Account scheduled for deletion. You have 30 days to contact support to reinstate."
}
```

Hard purge runs automatically after 30 days: it removes database rows, stored
message contents, and any custom-domain DNS configuration; an anonymized audit
trail is retained and a confirmation email is sent before purge.

**Errors you'll care about:**

- `409 LEGAL_HOLD_ACTIVE` — the account is under a customer legal hold; lift it
  first.
- `503 BILLING_TEARDOWN_RETRY` — billing teardown failed transiently; the account
  stays `active` and the request can be retried.
- On the `:id` form only: `400 VALIDATION_ERROR` when `:id` is not a UUID, and
  `403 INSUFFICIENT_SCOPE` when `:id` does not match the caller's account id.
