# Account API reference

Operations for managing an account itself: bootstrapping one programmatically,
checking your send budget and usage, turning on optional scanning, 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 through Telnyx
  Verify v2; signup responses expose only the masked destination.
- `accept_terms` must be `true`; it acknowledges the Terms of Service, Privacy
  Policy, Acceptable Use Policy, and DPA.
- 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 acknowledges the malicious-link-
  scanning (URL reputation) disclosure. Its absence creates the account normally
  with link scanning left disabled — you can enable it later via
  [`POST /v1/accounts/url-reputation`](#post-v1accountsurl-reputation). Mismatched
  values are treated the same as absence.

**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": "sent",
  "phone_number_masked": "•••• 0123",
  "message": "Check your email for a 6-digit verification code."
}
```

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 `sent`, `pending`, or `not_required`; `pending` means account creation succeeded
but the caller must retry the SMS with
`POST /v1/auth/resend-phone-verification`. 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. This 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 accepted privacy
version predates the disclosed sub-processor, the call returns
`409 PRIVACY_VERSION_TOO_OLD` — re-accept 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 an SSO-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.
