# Account and organization endpoints

> Authentication, sessions, your profile and security settings, organization governance, teams, and subscription billing.

Source: https://docs.ayinbox.com/api/reference/account-org/

This group covers everything tied to a human account and the workspace it belongs to: signing in, managing sessions, editing your profile and security settings (notification preferences, two-factor authentication, passkeys), running an organization (members, invitations, custom roles), grouping members into teams, and billing.

Most endpoints here are session only. They depend on a human-bound JWT and are never reachable with an API key. The exceptions are the team endpoints, which accept either a JWT or an API key; the workspace profile, limits, members and roles reads, `PATCH /organization/current`, and the plan, trial, feature and credit reads, which take a key with `READ_WORKSPACE` or `WRITE_WORKSPACE` (see [workspace, plan and credits](/api/endpoints/#workspace-plan-and-credits)); and the public auth endpoints (login, register, refresh, password reset, passkey login, 2FA verify), which carry no session at all because they exist to create one. Each endpoint below states its auth explicitly.

All error responses use the shared envelope (`error`, `message`, `code`, `request_id`). See [Error codes](/api/error-codes/).

Authentication [#authentication]

These endpoints are public (no session required). Login and registration are two-step: the first call sends a one-time code by email and returns a short-lived signed `session` token, the second call confirms the code and mints the real token pair. When the deployment runs a captcha, the start calls and both password reset calls require one: a Cloudflare Turnstile token from a browser, or an [App Attest proof](#app-attest-native-apps) from the native app.

Start login [#start-login]

`POST /auth/login`

Sends a login code to the account email and returns a signed session token to carry into the confirm step.

Auth: public (not available to API keys).

Request body [#request-body]

| Field        | Type   | Required                         | Description                                                                                |
| ------------ | ------ | -------------------------------- | ------------------------------------------------------------------------------------------ |
| `email`      | string | yes                              | Account email.                                                                             |
| `password`   | string | yes                              | Account password.                                                                          |
| `turnstile`  | string | yes, unless `app_attest` is sent | Cloudflare Turnstile token.                                                                |
| `app_attest` | object | no                               | The native app's [App Attest proof](#app-attest-native-apps), sent instead of `turnstile`. |

```json
{
  "email": "alex@acme.com",
  "password": "correct horse battery staple",
  "turnstile": "0.abc123..."
}
```

Response [#response]

```json
{
  "session": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```

When the password is an [issued initial password](/development/accounts-and-access/#initial-passwords), no code is sent and no session exists yet. The response asks for a new password instead:

```json
{
  "code_required": false,
  "password_change_required": true,
  "pending_token": "eyJhbGciOiJIUzI1NiIs...",
  "expires_in": 600
}
```

Choose a password after an issued one [#choose-a-password-after-an-issued-one]

`POST /auth/login/initial-password`

Replaces an issued initial password with the holder's own, then continues the sign-in exactly as `/auth/login` would: the response has the same shape, so it can carry `code_required` with a `session` for `/auth/login/confirm`, a `two_fa_required` challenge, or the `token` pair. Every other session the account holds is signed out first. The `pending_token` is single use and lasts ten minutes; it is accepted nowhere else, and no other token is accepted here.

Auth: public (not available to API keys).

| Field           | Type   | Required | Description                                                                                                                                                |
| --------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pending_token` | string | yes      | The `pending_token` from a `password_change_required` response.                                                                                            |
| `password`      | string | yes      | The new password. The usual rules apply (8 to 128 characters, not on the breached list), and the issued password itself is refused with `password_reused`. |
| `device_token`  | string | no       | The remembered-browser token, as sent to `/auth/login`.                                                                                                    |

A token that is unknown, expired or already used, or whose issued password has run out, answers `password_change_expired`. See [password refusals](/api/error-codes/#password-refusals).

Confirm login [#confirm-login]

`POST /auth/login/confirm`

Exchanges the emailed code plus the session token for a token pair. If the account has 2FA enabled, no token pair is returned: `two_fa_required` is `true` and a single-use `pending_token` is returned instead, to be passed to `/auth/2fa/verify/`.

Auth: public (not available to API keys).

Request body [#request-body-1]

| Field       | Type   | Required | Description                          |
| ----------- | ------ | -------- | ------------------------------------ |
| `session`   | string | yes      | The token returned by `/auth/login`. |
| `code`      | string | yes      | The one-time code from the email.    |
| `turnstile` | string | no       | Cloudflare Turnstile token.          |

```json
{
  "session": "eyJhbGciOiJIUzI1NiIs...",
  "code": "489210"
}
```

Response [#response-1]

On success, the full token pair:

```json
{
  "access_token": "eyJhbGc...",
  "access_token_expires_at": "2026-06-12T13:00:00Z",
  "refresh_token": "eyJhbGc...",
  "refresh_token_expires_at": "2026-07-12T12:00:00Z"
}
```

When 2FA is enabled, a challenge instead of a session:

```json
{
  "two_fa_required": true,
  "pending_token": "eyJhbGc...",
  "expires_in": 300
}
```

Start registration [#start-registration]

`POST /auth/register`

Creates a pending registration and emails a confirmation code. Returns a signed session token for the confirm step.

Auth: public (not available to API keys).

Request body [#request-body-2]

Same shape as `/auth/login`: `email`, `password`, and `turnstile` or `app_attest`.

```json
{
  "email": "newuser@acme.com",
  "password": "correct horse battery staple",
  "turnstile": "0.abc123..."
}
```

Response [#response-2]

```json
{
  "session": "eyJhbGciOiJIUzI1NiIs..."
}
```

Confirm registration [#confirm-registration]

`POST /auth/register/confirm`

Confirms the emailed code, creates the account and signs it in: the response is the same shape as a completed `/auth/login` (a `token` pair, or a `two_fa_required` challenge), so the client lands in the dashboard without a second sign-in. `/auth/register` itself returns that shape directly when the deployment does not require email verification. An address that already has an account is refused with `409` `conflict` (see [error codes](/api/error-codes/#409-conflict)).

Auth: public (not available to API keys).

Request body [#request-body-3]

| Field       | Type   | Required | Description                             |
| ----------- | ------ | -------- | --------------------------------------- |
| `session`   | string | yes      | The token returned by `/auth/register`. |
| `code`      | string | yes      | The one-time code from the email.       |
| `turnstile` | string | no       | Cloudflare Turnstile token.             |

Response [#response-3]

```json
{
  "code_required": false,
  "token": { "access_token": "…", "refresh_token": "…", "access_token_expires_at": "…" }
}
```

Refresh token [#refresh-token]

`POST /auth/refresh`

Exchanges a valid refresh token for a fresh token pair.

Auth: public (not available to API keys).

Request body [#request-body-4]

| Field           | Type   | Required | Description                       |
| --------------- | ------ | -------- | --------------------------------- |
| `refresh_token` | string | yes      | A valid, unexpired refresh token. |

```json
{
  "refresh_token": "eyJhbGc..."
}
```

Response [#response-4]

A new token pair, same shape as the login confirm success response (`access_token`, `access_token_expires_at`, `refresh_token`, `refresh_token_expires_at`).

Every refresh rotates the pair: store the new refresh token and discard the old one. A refresh token that has already been exchanged is refused with `401`. If it is presented again more than two minutes after it was exchanged, it has been used twice, so the whole session is ended and every token issued to it stops working. Inside those two minutes it is treated as two callers refreshing at once and only that request is refused.

A session lasts at most 180 days from the sign-in that created it, however often it is refreshed. Past that point `POST /auth/refresh` answers `401` and its access tokens stop working, and no token a refresh issues expires later than the session itself. Sign in again to start a new one.

Start password reset [#start-password-reset]

`POST /auth/reset-password`

Emails a reset code if the address has an account. Always returns `200 OK` (it does not reveal whether the address exists).

Auth: public (not available to API keys).

Request body [#request-body-5]

| Field        | Type   | Required                         | Description                                                                                |
| ------------ | ------ | -------------------------------- | ------------------------------------------------------------------------------------------ |
| `email`      | string | yes                              | Account email.                                                                             |
| `turnstile`  | string | yes, unless `app_attest` is sent | Cloudflare Turnstile token.                                                                |
| `app_attest` | object | no                               | The native app's [App Attest proof](#app-attest-native-apps), sent instead of `turnstile`. |

Response [#response-5]

`200 OK` with an empty body.

Open a reset session [#open-a-reset-session]

`POST /auth/reset-password/session`

Exchanges the token from the emailed reset link for a short-lived reset session, so the confirm step carries nothing in a URL. The link token is sent in the body and is not spent: only the confirm step spends it. A client should drop the link token from the address bar once it has the session.

Auth: public (not available to API keys).

Request body [#request-body-6]

| Field   | Type   | Required | Description                                                                                                                      |
| ------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `token` | string | yes      | The `session` value from the emailed reset link, carried in its fragment (`#session=`), which a browser never sends to a server. |

Response [#response-6]

```json
{
  "session": "q3Vh2c...",
  "expires_at": "2026-10-23T12:15:00Z"
}
```

The session lasts 15 minutes, or until the link expires if that is sooner. An expired, used or superseded link answers `401`.

Confirm password reset [#confirm-password-reset]

`POST /auth/reset-password/confirm`

Sets a new password. A reset link is single-use and bound to the password it was requested against: once the password is written by any path (this endpoint, the signed-in change, or an operator reset), every link issued before that write answers `401` however long its own expiry has left. Every session the account holds ends before the new password is stored.

Auth: public (not available to API keys).

Request body [#request-body-7]

| Field                    | Type    | Required                         | Description                                                                                                                                              |
| ------------------------ | ------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `session`                | string  | yes                              | The session from [Open a reset session](#open-a-reset-session). The link token itself is still accepted, for clients that predate that step.             |
| `password`               | string  | yes                              | The new password.                                                                                                                                        |
| `revoke_api_credentials` | boolean | no                               | Also revoke every app you authorized (OAuth grants and their `ayat_` tokens) and every API key you created, in all your workspaces. Defaults to `false`. |
| `turnstile`              | string  | yes, unless `app_attest` is sent | Cloudflare Turnstile token.                                                                                                                              |
| `app_attest`             | object  | no                               | The native app's [App Attest proof](#app-attest-native-apps), sent instead of `turnstile`.                                                               |

Response [#response-7]

`200 OK` with an empty body.

Begin passkey login [#begin-passkey-login]

`POST /auth/passkey/login/begin`

Starts a discoverable (usernameless) WebAuthn assertion. Returns the public-key request options the browser passes to `navigator.credentials.get()`.

Auth: public (not available to API keys).

Response [#response-8]

The WebAuthn assertion options object (challenge, RP id, allowed credentials, timeout), to feed directly to the WebAuthn API. The embedded `session` is echoed back in the finish step.

Finish passkey login [#finish-passkey-login]

`POST /auth/passkey/login/finish`

Verifies the signed assertion and mints a token pair. A passkey is strong auth, so there is no email code step.

Auth: public (not available to API keys).

Request body [#request-body-8]

| Field        | Type   | Required | Description                                                          |
| ------------ | ------ | -------- | -------------------------------------------------------------------- |
| `session`    | string | yes      | The session value from the begin step.                               |
| `credential` | object | yes      | The raw WebAuthn assertion (`PublicKeyCredential`) from the browser. |

Response [#response-9]

A token pair (same shape as the login confirm success response).

Verify 2FA login [#verify-2fa-login]

`POST /auth/2fa/verify`

Exchanges the single-use `pending_token` from a 2FA-gated login plus a TOTP or recovery code for a real session.

Auth: public (not available to API keys).

Request body [#request-body-9]

| Field           | Type   | Required | Description                                            |
| --------------- | ------ | -------- | ------------------------------------------------------ |
| `pending_token` | string | yes      | The `pending_token` returned by `/auth/login/confirm`. |
| `code`          | string | yes      | A current TOTP code or an unused recovery code.        |

```json
{
  "pending_token": "eyJhbGc...",
  "code": "123456"
}
```

Response [#response-10]

A token pair (same shape as the login confirm success response).

Social sign-in (native apps) [#social-sign-in-native-apps]

All three are public and unavailable to API keys. A provider-verified identity counts as strong auth, so there is no email code step, and a first sign-in creates the account, organization, and trial.

| Endpoint              | Body                                                   | Purpose                                                                                                                                                                                             |
| --------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /auth/providers` | none                                                   | Which native sign-in options this deployment supports, so a native app adapts to self-hosted backends without a rebuild. Returns `{apple, google}` objects with `enabled` and a public `client_id`. |
| `POST /auth/apple`    | `identity_token` (required), `first_name`, `last_name` | Exchanges a Sign in with Apple identity token, verified against Apple's keys. Apple shares the name only with the app, so pass it through for first-sign-in prefill.                                |
| `POST /auth/google`   | `id_token` (required)                                  | Exchanges a Google Sign-In ID token, verified against Google's keys and the client ID from `/auth/providers`.                                                                                       |

Apple and Google both return a token pair (same shape as the login confirm success response), or a two-factor challenge when the account has TOTP enrolled.

App Attest (native apps) [#app-attest-native-apps]

A native iOS app you build proves it is a genuine copy of the app with Apple App Attest (no app ships with AY Inbox), and that proof stands in for the captcha on the captcha-gated calls. Browsers keep sending a Turnstile token. Both endpoints below are public, unavailable to API keys, and share their own per-IP budget (`APP_ATTEST_IP_RATE_LIMIT`, 300 per 15 minutes by default) separate from the one on the rest of `/auth`. When the instance has App Attest off they answer `404` with code [`app_attest_unavailable`](/api/error-codes/#app-attest-refusals). `GET /auth/config` reports whether it is on in `app_attest`.

| Endpoint                          | Body                                                | Purpose                                                                                                                                                                                                                                                                           |
| --------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /auth/app-attest/challenge` | none                                                | Returns `{"challenge": "..."}`, a base64url string that is single-use and expires after five minutes. Every attestation and every assertion is made over a fresh one.                                                                                                             |
| `POST /auth/app-attest/keys`      | `key_id`, `attestation`, `challenge` (all required) | Registers the install's App Attest key, once per install. `key_id` is the base64 of the 32-byte key identifier and `attestation` the base64 object from `DCAppAttestService.attestKey`, made over a client data hash of SHA-256 of the UTF-8 bytes of `challenge`. Answers `204`. |

The server verifies the attestation's certificate chain against Apple's App Attest root, the nonce, the app id, a counter of 0, the environment (a development-signed build is refused unless the instance allows them) and the key id, then stores the key. A key id is registered once; a second registration of the same id is refused with `app_attest_failed`.

A captcha-gated call (`POST /auth/login`, `/auth/register`, `/auth/reset-password`, `/auth/reset-password/confirm`) then carries an `app_attest` object in place of `turnstile`:

| Field       | Type   | Required | Description                                                       |
| ----------- | ------ | -------- | ----------------------------------------------------------------- |
| `key_id`    | string | yes      | The registered key identifier, base64.                            |
| `assertion` | string | yes      | The base64 assertion from `DCAppAttestService.generateAssertion`. |
| `challenge` | string | yes      | A fresh challenge from `POST /auth/app-attest/challenge`.         |

```json
{
  "email": "alex@acme.com",
  "password": "correct horse battery staple",
  "app_attest": {
    "key_id": "q1Zf3...=",
    "assertion": "omlzaWduYXR1cmVYRzBF...",
    "challenge": "b3Vy1Wq0..."
  }
}
```

The assertion is made over a client data hash of SHA-256 of this UTF-8 string, with a newline between the parts:

```text
ayinbox-app-attest/v1
<action>
<challenge>
```

`<action>` names the call: `login`, `register`, `reset_password` or `reset_password_confirm`. A proof made for one call does not verify on another. Each challenge is spent by the first call that presents it, and the assertion's counter must be higher than the last one the key presented. Each registered key also has its own budget of 30 protected calls per hour; past it the call answers `400` with the message `Too many attempts, please try again later.`, the same answer as the other sign-in attempt budgets.

A proof that does not verify answers [`app_attest_failed`](/api/error-codes/#app-attest-refusals). A key id this server has never registered answers `app_attest_key_unknown`: register the key again with a fresh challenge, then retry the call. When the deployment runs no captcha, neither `turnstile` nor `app_attest` is checked.

`GET /auth/config` carries `captcha` and `app_attest` together, and the app reads them as one decision: no captcha means nothing to send, a captcha with `app_attest` means the app sends a proof, and a captcha without `app_attest` means email and password sign-in is unavailable in the app. Sign in with Apple, Google and passkeys never need a captcha. The iOS simulator cannot use App Attest, so local development keeps using `TURNSTILE_BYPASS_TOKEN`, which is honoured only when `APP_ENV=dev`.

Browser sign-in [#browser-sign-in]

A browser cannot authenticate with the provider itself, so it is sent there and comes back with an authorization code. Sign in with Google, Sign in with Apple and generic OpenID Connect are the same three steps against different providers, and which ones a deployment offers is the `providers` array in `GET /auth/config`.

| Endpoint                    | Body                                           | Purpose                                                                                                                                                                                                     |
| --------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /auth/google/begin`   | none                                           | Returns `{"url": "...", "binding": "..."}`. Send the browser to `url`; keep `binding` in that browser and present it at the exchange. The state, nonce and PKCE verifier are minted and held server-side.   |
| `POST /auth/apple/begin`    | none                                           | The same for Sign in with Apple.                                                                                                                                                                            |
| `POST /auth/oidc/begin`     | none                                           | The same for the configured OpenID Connect provider.                                                                                                                                                        |
| `GET /auth/google/callback` | none                                           | Where Google returns the browser. Answers with a redirect to `<APP_URL>/auth/sso?code=...`, or to `<APP_URL>/auth/login?sso_error=...` when the sign-in was refused.                                        |
| `POST /auth/apple/callback` | form post                                      | Where Apple returns the browser. Apple uses `response_mode=form_post` because the flow requests the email scope, so this arrives as a form rather than a redirect; it also accepts `GET` for a refusal.     |
| `GET /auth/oidc/callback`   | none                                           | Where the OpenID Connect provider returns the browser.                                                                                                                                                      |
| `POST /auth/sso/exchange`   | `code` and `binding` (both required)           | Swaps the single-use handoff code from any callback for the session. `POST /auth/oidc/exchange` is the older name for the same endpoint.                                                                    |
| `POST /auth/sso/link`       | `pending_token` and `password` (both required) | Completes a sign-in that the exchange answered with `link_required`. Takes the password of the existing account, attaches the provider identity to it, and returns the session (or a two-factor challenge). |

The callback never returns a token. It hands the browser an opaque, single-use code with a 60 second lifetime and the client exchanges it over `POST`, so no token lands in a URL, browser history, `Referer` header or proxy log. The exchange returns a token pair, or a two-factor challenge when the account has TOTP enrolled.

The exchange has a third outcome. A provider identity is bound to an account by its `(issuer, subject)` pair; the first sign-in with a new identity falls back to the verified email address to find an existing account. When that account has a password, the exchange returns `link_required: true` with a `pending_token` (ten minute lifetime), `link_email` and `link_provider`, and nothing is linked and no session exists yet. The client asks for the account's password and posts it with the token to `POST /auth/sso/link`; a correct password attaches the identity and returns the session, so every later sign-in with that provider resolves directly. When the account has TOTP enrolled the answer is a two-factor challenge instead, and the identity is attached only once `POST /auth/2fa/verify` passes. A wrong password is refused with the same `Invalid email or password.` as sign-in and counts against the same per-address budget; three wrong answers, or ten minutes, end the challenge with [`sso_link_expired`](/api/error-codes/) and the sign-in starts over. An account with no password (one created through a provider or an invitation) has nothing to ask for and links on the first sign-in. The native `POST /auth/google` and `POST /auth/apple` return the same `link_required` result and complete through the same endpoint.

The exchange also requires the `binding` secret that `begin` returned. A one-time `state` proves the callback answers a request this server made; it does not prove it answers one this browser made. Without the binding, someone could run the flow with their own provider account and send you the resulting link, signing you into their workspace. The binding never travels to the provider and never appears in a URL, so a forwarded link arrives without it and is refused with [`sso_wrong_browser`](/api/error-codes/).

Redirect URIs are served by the API, not the dashboard: they default to `<API_PUBLIC_URL>/v1/auth/<provider>/callback` and that is the value to register at the provider. See [deployment](/development/deployment-guide/#sign-in-with-google-or-apple-optional).

Sessions [#sessions]

Self-service session management for the signed-in user. Revoking by id can only ever touch the caller's own sessions.

Sign out [#sign-out]

`POST /auth/logout`

Revokes the current session and closes the user's open realtime connections. Returns `204 No Content`.

The body is optional. `device_tokens` (an array of up to 16 strings) names the remembered-device tokens the signing-out browser holds; each one that belongs to this account is forgotten, so the next sign-in from that browser asks for the emailed code. A token of another account changes nothing.

```json
{ "device_tokens": ["<token>"] }
```

Auth: Session only (not available to API keys).

Sign out everywhere [#sign-out-everywhere]

`POST /auth/logout-all`

Revokes every session for the user, this one included, and closes their open realtime connections. A revoked session can no longer be refreshed. Returns `204 No Content`.

Auth: Session only (not available to API keys).

List sessions [#list-sessions]

`GET /auth/sessions`

Returns the user's active sessions for the account security page, with the current one flagged and floated to the top.

Auth: Session only (not available to API keys).

Response [#response-11]

A bare array of session views.

```json
[
  {
    "id": "1f1d...",
    "current": true,
    "browser": "Chrome",
    "os": "macOS",
    "location_city": "Austin",
    "location_region": "Texas",
    "location_country": "United States",
    "country_code": "US",
    "auth_provider": "email",
    "created_at": "2026-06-10T08:12:00Z",
    "last_active_at": "2026-06-12T09:40:00Z"
  }
]
```

Revoke other sessions [#revoke-other-sessions]

`DELETE /auth/sessions`

Ends every active session except the current one. Returns `204 No Content`.

Auth: Session only (not available to API keys).

Revoke a session [#revoke-a-session]

`DELETE /auth/sessions/:id`

Ends one of the user's other sessions by id. Returns `204 No Content`.

Auth: Session only (not available to API keys).

| Parameter | In   | Type | Description               |
| --------- | ---- | ---- | ------------------------- |
| `id`      | path | uuid | The session id to revoke. |

Profile and account [#profile-and-account]

Get current user [#get-current-user]

`GET /auth/me`

Returns the signed-in user, including admin flags and the label registries (folders, tags, and the workspace labels returned as `categories`) the dashboard needs on initial load.

The registries belong to the workspace, not to the caller: every member of an organization sees the same folders, tags and labels, whoever created them. They are read for the session's currently selected workspace, so switching workspaces changes what comes back.

Auth: Session only (not available to API keys).

Response [#response-12]

```json
{
  "id": "9c2a...",
  "first_name": "Alex",
  "last_name": "Rivera",
  "email": "alex@acme.com",
  "avatar_url": "https://api.ayinbox.com/public/avatars/users/9c2a-...jpg",
  "roles": ["b1e4..."],
  "referral_source": "google",
  "onboarding_completed_at": "2026-05-01T10:00:00Z",
  "max_organizations": 3,
  "free_trial_used": true,
  "admin_permissions": 0,
  "is_admin": false,
  "folders": [],
  "tags": [],
  "categories": [],
  "created_at": "2026-04-20T12:00:00Z",
  "updated_at": "2026-06-10T08:00:00Z"
}
```

Update profile [#update-profile]

`PATCH /auth/me`

Updates editable profile fields. First and last name are both required and capped at 50 characters. Returns `204 No Content`.

Auth: Session only (not available to API keys).

Request body [#request-body-10]

| Field        | Type   | Required | Description         |
| ------------ | ------ | -------- | ------------------- |
| `first_name` | string | yes      | 1 to 50 characters. |
| `last_name`  | string | yes      | 1 to 50 characters. |

```json
{
  "first_name": "Alex",
  "last_name": "Rivera"
}
```

Complete onboarding [#complete-onboarding]

`PATCH /auth/me/onboarding`

Persists the onboarding questionnaire (name plus optional persona answers). Returns `204 No Content`.

Auth: Session only (not available to API keys).

Request body [#request-body-11]

| Field             | Type   | Required | Description                                                             |
| ----------------- | ------ | -------- | ----------------------------------------------------------------------- |
| `first_name`      | string | yes      | 1 to 50 characters.                                                     |
| `last_name`       | string | yes      | 1 to 50 characters.                                                     |
| `referral_source` | string | yes      | One of `reddit`, `x`, `facebook`, `google`, `other`.                    |
| `role`            | string | no       | One of `founder`, `sales`, `marketing`, `agency`, `recruiter`, `other`. |
| `team_size`       | string | no       | One of `just_me`, `2-10`, `11-50`, `51-200`, `200+`.                    |

```json
{
  "first_name": "Alex",
  "last_name": "Rivera",
  "referral_source": "google",
  "role": "sales",
  "team_size": "2-10"
}
```

Upload avatar [#upload-avatar]

`POST /auth/me/avatar`

Uploads a profile image. `multipart/form-data` with a single `file` field. PNG or JPG only, max 2 MB, max 1024x1024 px.

Auth: Session only (not available to API keys).

Response [#response-13]

```json
{
  "avatar_url": "https://api.ayinbox.com/public/avatars/users/9c2a-1718193600.jpg"
}
```

Remove avatar [#remove-avatar]

`DELETE /auth/me/avatar`

Clears the profile image and deletes the stored file on a best-effort basis. Returns `204 No Content`.

Auth: Session only (not available to API keys).

Change password [#change-password]

`POST /auth/me/password`

Updates the signed-in user's password. Every other session the account holds is ended before the new password is stored, and if that fails the password is left unchanged. The session making the request ends with the change too, and the response is the token pair of a new session for the calling device. Tokens issued before the change are refused after it, so the client must replace its stored pair with the one returned. Returns `200 OK`.

If the new password was stored but the device could not be issued a new session, the response is `409` with code `password_changed_sign_in_again`. The old tokens are not valid in that case either: discard them and sign in with the new password.

Auth: Session only (not available to API keys).

Request body [#request-body-12]

| Field                    | Type    | Required | Description                                                                                                                                                                                           |
| ------------------------ | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `current_password`       | string  | yes      | The existing password.                                                                                                                                                                                |
| `new_password`           | string  | yes      | The new password.                                                                                                                                                                                     |
| `revoke_api_credentials` | boolean | no       | Also revoke every app you authorized (OAuth grants and their `ayat_` tokens) and every API key you created, in all your workspaces, including keys made by `ayinbox auth login`. Defaults to `false`. |

```json
{
  "current_password": "correct horse battery staple",
  "new_password": "a longer better passphrase"
}
```

Response [#response-14]

```json
{
  "access_token": "eyJhbGciOiJIUzI1NiJ9...",
  "access_token_expires_at": "2026-09-21T22:00:00Z",
  "refresh_token": "eyJhbGciOiJIUzI1NiJ9...",
  "refresh_token_expires_at": "2026-10-21T10:00:00Z"
}
```

Send a set-password link [#send-a-set-password-link]

`POST /auth/me/password/link`

Emails the signed-in user a single-use link to set a password, to the address on the account. It is how an account created through Google, Apple or single sign-on adds email and password sign-in without a second account; it also works for an account that already has a password. The link is the same one **Forgot password** sends: it expires after one hour, setting the password through it ends every session (this one included), and it shares that flow's budget of 2 links per address per 4 hours. No request body. Returns `200 OK`.

The server refuses with `403` `password_login_disabled` when password sign-in is turned off, and with `400` when the budget is spent.

Auth: Session only (not available to API keys).

List sign-in methods [#list-sign-in-methods]

`GET /auth/me/identities`

The providers linked to the signed-in user: Google, Apple or the deployment's single sign-on. `provider` is `google`, `apple` or `oidc`; `email` is the address the provider reported, which for Apple can be a Hide My Email relay address. A password and passkeys are not listed here.

Auth: Session only (not available to API keys).

Response [#response-15]

```json
{
  "data": [
    {
      "provider": "apple",
      "label": "Apple",
      "email": "[EMAIL]",
      "created_at": "2026-03-02T09:14:00Z",
      "last_login_at": "2026-10-20T08:01:00Z"
    }
  ]
}
```

Remove a sign-in method [#remove-a-sign-in-method]

`DELETE /auth/me/identities/:provider`

Stops the provider signing in to this account and ends every other session that signed in through it; the session making the request stays signed in. The account, its address, workspaces, roles, plan and connected mailboxes are unchanged. Signing in with the same provider and address again later links it back, after asking for the account's password when it has one. Requires a fresh confirmation (`reauth_required` otherwise). Returns `204 No Content`, also when the provider was already removed, so a repeat is safe.

Refused with `409` `last_sign_in_method` when the account would be left with no password, no passkey and no other provider, and with `404` `identity_not_found` when `:provider` is not `google`, `apple` or `oidc`. If the provider was removed but its sessions could not all be ended, the answer is `503` `sessions_not_ended` with `Retry-After`; repeat the request to finish.

Auth: Session only (not available to API keys).

Notification preferences and feed [#notification-preferences-and-feed]

User-scoped, no organization gate. Preferences are a single per-user object; the feed is the in-app notification list.

Get notification preferences [#get-notification-preferences]

`GET /auth/me/notification-preferences`

Returns the caller's preferences merged over the defaults.

Auth: Session only (not available to API keys).

Response [#response-16]

```json
{
  "preferences": {
    "inbound_reply": { "enabled": false, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "inbound_out_of_office": { "enabled": false, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "health_bounce": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "health_complaint": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "health_worker_downtime": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "health_domain_auth": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
    "security_new_signin": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "billing_alert": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
    "team_activity": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "campaign_paused": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
    "placement_finished": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "placement_alert": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
    "inbox_action_required": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
    "email_digest_minutes": 30
  }
}
```

Update notification preferences [#update-notification-preferences]

`PUT /auth/me/notification-preferences`

Replaces the caller's preferences. The full preferences object is sent under a `preferences` key and echoed back.

Auth: Session only (not available to API keys).

Request body [#request-body-13]

| Field         | Type   | Required | Description                                                                                                                                                                                                                                                               |
| ------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `preferences` | object | yes      | The complete preferences object. Each category has `enabled` plus a `channels` object (`in_app`, `email`, `slack`, `push`), alongside `email_digest_minutes` (the email bundling window, `30` to `1440`). Categories absent from the request fall back to their defaults. |

```json
{
  "preferences": {
    "inbound_reply": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "inbound_out_of_office": { "enabled": false, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "health_bounce": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "health_complaint": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "health_worker_downtime": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "health_domain_auth": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
    "security_new_signin": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "billing_alert": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
    "team_activity": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "campaign_paused": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
    "placement_finished": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
    "placement_alert": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
    "inbox_action_required": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
    "email_digest_minutes": 30
  }
}
```

Response [#response-17]

The same `{ "preferences": { ... } }` object that was sent.

List notifications [#list-notifications]

`GET /auth/me/notifications`

Returns the caller's recent in-app feed plus the unread count. This is a fixed-size feed, not a cursor-paginated list.

Auth: Session only (not available to API keys).

| Parameter | In    | Type   | Description                                |
| --------- | ----- | ------ | ------------------------------------------ |
| `limit`   | query | int    | Max items to return (default 50).          |
| `unread`  | query | string | `1` or `true` to return only unread items. |

Response [#response-18]

```json
{
  "notifications": [
    {
      "id": "f0aa...",
      "user_id": "9c2a...",
      "organization_id": "3d11...",
      "category": "health_bounce",
      "title": "Hard bounce on alex@acme.com",
      "body": "A message bounced and the recipient was suppressed.",
      "link": "/app/mailboxes/abc",
      "read_at": null,
      "created_at": "2026-06-12T09:00:00Z"
    }
  ],
  "unread": 3
}
```

Mark all read [#mark-all-read]

`PUT /auth/me/notifications`

Marks the caller's whole feed read.

Auth: Session only (not available to API keys).

Response [#response-19]

```json
{ "ok": true }
```

Mark one read [#mark-one-read]

`POST /auth/me/notifications/:id/read`

Marks a single notification read.

Auth: Session only (not available to API keys).

| Parameter | In   | Type | Description          |
| --------- | ---- | ---- | -------------------- |
| `id`      | path | uuid | The notification id. |

Response [#response-20]

```json
{ "ok": true }
```

Register a device token [#register-a-device-token]

`POST /auth/me/device-tokens`

Registers an APNs device token for mobile push delivery. A native app calls this after the user allows notifications; it is an upsert, so repeating it on every launch is safe. A token already registered by another account moves to the caller (the device changed accounts). Delivery of enabled notifications follows the `push` channel in the preferences above, batched server-side: the first event in a quiet stretch pushes immediately, later events inside the batching window arrive as one summary.

Auth: Session only (not available to API keys).

Request body [#request-body-14]

| Field         | Type   | Required | Description                                                 |
| ------------- | ------ | -------- | ----------------------------------------------------------- |
| `token`       | string | yes      | The hex APNs device token.                                  |
| `platform`    | string | no       | Only `ios` today (the default).                             |
| `environment` | string | no       | `production` (default) or `development` for sandbox builds. |

```json
{ "token": "a1b2c3...", "platform": "ios", "environment": "production" }
```

Response [#response-21]

```json
{ "ok": true }
```

Delete a device token [#delete-a-device-token]

`DELETE /auth/me/device-tokens/:token`

Removes one of the caller's device tokens. A native app calls this on sign-out so the device stops receiving the account's pushes.

Auth: Session only (not available to API keys).

| Parameter | In   | Type   | Description                     |
| --------- | ---- | ------ | ------------------------------- |
| `token`   | path | string | The hex device token to remove. |

Response [#response-22]

```json
{ "ok": true }
```

Two-factor authentication [#two-factor-authentication]

TOTP-based 2FA. Enrollment and management require a live session. The login challenge itself (`/auth/2fa/verify`) is public and documented above.

2FA status [#2fa-status]

`GET /auth/2fa/status`

Reports whether the caller has 2FA enabled, when it was confirmed, and how many recovery codes are left. `confirmed_at` is omitted while 2FA is off.

Auth: Session only (not available to API keys).

Response [#response-23]

```json
{
  "enabled": true,
  "confirmed_at": "2026-09-19T10:24:00Z",
  "recovery_codes_remaining": 8,
  "recovery_codes_total": 10
}
```

Begin enrollment [#begin-enrollment]

`POST /auth/2fa/enroll/start`

Generates a TOTP secret and the `otpauth://` provisioning URI (returned once). The client renders the URI as a QR code; the remaining fields spell out the same parameters for manual entry.

Requires a recent confirmation (`POST /auth/reauth`), like confirming enrollment below; without one it answers `403 reauth_required`. An account with neither a password nor 2FA confirms with a passkey or its linked sign-in provider (`GET /auth/reauth/methods`). One with none of those is accepted from a session signed in within the last five minutes, and an older session answers `400 reauth_no_factor`.

Auth: Session only (not available to API keys).

Response [#response-24]

```json
{
  "secret": "JBSWY3DPEHPK3PXP",
  "otpauth_uri": "otpauth://totp/AY%20Inbox:alex@acme.com?secret=JBSWY3DPEHPK3PXP&issuer=AY+Inbox&algorithm=SHA1&digits=6&period=30",
  "issuer": "AY Inbox",
  "account": "alex@acme.com",
  "algorithm": "SHA1",
  "digits": 6,
  "period": 30
}
```

Confirm enrollment [#confirm-enrollment]

`POST /auth/2fa/enroll/confirm`

Verifies a TOTP code, enables 2FA, and returns the one-time recovery codes (shown once). A code that does not match answers `400` with code `two_fa_invalid_code`. Needs the same recent confirmation as beginning enrollment.

Auth: Session only (not available to API keys).

Request body [#request-body-15]

| Field  | Type   | Required | Description                                 |
| ------ | ------ | -------- | ------------------------------------------- |
| `code` | string | yes      | A current TOTP code from the authenticator. |

Response [#response-25]

```json
{
  "recovery_codes": ["a1b2-c3d4", "e5f6-g7h8", "..."]
}
```

Regenerate recovery codes [#regenerate-recovery-codes]

`POST /auth/2fa/recovery-codes`

Replaces every recovery code with a fresh set, returned once. Requires a current TOTP or recovery code in the body; a code that does not match answers `400` with code `two_fa_invalid_code`. The previous codes stop working as soon as the new set is issued. Wrong codes here and on `DELETE /auth/2fa` count against the same per-account attempt budget as `POST /auth/reauth`; once it is spent both answer `400` with "Too many attempts" until it resets. No `Idempotency-Key` is needed: the proof code is single-use, so a retried request is refused rather than rotating the set twice.

Auth: Session only (not available to API keys).

Request body [#request-body-16]

| Field  | Type   | Required | Description                      |
| ------ | ------ | -------- | -------------------------------- |
| `code` | string | yes      | A current TOTP or recovery code. |

Response [#response-26]

```json
{
  "recovery_codes": ["a1b2-c3d4", "e5f6-g7h8", "..."]
}
```

Disable 2FA [#disable-2fa]

`DELETE /auth/2fa`

Turns off 2FA. Requires a current TOTP or recovery code in the body; a code that does not match answers `400` with code `two_fa_invalid_code`.

Auth: Session only (not available to API keys).

Request body [#request-body-17]

| Field  | Type   | Required | Description                      |
| ------ | ------ | -------- | -------------------------------- |
| `code` | string | yes      | A current TOTP or recovery code. |

Response [#response-27]

```json
{ "ok": true }
```

Passkeys [#passkeys]

WebAuthn credential management for the signed-in user. The login flow (`/auth/passkey/login/begin` and `/finish`) is public and documented above.

Begin passkey registration [#begin-passkey-registration]

`POST /auth/passkey/register/begin`

Returns the WebAuthn creation options the browser passes to `navigator.credentials.create()`.

Auth: Session only (not available to API keys).

Response [#response-28]

The WebAuthn creation options object (challenge, RP, user, pubkey params).

Finish passkey registration [#finish-passkey-registration]

`POST /auth/passkey/register/finish`

Stores the new credential and returns its display view.

Auth: Session only (not available to API keys).

Request body [#request-body-18]

| Field        | Type   | Required | Description                                                            |
| ------------ | ------ | -------- | ---------------------------------------------------------------------- |
| `name`       | string | no       | A friendly label for the passkey.                                      |
| `credential` | object | yes      | The raw WebAuthn attestation (`PublicKeyCredential`) from the browser. |

Response [#response-29]

```json
{
  "id": "c41f...",
  "name": "MacBook Touch ID",
  "credential_id": "b64url...",
  "transports": ["internal"],
  "backup_state": true,
  "created_at": "2026-06-12T09:00:00Z",
  "last_used_at": null
}
```

List passkeys [#list-passkeys]

`GET /auth/passkey/credentials`

Returns the user's stored passkeys.

Auth: Session only (not available to API keys).

Response [#response-30]

A bare array of credential views (same shape as the register finish response).

Rename a passkey [#rename-a-passkey]

`PATCH /auth/passkey/credentials/:id`

Renames a stored passkey and returns the updated view.

Auth: Session only (not available to API keys).

| Parameter | In   | Type | Description        |
| --------- | ---- | ---- | ------------------ |
| `id`      | path | uuid | The credential id. |

Request body [#request-body-19]

| Field  | Type   | Required | Description    |
| ------ | ------ | -------- | -------------- |
| `name` | string | yes      | The new label. |

Delete a passkey [#delete-a-passkey]

`DELETE /auth/passkey/credentials/:id`

Removes a stored passkey. Returns `204 No Content`. Refused with `409` `last_sign_in_method` when it is the account's only way to sign in (no password and no linked provider); set a password or link a provider first.

Auth: Session only (not available to API keys).

| Parameter | In   | Type | Description        |
| --------- | ---- | ---- | ------------------ |
| `id`      | path | uuid | The credential id. |

Account danger zone [#account-danger-zone]

Delayed hard-delete of the caller's own account, with a confirmation phrase and a grace window.

Get account danger-zone status [#get-account-danger-zone-status]

`GET /me/danger-zone`

Returns the danger-zone summary plus any pending deletion.

Auth: Session only (not available to API keys).

Response [#response-31]

```json
{
  "resource_type": "user",
  "resource_id": "9c2a...",
  "resource_name": "alex@acme.com",
  "confirmation_hint": "alex@acme.com",
  "grace_days": 14,
  "pending_deletion": null
}
```

Schedule account deletion [#schedule-account-deletion]

`POST /me/danger-zone/delete`

Schedules the account for a delayed hard delete. The confirmation must match the email. Returns `202 Accepted` with the scheduled-deletion record.

Auth: Session only (not available to API keys).

Request body [#request-body-20]

| Field          | Type   | Required | Description                   |
| -------------- | ------ | -------- | ----------------------------- |
| `confirmation` | string | yes      | Must equal the account email. |
| `reason`       | string | no       | Optional free-text reason.    |

```json
{
  "confirmation": "alex@acme.com",
  "reason": "switching tools"
}
```

Response [#response-32]

```json
{
  "resource_type": "user",
  "resource_id": "9c2a...",
  "requested_by_user_id": "9c2a...",
  "scheduled_at": "2026-06-12T09:00:00Z",
  "execute_after": "2026-06-26T09:00:00Z",
  "grace_days": 14,
  "status": "pending"
}
```

Cancel account deletion [#cancel-account-deletion]

`DELETE /me/danger-zone/delete`

Cancels a pending account deletion.

Auth: Session only (not available to API keys).

Request body (optional) [#request-body-optional]

| Field    | Type   | Required | Description                |
| -------- | ------ | -------- | -------------------------- |
| `reason` | string | no       | Optional free-text reason. |

Response [#response-33]

```json
{ "message": "deletion cancelled" }
```

Websocket bootstrap [#websocket-bootstrap]

Generate a websocket token [#generate-a-websocket-token]

`POST /getaway`

Mints a single-session token for the realtime websocket and returns the connect URL plus its TTL in seconds.

Auth: Session only (not available to API keys).

Response [#response-34]

```json
{
  "url": "wss://realtime.ayinbox.com/socket?token=...",
  "expires_in": 60
}
```

Organizations [#organizations]

The organization is the workspace tenant. These endpoints handle creating and switching workspaces, the current workspace and its limits, members, custom roles, invitations, ownership transfer, avatar, and the workspace danger zone. All of `/organization/*` is session only. Mutations are gated by the caller's organization role, noted per endpoint.

Get the workspace sending posture [#get-the-workspace-sending-posture]

`GET /organization/current/risk`

Returns the workspace's abuse posture and, when it is restricted, why. Readable by any member: a workspace whose sending is limited should be able to see that it is.

Auth: Session only (not available to API keys).

Response [#response-35]

| Field        | Type    | Description                                               |
| ------------ | ------- | --------------------------------------------------------- |
| `state`      | string  | `trusted`, `watch`, `restricted`, or `suspended`.         |
| `restricted` | boolean | True for `restricted` and `suspended`; volume is reduced. |
| `suspended`  | boolean | True when sending is stopped entirely.                    |
| `reason`     | string  | Plain-language summary, when there is one.                |

```json
{
  "state": "restricted",
  "restricted": true,
  "suspended": false,
  "reason": "imported list is 30% invalid; disposable signup domain"
}
```

The detector evidence behind a posture is deliberately not returned. It is operator-facing, and handing an actor the exact signals that flagged them is a map for evading the next check. What each state means, and which signals can reach it, is described in [workspace sending posture](/guides/deliverability/).

Create an organization [#create-an-organization]

`POST /organization`

Creates a new organization owned by the caller. Returns `201 Created`.

Auth: Session only (not available to API keys).

Request body [#request-body-21]

| Field      | Type   | Required | Description                                                                                                                                                                                                                              |
| ---------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`     | string | yes      | 1 to 255 characters.                                                                                                                                                                                                                     |
| `timezone` | string | no       | Workspace timezone, an IANA name such as `America/New_York`. New campaigns start their schedule in it, and a mailbox with no timezone of its own reads its warmup hours and working hours in it. The dashboard sends the browser's zone. |

```json
{ "name": "Acme Outbound", "timezone": "America/New_York" }
```

Response [#response-36]

The created organization object.

```json
{
  "id": "3d11...",
  "name": "Acme Outbound",
  "slug": null,
  "avatar_url": null,
  "owner_user_id": "9c2a...",
  "presence_show_online": true,
  "presence_show_activity": true,
  "timezone": "America/New_York",
  "created_at": "2026-06-12T09:00:00Z",
  "updated_at": "2026-06-12T09:00:00Z"
}
```

List my organizations [#list-my-organizations]

`GET /organization`

Returns the organizations the caller is a member of.

Auth: Session only (not available to API keys).

Response [#response-37]

```json
{
  "data": [
    {
      "id": "ab12...",
      "organization_id": "3d11...",
      "user_id": "9c2a...",
      "role": "owner",
      "permissions": 524287,
      "email": "alex@acme.com",
      "name": "Alex Rivera",
      "invited_at": "2026-06-12T09:00:00Z"
    }
  ]
}
```

Switch organization [#switch-organization]

`POST /organization/switch/:id`

Sets the session's current organization. The caller must be a member.

Auth: Session only (not available to API keys).

| Parameter | In   | Type | Description                    |
| --------- | ---- | ---- | ------------------------------ |
| `id`      | path | uuid | The organization to switch to. |

Response [#response-38]

```json
{
  "message": "organization switched",
  "organization_id": "3d11..."
}
```

Get current organization [#get-current-organization]

`GET /organization/current`

Returns the session's current organization with its resolved limits and live counts.

Auth: **Scope** `READ_WORKSPACE`. Any member. A key limited to some mailboxes is refused.

Response [#response-39]

```json
{
  "id": "3d11...",
  "name": "Acme Outbound",
  "owner_user_id": "9c2a...",
  "presence_show_online": true,
  "presence_show_activity": true,
  "timezone": "America/New_York",
  "created_at": "2026-06-12T09:00:00Z",
  "updated_at": "2026-06-12T09:00:00Z",
  "limits": {
    "max_campaigns": 50,
    "max_team_members": 10,
    "max_email_accounts": 25,
    "daily_campaign_limit": 500
  },
  "counts": {
    "total_campaigns": 8,
    "active_campaigns": 2,
    "total_contacts": 1240,
    "total_members": 3,
    "email_accounts": 6,
    "emails_sent_today": 180
  }
}
```

Update current organization [#update-current-organization]

`PATCH /organization/current`

Updates the current organization (name, slug, timezone, presence privacy toggles). A presence privacy change re-gates connected sockets live.

Auth: **Scope** `WRITE_WORKSPACE` · **Org permission** `manage_settings`. A key limited to some mailboxes is refused. `product_description`, `icp_notes` and `voice_profile` are each limited to 2,000 characters (`400 org_grounding_too_long`).

Request body [#request-body-22]

| Field                    | Type    | Required | Description                                                                                                                                                                                             |
| ------------------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                   | string  | no       | New workspace name.                                                                                                                                                                                     |
| `slug`                   | string  | no       | New workspace slug.                                                                                                                                                                                     |
| `presence_show_online`   | boolean | no       | When false the realtime service tracks no member (nobody appears online).                                                                                                                               |
| `presence_show_activity` | boolean | no       | When false online is still shown but viewing/editing detail is stripped.                                                                                                                                |
| `timezone`               | string  | no       | Workspace timezone, an IANA name. New campaigns default to it and mailboxes with no timezone of their own read their hours in it. Send an empty string to clear it, after which those fall back to UTC. |

```json
{
  "name": "Acme Outbound",
  "timezone": "America/New_York",
  "presence_show_activity": false
}
```

Response [#response-40]

The updated organization object.

Get organization limits [#get-organization-limits]

`GET /organization/current/limits`

Returns the current organization's plan-resolved limits and current usage counts.

Auth: **Scope** `READ_WORKSPACE`. Requires a selected organization. A key limited to some mailboxes is refused.

Response [#response-41]

```json
{
  "limits": {
    "max_campaigns": 50,
    "max_team_members": 10,
    "max_email_accounts": 25,
    "daily_campaign_limit": 500
  },
  "counts": {
    "total_campaigns": 8,
    "active_campaigns": 2,
    "total_contacts": 1240,
    "total_members": 3,
    "email_accounts": 6,
    "emails_sent_today": 180
  }
}
```

List members [#list-members]

`GET /organization/members`

Returns the current organization's members, each hydrated with the user's email, display name, role, and effective permission bitmask.

Auth: **Scope** `READ_WORKSPACE`. Requires a selected organization. A key limited to some mailboxes is refused. Answers `data` and `pagination` (every member in one page).

Response [#response-42]

```json
{
  "data": [
    {
      "id": "ab12...",
      "organization_id": "3d11...",
      "user_id": "9c2a...",
      "role": "owner",
      "roles": [{ "id": "r1...", "name": "Owner", "color": "#0ea5e9" }],
      "permissions": 524287,
      "email": "alex@acme.com",
      "name": "Alex Rivera",
      "invited_at": "2026-06-12T09:00:00Z",
      "accepted_at": "2026-06-12T09:05:00Z"
    }
  ]
}
```

Invite a member [#invite-a-member]

`POST /organization/members/invite`

Invites an email to the organization and emails an accept link. The invitee lands in the given role set. Returns `201 Created`.

Auth: Session only (not available to API keys). **Org permission** `manage_team`.

Request body [#request-body-23]

| Field      | Type    | Required | Description                                                |
| ---------- | ------- | -------- | ---------------------------------------------------------- |
| `email`    | string  | yes      | The invitee's email.                                       |
| `role_ids` | uuid\[] | no       | The workspace roles to assign (at least one role overall). |
| `role_id`  | uuid    | no       | Single-role shorthand, merged with `role_ids`.             |

```json
{
  "email": "sam@acme.com",
  "role_ids": ["r2..."]
}
```

Response [#response-43]

```json
{
  "message": "invitation sent",
  "invitation": {
    "id": "inv1...",
    "organization_id": "3d11...",
    "email": "sam@acme.com",
    "role": "member",
    "permissions": 12288,
    "invited_by": "9c2a...",
    "expires_at": "2026-06-19T09:00:00Z",
    "created_at": "2026-06-12T09:00:00Z"
  }
}
```

Update a member's roles [#update-a-members-roles]

`PATCH /organization/members/:id`

Replaces a member's assigned role set. Returns the updated member.

Auth: Session only (not available to API keys). **Org permission** `manage_team`.

| Parameter | In   | Type | Description           |
| --------- | ---- | ---- | --------------------- |
| `id`      | path | uuid | The member's user id. |

Request body [#request-body-24]

| Field      | Type    | Required | Description                                        |
| ---------- | ------- | -------- | -------------------------------------------------- |
| `role_ids` | uuid\[] | no       | New assigned role set (at least one role overall). |
| `role_id`  | uuid    | no       | Single-role shorthand, merged with `role_ids`.     |

```json
{ "role_ids": ["r2...", "r3..."] }
```

Response [#response-44]

The updated member object (same shape as a list-members entry).

Remove a member [#remove-a-member]

`DELETE /organization/members/:id`

Removes a member from the organization. The member's sessions stop selecting it at once, so their next request that needs a workspace answers `400` until they switch to another one. Every OAuth app authorization the member granted in the workspace is revoked. API keys stay, because they belong to the workspace rather than to the member who created them.

Auth: Session only (not available to API keys). **Org permission** `manage_team`.

| Parameter | In   | Type | Description           |
| --------- | ---- | ---- | --------------------- |
| `id`      | path | uuid | The member's user id. |

Response [#response-45]

```json
{ "message": "member removed" }
```

List custom roles [#list-custom-roles]

`GET /organization/roles`

Returns the organization's custom roles (named permission sets). Listing is open to every member so role chips render on the roster.

Auth: **Scope** `READ_WORKSPACE`. Requires a selected organization. A key limited to some mailboxes is refused. Answers `data` and `pagination` (every role in one page).

Response [#response-46]

```json
{
  "data": [
    {
      "id": "r2...",
      "organization_id": "3d11...",
      "name": "Sales rep",
      "description": "Run campaigns, view contacts",
      "color": "#22c55e",
      "permissions": 4352,
      "member_count": 4,
      "created_at": "2026-05-01T09:00:00Z",
      "updated_at": "2026-06-01T09:00:00Z"
    }
  ]
}
```

Create a custom role [#create-a-custom-role]

`POST /organization/roles`

Creates a custom role. Returns `201 Created` with the role.

Auth: Session only (not available to API keys). **Org permission** `manage_team`.

Request body [#request-body-25]

| Field         | Type   | Required | Description                                          |
| ------------- | ------ | -------- | ---------------------------------------------------- |
| `name`        | string | yes      | Role name.                                           |
| `description` | string | no       | Role description.                                    |
| `color`       | string | no       | Hex color for the chip.                              |
| `permissions` | int    | no       | Organization permission bitmask granted by the role. |

```json
{
  "name": "Sales rep",
  "description": "Run campaigns, view contacts",
  "color": "#22c55e",
  "permissions": 4352
}
```

Update a custom role [#update-a-custom-role]

`PATCH /organization/roles/:id`

Edits a custom role. Edits propagate to every member assigned to it (permission readers stay JOIN-free).

Auth: Session only (not available to API keys). **Org permission** `manage_team`.

| Parameter | In   | Type | Description  |
| --------- | ---- | ---- | ------------ |
| `id`      | path | uuid | The role id. |

Request body [#request-body-26]

All fields optional; nil fields are left untouched.

| Field         | Type   | Required | Description             |
| ------------- | ------ | -------- | ----------------------- |
| `name`        | string | no       | New name.               |
| `description` | string | no       | New description.        |
| `color`       | string | no       | New chip color.         |
| `permissions` | int    | no       | New permission bitmask. |

Delete a custom role [#delete-a-custom-role]

`DELETE /organization/roles/:id`

Removes a custom role. Returns `204 No Content`.

Auth: Session only (not available to API keys). **Org permission** `manage_team`.

| Parameter | In   | Type | Description  |
| --------- | ---- | ---- | ------------ |
| `id`      | path | uuid | The role id. |

List pending invitations [#list-pending-invitations]

`GET /organization/invitations`

Returns the organization's outstanding invitations.

Auth: Session only (not available to API keys). **Org permission** `manage_team`.

Response [#response-47]

```json
{
  "data": [
    {
      "id": "inv1...",
      "organization_id": "3d11...",
      "email": "sam@acme.com",
      "role": "member",
      "permissions": 12288,
      "invited_by": "9c2a...",
      "expires_at": "2026-06-19T09:00:00Z",
      "created_at": "2026-06-12T09:00:00Z"
    }
  ]
}
```

Cancel an invitation [#cancel-an-invitation]

`DELETE /organization/invitations/:id`

Cancels a pending invitation.

Auth: Session only (not available to API keys). **Org permission** `manage_team`.

| Parameter | In   | Type | Description        |
| --------- | ---- | ---- | ------------------ |
| `id`      | path | uuid | The invitation id. |

Response [#response-48]

```json
{ "message": "invitation cancelled" }
```

Get an invitation link [#get-an-invitation-link]

`GET /organization/invitations/:id/link`

Mints a shareable accept token for a pending invitation so a team manager can copy a real accept link. Invitation tokens are stored only as digests, so each call issues a new link token and the previously copied link stops working; the emailed link keeps working alongside it.

Auth: Session only (not available to API keys). **Org permission** `manage_team`.

| Parameter | In   | Type | Description        |
| --------- | ---- | ---- | ------------------ |
| `id`      | path | uuid | The invitation id. |

Response [#response-49]

```json
{ "token": "inv_token_abc123" }
```

Transfer ownership [#transfer-ownership]

`POST /organization/transfer-ownership`

Transfers organization ownership to another member.

Auth: Session only (not available to API keys). **Org permission** `transfer_ownership`.

Request body [#request-body-27]

| Field               | Type | Required | Description                               |
| ------------------- | ---- | -------- | ----------------------------------------- |
| `new_owner_user_id` | uuid | yes      | The member's user id to promote to owner. |

```json
{ "new_owner_user_id": "7b88..." }
```

Response [#response-50]

```json
{ "message": "ownership transferred" }
```

Upload organization avatar [#upload-organization-avatar]

`POST /organization/avatar`

Uploads the workspace image. `multipart/form-data` with a single `file` field. Owner only. PNG or JPG, max 2 MB, max 1024x1024 px.

Auth: Session only (not available to API keys). Requires a selected organization (owner only).

Response [#response-51]

```json
{ "avatar_url": "https://api.ayinbox.com/public/avatars/organizations/3d11-1718193600.jpg" }
```

Remove organization avatar [#remove-organization-avatar]

`DELETE /organization/avatar`

Clears the workspace image and deletes the stored file on a best-effort basis. Owner only. Returns `204 No Content`.

Auth: Session only (not available to API keys). Requires a selected organization (owner only).

Get organization danger-zone status [#get-organization-danger-zone-status]

`GET /organization/current/danger-zone`

Returns the workspace danger-zone summary plus any pending deletion.

Auth: Session only (not available to API keys). Requires a selected organization.

Response [#response-52]

Same shape as the account danger zone, with `resource_type` of `organization` and the org name as the confirmation hint.

Schedule organization deletion [#schedule-organization-deletion]

`POST /organization/current/danger-zone/delete`

Schedules the current organization for a delayed hard delete. Owner only; the confirmation must match the org name. Returns `202 Accepted` with the scheduled-deletion record.

Auth: Session only (not available to API keys). Requires a selected organization (owner only).

Request body [#request-body-28]

| Field          | Type   | Required | Description                       |
| -------------- | ------ | -------- | --------------------------------- |
| `confirmation` | string | yes      | Must equal the organization name. |
| `reason`       | string | no       | Optional free-text reason.        |

```json
{ "confirmation": "Acme Outbound" }
```

Cancel organization deletion [#cancel-organization-deletion]

`DELETE /organization/current/danger-zone/delete`

Cancels a pending organization deletion.

Auth: Session only (not available to API keys). Requires a selected organization.

Request body (optional) [#request-body-optional-1]

| Field    | Type   | Required | Description                |
| -------- | ------ | -------- | -------------------------- |
| `reason` | string | no       | Optional free-text reason. |

Response [#response-53]

```json
{ "message": "deletion cancelled" }
```

Submit a limit-increase request [#submit-a-limit-increase-request]

`POST /organization/:orgId/limit-requests`

Submits a request to raise one of the organization's limits. The current effective value is captured server-side at submission time. Returns `201 Created`.

A workspace without a plan that sends (a free workspace, or one on the Warmup plan) can only request `max_email_accounts`; any other field returns `400 bad_request`, because none of those limits applies until the workspace sends. A `daily_campaign_limit` request from a workspace whose daily sends are already unlimited also returns `400 bad_request`, since an approved value would impose a cap rather than raise one.

Auth: Session only (not available to API keys).

| Parameter | In   | Type | Description          |
| --------- | ---- | ---- | -------------------- |
| `orgId`   | path | uuid | The organization id. |

Request body [#request-body-29]

| Field       | Type   | Required | Description                                                               |
| ----------- | ------ | -------- | ------------------------------------------------------------------------- |
| `field`     | string | yes      | A limit field (for example `max_email_accounts`, `daily_campaign_limit`). |
| `requested` | int    | yes      | The requested value, must be greater than the current effective limit.    |
| `reason`    | string | yes      | 1 to 2000 characters.                                                     |

```json
{
  "field": "max_email_accounts",
  "requested": 50,
  "reason": "Onboarding a new sales team next month."
}
```

Response [#response-54]

```json
{
  "id": "lr1...",
  "organization_id": "3d11...",
  "field": "max_email_accounts",
  "current_effective": 25,
  "requested": 50,
  "reason": "Onboarding a new sales team next month.",
  "status": "pending",
  "submitted_by": "9c2a...",
  "submitted_at": "2026-06-12T09:00:00Z",
  "review_notes": ""
}
```

List limit requests [#list-limit-requests]

`GET /organization/:orgId/limit-requests`

Returns the organization's limit-increase requests.

Auth: Session only (not available to API keys).

| Parameter | In   | Type | Description          |
| --------- | ---- | ---- | -------------------- |
| `orgId`   | path | uuid | The organization id. |

Response [#response-55]

```json
{ "data": [ { "id": "lr1...", "field": "max_email_accounts", "status": "pending", "requested": 50 } ] }
```

Cancel a limit request [#cancel-a-limit-request]

`DELETE /limit-requests/:id`

Cancels a pending limit request. Submitter only. Returns `204 No Content`.

Auth: Session only (not available to API keys).

| Parameter | In   | Type | Description           |
| --------- | ---- | ---- | --------------------- |
| `id`      | path | uuid | The limit-request id. |

Invitations (for the invitee) [#invitations-for-the-invitee]

These sit outside `/organization` because they act on the signed-in user's own pending invitations.

List my pending invitations [#list-my-pending-invitations]

`GET /invitations`

Returns invitations addressed to the caller's email.

Auth: Session only (not available to API keys).

Response [#response-56]

```json
{
  "data": [
    {
      "id": "inv1...",
      "organization_id": "3d11...",
      "email": "alex@acme.com",
      "role": "member",
      "expires_at": "2026-06-19T09:00:00Z",
      "created_at": "2026-06-12T09:00:00Z",
      "organization": { "id": "3d11...", "name": "Acme Outbound" }
    }
  ]
}
```

Accept an invitation [#accept-an-invitation]

`POST /invitations/accept`

Accepts an invitation, either by the secret token (from a `/invite` link) or by the invitation id (from the caller's own pending list). Returns the new membership.

Auth: Session only (not available to API keys).

Request body [#request-body-30]

Exactly one of `token` or `invitation_id` is required.

| Field           | Type   | Required    | Description                                       |
| --------------- | ------ | ----------- | ------------------------------------------------- |
| `token`         | string | conditional | The secret token from a `/invite` link.           |
| `invitation_id` | uuid   | conditional | The invitation id from the caller's pending list. |

```json
{ "token": "inv_token_abc123" }
```

Response [#response-57]

```json
{
  "message": "invitation accepted",
  "member": {
    "id": "ab13...",
    "organization_id": "3d11...",
    "user_id": "9c2a...",
    "role": "member",
    "permissions": 12288,
    "email": "alex@acme.com",
    "name": "Alex Rivera"
  }
}
```

Teams [#teams]

Teams group existing organization members under a named, color-tagged label (used for CRM ownership and routing). Unlike the rest of this group, the team endpoints accept either a JWT or an API key, and all require a selected organization. Reads map to the CRM read scope and writes to the CRM write scope.

List teams [#list-teams]

`GET /teams`

Returns the organization's teams, each hydrated with its members.

Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts`. Requires a selected organization.

Response [#response-58]

```json
{
  "data": [
    {
      "id": "t1...",
      "organization_id": "3d11...",
      "name": "West coast",
      "color": "#0ea5e9",
      "members": [
        { "user_id": "9c2a...", "email": "alex@acme.com", "name": "Alex Rivera", "added_at": "2026-06-01T09:00:00Z" }
      ],
      "created_at": "2026-05-01T09:00:00Z",
      "updated_at": "2026-06-01T09:00:00Z"
    }
  ]
}
```

Create a team [#create-a-team]

`POST /teams`

Creates a team. Returns `201 Created` with the team.

Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization.

Request body [#request-body-31]

| Field   | Type   | Required | Description                        |
| ------- | ------ | -------- | ---------------------------------- |
| `name`  | string | yes      | 1 to 255 characters.               |
| `color` | string | no       | Hex color (defaults to `#94a3b8`). |

```json
{ "name": "West coast", "color": "#0ea5e9" }
```

Response [#response-59]

The created team object (members starts empty).

Get a team [#get-a-team]

`GET /teams/:id`

Returns a single team with its members.

Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts`. Requires a selected organization.

| Parameter | In   | Type | Description  |
| --------- | ---- | ---- | ------------ |
| `id`      | path | uuid | The team id. |

Update a team [#update-a-team]

`PATCH /teams/:id`

Partial-updates a team's name or color. Nil fields are left untouched.

Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization.

| Parameter | In   | Type | Description  |
| --------- | ---- | ---- | ------------ |
| `id`      | path | uuid | The team id. |

Request body [#request-body-32]

| Field   | Type   | Required | Description |
| ------- | ------ | -------- | ----------- |
| `name`  | string | no       | New name.   |
| `color` | string | no       | New color.  |

Response [#response-60]

The updated team object.

Delete a team [#delete-a-team]

`DELETE /teams/:id`

Deletes a team. Returns `204 No Content`.

Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization.

| Parameter | In   | Type | Description  |
| --------- | ---- | ---- | ------------ |
| `id`      | path | uuid | The team id. |

Add a team member [#add-a-team-member]

`POST /teams/:id/members`

Adds an existing organization member to the team. Returns the updated team.

Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization.

| Parameter | In   | Type | Description  |
| --------- | ---- | ---- | ------------ |
| `id`      | path | uuid | The team id. |

Request body [#request-body-33]

| Field     | Type | Required | Description                                                     |
| --------- | ---- | -------- | --------------------------------------------------------------- |
| `user_id` | uuid | yes      | The member's user id (must already belong to the organization). |

```json
{ "user_id": "7b88..." }
```

Response [#response-61]

The updated team object, including the new member.

Remove a team member [#remove-a-team-member]

`DELETE /teams/:id/members/:userId`

Removes a member from the team. Returns `204 No Content`.

Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization.

| Parameter | In   | Type | Description           |
| --------- | ---- | ---- | --------------------- |
| `id`      | path | uuid | The team id.          |
| `userId`  | path | uuid | The member's user id. |

Subscription and billing [#subscription-and-billing]

The subscription is per-organization. These endpoints read the current subscription, trial, feature access, manage Stripe checkout and the billing portal, change plans, validate discount codes, and submit enterprise inquiries. All of `/subscription/*` is session only.

Get subscription [#get-subscription]

`GET /subscription`

Returns the current organization's subscription.

Auth: **Scope** `READ_WORKSPACE`. Requires a selected organization. For a key or an OAuth app the Stripe identifiers, `managed_by`, `managed_reason` and `user_email` are left out.

Response [#response-62]

```json
{
  "id": "sub1...",
  "user_id": "9c2a...",
  "organization_id": "3d11...",
  "plan_id": "plan_pro...",
  "stripe_customer_id": "cus_...",
  "stripe_subscription_id": "sub_...",
  "status": "active",
  "current_period_start": "2026-06-01T00:00:00Z",
  "current_period_end": "2026-07-01T00:00:00Z",
  "cancel_at_period_end": false,
  "is_enterprise": false,
  "plan": { "id": "plan_pro...", "name": "Pro", "price": 99, "duration": "month" },
  "created_at": "2026-04-20T12:00:00Z",
  "updated_at": "2026-06-01T00:00:00Z"
}
```

Get subscription with limits [#get-subscription-with-limits]

`GET /subscription/limits`

Returns the subscription plus the plan's rate limits.

Auth: **Scope** `READ_WORKSPACE`. Requires a selected organization. For a key or an OAuth app the billing identifiers are left out, as on Get subscription.

Response [#response-63]

The subscription object as above, extended with the plan's limit fields.

Get trial status [#get-trial-status]

`GET /subscription/trial`

Returns the organization's free-trial status.

Auth: **Scope** `READ_WORKSPACE`. Requires a selected organization.

Response [#response-64]

The trial status object (whether a trial is active, when it ends, whether it has been used).

Get feature status [#get-feature-status]

`GET /subscription/features`

Returns the organization's feature-access status plus convenience flags for the major gated features.

Auth: **Scope** `READ_WORKSPACE`. Requires a selected organization.

Response [#response-65]

```json
{
  "subscription": { "status": "active", "plan": "Pro" },
  "can_send_campaigns": true,
  "can_use_warmup": true,
  "can_use_unibox": true
}
```

Create a checkout session [#create-a-checkout-session]

`POST /subscription/checkout`

Creates a Stripe checkout session for a price and returns the redirect URL.

Auth: Session only (not available to API keys). **Org permission** `manage_billing`.

Request body [#request-body-34]

| Field           | Type   | Required | Description                                |
| --------------- | ------ | -------- | ------------------------------------------ |
| `price_id`      | string | yes      | The Stripe price id to subscribe to.       |
| `success_url`   | string | yes      | Redirect target after successful checkout. |
| `cancel_url`    | string | yes      | Redirect target if the user cancels.       |
| `discount_code` | string | no       | A discount code to apply.                  |

```json
{
  "price_id": "price_123",
  "success_url": "https://app.ayinbox.com/billing?status=success",
  "cancel_url": "https://app.ayinbox.com/billing?status=cancel"
}
```

Response [#response-66]

```json
{
  "session_id": "cs_test_...",
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_..."
}
```

The price must belong to a public plan, or to a private plan the workspace holds an open offer for. Any other price answers `403` with code `plan_offer_required`, and a price that is no plan on the instance answers `400`.

List offered plans [#list-offered-plans]

`GET /subscription/offers`

Lists the private plans AY Inbox has offered this workspace and that are still open. See [plans offered to a workspace](/guides/billing/#plans-offered-to-a-workspace).

Auth: Session only (not available to API keys). Requires a selected organization.

Response [#response-67]

```json
{
  "offers": [
    {
      "plan_id": "00000000-0000-0000-0000-000000000003",
      "name": "Partnership",
      "currency": "usd",
      "monthly_usd": 90,
      "monthly_available": true,
      "yearly_available": false,
      "available": true,
      "warmup_only": true,
      "expires_at": null
    }
  ]
}
```

`available` is false while the instance bills nothing or the plan has no Stripe price for either period. `yearly_usd` is present only when a yearly price is set.

Check out an offered plan [#check-out-an-offered-plan]

`POST /subscription/offers/:planId/checkout`

Buys an offered plan. The Stripe price is resolved on the server from the plan, never taken from the request.

Auth: Session only (not available to API keys). **Org permission** `manage_billing`, and a recent sign-in.

Request body [#request-body-35]

| Field      | Type   | Required | Description                  |
| ---------- | ------ | -------- | ---------------------------- |
| `interval` | string | no       | `month` (default) or `year`. |

Response [#response-68]

```json
{ "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_..." }
```

A workspace with an active Stripe subscription is switched to the plan on that subscription instead, prorated, and the answer is `{ "changed": true }`. Without an open offer the answer is `403` `plan_offer_required`; a period the plan has no price for is `503`.

Validate a discount code [#validate-a-discount-code]

`POST /subscription/discount/validate`

Previews whether a discount code is valid for the current organization (optionally against a target plan), returning the discount details for display.

Auth: Session only (not available to API keys). Requires a selected organization.

Request body [#request-body-36]

| Field     | Type   | Required | Description                                             |
| --------- | ------ | -------- | ------------------------------------------------------- |
| `code`    | string | yes      | The discount code.                                      |
| `plan_id` | uuid   | no       | A target plan to compute the discounted amount against. |

```json
{ "code": "LAUNCH20", "plan_id": "plan_pro..." }
```

Response [#response-69]

```json
{
  "valid": true,
  "code": "LAUNCH20",
  "type": "percent",
  "percent_off": 20,
  "duration": "once"
}
```

When invalid, `valid` is `false` and `reason` explains why.

Create a billing portal session [#create-a-billing-portal-session]

`POST /subscription/portal`

Creates a Stripe billing portal session for the organization's customer and returns the portal URL.

Auth: Session only (not available to API keys). **Org permission** `manage_billing`.

Request body [#request-body-37]

| Field        | Type   | Required | Description                                     |
| ------------ | ------ | -------- | ----------------------------------------------- |
| `return_url` | string | yes      | Where Stripe returns the user after the portal. |

```json
{ "return_url": "https://app.ayinbox.com/billing" }
```

Response [#response-70]

```json
{ "portal_url": "https://billing.stripe.com/p/session/..." }
```

Cancel subscription [#cancel-subscription]

`POST /subscription/cancel`

Cancels the organization's subscription, either at period end or immediately.

Auth: Session only (not available to API keys). **Org permission** `manage_billing`.

Request body [#request-body-38]

| Field                  | Type    | Required | Description                                                                |
| ---------------------- | ------- | -------- | -------------------------------------------------------------------------- |
| `cancel_at_period_end` | boolean | no       | When true, cancel at the end of the current period instead of immediately. |

```json
{ "cancel_at_period_end": true }
```

Response [#response-71]

```json
{ "message": "subscription cancelled" }
```

Change plan [#change-plan]

`POST /subscription/change-plan`

Changes the organization's plan with proration.

Auth: Session only (not available to API keys). **Org permission** `manage_billing`. Requires a selected organization.

Request body [#request-body-39]

| Field                | Type   | Required | Description                                           |
| -------------------- | ------ | -------- | ----------------------------------------------------- |
| `plan_id`            | uuid   | yes      | The target plan id.                                   |
| `proration_behavior` | string | no       | One of `create_prorations`, `always_invoice`, `none`. |
| `discount_code`      | string | no       | A discount code to apply.                             |
| `interval`           | string | no       | `month` or `year` (defaults to monthly).              |

```json
{
  "plan_id": "plan_scale...",
  "proration_behavior": "create_prorations",
  "interval": "year"
}
```

The target must be a public plan, or a private plan the workspace holds an open offer for; otherwise the answer is `403` `plan_offer_required`.

Response [#response-72]

```json
{
  "message": "plan changed successfully",
  "subscription": { "id": "sub1...", "plan_id": "plan_scale...", "status": "active" }
}
```

Preview a plan change [#preview-a-plan-change]

`GET /subscription/preview-change`

Previews the proration for a plan change without applying it.

Auth: Session only (not available to API keys). **Org permission** `manage_billing`. Requires a selected organization.

| Parameter     | In    | Type | Description         |
| ------------- | ----- | ---- | ------------------- |
| `new_plan_id` | query | uuid | The target plan id. |

Response [#response-73]

The proration preview object (line items, immediate charge, and next invoice amount).

Submit an enterprise inquiry [#submit-an-enterprise-inquiry]

`POST /subscription/enterprise-inquiry`

Submits an enterprise pricing inquiry. Returns a confirmation plus the new inquiry id.

Auth: Session only (not available to API keys).

Request body [#request-body-40]

| Field              | Type   | Required | Description                     |
| ------------------ | ------ | -------- | ------------------------------- |
| `company_name`     | string | yes      | Company name.                   |
| `contact_name`     | string | yes      | Contact name.                   |
| `contact_email`    | string | yes      | Contact email.                  |
| `estimated_volume` | int    | no       | Estimated monthly email volume. |
| `team_size`        | int    | no       | Team size.                      |
| `notes`            | string | no       | Free-text notes.                |

```json
{
  "company_name": "Acme Inc",
  "contact_name": "Alex Rivera",
  "contact_email": "alex@acme.com",
  "estimated_volume": 50000,
  "team_size": 25
}
```

Response [#response-74]

```json
{
  "message": "Thank you! Our team will contact you within 24 hours.",
  "inquiry_id": "eiq1..."
}
```

Promo codes and referrals [#promo-codes-and-referrals]

All session only, requiring org permission `manage_billing` and a selected organization. See the [referral program guide](/guides/referral-program/) for the product rules.

| Endpoint                                  | Returns                                                                                                                                                                                                                   |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /subscription/discounts`             | The org's promo-code redemption history (`code`, `type`, `percent_off`, `duration`, `redeemed_at`).                                                                                                                       |
| `GET /subscription/referral`              | The org's referral status and code.                                                                                                                                                                                       |
| `POST /subscription/referral`             | Creates the org's referral code.                                                                                                                                                                                          |
| `GET /subscription/referral/attributions` | One row per referred signup. `status` is `pending` (signed up, not yet paid), `qualified` (reached a paid checkout), `rewarded`, or `void` (clawback, self-referral, or cap). `reward_cents` is integer cents.            |
| `GET /subscription/referral/earnings`     | The reward ledger. `amount_cents` is positive for a reward, negative for a clawback within the 30-day window; `reason` is `referral_reward` or `referral_clawback:<cause>`; `balance_after_cents` is the running balance. |

Reference data [#reference-data]

Two read-only reference endpoints sit alongside billing. Unlike the rest of this group they accept any authenticated key (JWT or API key); auth only exists to keep them from being scraped.

List plans [#list-plans]

`GET /plans`

Returns the available public subscription plans.

Auth: any authenticated caller (JWT or API key).

Response [#response-75]

```json
{
  "plans": [
    { "id": "plan_pro...", "name": "Pro", "price": 99, "duration": "month", "public": true }
  ]
}
```

List timezones [#list-timezones]

`GET /timezones`

Returns the timezone list the dashboard pickers offer, each with a display name carrying its current UTC offset. Any IANA name is accepted wherever a timezone is written (a campaign schedule, a mailbox, the workspace), so the list is a convenience rather than the set of valid values.

Auth: any authenticated caller (JWT or API key).

See also [#see-also]

* [Authentication](/api/authentication/)
* [Permissions reference](/api/permissions/)
* [Error codes](/api/error-codes/)
