# Endpoint scope map

> Every AY Inbox API endpoint with the auth types it accepts and the permission it requires, so you know exactly what an API key or OAuth token can reach.

Source: https://docs.ayinbox.com/api/endpoints/

This page is the source of truth for what an API key can and cannot reach. Every route in the backend falls into one of three buckets:

* **API key accepted**: works with both a JWT (dashboard user) and an API key with the listed permission.
* **JWT only**: never reachable via an API key. Used for billing, governance, websocket bootstrap, danger-zone destruction, the OAuth onboarding flow, and admin tools.
* **Public**: no auth required (webhooks with signature verification, health check, OAuth bouncer pages).

When an endpoint says "JWT permission: X / API permission: Y", the dual-auth middleware checks the relevant one based on which credential the caller used.

A member restricted to [selected resources](/guides/team-roles/#access-scope) is held to a short list of read routes whatever credential they use, and those routes filter to their grants: `GET /campaigns`, `/campaigns-overview`, `/campaigns/:id` with its `/logs`, `/steps` and `/ab-variants`, `GET /analytics/dashboard` (whose `campaign_ids` and `folder_ids` filters must name granted campaigns and folders), `/analytics/campaigns/compare`, `/analytics/campaigns/:id` with `/daily` and `/hourly`, `GET /analytics/accounts` and `/analytics/accounts/:id`, `GET /emails` and `/emails/:id`, and the unibox reads `GET /unibox`, `/unibox/count`, `/unibox/overview`, `/unibox/thread`, `/unibox/thread/labels`, `/unibox/scheduled` and `/unibox/:id` (which, for them, does not mark the message read). Their own account routes under `/auth/*`, `/me/*`, `GET /organization`, `/organization/current`, `/organization/switch/:id`, `/invitations`, `GET /subscription`, `/subscription/features`, `GET /timezones` and `POST /getaway` keep working. Every other route answers `403` `member_access_restricted`, and a campaign or mailbox outside their grants answers `404`. An API key's own mailbox allowlist still applies on top: the key reaches the mailboxes on both lists.

All paths below are relative to the versioned base URL `https://api.ayinbox.com/v1` (for example `/campaigns` is `https://api.ayinbox.com/v1/campaigns`). See [versioning](/api/) for details.

Restricted response data and workspace policies [#restricted-response-data-and-workspace-policies]

* `GET /organization/current` returns a restricted summary: `total_campaigns` and `active_campaigns` count granted campaigns; `total_contacts` counts distinct contacts in those campaigns; `email_accounts` counts granted mailboxes; and `emails_sent_today` counts confirmed campaign sends in both granted campaigns and granted mailboxes. `member_count`, `product_description`, `icp_notes` and `voice_profile` are omitted. Unrestricted responses keep their workspace-wide shape.
* `GET /analytics/dashboard` intersects campaign and folder filters with grants before returning `scope.campaign_count`.
* `GET /campaigns/:id/logs` keeps pagination and campaign activity, but restricted responses use safe messages and explicitly selected metadata. Mailbox addresses, caps, cross-campaign sending state, warmup health and account IDs outside mailbox grants are removed. Tracking domains, raw provider errors, unknown fields and nested metadata are not exposed. Granting a campaign alone does not grant its senders.
* `GET /auth/me` and `/unibox/overview` include only categories applied to messages in accessible mailboxes for restricted members. Unrestricted members keep the workspace category registry. Inbox access is mailbox-based: granting a shared mailbox exposes its conversations from other campaigns too.
* `GET /unibox/overview` includes `drafts`, the distinct conversation count across mailbox drafts and visible pending inbox-agent replies, scoped to accessible mailboxes. Older servers omit this field; during rolling upgrades the dashboard uses their mailbox-only Drafts count until it is available.
* `GET /unibox/thread/labels` checks for a message in the selected organization and effective mailbox allowlist before listing labels. Unknown, cross-workspace and inaccessible threads have the same `404`. An accessible thread with no labels returns an empty list.
* `GET /emails` resolves a pagination cursor only inside the selected organization and effective mailbox allowlist. A cursor outside either boundary does not supply a timestamp for pagination.

Workspace creation and limit-request routes are exceptions to the route-wide refusal described above: they authorize the account or target workspace in the service. `POST /organization` refuses accounts whose memberships are all restricted with `403` `member_access_restricted`, unless they are instance administrators. Accounts with no memberships or at least one unrestricted membership remain subject to the existing creation limits. Submitting or listing `/organization/:orgId/limit-requests`, and deleting `/limit-requests/:id`, requires an unrestricted membership in the request's target organization. Switching the selected workspace does not bypass these checks.

Workspace notifications and owner-addressed realtime delivery also check current organization membership. Resource ownership alone does not authorize a workspace-wide event. Restricted, removed and unaccepted members are excluded from workspace notifications, including persisted feeds and deferred email/push delivery. Personal sign-in security notices remain available. See [realtime authorization and revocation](/api/realtime/#channels) for the 30-second refresh bound on open resource channels.

These checks use existing grants on hosted and self-hosted instances and need no new migration or configuration. Existing unrestricted behavior is preserved.

API key accepted [#api-key-accepted]

Emails [#emails]

`PATCH /emails/:id` validates `warmup_start_time` and `warmup_end_time` together: the end must be later than the start on the same day. A partial time patch uses the other stored endpoint; an invalid pair returns `400`. Unrelated patches do not rewrite existing invalid hours. See [warmup hours](/guides/warmup/).

| Method | Path                           | API Permission   |
| ------ | ------------------------------ | ---------------- |
| GET    | `/emails`                      | `READ_EMAILS`    |
| GET    | `/emails/:id`                  | `READ_EMAILS`    |
| PATCH  | `/emails/:id`                  | `WRITE_EMAILS`   |
| PATCH  | `/emails/tags`                 | `WRITE_EMAILS`   |
| GET    | `/emails/allowance`            | `READ_EMAILS`    |
| GET    | `/emails/:id/track`            | `READ_EMAILS`    |
| PATCH  | `/emails/:id/track`            | `WRITE_EMAILS`   |
| POST   | `/emails/:id/track/verify`     | `WRITE_EMAILS`   |
| PATCH  | `/emails/:id/direct-tracking`  | `WRITE_EMAILS`   |
| GET    | `/emails/:id/sync`             | `READ_EMAILS`    |
| PUT    | `/emails/:id/sync`             | `WRITE_EMAILS`   |
| GET    | `/emails/:id/certificates`     | `READ_EMAILS`    |
| GET    | `/emails/:id/identity`         | `READ_EMAILS`    |
| POST   | `/emails/:id/identity/refresh` | `WRITE_EMAILS`   |
| GET    | `/emails/:id/auth-check`       | `READ_EMAILS`    |
| POST   | `/emails/:id/auth-check`       | `WRITE_EMAILS`   |
| GET    | `/emails/:id/behavior`         | `READ_EMAILS`    |
| PUT    | `/emails/:id/behavior`         | `WRITE_EMAILS`   |
| GET    | `/emails/:id/behavior/plan`    | `READ_EMAILS`    |
| POST   | `/emails/:id/hold`             | `WRITE_EMAILS`   |
| POST   | `/emails/:id/release`          | `WRITE_EMAILS`   |
| DELETE | `/emails/:id`                  | `WRITE_EMAILS`   |
| POST   | `/emails/:id/send`             | `SEND_CAMPAIGNS` |

Campaigns and sequences [#campaigns-and-sequences]

Campaigns, steps and activity logs are scoped to the selected organization for session callers and the key's organization for API-key callers. Access depends on the permissions below, regardless of who created the campaign. Step updates and deletes also require the step to belong to the campaign in the URL. Campaign analytics, including daily, hourly and comparison endpoints, use this same organization boundary and require `READ_ANALYTICS` (or **View analytics** for session callers).

| Method                | Path                                             | API Permission                                        |
| --------------------- | ------------------------------------------------ | ----------------------------------------------------- |
| GET                   | `/campaigns`                                     | `READ_CAMPAIGNS`                                      |
| GET                   | `/campaigns-overview`                            | `READ_CAMPAIGNS`                                      |
| POST                  | `/campaigns-estimate`                            | `READ_CAMPAIGNS`                                      |
| POST                  | `/campaigns`                                     | `WRITE_CAMPAIGNS`                                     |
| GET                   | `/campaigns/:id`                                 | `READ_CAMPAIGNS`                                      |
| PATCH                 | `/campaigns/:id`                                 | `WRITE_CAMPAIGNS`                                     |
| DELETE                | `/campaigns/:id`                                 | `WRITE_CAMPAIGNS`                                     |
| POST                  | `/campaigns/:id/duplicate`                       | `WRITE_CAMPAIGNS`                                     |
| GET                   | `/campaigns/:id/attachments`                     | `READ_CAMPAIGNS`                                      |
| POST                  | `/campaigns/:id/attachments`                     | `WRITE_CAMPAIGNS`                                     |
| DELETE                | `/campaigns/:id/attachments/:attachmentId`       | `WRITE_CAMPAIGNS`                                     |
| GET                   | `/campaigns/:id/segments`                        | `READ_CAMPAIGNS`                                      |
| PUT                   | `/campaigns/:id/segments`                        | `WRITE_CAMPAIGNS`                                     |
| GET                   | `/campaigns/:id/advanced`                        | `READ_CAMPAIGNS`                                      |
| PATCH                 | `/campaigns/:id/advanced`                        | `WRITE_CAMPAIGNS`                                     |
| GET                   | `/campaigns/:id/ab-variants`                     | `READ_CAMPAIGNS`                                      |
| POST                  | `/campaigns/:id/ab-variants`                     | `WRITE_CAMPAIGNS`                                     |
| PATCH                 | `/campaigns/:id/ab-variants/:variantId`          | `WRITE_CAMPAIGNS`                                     |
| DELETE                | `/campaigns/:id/ab-variants/:variantId`          | `WRITE_CAMPAIGNS`                                     |
| GET                   | `/campaigns/:id/ab-analysis`                     | `READ_ANALYTICS`                                      |
| POST                  | `/campaigns/:id/preflight`                       | `SEND_CAMPAIGNS`                                      |
| POST                  | `/campaigns/:id/test-email`                      | `SEND_CAMPAIGNS`                                      |
| POST                  | `/campaigns/:id/start`                           | `SEND_CAMPAIGNS`                                      |
| POST                  | `/campaigns/:id/stop`                            | `SEND_CAMPAIGNS`                                      |
| GET                   | `/campaigns/:id/placement-monitor`               | `READ_CAMPAIGNS`                                      |
| PUT                   | `/campaigns/:id/placement-monitor`               | `SEND_CAMPAIGNS`                                      |
| DELETE                | `/campaigns/:id/placement-monitor`               | `SEND_CAMPAIGNS`                                      |
| GET                   | `/campaigns/:id/leads/:contactId/hold`           | `READ_CAMPAIGNS`                                      |
| POST                  | `/campaigns/:id/leads/:contactId/pause`          | `WRITE_CAMPAIGNS`                                     |
| POST                  | `/campaigns/:id/leads/:contactId/resume`         | `WRITE_CAMPAIGNS`                                     |
| GET                   | `/campaigns/:id/reviews`                         | `READ_CAMPAIGNS` + `READ_CONTACTS`                    |
| POST                  | `/campaigns/:id/reviews/:reviewId/approve`       | `SEND_CAMPAIGNS` + `READ_CONTACTS`                    |
| POST                  | `/campaigns/:id/reviews/:reviewId/edit`          | `SEND_CAMPAIGNS` + `READ_CONTACTS`                    |
| POST                  | `/campaigns/:id/reviews/:reviewId/reject`        | `SEND_CAMPAIGNS` + `READ_CONTACTS`                    |
| POST                  | `/campaigns/:id/reviews/bulk-approve`            | `SEND_CAMPAIGNS` + `READ_CONTACTS`                    |
| GET                   | `/campaigns/:id/leads/:contactId/cc`             | `READ_CAMPAIGNS` + `READ_CONTACTS`                    |
| PUT                   | `/campaigns/:id/leads/:contactId/cc`             | `WRITE_CAMPAIGNS` + `READ_CONTACTS`                   |
| GET                   | `/campaigns/:id/leads/:contactId/cc/suggestions` | `READ_CAMPAIGNS` + `READ_CONTACTS`                    |
| GET                   | `/campaigns/:id/logs`                            | `READ_CAMPAIGNS`                                      |
| GET                   | `/campaigns/:id/send-plan`                       | `READ_CAMPAIGNS`                                      |
| GET                   | `/campaigns/:id/forms`                           | `READ_CAMPAIGNS`                                      |
| GET/POST/PATCH/DELETE | `/campaigns/:id/steps[/:sid]`                    | `READ_CAMPAIGNS` for GET, `WRITE_CAMPAIGNS` otherwise |
| PATCH                 | `/campaigns/:id/step-layout`                     | `WRITE_CAMPAIGNS`                                     |
| POST                  | `/generation/write`                              | `WRITE_CAMPAIGNS`                                     |
| POST                  | `/generation/edit`                               | `WRITE_CAMPAIGNS`                                     |
| POST                  | `/generation/ai-variable`                        | `WRITE_CAMPAIGNS`                                     |
| GET                   | `/email-images`                                  | `READ_CAMPAIGNS`                                      |
| POST                  | `/email-images`                                  | `WRITE_CAMPAIGNS`                                     |
| DELETE                | `/email-images/:id`                              | `WRITE_CAMPAIGNS`                                     |

The `hold`, `pause` and `resume` calls under `/campaigns/:id/leads/:contactId/` hold one contact's flow inside one campaign: an out-of-office auto-reply writes one automatically, and a member can write one by hand. The contact stays subscribed and stays a lead, so this is not an unsubscribe and not a suppression. Both writes state an absolute hold rather than applying a delta, and replacing a live hold keeps its original start, so a retry lands on the same row and neither needs an `Idempotency-Key`. See [pause a lead](/api/reference/campaigns/#pause-a-lead).

The `reviews` calls are the [first-email review queue](/guides/campaigns/#review-first-emails-before-they-send) of a campaign with `review_first_emails` on. `GET` lists the queue oldest render first, `?status=` `pending` (the default), `approved` or `rejected`, `?limit=` 1 to 100, `?cursor=` the opaque `pagination.next_cursor`, and returns `data`, `pagination` and `counts` by status. Each item carries the rendered `subject`, `body_html` and `body_plain`, the mailbox it was rendered for, its `version` and a `text_hash`. Each item shows a recipient's address, name and personalized text, so every call also takes `READ_CONTACTS` (for a member, **View contacts**). Approving lets mail go out, so every write takes `SEND_CAMPAIGNS` (for a member, **Send campaigns**):

* `approve` takes `{"text_hash": "..."}`, the hash of the text the caller reviewed, and refuses with `409 review_text_changed` when the stored text no longer matches it.
* `edit` takes the same `text_hash` plus `subject` and `body_plain` (and optionally `body_html`; without it an existing HTML part is rebuilt from the text), stores them as a new version and approves it.
* `reject` takes an optional `{"reason": "..."}` (up to 500 characters) and skips the lead.
* `bulk-approve` takes `{"items": [{"id": "...", "text_hash": "..."}]}`, up to 200, approves every item whose text still matches, and answers `{"approved": [...], "refused": [{"id", "code", "reason"}]}`. If a write fails partway, the items already approved stay approved and the rest come back refused with `internal_error`, so retry only those; a failure before anything was approved is a `500`.

Each write changes a pending item once and answers `409 review_not_pending` after that, and each accepts an `Idempotency-Key`, so a retried request is either replayed or refused rather than applied twice.

Turning review on through `PATCH /campaigns/:id` takes `WRITE_CAMPAIGNS` like any other setting. Turning it off on a campaign that has it on releases every waiting first email, so that also takes `SEND_CAMPAIGNS` (for a member, **Send campaigns**) and answers `403 review_off_needs_send` without it.

The `cc` calls under the same path set the contacts copied on every email to one lead. Their answers carry contact names and addresses, so they need contact read access as well as the campaign scope. `PUT` sends the whole list, so a retry lands on the same state and needs no `Idempotency-Key`. See [set a lead's CC](/api/reference/campaigns/#set-a-leads-cc).

`GET /campaigns/:id/forms` reports the forms this campaign links to and what its recipients did with them: personalized links handed out, who opened one, who started filling it in and who submitted. See the [forms guide](/guides/forms/).

`GET /campaigns/:id/send-plan` returns today's sending plan: what the campaign is expected to send, and every limit that decided the number. For an active campaign the plan is computed in the background and served from a stored snapshot, so the read stays fast on a campaign of any size. `computed_at` is when that snapshot was walked, and `stale` is `true` when the campaign has since been edited, started, stopped, or has crossed into a new budget day and a refreshed plan is already being computed: the figures are the last good ones and a current plan follows within moments. A campaign that has no snapshot yet (a brand-new one) is computed on the request instead. See the [send plan guide](/guides/campaigns/#todays-sending-plan).

`/email-images` is the workspace's image library for email bodies. `POST` takes a multipart `file` field (PNG, JPG, GIF or WebP, up to 5 MB) and returns the row with the public `url` you put in an `<img src>`; those bytes count against the same storage quota as campaign attachments. `GET` is keyset-paginated newest first (`?limit=` 1 to 100, `?cursor=` the opaque `pagination.next_cursor`). `DELETE` removes the object before the row and refuses with a `503` if storage will not take the delete, so a failed call changes nothing and can be retried; once it succeeds, an image in mail already sent stops loading.

Contacts [#contacts]

| Method | Path                               | API Permission                                        |
| ------ | ---------------------------------- | ----------------------------------------------------- |
| POST   | `/contacts/search`                 | `READ_CONTACTS`                                       |
| GET    | `/contacts/custom-fields`          | `READ_CONTACTS`                                       |
| GET    | `/contacts/lookup`                 | `READ_CONTACTS` (plus `READ_UNIBOX` with `thread_id`) |
| POST   | `/contacts`                        | `WRITE_CONTACTS`                                      |
| DELETE | `/contacts`                        | `BULK_CONTACTS`                                       |
| PATCH  | `/contacts`                        | `BULK_CONTACTS`                                       |
| GET    | `/contacts/verification`           | `READ_CONTACTS`                                       |
| POST   | `/contacts/verification`           | `BULK_CONTACTS`                                       |
| GET    | `/contacts/:id`                    | `READ_CONTACTS`                                       |
| PATCH  | `/contacts/:id`                    | `WRITE_CONTACTS`                                      |
| DELETE | `/contacts/:id`                    | `WRITE_CONTACTS`                                      |
| GET    | `/contacts/:id/emails`             | `READ_CONTACTS`                                       |
| GET    | `/contacts/:id/timeline`           | `READ_CONTACTS`                                       |
| GET    | `/contacts/:id/campaigns`          | `READ_CONTACTS`                                       |
| PUT    | `/contacts/:id/consent`            | `WRITE_CONTACTS`                                      |
| GET    | `/contacts/:id/segments`           | `READ_CONTACTS`                                       |
| GET    | `/contacts/:id/lead-score`         | `READ_CONTACTS`                                       |
| GET    | `/contacts/:id/graph`              | `READ_CONTACTS`                                       |
| POST   | `/contacts/export`                 | `READ_CONTACTS`                                       |
| POST   | `/contacts/import/preview`         | `WRITE_CONTACTS`                                      |
| POST   | `/contacts/import/commit`          | `BULK_CONTACTS`                                       |
| POST   | `/contacts/imports`                | `WRITE_CONTACTS`                                      |
| GET    | `/contacts/imports`                | `READ_CONTACTS`                                       |
| GET    | `/contacts/imports/:id`            | `READ_CONTACTS`                                       |
| PATCH  | `/contacts/imports/:id`            | `WRITE_CONTACTS`                                      |
| POST   | `/contacts/imports/:id/analyze`    | `WRITE_CONTACTS`                                      |
| POST   | `/contacts/imports/:id/start`      | `BULK_CONTACTS`                                       |
| POST   | `/contacts/imports/:id/cancel`     | `BULK_CONTACTS`                                       |
| GET    | `/contacts/imports/:id/failed.csv` | `READ_CONTACTS`                                       |
| GET    | `/contacts/:id/notes`              | `READ_CONTACTS`                                       |
| POST   | `/contacts/:id/notes`              | `WRITE_CONTACTS`                                      |
| PATCH  | `/contacts/:id/notes/:noteId`      | `WRITE_CONTACTS`                                      |
| DELETE | `/contacts/:id/notes/:noteId`      | `WRITE_CONTACTS`                                      |
| GET    | `/contacts/:id/activities`         | `READ_CONTACTS`                                       |
| GET    | `/contacts/:id/deals`              | `READ_CRM`                                            |
| POST   | `/contacts/:id/research`           | `AI_RESEARCH`                                         |
| GET    | `/contacts/:id/research`           | `AI_RESEARCH`                                         |
| POST   | `/contacts/research/batch`         | `AI_RESEARCH`                                         |
| GET    | `/contacts/first-lines[/:jobId]`   | `AI_RESEARCH`                                         |
| POST   | `/contacts/first-lines`            | `AI_RESEARCH` and `BULK_CONTACTS`                     |

`PUT /contacts/:id/consent` records the contact's [prior consent](/guides/contacts-crm/#prior-consent): `{"given": true}` records it now, with an optional `given_at` (not in the future) and `source` (up to 100 characters, `api` by default for a key and `manual` for a member); `{"given": false}` clears it. It answers `consent_given_at` and `consent_source`. The body states the whole record, so a retry lands on the same state and needs no `Idempotency-Key`. `GET /contacts/:id` reports `consent_given_at`, `consent_source`, `consent_country` (the code the contact's country field names) and `consent_required` (whether the workspace's list holds it).

`GET /contacts/custom-fields` lists the distinct custom-field keys used across your contacts (frequency-ranked), for building personalization pickers and import column mappers. It returns a flat string array under `data`, capped at 200 keys.

The import pair is two steps over the same file: preview parses it and suggests a column mapping (matching headers to your existing custom fields), commit applies the mapping you chose. Commit takes the stricter `BULK_CONTACTS` scope because one call writes up to 50,000 rows. A mapping problem, an unusable custom-field name or no `email` column, is a `400` on the whole request; see [contacts](/api/reference/contacts/).

AI contact research charges credits (2 per run, billable even when it finds nothing) and only saves cited findings. See the [AI contact research](/guides/ai-contact-research/) guide. The batch endpoint accepts up to 500 contact ids and drains in the background. A run's `result.first_line` holds `text`, `fact`, `fact_date` and `source_url`; a `text` of exactly `NO_FACT` means send with no personalization, and runs saved before first lines existed have no `first_line`. A finished run also writes the line onto the contact as the custom fields `first_line`, `first_line_source` and `first_line_checked_at`, unless the contact already holds a first line with no `first_line_source`, which stays as it is.

`POST /contacts/first-lines` starts a [first-line job](/guides/ai-contact-research/#write-first-lines-in-bulk) over up to 5,000 contacts, named with the same [selection](/api/reference/contacts/#selecting-contacts-for-a-bulk-action) as the other bulk actions or as a `segment_id`, with a budget in `max_cost_usd` ($0.05 to $100, default $5). It spends the instance's Looot balance and the workspace's AI credits and writes many contacts, so it needs `BULK_CONTACTS` as well as `AI_RESEARCH` (for a member, `manage_contacts` and `use_ai`). Send an `Idempotency-Key`: a repeated key returns the job the first request started, with `202` and the same body. The answer carries the `job`, `estimated_max_usd` (every contact alone at its company), `budget_usd`, `llm_estimate_usd_per_contact` and `llm_priced`, which is `false` when the model's token prices are unknown and the conservative cap applies. `GET /contacts/first-lines` lists the workspace's latest jobs under `data` (newest first, `limit` 1 to 100, default 20, with no `pagination` or cursor because it is a progress view of recent jobs, not an archive to page through), and `GET /contacts/first-lines/:jobId` returns one with its progress: `contacts_done` of `total`, `written`, `no_fact`, `skipped_fresh`, `kept` (contacts that already held a first line of their own), `skipped`, `failed`, the Looot spend (`looot_spent_micros`, `looot_held_micros` for lookups whose cost Looot did not report), `llm_spent_micros`, `credits_charged` and `budget_reached`. Amounts are in millionths of a dollar.

Segments [#segments]

| Method | Path                            | API Permission    |
| ------ | ------------------------------- | ----------------- |
| GET    | `/segments`                     | `READ_CONTACTS`   |
| GET    | `/segments/fields`              | `READ_CONTACTS`   |
| POST   | `/segments/preview`             | `READ_CONTACTS`   |
| POST   | `/segments`                     | `WRITE_CONTACTS`  |
| GET    | `/segments/:id`                 | `READ_CONTACTS`   |
| PATCH  | `/segments/:id`                 | `WRITE_CONTACTS`  |
| DELETE | `/segments/:id`                 | `WRITE_CONTACTS`  |
| POST   | `/segments/:id/members`         | `WRITE_CONTACTS`  |
| POST   | `/segments/:id/members/lookup`  | `READ_CONTACTS`   |
| GET    | `/segments/:id/overrides`       | `READ_CONTACTS`   |
| POST   | `/segments/:id/add-to-campaign` | `WRITE_CAMPAIGNS` |

Segments are saved contact audiences: a condition list plus per-contact manual overrides, evaluated live. Contact scopes cover them because a segment is a view over contacts; enrolling one into a campaign writes leads, so that call takes the campaign write scope, as does managing a campaign's linked segments (`/campaigns/:id/segments` above). `POST /contacts/search` and `POST /contacts/export` accept `segment_ids` to scope any contact query to a segment. See [contacts](/api/reference/contacts/#segments) for the condition format.

Lead scoring [#lead-scoring]

| Method | Path                                     | API Permission                     |
| ------ | ---------------------------------------- | ---------------------------------- |
| GET    | `/lead-scoring/profile`                  | `READ_CONTACTS`                    |
| PUT    | `/lead-scoring/profile`                  | `BULK_CONTACTS`                    |
| GET    | `/lead-scoring/summary`                  | `READ_CONTACTS`                    |
| GET    | `/lead-scoring/enrichment-runs[/:runId]` | `READ_CONTACTS`                    |
| POST   | `/lead-scoring/enrichment-runs`          | `INTEGRATIONS` and `BULK_CONTACTS` |

[Lead scoring](/guides/lead-scoring/) grades every contact A to D against the workspace's ideal customer profile. `GET /lead-scoring/profile` returns the saved profile, or the default one with `configured: false` before the workspace saves its own. `PUT` replaces it with `enabled` and `criteria` (both required) and takes the bulk contact scope because a change re-grades every contact in the background (for a member, `manage_contacts`); saving the same profile again changes nothing, so a retry is safe without an `Idempotency-Key`. `GET /lead-scoring/summary` counts the scored contacts per grade and how many are up to date with the current profile. `GET /contacts/:id/lead-score` returns one contact's grade, score, hard-rule cap and the reason for each criterion under `lead_score`, which is `null` until the contact is scored; contact search rows carry `lead_score` (`grade` and `score`) when there is one.

Starting a company enrichment run spends the instance's Looot balance and writes company facts that move grades, so it takes `INTEGRATIONS` as well as `BULK_CONTACTS` (for a member, `use_integrations` and `manage_contacts`) and, on a hosted workspace, a paid plan that includes integrations. Send `max_cost_usd` (from $0.05 to $50, $5 by default) and `include_technographics`, and an `Idempotency-Key` header so a retry returns the same run. The answer is `202` with the `run`, `budget_usd` and `worst_case_usd_per_company`.

Company signals [#company-signals]

| Method       | Path                            | API Permission                      |
| ------------ | ------------------------------- | ----------------------------------- |
| GET          | `/signals`                      | `READ_CONTACTS`                     |
| GET          | `/signals/domains`              | `READ_CONTACTS`                     |
| GET          | `/signals/companies`            | `READ_CONTACTS`                     |
| POST         | `/signals/companies`            | `WRITE_CONTACTS`                    |
| PATCH/DELETE | `/signals/companies/:companyId` | `WRITE_CONTACTS`                    |
| GET          | `/signals/settings`             | `READ_CONTACTS`                     |
| PUT          | `/signals/settings`             | `INTEGRATIONS`                      |
| POST         | `/signals/check`                | `INTEGRATIONS` and `WRITE_CONTACTS` |

[Company signals](/guides/signals/) are contact data, so reading them takes `READ_CONTACTS` (for a member, `view_contacts`) and editing the watch list takes `WRITE_CONTACTS` (`manage_contacts`). A check and the daily budget spend the instance's Looot balance: `POST /signals/check` also takes `INTEGRATIONS` (`use_integrations` and `manage_contacts` for a member), and `PUT /signals/settings` takes `INTEGRATIONS` and, for a member, `manage_settings`. Both need a paid plan that includes integrations on a hosted workspace. Every route answers `503` `signals_disabled` until the operator turns signals on. `POST /signals/companies` and `POST /signals/check` accept an `Idempotency-Key`.

Forms [#forms]

| Method | Path                          | API Permission   |
| ------ | ----------------------------- | ---------------- |
| GET    | `/forms`                      | `READ_CONTACTS`  |
| GET    | `/forms/config`               | `READ_CONTACTS`  |
| POST   | `/forms`                      | `WRITE_CONTACTS` |
| GET    | `/forms/:id`                  | `READ_CONTACTS`  |
| PATCH  | `/forms/:id`                  | `WRITE_CONTACTS` |
| DELETE | `/forms/:id`                  | `WRITE_CONTACTS` |
| GET    | `/forms/:id/submissions`      | `READ_CONTACTS`  |
| DELETE | `/forms/:id/submissions/:sid` | `WRITE_CONTACTS` |
| GET    | `/forms/:id/stats`            | `READ_CONTACTS`  |
| GET    | `/forms/domain`               | `READ_CONTACTS`  |
| PUT    | `/forms/domain`               | `WRITE_CONTACTS` |
| POST   | `/forms/domain/verify`        | `WRITE_CONTACTS` |
| GET    | `/forms/:id/links/:contactID` | `WRITE_CONTACTS` |
| POST   | `/forms/:id/assets/:kind`     | `WRITE_CONTACTS` |
| DELETE | `/forms/:id/assets/:kind`     | `WRITE_CONTACTS` |

Hosted lead-capture forms; see the [forms guide](/guides/forms/). A form's `redirect_url` must be on one of the workspace's own domains when it is set or changed (`redirect_target_not_own_domain` otherwise), and a form with one carries `redirect_url_active`, `false` when the saved address no longer qualifies and visitors see the success message instead. Contact scopes cover them because a form exists to create contacts. Minting a personalized link is a `GET` on top of an upsert, so retries always return the same token. The `/forms/domain` routes are the workspace-wide custom forms domain and additionally need the `manage_settings` organization permission for JWT callers; `PUT` resolves the record as part of saving, and only a verified domain is ever used to build a URL. A new domain verifies only with the workspace's ownership `TXT` record beside the `CNAME`; the status carries it as `txt_name` and `txt_value`, and a domain another workspace holds is refused with `forms_domain_taken`. On the custom domain the forms service serves only that workspace's forms. The public page (`/f/:public_id`), its submit endpoint and the embed loader (`/forms.js`) are served by the standalone forms service on its own host (`FORMS_DOMAIN`), not the API origin, and take no authentication; the unguessable public id is the capability, and the JSON endpoints additionally require the render token the page shell carries.

Lead sync [#lead-sync]

Saved Google Sheets sources that upsert contacts on demand. Gated under the contact write scope because a sync ultimately writes contacts. Nothing syncs on a timer: a source only runs when `/lead-sync/sources/:id/sync` is called.

| Method | Path                                           | API Permission   |
| ------ | ---------------------------------------------- | ---------------- |
| GET    | `/lead-sync/google/connection`                 | `WRITE_CONTACTS` |
| POST   | `/lead-sync/google/spreadsheet`                | `WRITE_CONTACTS` |
| POST   | `/lead-sync/google/preview`                    | `WRITE_CONTACTS` |
| GET    | `/lead-sync/sources`, `/lead-sync/sources/:id` | `WRITE_CONTACTS` |
| POST   | `/lead-sync/sources`                           | `WRITE_CONTACTS` |
| PATCH  | `/lead-sync/sources/:id`                       | `WRITE_CONTACTS` |
| DELETE | `/lead-sync/sources/:id`                       | `WRITE_CONTACTS` |
| POST   | `/lead-sync/sources/:id/sync`                  | `WRITE_CONTACTS` |

A source's `column_mapping` is validated when the source is written, not on its next sync, so a mapping with no `email` column or an unusable custom-field name is a `400` on create or update. See [integrations](/api/reference/integrations/).

Unibox [#unibox]

| Method | Path                               | API Permission                                                |
| ------ | ---------------------------------- | ------------------------------------------------------------- |
| GET    | `/unibox`                          | `READ_UNIBOX`                                                 |
| GET    | `/unibox/count`                    | `READ_UNIBOX`                                                 |
| GET    | `/unibox/thread`                   | `READ_UNIBOX`                                                 |
| GET    | `/unibox/:id`                      | `READ_UNIBOX`                                                 |
| PATCH  | `/unibox/seen`                     | `WRITE_UNIBOX`                                                |
| PATCH  | `/unibox/folder`                   | `WRITE_UNIBOX`                                                |
| POST   | `/unibox/reply`                    | `WRITE_UNIBOX` (plus `READ_UNIBOX` with `forward_message_id`) |
| POST   | `/unibox/reply/draft`              | `READ_UNIBOX`                                                 |
| GET    | `/unibox/compose/candidates`       | `READ_UNIBOX`                                                 |
| POST   | `/unibox/compose`                  | `WRITE_UNIBOX`                                                |
| POST   | `/unibox/compose/draft`            | `READ_UNIBOX`                                                 |
| GET    | `/unibox/drafts`                   | `READ_UNIBOX`                                                 |
| PUT    | `/unibox/drafts/:id`               | `WRITE_UNIBOX`                                                |
| DELETE | `/unibox/drafts/:id`               | `WRITE_UNIBOX`                                                |
| GET    | `/unibox/agent-drafts`             | `READ_UNIBOX`                                                 |
| POST   | `/unibox/agent-drafts/:id/approve` | `WRITE_UNIBOX`                                                |
| POST   | `/unibox/agent-drafts/:id/discard` | `WRITE_UNIBOX`                                                |

`PATCH /unibox/folder` re-files into `inbox`, `archive` or `trash`. Name the messages with `email_ids`, the conversations with `thread_ids`, or both; up to 500 of each. Prefer `thread_ids` when you have one: filing part of a conversation leaves it listed, because a thread shows wherever any message of it still sits. The move is then relayed to each mailbox with `relay_folder_moves` on (the default): the provider's copy is archived, moved to its trash, or put back in its inbox, after the response and best-effort, and the next sync will not undo a filing either way, because the provider's placement is tracked separately and followed only when the provider itself moves the message. `sent`, `drafts` and `spam` are placements a provider reaches rather than somewhere a person files mail, so they are rejected with a `400`. See [filing a conversation](/guides/unibox/#filing-a-conversation).

`PATCH /unibox/seen` takes the same two forms: `email_ids` for individual messages, `thread_ids` for whole conversations. `folder` sweeps one folder instead and cannot be combined with either. Unread by conversation marks only its newest received message (preferring one outside spam and trash), or its newest sent copy if it has no received message; otherwise sent copies stay read. Drafts are never marked unread. Explicit `email_ids` and `folder` sweeps never mark sent or draft copies unread.

`GET /unibox` lists every working folder by default. Spam, trash and archive stay out, so a conversation you file leaves every view rather than only the Inbox folder; pass `include_archived=true` for the All mail behaviour, or `folder=archive` to read the folder itself.

`POST /unibox/snooze` accepts `thread_id` for one conversation or `thread_ids` for up to 500. The single form answers with the snooze row, as before; the bulk form answers with `data`. `DELETE /unibox/snooze?thread_id=` accepts a comma-separated list.

`POST /unibox/reply` with `forward_message_id` forwards a stored message, which discloses it, so the key also needs `READ_UNIBOX` and, when it is restricted to certain mailboxes, the mailbox the message belongs to. See [Forwarding a message](/api/reference/unibox/#forwarding-a-message).

`POST /unibox/reply/draft` returns an AI-drafted reply (it never sends) grounded in the thread, the contact, the campaign step the thread answers, and the [campaign brain](/guides/campaign-brain/) that applies, else your [voice profile](/guides/unibox/#ai-drafts). When a campaign or brain applied, the response carries `grounding`: what the draft read (names and counts, never text) and any `flags`. A brain that cannot be read answers `503 brain_unavailable` before anything is charged. It charges AI credits; see [AI credits](/guides/ai-credits/). An `Idempotency-Key` pays for one draft: a retry replays the first response, and a key whose credits were given back after a model failure is charged again.

`POST /unibox/compose/draft` returns a grounded AI draft for a new email (it never sends): the recipient's contact record, correspondence history, and the workspace voice profile feed the prompt, and the response carries either `text` or a clarifying `question` plus a `grounding` report. When the workspace default brain applies, `grounding.brain` holds what the draft read and any flags. Charges AI credits like the reply draft; see [AI credits](/guides/ai-credits/).

`POST /unibox/compose` sends a brand-new outbound email (not a reply). `body_html` goes out as written; with `body_plain` left empty, the plain-text part is rendered from it, which `POST /unibox/reply` does too. Omit `email_account_id` (or pass `"auto"`) and the backend picks the best mailbox for the first recipient; the response reports `account_id`, `account_email`, `auto`, and a `picked_reason`. Recipients on the workspace suppression list are rejected with a `400` before anything queues. `GET /unibox/compose/candidates?to=<address>` returns every active mailbox scored for that recipient (conversation history, remaining daily budget, domain-auth health) plus the resolved contact and suppression state; see [composing email](/guides/unibox/#composing).

The `/unibox/drafts` endpoints hold autosaved compose drafts, scoped to the calling user within the organization. The draft id is client-generated, so `PUT` is idempotent (safe for debounced autosave and retries); deleting a missing draft is a no-op. A draft carries `body` (the plain text) and `body_html`, which is empty for a plain-text draft; each is capped at 100,000 characters.

`GET /emails/:id/auth-check` runs a live SPF/DKIM/DMARC lookup on the mailbox's sending domain and reports it without changing anything. `POST /emails/:id/auth-check` runs the same lookup and records the verdict against every active mailbox on that domain, which is what clears the send gate after a DNS fix, so it needs `WRITE_EMAILS` rather than `READ_EMAILS`. The write takes no `Idempotency-Key`: it is derived entirely from public DNS with no request body, so repeating it converges on the same result. See [domain authentication](/guides/deliverability/#domain-authentication).

`GET /emails/:id/track` reports the mailbox's stored tracking domain plus the `CNAME` value this deployment expects (`cname_target`, taken from its `TRACKING_DOMAIN`), and does no DNS work. It also carries the ownership `TXT` record (`txt_name`, `txt_value`) a host the workspace has not verified before needs beside the `CNAME`; until it is published the status is `proof_missing`. `PATCH /emails/:id/track` sets the domain and resolves it once; `POST /emails/:id/track/verify` re-resolves the saved one and records the verdict, which is what makes a record that has finished propagating start being used. Both writes are derived from public DNS with no request body, so they take no `Idempotency-Key`. Only a verified domain is used at send time; until then opens and clicks go through the shared tracking host. See [custom tracking domain](/guides/mailboxes/#custom-tracking-domain).

`POST /emails/:id/hold` keeps a mailbox out of campaign sending until `POST /emails/:id/release` puts it back; warmup is unaffected and the automatic rest logic never releases a hold. `release` is also the manual exit for a mailbox that is `resting` automatically. Both are bodyless and idempotent, so they take no `Idempotency-Key`. See [holding a mailbox yourself](/guides/mailboxes/#holding-a-mailbox-yourself).

`GET /emails/allowance` reports how many mailboxes the workspace holds (`used`), how many it may hold (`allowance`, `null` for unlimited), `remaining`, and the `basis` of the number: `fair_use` (the plan's daily sends divided by `sends_per_mailbox`), `plan`, `override` (an approved request), `free`, or `unlimited`. `pending_request` is the open limit-increase request for mailboxes, if any. Every connect path refuses with `mailbox_allowance_reached` once `remaining` is `0`. See [mailbox allowance](/guides/mailboxes/#mailbox-allowance).

`GET /emails/:id/identity` reports which addresses the mailbox's provider will let it send as, which one it uses, and whether its signature was imported or written here; it contacts no provider. `POST /emails/:id/identity/refresh` re-reads that list from the provider and stores it, and with `import_signature` also replaces the stored signature with the one configured at the provider. Storing the list is what a `send_as_email` choice is validated against, so the refresh needs `WRITE_EMAILS` rather than `READ_EMAILS`; it takes no `Idempotency-Key` because it writes exactly what the provider currently says. Gmail only. See [sending identity](/guides/mailboxes/#sending-identity).

`GET /emails/:id/sync` reports, alongside the import state and budget, `skip_folders` (the folders the mailbox's sync leaves alone) and `folders` (what the worker last listed on the server, each with its `name` and the canonical `folder` it files under, INBOX first; empty for Gmail and Outlook). `PUT /emails/:id/sync` takes `{"skip_folders": [...]}` and replaces the list: names as the server lists them, matched without regard to case, each covering its subfolders. Mail already stored from a newly skipped folder is removed, the mailbox is re-shipped to its worker so the change applies on the next pass, and the response is the list as stored. It refuses `INBOX` and any sent, drafts, spam, trash or archive folder with `400 invalid_sync_folder`, as it does a list of more than `50` names or a mailbox that is not IMAP. The body is the desired state, so it takes no `Idempotency-Key`. See [folders you do not want synced](/guides/mailboxes/#folders-you-do-not-want-synced).

`GET /emails/:id/certificates` reports, for an SMTP/IMAP mailbox, each server's `host`, `port`, `tls_unverified` (whether it is trusted on its own certificate rather than a certificate authority) and `tls_pin` (the SHA-256 fingerprint of the public key it is trusted on, lowercase hex). It contacts no server. A Gmail or Outlook mailbox answers `400`, since it has no certificate choice. On `POST /emails/onboarding/smtp-imap` and `PUT /emails/onboarding/smtp-imap/:id`, the `smtp` and `imap` objects accept `tls_unverified` (boolean, default false) and `tls_pin`. A server whose certificate verifies keeps normal checking whatever `tls_unverified` says; one that does not is pinned to what the connection test saw, or to `tls_pin` when it is given and matches. See [servers with their own certificate](/guides/mailboxes/#servers-with-their-own-certificate).

`PATCH /emails/:id` accepts `save_to_sent` (boolean) on SMTP/IMAP mailboxes: when true, which is the default, the worker files a copy of each outbound message in the mailbox's Sent folder. It has no effect on Gmail and Outlook mailboxes, whose APIs file their own copy. See [keeping a copy of sent mail](/guides/mailboxes/#keeping-a-copy-of-sent-mail).

`PATCH /emails/:id` also accepts `relay_folder_moves` (boolean, default true), on every provider: when true, filing a conversation with `PATCH /unibox/folder` moves it in the mailbox too. See [filing in the mailbox](/guides/mailboxes/#filing-in-the-mailbox).

The same `WRITE_EMAILS` mailbox PATCH accepts `test_mode` (`diagnostic`, `off`, `legacy`), independent `test_send_enabled`/`test_receive_enabled`, positive `shared_daily_limit`/`rolling_recipient_limit`, and `send_recovery_resolution`. Repair resolution binds `held_task_id`/`held_reason` and requires either `authentication_repaired` with a newer definitive successful same-mailbox outbound `evidence_task_id`, or `operator_provider_confirmation` with a same-mailbox definitive applied `evidence_task_id` and non-empty `confirmation_reference`. Fresh inbox sync alone is not evidence. For `held_reason: "unknown"`, explicit provider confirmation accepts `operator_confirmed_sent` (requires `message_id`, optional `provider_msg_id`/`thread_id`) or `operator_confirmed_not_sent` (no message/thread handles), with a non-empty `confirmation_reference` and no `evidence_task_id`. This queues an audited normal send result, not an immediate hold reset. The consumer reconciles it before reopening admission; other unknown tasks remain held. A timeout or missing Sent copy never permits automatic retry. Existing NULL participation remains legacy; new mailboxes default off. See [send safety and exact resolution requirements](/development/send-safety/).

`DELETE /emails/:id` releases the mailbox on AY Inbox Cloud before removing the local mailbox. An enrolled mailbox's stored credential comes out of the pool, and a cloud-managed mirror's claim is released so the mailbox returns to the cloud workspace and can be adopted again. If the cloud cannot confirm the release, the delete returns `409 mailbox_cloud_unenroll_failed` and keeps the mailbox record so the request can be retried safely. AY Inbox attempts to restore the mailbox onto its worker immediately, and the worker reconciler may restore it later if that attempt fails. See [mailboxes](/guides/mailboxes/).

`GET /unibox` and `GET /unibox/thread` return message previews: each row carries `snippet`, a one-line summary, not the message body. Read a full message with `GET /unibox/:id`, which returns `body_plain` plus `body_html`. The HTML is sanitized before it leaves the API (scripts, event handlers, embedded frames, and unsafe URL schemes are removed), so it is safe to render, and links carry `target="_blank"` with `rel="noopener"`. AY Inbox open-tracking pixels are also removed from this display copy, including quoted history, so rendering it does not record a campaign open. Stored and delivered copies keep their pixels. `body_truncated` is `true` on the rare message whose stored body could not be read, where `body_plain` falls back to the snippet. The same response carries the full envelope (`from`, `to`, `cc`, `bcc`, `ReplyTo`, `date`, `internal_date`, `message_id`, `in_reply_to`, `size`), the mailbox it belongs to (`email_id`) and its canonical `folder`.

`GET /unibox` also accepts `address=<value>` (matches sender or recipient, for "every conversation with this person") and `direction=sent|received` (resolved against your own mailbox addresses).

The `agent-drafts` endpoints back the [inbox agent](/guides/inbox-agent/): the agent drafts a suggested reply on an inbound human reply and holds it here for review. `GET` lists the pending drafts, each with its `grounding` (what it read and any `flags`); `approve` sends one (optionally with an edited `body`) through the normal reply path and is safe to retry with an `Idempotency-Key`; `discard` dismisses it. Approving is the only path that sends. An API key or OAuth caller approving a draft whose `grounding.flags` is not empty gets `409 draft_flagged` until it sends `"acknowledge_flags": true` (with a new `Idempotency-Key`, or none), and an edited body still needs it. A caller without `WRITE_CAMPAIGNS` is held and answered only on the flags the list shows it. A signed-in member is never held.

Templates and CRM [#templates-and-crm]

| Method            | Path                                                         | API Permission                           |
| ----------------- | ------------------------------------------------------------ | ---------------------------------------- |
| GET               | `/templates`, `/templates/:id`                               | `READ_TEMPLATES`                         |
| POST              | `/templates/score`                                           | `READ_TEMPLATES`                         |
| POST              | `/templates/analyze`                                         | `WRITE_TEMPLATES` (it spends AI credits) |
| POST/PATCH/DELETE | `/templates[/:id]`                                           | `WRITE_TEMPLATES`                        |
| GET               | `/crm/pipelines`, `/crm/pipelines/:id`                       | `READ_CRM`                               |
| POST/PATCH/DELETE | `/crm/pipelines[/:id][/stages[/:stageId]]`                   | `WRITE_CRM`                              |
| GET               | `/crm/deals`, `/crm/deals/:id`                               | `READ_CRM`                               |
| POST/PATCH/DELETE | `/crm/deals[/:id]`                                           | `WRITE_CRM`                              |
| GET               | `/crm/tasks`, `/crm/tasks/:id`                               | `READ_CRM`                               |
| POST/PATCH/DELETE | `/crm/tasks[/:id]`                                           | `WRITE_CRM`                              |
| PATCH/DELETE      | `/crm/tasks` (bulk, over an id list or a whole filter)       | `WRITE_CRM`                              |
| GET               | `/crm/task-types`                                            | `READ_CRM`                               |
| POST/PATCH/DELETE | `/crm/task-types[/:id]`                                      | `WRITE_CRM`                              |
| GET               | `/crm/settings`, `/crm/metadata`, `/crm/owners`, `/crm/sync` | `READ_CRM`                               |
| PUT               | `/crm/settings`                                              | `INTEGRATIONS`                           |
| PUT               | `/crm/owners/:externalId`                                    | `INTEGRATIONS`                           |
| POST              | `/crm/sync`, `/crm/sync/retry`, `/crm/sync/discard`          | `INTEGRATIONS`                           |
| GET/POST          | `/crm/backfill`                                              | `INTEGRATIONS`                           |
| GET               | `/crm/contacts/:id`                                          | `READ_CRM`                               |
| POST              | `/crm/contacts/:id/refresh`                                  | `READ_CRM`                               |
| POST              | `/crm/contacts/:id/link`                                     | `WRITE_CRM`                              |
| PATCH             | `/crm/contacts/:id`                                          | `WRITE_CRM`                              |
| GET               | `/crm/lists`                                                 | `READ_CRM`                               |
| POST              | `/crm/lists/preview`, `/crm/lists/import`                    | `WRITE_CONTACTS`                         |

The `/crm/settings` through `/crm/lists` routes are CRM provider mode ([HubSpot](/guides/hubspot/) or [Pipedrive](/guides/pipedrive/)), and act on the CRM the workspace runs on. For a dashboard session they gate on `view_contacts` to read, `manage_contacts` to edit a contact's CRM record or import a list, and `manage_settings` to change the mode, match owners, run a sync or copy records. `POST /crm/sync/retry`, `POST /crm/sync/discard` and `POST /crm/backfill` are naturally safe to retry: a requeued job is not queued twice and a copied record is never copied again. While HubSpot or Pipedrive is the workspace's CRM, writes to pipelines, stages and task types answer `409 crm_managed_externally`. See [CRM](/api/reference/crm/#crm-provider-mode).

Analytics and audit [#analytics-and-audit]

| Method | Path                                                                                                                    | API Permission    |
| ------ | ----------------------------------------------------------------------------------------------------------------------- | ----------------- |
| GET    | `/analytics/*` (dashboard, direct, inbox-tagging, deliverability, warmup, warmup/placement, campaigns, accounts, usage) | `READ_ANALYTICS`  |
| GET    | `/audit-logs`                                                                                                           | `READ_AUDIT_LOGS` |

Analytics reads use the selected workspace for JWT callers and the key's workspace for API-key callers. This includes warmup and usage totals, so teammates see the same workspace history regardless of which member connected a mailbox or created a campaign.

<Callout type="warn" title="Not available in this build">
  The Cloud warmup reports below and the `/pool-link/instance/analytics/*` routes belong to the warmup pool link, which this build switches off (`config.PoolLinkEnabled` and `config.CloudLinkEnabled` in `internal/config/links.go`). The routes are not registered and answer `404`; the text stays for reference. See [AY Inbox Cloud](/guides/ayinbox-cloud/).
</Callout>

`GET /analytics/warmup` and `GET /analytics/warmup/placement` include detailed Cloud warmup reports for enrolled mailboxes on a linked self-hosted instance, alongside local warmup history. The optional `email_id` is always the local mailbox ID; workspace reports request only that workspace's enrollments. Cloud failures are returned as errors, not empty or partial reports. Daily warmup stats include `active`, whether that date had a warmup plan, so combining sources counts each active UTC date once.

Cloud exposes `POST /pool-link/instance/analytics/warmup` and `POST /pool-link/instance/analytics/warmup/placement` for instance tokens only, not API keys. Both accept `{ "remote_ids": ["uuid"], "from": "YYYY-MM-DD", "to": "YYYY-MM-DD" }`, with 1 to 100 enrolled remote IDs and up to 366 inclusive UTC days. Invalid ranges or batch sizes return `400`; any ID not enrolled by the calling instance refuses the whole request with `pool_link_mailbox_not_found`. The first returns daily counts, including `active`; the second returns raw `daily`, `hosts`, `sent`, `unconfirmed` and `windows` counts with instance-side remote IDs. Daily placement includes the lookback before `from` needed for rolling rates, hosts and send counts cover the requested range, and windows cover the current trailing window.

`GET /analytics/inbox-tagging` accepts `limit`, opaque `cursor`, and `needs_review` query parameters and returns `data`, aggregate `summary`, and `pagination`. Each row carries `actions`, the list of what the verdict was allowed to do (`hold`, `stop`, `task`, `suppress`), and `summary.acted` counts the rows that did anything. `return_date` (`YYYY-MM-DD`, or `null`) is the out-of-office return date the model was asked to confirm, with its answer under `answers.return_date`. Invalid cursors, limits, or booleans return `400`.

`GET /analytics/accounts` returns account health and usage in the standard `data` plus `pagination` envelope with an opaque `cursor`. Pass `email_ids` (comma-separated, up to 200) to ask only for the mailboxes a page shows, or `limit` (`1`-`1000`, default `1000`) and `cursor` to walk the whole inventory. Per-mailbox reads are batched across the page, so a page's cost does not grow with the total inventory, and a workspace with more mailboxes than one page reaches the rest through `next_cursor` instead of having them silently dropped. Invalid cursors, limits, or ids return `400`.

Inbox placement tests [#inbox-placement-tests]

A [placement test](/guides/placement-tests/) sends real mail from one of the workspace's mailboxes, so starting and cancelling one takes the same permission as starting a campaign, while reading results is an analytics read. Everything here is scoped to the workspace: a test, a sending mailbox, a campaign, a step and a contact are each checked to belong to it before anything is sent.

| Method | Path                                 | API Permission   |
| ------ | ------------------------------------ | ---------------- |
| GET    | `/placement/overview`                | `READ_ANALYTICS` |
| GET    | `/placement/tests`                   | `READ_ANALYTICS` |
| GET    | `/placement/tests/:id`               | `READ_ANALYTICS` |
| POST   | `/placement/tests`                   | `SEND_CAMPAIGNS` |
| POST   | `/placement/tests/:id/cancel`        | `SEND_CAMPAIGNS` |
| GET    | `/placement/seeds`                   | `READ_EMAILS`    |
| PUT    | `/placement/seeds/:email_account_id` | `WRITE_EMAILS`   |
| GET    | `/placement/batches`                 | `READ_ANALYTICS` |
| GET    | `/placement/batches/:id`             | `READ_ANALYTICS` |
| GET    | `/placement/batches/:id/senders`     | `READ_ANALYTICS` |
| POST   | `/placement/batches/preview`         | `SEND_CAMPAIGNS` |
| POST   | `/placement/batches`                 | `SEND_CAMPAIGNS` |
| POST   | `/placement/batches/:id/cancel`      | `SEND_CAMPAIGNS` |
| GET    | `/placement/coverage`                | `READ_ANALYTICS` |

For session callers the matching organization permissions are **View analytics** for the reads (tests, batches, a batch's senders and coverage), **Send campaigns** for starting, previewing and cancelling a test or a batch, **View campaigns** for listing seed inboxes and **Manage mailboxes** for marking one. The campaign's placement monitor (`/campaigns/:id/placement-monitor`, listed under campaigns above) is read with **View campaigns** and changed with **Send campaigns**.

A key restricted to certain mailboxes can only start a test from one of them and only sees and marks those mailboxes as seeds. `POST /placement/tests` accepts an `Idempotency-Key`; `PUT /placement/seeds/:email_account_id` and `PUT /campaigns/:id/placement-monitor` state an absolute value, so a retry lands on the same state. `GET /placement/tests` takes `limit` (`1` to `100`, default `25`), an opaque `cursor` and an optional `campaign_id`, and returns `data` plus `pagination`; an invalid cursor or limit is a `400`. It leaves out the tests a batch started, which are read through their batch.

A [placement batch](/guides/placement-tests/#testing-a-fleet-with-batches) runs the same test from many mailboxes. `POST /placement/batches` takes the copy, panel, tracking and pace a single test takes, plus exactly one of `sender_account_ids` or `sender_scope` (`{"type": "campaign", "campaign_id": ...}` or `{"type": "workspace"}`, with optional `providers`, `domains`, `tag_ids`, `include_inactive` and `untested_days`), an optional `sample`, `on_unavailable` (`defer` or `skip`) and `max_credits` for the whole batch. It answers `201` with the batch `queued` as soon as the senders are written down; the backend starts them a few at a time. `POST /placement/batches/preview` takes the same body and returns the counts it would come to (senders, tests, the most copies sent, free and paid tests, credits) without starting anything or writing a row. A key restricted to certain mailboxes can only name those in `sender_account_ids` (another is a `403`), and a server-side scope resolves to those alone. `POST /placement/batches` accepts an `Idempotency-Key`; cancelling states an end state, so a retry answers `placement_batch_not_running` and changes nothing. `GET /placement/batches` and `GET /placement/batches/:id/senders` take `limit` and `cursor` like the tests list; the senders list also takes `sort` (`worst`, the default, `best`, `email` or `status`), `status` and `q` (a substring of the address), and an unknown value of any of them is a `400`. See the [placement endpoint reference](/api/reference/placement/).

Advisor [#advisor]

Recommendations about deliverability, mailbox configuration, warmup, campaign performance, copy, and list quality. See the [Advisor guide](/guides/advisor/).

`GET /advisor/recommendations` returns open and applied findings by default. Pass `status=open` for outstanding suggestions only, `status=applied` for applied fixes, or `status=resolved` for findings cleared by a recheck. Repeat `status` to request several states. The dashboard requests open findings so applied fixes leave its suggestions immediately. Campaign, step and mailbox changes trigger a background recheck that clears findings which no longer apply.

Reads are an analytics read of the workspace's sending posture. Applying or undoing a fix has no scope of its own: the change runs through the tool the fix actually uses and is refused if the caller lacks that tool's permission, so a key that can read recommendations cannot use them to make changes it could not make directly.

| Method | Path                                    | API Permission                                                 |
| ------ | --------------------------------------- | -------------------------------------------------------------- |
| GET    | `/advisor/recommendations`              | `READ_ANALYTICS`                                               |
| GET    | `/advisor/summary`                      | `READ_ANALYTICS`                                               |
| GET    | `/advisor/settings`                     | `READ_ANALYTICS`                                               |
| POST   | `/advisor/refresh`                      | `READ_ANALYTICS`                                               |
| POST   | `/advisor/recommendations/:id/snooze`   | `READ_ANALYTICS`                                               |
| POST   | `/advisor/recommendations/:id/dismiss`  | `READ_ANALYTICS`                                               |
| POST   | `/advisor/recommendations/:id/feedback` | `READ_ANALYTICS`                                               |
| POST   | `/advisor/recommendations/:id/apply`    | `READ_ANALYTICS`, plus the permission of the underlying change |
| POST   | `/advisor/recommendations/:id/undo`     | `READ_ANALYTICS`, plus the permission of the underlying change |

Apply and undo run the fix under the key's own scopes, and a key limited to certain mailboxes can only apply or undo a fix for one of them (`403` otherwise). Changing Advisor settings (`PATCH /advisor/settings`) is JWT only, alongside the rest of organization settings. Silencing checks for a whole workspace is governance, and no read scope should be able to do it. The same applies to `POST /advisor/recommendations/:id/agent-fix`, which is JWT only and needs `USE_AI`: it acts as a named member and spends credits, and no API scope should let a key rewrite a workspace's campaigns unattended.

API key self-service [#api-key-self-service]

| Method | Path                      | API Permission               |
| ------ | ------------------------- | ---------------------------- |
| GET    | `/api-keys`               | `API_KEYS`                   |
| POST   | `/api-keys`               | `API_KEYS`, not OAuth tokens |
| GET    | `/api-keys/permissions`   | `API_KEYS`                   |
| GET    | `/api-keys/:id`           | `API_KEYS`                   |
| PATCH  | `/api-keys/:id`           | `API_KEYS`, not OAuth tokens |
| DELETE | `/api-keys/:id`           | `API_KEYS`, not OAuth tokens |
| DELETE | `/api-keys/:id/permanent` | `API_KEYS`, not OAuth tokens |
| DELETE | `/api-keys/self`          | none                         |

`DELETE /api-keys/:id/permanent` deletes a key that has already been revoked or has expired, taking its usage logs with it. A key that could still authenticate gets a `409`, because revoking is what records that a credential was ended and why. See [Ending a key](/api/authentication/).

`POST /api-keys` and `PATCH /api-keys/:id` require a recent confirmation (`POST /auth/reauth`) when called from a signed-in session, because a key outlives the session that made it and a stolen browser token should not be able to leave one behind or widen one. An API key calling them is unaffected: it has no session and no second factor to present, it was itself minted from a confirmed session, and the `API_KEYS` scope is the explicit grant that governs it. Automation and the CLI keep working.

Creating, editing, revoking and deleting keys is for a signed-in member or an API key; an OAuth app token gets `403` `oauth_token_not_allowed`. A new or edited key stays within its creator: a member cannot give it a permission their role does not cover, and an API key cannot give it a permission, mailbox or IP address the calling key does not have (`403` `api_key_permissions_exceed_caller`). See [what a role can delegate](/api/permissions/#what-a-role-can-delegate). `GET /api-keys/permissions` reports the caller's bound as `grantable`.

`DELETE /api-keys/self` revokes the key the request was made with, and is the one route here that needs no scope. A credential must always be able to end itself: requiring `API_KEYS` to sign out would leave a read-only key on a laptop someone is handing back live, which is what [`ayinbox auth logout`](/api/cli/) promises to prevent. A JWT caller gets a `400`: there is no key in that request to end, only a session, which `POST /auth/logout` ends.

OAuth apps [#oauth-apps]

Registering and managing the OAuth apps your workspace owns. The flow itself (authorize, token, revoke) is listed under JWT only and Public below. Every route here, and `POST /oauth/application-logo`, refuses an OAuth app token with `403` `oauth_token_not_allowed`.

| Method | Path                                            | API Permission |
| ------ | ----------------------------------------------- | -------------- |
| GET    | `/oauth/applications`                           | `API_KEYS`     |
| POST   | `/oauth/applications`                           | `API_KEYS`     |
| GET    | `/oauth/applications/:id`                       | `API_KEYS`     |
| PATCH  | `/oauth/applications/:id`                       | `API_KEYS`     |
| DELETE | `/oauth/applications/:id`                       | `API_KEYS`     |
| POST   | `/oauth/applications/:id/rotate-secret`         | `API_KEYS`     |
| POST   | `/oauth/applications/:id/logo`                  | `API_KEYS`     |
| DELETE | `/oauth/applications/:id/logo`                  | `API_KEYS`     |
| GET    | `/oauth/applications/:id/webhook-secret`        | `API_KEYS`     |
| POST   | `/oauth/applications/:id/webhook-secret/rotate` | `API_KEYS`     |
| GET    | `/oauth/applications/:id/webhook-endpoints`     | `API_KEYS`     |
| GET    | `/oauth/applications/:id/webhook-deliveries`    | `API_KEYS`     |
| GET    | `/oauth/applications/:id/listing`               | `API_KEYS`     |
| PUT    | `/oauth/applications/:id/listing`               | `API_KEYS`     |
| DELETE | `/oauth/applications/:id/listing`               | `API_KEYS`     |

The `listing` routes publish an app to the [community directory](/api/oauth/#publish-to-the-community-directory). `PUT` replaces the whole listing and `DELETE` succeeds when there is nothing to remove, so both are safe to retry. Browsing the directory is `GET /integrations/community` and `GET /integrations/community/:slug`, under `/integrations/*` below.

Operations [#operations]

| Method                | Path                                                                                                          | API Permission                                                                                 |
| --------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| GET/PATCH             | `/outreach/settings`                                                                                          | `WRITE_CAMPAIGNS`                                                                              |
| GET/PUT               | `/outreach/consent`                                                                                           | `WRITE_CAMPAIGNS`                                                                              |
| POST                  | `/deliverability/events`                                                                                      | `WRITE_CAMPAIGNS`                                                                              |
| GET                   | `/suppressions`                                                                                               | `READ_CONTACTS`                                                                                |
| POST                  | `/suppressions`                                                                                               | `WRITE_CONTACTS`                                                                               |
| DELETE                | `/suppressions/:id`                                                                                           | `WRITE_CONTACTS`                                                                               |
| GET                   | `/tasks/dlq`                                                                                                  | `SEND_CAMPAIGNS`                                                                               |
| POST                  | `/tasks/dlq/:id/replay`                                                                                       | `SEND_CAMPAIGNS`                                                                               |
| GET/POST/PATCH/DELETE | `/webhooks[/:id]`                                                                                             | `WEBHOOKS`                                                                                     |
| POST                  | `/webhooks/:id/rotate-secret`                                                                                 | `WEBHOOKS`                                                                                     |
| POST                  | `/webhooks/:id/verify`                                                                                        | `WEBHOOKS`                                                                                     |
| GET                   | `/webhooks/event-types`                                                                                       | `WEBHOOKS`                                                                                     |
| GET                   | `/webhooks/deliveries`                                                                                        | `WEBHOOKS`                                                                                     |
| GET                   | `/webhooks/:id/deliveries`                                                                                    | `WEBHOOKS`                                                                                     |
| POST                  | `/webhooks/deliveries/:deliveryId/redeliver`                                                                  | `WEBHOOKS`                                                                                     |
| GET                   | `/webhooks/throttle-drops`                                                                                    | `WEBHOOKS`                                                                                     |
| GET/POST/DELETE       | `/integrations/*` (except `/integrations/slack/*`, which is [JWT only](#slack), and `/integrations/bookings`) | `INTEGRATIONS`                                                                                 |
| GET                   | `/integrations/bookings`                                                                                      | `READ_CONTACTS`                                                                                |
| GET/PUT               | `/integrations/salesforce/:id/settings`                                                                       | `INTEGRATIONS`                                                                                 |
| GET                   | `/integrations/salesforce/:id/overview`, `/metadata`, `/users`, `/list-views`, `/campaigns`, `/activity`      | `INTEGRATIONS`                                                                                 |
| POST                  | `/integrations/salesforce/:id/import/preview`                                                                 | `INTEGRATIONS`                                                                                 |
| GET/POST/PATCH/DELETE | `/integrations/salesforce/:id/import-sources[/:sourceId]`                                                     | `INTEGRATIONS`                                                                                 |
| POST                  | `/integrations/salesforce/:id/import-sources/:sourceId/run`                                                   | `INTEGRATIONS`                                                                                 |
| POST                  | `/integrations/salesforce/:id/activity/retry`, `/sync-now`                                                    | `INTEGRATIONS`                                                                                 |
| GET                   | `/contacts/:id/salesforce`                                                                                    | `READ_CRM`                                                                                     |
| POST                  | `/contacts/:id/salesforce/sync`                                                                               | `INTEGRATIONS`                                                                                 |
| DELETE                | `/contacts/:id/salesforce/links/:linkId`                                                                      | `INTEGRATIONS`                                                                                 |
| GET                   | `/integrations/apollo/:id`, `/lists`                                                                          | `INTEGRATIONS`                                                                                 |
| PUT                   | `/integrations/apollo/:id/key`                                                                                | `INTEGRATIONS`                                                                                 |
| POST                  | `/integrations/apollo/:id/import/preview`                                                                     | `INTEGRATIONS`                                                                                 |
| GET/POST/PATCH/DELETE | `/integrations/apollo/:id/import-sources[/:sourceId]`                                                         | `INTEGRATIONS`                                                                                 |
| POST                  | `/integrations/apollo/:id/import-sources/:sourceId/run`                                                       | `INTEGRATIONS`                                                                                 |
| GET                   | `/integrations/apollo/:id/enrichments[/:runId]`                                                               | `INTEGRATIONS`                                                                                 |
| POST                  | `/integrations/apollo/:id/enrichments`                                                                        | `INTEGRATIONS` and `BULK_CONTACTS`                                                             |
| GET                   | `/integrations/looot/lead-jobs[/:jobId]`                                                                      | `INTEGRATIONS`                                                                                 |
| POST                  | `/integrations/looot/lead-jobs`                                                                               | `INTEGRATIONS` and `BULK_CONTACTS`, plus `SEND_CAMPAIGNS` with a `campaign_id` that is sending |
| GET/PATCH             | `/integrations/clay/:id`                                                                                      | `INTEGRATIONS`                                                                                 |
| GET/POST/PATCH/DELETE | `/automations[/:id]`                                                                                          | `INTEGRATIONS`                                                                                 |
| PATCH                 | `/automations/:id/layout`                                                                                     | `INTEGRATIONS`                                                                                 |

`/outreach/consent` is the workspace's list of countries whose contacts need [prior consent](/guides/contacts-crm/#prior-consent) before any campaign email: `{"countries": ["DE", "MA"]}`, ISO 3166-1 alpha-2 codes, up to 300. `PUT` replaces the whole list, so a retry lands on the same state. `countries` is required: `[]` empties the list, and a body without it is a `400` rather than an empty list. An unknown code is `400 invalid_country_code`. A signed-in member needs `manage_settings`, as for `/outreach/settings`.

A signed-in member needs `manage_settings` to create, edit, enable or delete an automation, because a flow runs as the workspace; `use_integrations` is enough to list, open, test and read the run history.

Starting a [Looot lead job](/guides/looot-leads/) writes contacts and spends the instance's Looot balance, so it takes the same two scopes. A job that names a campaign which is active, finished or parked for undeliverable leads mails what it finds, so it also takes `SEND_CAMPAIGNS` (`send_campaigns` for a member) and is otherwise refused with `403` [`looot_campaign_send_required`](/api/error-codes/#looot_campaign_send_required); a draft or paused campaign needs nothing more. Starting an [Apollo](/guides/apollo/) enrichment writes contacts and spends the workspace's Apollo credits, so it needs `BULK_CONTACTS` as well as `INTEGRATIONS` (for a member, `use_integrations` and `manage_contacts`). Saving the key, imports and Clay settings need `manage_settings`; previewing and running an import need `use_integrations`. `POST /integrations/inbound/clay/:secret` is the URL a [Clay](/guides/clay/) table posts rows to. It takes no API key, because the secret in its path is the credential.
\| GET/POST/PATCH/DELETE | `/warmup/routing[/:id]` | `WARMUP_ROUTING` |

Retry safety [#retry-safety]

Mutating API requests may include an `Idempotency-Key` header. AY Inbox stores the completed response for 24 hours per organization and key. A request is identified by the credential that sent it (the signed-in user, the API key, or the OAuth authorization), the method, the concrete path including its resource ids, the query and the body. Reusing the same key for the same request replays the original response with `X-Idempotent-Replayed: true` and does not run the operation again; reusing it for anything else, including the same route on a different resource or a different credential, returns `409 Conflict`. A second request sent while the first is still running also gets `409 Conflict`. A response that carries a secret shown once (creating an API key, a webhook endpoint, an OAuth app or an integration connection, or rotating one of their secrets) is never stored: its retry answers `409` [`idempotent_secret_withheld`](/api/error-codes/) instead of running again.

The outcome is recorded even when the client disconnects after the operation went through, so retry with the same key rather than assuming it failed. If AY Inbox cannot write the outcome down, the key answers as still processing and, after ten minutes, as unknown. A request refused before it ran (`401`, `403` or `429`) does not use up its key, so the same key works once the refusal is resolved; a refusal never clears an outcome an earlier attempt already recorded. A request that ended in a server error (`5xx`), or never finished, has an unknown outcome: for the 24 hours the key is kept it answers `409` with `code: "idempotency_outcome_unknown"` and does not run again. The operation may have happened even when a read shows no trace of it yet (a send can be delivered before its Sent copy appears), so retry with a new key only once you have established that the change did not happen.

Identity [#identity]

| Method | Path  | API Permission          |
| ------ | ----- | ----------------------- |
| GET    | `/me` | (any authenticated key) |

`GET /me` returns who the active credential belongs to: `user_id`, `email`, `name`, `organization_id`, `organization_name`, `auth_type` (`api_key`, `oauth`, or `jwt`), and the granted `scopes`. It requires no specific permission, so it is the right call for validating a connection and rendering a human-readable label. Unlike `/auth/me` (JWT only), it is reachable by API keys and OAuth tokens.

Labels [#labels]

Folders (on campaigns), tags (on mailboxes) and labels (on contacts, inbox conversations and forms, managed through the `/categories` endpoints) are three registries with the same shape. Each belongs to the **workspace**, not to whoever created it. Every member sees the same set, whoever created it. A key or a session lists each with `GET /folders`, `/tags` or `/categories`, which answer `{data, pagination}` with every entry in one page (`has_more` is always `false`); a session also gets all three from `GET /auth/me`, which is the filtered path for a member restricted to selected resources. Reading is gated like the records each registry labels, and changing one too, so each takes the API permission in the table below, or the matching JWT permission. The lists refuse a key limited to some mailboxes with `403 api_key_mailbox_limited`, since they are workspace-wide.

`position` is the order within its own registry, `0`-based and contiguous. A move returns the full new ordering. A registry holds at most `100` entries.

| Method | Path                   | API Permission                      |
| ------ | ---------------------- | ----------------------------------- |
| GET    | `/folders`             | `READ_CAMPAIGNS` (`view_campaigns`) |
| GET    | `/tags`                | `READ_EMAILS` (`view_campaigns`)    |
| GET    | `/categories`          | `READ_CONTACTS` (`view_contacts`)   |
| POST   | `/folders`             | `WRITE_CAMPAIGNS`                   |
| PATCH  | `/folders/:id`         | `WRITE_CAMPAIGNS`                   |
| PATCH  | `/folders/:id/move`    | `WRITE_CAMPAIGNS`                   |
| DELETE | `/folders/:id`         | `WRITE_CAMPAIGNS`                   |
| POST   | `/tags`                | `WRITE_EMAILS`                      |
| PATCH  | `/tags/:id`            | `WRITE_EMAILS`                      |
| PATCH  | `/tags/:id/move`       | `WRITE_EMAILS`                      |
| DELETE | `/tags/:id`            | `WRITE_EMAILS`                      |
| POST   | `/categories`          | `WRITE_CONTACTS`                    |
| PATCH  | `/categories/:id`      | `WRITE_CONTACTS`                    |
| PATCH  | `/categories/:id/move` | `WRITE_CONTACTS`                    |
| DELETE | `/categories/:id`      | `WRITE_CONTACTS`                    |

Workspace, plan and credits [#workspace-plan-and-credits]

The workspace's profile, plan, limits and AI credit balance, so an agent can check what it may spend before a daily loop. Every route here refuses a key limited to some mailboxes with `403 api_key_mailbox_limited`. For a key or an OAuth app, `GET /subscription` and `/subscription/limits` leave out the Stripe identifiers, `managed_by`, `managed_reason` and `user_email`. Every other `/organization` and `/subscription` route (members' invitations and roles writes, exports, transfer, billing changes) stays with a signed-in person; see [JWT only](#jwt-only).

| Method | Path                                 | API Permission    | Member needs      |
| ------ | ------------------------------------ | ----------------- | ----------------- |
| GET    | `/organization/current`              | `READ_WORKSPACE`  | any member        |
| GET    | `/organization/current/limits`       | `READ_WORKSPACE`  | any member        |
| GET    | `/organization/current/risk`         | `READ_WORKSPACE`  | any member        |
| PATCH  | `/organization/current`              | `WRITE_WORKSPACE` | `manage_settings` |
| GET    | `/organization/members`              | `READ_WORKSPACE`  | any member        |
| GET    | `/organization/roles`                | `READ_WORKSPACE`  | any member        |
| GET    | `/subscription`                      | `READ_WORKSPACE`  | any member        |
| GET    | `/subscription/limits`               | `READ_WORKSPACE`  | any member        |
| GET    | `/subscription/trial`                | `READ_WORKSPACE`  | any member        |
| GET    | `/subscription/features`             | `READ_WORKSPACE`  | any member        |
| GET    | `/subscription/credits`              | `READ_WORKSPACE`  | `manage_billing`  |
| GET    | `/subscription/credits/transactions` | `READ_WORKSPACE`  | `manage_billing`  |
| GET    | `/subscription/credits/usage`        | `READ_WORKSPACE`  | `manage_billing`  |
| GET    | `/subscription/credits/settings`     | `READ_WORKSPACE`  | `manage_billing`  |

`PATCH /organization/current` also sets `product_description`, `icp_notes` and `voice_profile`, which every AI writing surface is grounded on; each is limited to 2,000 characters (`400 org_grounding_too_long`). `GET /organization/members` and `/organization/roles` answer `{data, pagination}` with every entry in one page.

A route newly opened to keys may answer `401` from a server still on the previous release for the length of a rolling deploy; retry it.

Sending domains [#sending-domains]

The tracking host per sending domain and the bare-domain redirect (see [sending domains](/guides/sending-domains/)). They act across the workspace's domains, so they refuse a key limited to some mailboxes with `403 api_key_mailbox_limited`, and the member behind a key needs `manage_emails`. `GET /emails/domains` answers `{data, pagination}` with every domain in one page.

| Method | Path                                          | API Permission |
| ------ | --------------------------------------------- | -------------- |
| GET    | `/emails/domains`                             | `READ_EMAILS`  |
| GET    | `/emails/domains/:domain/tracking-suggestion` | `READ_EMAILS`  |
| POST   | `/emails/domains/bulk`                        | `WRITE_EMAILS` |
| PUT    | `/emails/domains/:domain/tracking`            | `WRITE_EMAILS` |
| PUT    | `/emails/domains/:domain/redirect`            | `WRITE_EMAILS` |
| POST   | `/emails/domains/:domain/redirect/verify`     | `WRITE_EMAILS` |
| POST   | `/emails/domains/:domain/redirect/website`    | `WRITE_EMAILS` |
| DELETE | `/emails/domains/:domain/redirect`            | `WRITE_EMAILS` |
| PUT    | `/emails/domains/:domain/vendor-forwarding`   | `WRITE_EMAILS` |
| POST   | `/emails/domains/:domain/vendor-tracking`     | `WRITE_EMAILS` |

Slack settings [#slack-settings]

The workspace's [Slack](/guides/slack/) settings. Linking a person's own Slack account stays with that person (see [Slack](#slack) below).

| Method | Path                            | API Permission | Member needs                            |
| ------ | ------------------------------- | -------------- | --------------------------------------- |
| GET    | `/integrations/slack/status`    | `INTEGRATIONS` | any member                              |
| GET    | `/integrations/slack/channels`  | `INTEGRATIONS` | `manage_settings` or `use_integrations` |
| PUT    | `/integrations/slack/settings`  | `INTEGRATIONS` | `manage_settings`                       |
| DELETE | `/integrations/slack/links/:id` | `INTEGRATIONS` | `manage_settings`                       |

AI skills [#ai-skills]

Org playbooks the AI features follow (see the [AI skills](/guides/ai-skills/) guide). Gated on `manage_settings` for JWT callers, or the `AI_AGENT` scope for API keys. Creating, editing or deleting one with an API key also needs the member who created the key to hold `manage_settings`.

| Method | Path             | Permission                     |
| ------ | ---------------- | ------------------------------ |
| GET    | `/ai/skills`     | `manage_settings` / `AI_AGENT` |
| POST   | `/ai/skills`     | `manage_settings` / `AI_AGENT` |
| PATCH  | `/ai/skills/:id` | `manage_settings` / `AI_AGENT` |
| DELETE | `/ai/skills/:id` | `manage_settings` / `AI_AGENT` |

Agent tools (REST) [#agent-tools-rest]

The AI tool registry over plain HTTP for function-calling agents that do not speak MCP (see [Agent tools](/api/agent-tools/)). A member session needs `use_ai`; a key or OAuth token is gated per tool, each by its own permission. The list reflects only what the caller may use, and send-class tools are never exposed. `?toolsets=` narrows the list.

| Method | Path                   | Permission                                                                 |
| ------ | ---------------------- | -------------------------------------------------------------------------- |
| GET    | `/ai/tools`            | `use_ai` for a session; for a key, the permission of each listed tool      |
| POST   | `/ai/tools/:name/call` | `use_ai` for a session; for a key, the permission of the tool being called |

Reference data [#reference-data]

| Method | Path         | API Permission          |
| ------ | ------------ | ----------------------- |
| GET    | `/plans`     | (any authenticated key) |
| GET    | `/timezones` | (any authenticated key) |

How coverage is kept [#how-coverage-is-kept]

Every `/v1` route answers an API key at a scope gate, or stays with a signed-in person for a recorded reason: it signs a person in or changes how they sign in, changes who can get in, moves money, is the owner's whole-workspace export or deletion, needs a person's consent in a browser, or is one person's own screen. A test probes the router with a key holding no scope on every build, so a new dashboard action cannot ship without one or the other.

JWT only [#jwt-only]

These never accept an API key. They depend on a human-bound session: billing flows, governance, OAuth onboarding, websocket bootstrap, and operational destruction.

With [multiple dashboards sharing a backend](/development/configuration/#google-sign-in-per-dashboard-deployment), password-reset and invitation emails, referral links and billing returns use the initiating browser's exact configured dashboard origin. Without a trusted origin they use the primary dashboard. Mailbox, administrator-grant, integration and Slack identity OAuth callbacks recover their target from live server-side state, never from callback query parameters. Expired or consumed routing state is not forwarded to another dashboard.

* `POST /auth/login`, `/auth/login/confirm`, `/auth/register`, `/auth/register/confirm`, `/auth/refresh`, `/auth/reset-password`, `/auth/reset-password/session`, `/auth/reset-password/confirm`
* `POST /auth/reauth` (re-prove the account holder behind a live session, for the changes that require a recent confirmation)
* `GET /auth/reauth/methods` (which ways this account can confirm: password, two-factor code, passkey, and the linked sign-in providers it can re-verify)
* `POST /auth/reauth/passkey/begin`, `/auth/reauth/passkey/finish` (confirm with one of the account's own passkeys)
* `POST /auth/reauth/sso/begin`, `/auth/reauth/sso/finish` (confirm through the Google, Apple or single sign-on identity linked to the account, in a browser window)
* `POST /auth/reauth/native/begin`, `/auth/reauth/native` (confirm with a native Sign in with Apple or Google ID token carrying the session's nonce)
* `GET /auth/config` (public deployment capabilities: which sign-in methods this backend has enabled, whether a login code step follows, whether signups are open, whether the instance still needs claiming)
* `POST /auth/app-attest/challenge`, `/auth/app-attest/keys` (the native app's [App Attest](/api/reference/account-org/#app-attest-native-apps) challenge and one-time key registration, on their own per-IP budget `APP_ATTEST_IP_RATE_LIMIT`)
* `GET /auth/instance` (JWT only: the running AY Inbox version of a self-hosted instance and whether a newer release exists, for the dashboard's version pill; a hosted deployment answers `self_hosted: false` and nothing else)

`POST /auth/register` accepts an optional `invite` field carrying an invitation token. On a deployment running `DISABLE_REGISTRATION=invite_only` it is what permits the signup, and the account is created inside the inviting organization rather than in a new one. The token must resolve to a live invitation whose email equals the submitted address, otherwise the request is refused with `invitation_invalid`. Omitting it on a closed deployment returns `registration_invite_only` or `registration_closed`. See [error codes](/api/error-codes/#registration-and-invitation-refusals).

Actions that need a recent confirmation [#actions-that-need-a-recent-confirmation]

These changes require the session to have confirmed the account holder within the last five minutes, over and above the permission they already need:

| Action                                                                                                                                                       | Route                                                                                                                                                                                                                                                                              |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Create or edit an API key                                                                                                                                    | `POST /api-keys`, `PATCH /api-keys/:id`                                                                                                                                                                                                                                            |
| Create or edit a webhook endpoint                                                                                                                            | `POST /webhooks`, `PATCH /webhooks/:id`                                                                                                                                                                                                                                            |
| Approve an app on the OAuth consent screen                                                                                                                   | `POST /oauth/authorize`                                                                                                                                                                                                                                                            |
| Approve a CLI sign-in                                                                                                                                        | `POST /auth/cli/codes/:code/approve`                                                                                                                                                                                                                                               |
| Add a passkey                                                                                                                                                | `POST /auth/passkey/register/begin`, `/finish`                                                                                                                                                                                                                                     |
| Remove a passkey                                                                                                                                             | `DELETE /auth/passkey/credentials/:id`                                                                                                                                                                                                                                             |
| Turn on two-factor authentication                                                                                                                            | `POST /auth/2fa/enroll/start`, `/confirm` (see below for accounts with no password)                                                                                                                                                                                                |
| Transfer a workspace                                                                                                                                         | `POST /organization/transfer-ownership`                                                                                                                                                                                                                                            |
| Invite or remove a member, change a member's role or change their access scope                                                                               | `POST /organization/members/invite`, `DELETE /organization/members/:id`, `PATCH /organization/members/:id`, `PUT /organization/members/:id/access`                                                                                                                                 |
| Create, edit or delete a custom role                                                                                                                         | `POST /organization/roles`, `PATCH /organization/roles/:id`, `DELETE /organization/roles/:id`                                                                                                                                                                                      |
| Export or import a whole workspace                                                                                                                           | `POST /organization/current/export`, `POST /organization/current/import`                                                                                                                                                                                                           |
| Schedule a workspace or account for deletion                                                                                                                 | `POST /organization/current/danger-zone/delete`, `POST /me/danger-zone/delete`                                                                                                                                                                                                     |
| Approve a self-hosted instance's warmup pool link (not available in this build)                                                                              | `POST /pool-link/codes/:code/approve`                                                                                                                                                                                                                                              |
| Start a checkout, open the billing portal, change or cancel the plan, buy AI credits or change AI spend controls                                             | `POST /subscription/checkout`, `POST /subscription/portal`, `POST /subscription/change-plan`, `POST /subscription/cancel`, `POST /subscription/credits/checkout`, `PATCH /subscription/credits/settings`, `POST /pool-link/checkout`, `POST /subscription/offers/:planId/checkout` |
| Reveal or rotate a signing secret                                                                                                                            | `POST /webhooks/:id/rotate-secret`, `GET /integrations/connections/:id/webhook-secret`, `POST /oauth/applications/:id/rotate-secret`, `GET /oauth/applications/:id/webhook-secret`, `POST /oauth/applications/:id/webhook-secret/rotate`                                           |
| Record, use or remove an admin grant over a whole domain                                                                                                     | `POST /emails/grants/google/finish`, `POST /emails/grants/microsoft/finish`, `POST /emails/grants/:id/connect`, `DELETE /emails/grants/:id`                                                                                                                                        |
| Admin panel: issue a fleet join token, grant admin permissions, export or import any workspace, issue a tester a new password, change a login-code exemption | `POST /admin/fleet/join-token`, `POST /admin/admins/:userId/grant`, `POST /admin/organizations/:id/exports`, `POST /admin/organizations/:id/imports`, `POST /admin/testers/:id/password`, `PUT /admin/users/:id/login-code-exempt`                                                 |

Each either hands out a credential that outlives the session that created it, reveals a secret, changes who can get in, moves money, cannot be reversed by the person it was done to, or reaches a whole domain's mail. Without a confirmation they answer `403` with code `reauth_required`; confirm and retry. See [error codes](/api/error-codes/#confirmation-required).

`GET /auth/reauth/methods` says which ways the account can confirm, so a client offers only those:

```json
{
  "password": false,
  "totp": false,
  "passkey": true,
  "providers": [{ "id": "google", "label": "Google" }],
  "native_providers": ["google"],
  "valid_for_seconds": 300
}
```

* **Password or code:** `POST /auth/reauth` with `password` or a current two-factor or recovery `code`.
* **Passkey:** `POST /auth/reauth/passkey/begin` returns WebAuthn request `options` limited to the account's own passkeys; pass `options.publicKey` to the browser and send the assertion to `POST /auth/reauth/passkey/finish` as `credential`. The authenticator must verify the user.
* **Linked identity in a browser:** `POST /auth/reauth/sso/begin` with `provider` (one of `providers[].id`) returns `url` and `binding`. Open `url` in a window and keep `binding`. The provider returns that window to the dashboard's `/reauth/complete` with a single-use `code`, which only the session that began the flow can redeem: `POST /auth/reauth/sso/finish` with `code` and `binding`. The identity the provider verifies must already be linked to the account; another account is refused with `reauth_identity_mismatch`, never matched by email address.
* **Native app:** `POST /auth/reauth/native/begin` returns a `nonce`. Pass it unchanged as the Sign in with Apple request nonce or the Google authorization `nonce`, then send the ID token to `POST /auth/reauth/native` with `provider` (`apple` or `google`). The nonce is single-use and bound to the session.

Each succeeds with `{ "valid_for_seconds": 300 }` and confirms only the session that made the call.

This applies to session callers. An API key or OAuth token has no session to confirm and is not the threat here, so it passes straight through to the route's permission gate.

A completed sign-in counts as a confirmation for the first five minutes of the session. `POST /auth/reauth` answers an account with neither a password nor two-factor authentication `400` with code `reauth_no_factor`; it confirms with a passkey or its linked identity instead. An account with none of those signs in again.

`GET /auth/config` gained two fields: `invites_required` (boolean, true when an invitation token is needed to create an account) and `docs_url` (string, the deployment's link to the accounts and access documentation, for a client to surface next to a refusal).

`GET /auth/config` also carries `websocket_url` and `app_url` (both strings, each omitted when the instance has none). They are the realtime gateway a developer client connects to and the dashboard origin a client sends someone to. Both are served here because on a self-hosted instance the host layout is whatever the operator chose, and there is no other way to discover it: the [CLI](/api/cli/) reads them for `ayinbox events tail` and `ayinbox browse`.

`POST /getaway` is session-only and mints a ten-minute WebSocket ticket. Its `url` remains the direct gateway; the optional `proxy_path` is resolved against `/v1` for browser connections. `GET /realtime/socket/websocket` upgrades a connection using that ticket, preserving the configured realtime service's origin checks and connection limits. It accepts no ordinary access tokens, API keys or OAuth tokens. See [Realtime WebSocket](/api/realtime/).

Alongside them, `api_url` is this API's own public base (for a copyable example that names the right server), and `brand` is who the deployment says it is: `name`, and `website_url`, `website_label`, `terms_url`, `privacy_url` and `support_email`, each omitted when unset. On a self-hosted instance that configured no `EMAIL_BRAND_*` only `name` is present, and a client should render no link at all rather than substituting one of its own. See [configuration](/development/configuration/).

`GET /auth/config` also carries `gmail_oauth_connect` (boolean): whether a new Gmail mailbox may be connected with Google sign-in. Both `BOX_GOOGLE_CLIENT_ID` and `BOX_GOOGLE_CLIENT_SECRET` are required. With them set, the capability defaults to `true`; explicit `BOX_GOOGLE_OAUTH_CONNECT=false` or an invalid value disables it. When `false`, offer the app-password route (`POST /emails/onboarding/smtp-imap` against Gmail's servers) rather than start an OAuth round trip that returns `mailbox_gmail_oauth_disabled`. The dashboard additionally requires its deployment-local [frontend opt-in](/development/configuration/#google-sign-in-per-dashboard-deployment) to show Google sign-in. Existing Google sign-in mailboxes are unaffected either way.

`GET /auth/config` also carries `app_attest` (boolean): whether this server accepts an [App Attest proof](/api/reference/account-org/#app-attest-native-apps) from the native app in place of a Turnstile token on `POST /auth/login`, `/auth/register`, `/auth/reset-password` and `/auth/reset-password/confirm`. Read it with `captcha`: no captcha means nothing to send, a captcha with `app_attest` means the app sends a proof, and a captcha without it means email and password sign-in is unavailable in the app.

`GET /auth/config` also carries `billing_enabled` (boolean). It is `false` when the deployment runs with `BILLING_PROVIDER=none`, which is the self-host default: every feature is unlocked server-side, so the dashboard shows the workspace as self-hosted instead of on a free trial and hides the billing and referral pages. `self_hosted` alone does not imply this, because a self-hosted install may still run Stripe.

* `POST /auth/setup` (first-run claim: exchanges the one-time token printed at boot for the owner account. Refused once any account exists)
* `GET /auth/providers`, `POST /auth/apple`, `POST /auth/google` (native-app social sign-in)
* `POST /auth/oidc/begin`, `GET /auth/oidc/callback` (generic OpenID Connect sign-in)
* `POST /auth/google/begin`, `GET /auth/google/callback`, `POST /auth/apple/begin`, `POST /auth/apple/callback` (browser Sign in with Google and Sign in with Apple)
* `POST /auth/sso/exchange` (swaps the single-use handoff code from any of those callbacks for the session; `POST /auth/oidc/exchange` is the older name for the same endpoint)
* `POST /auth/sso/link` (completes a provider sign-in that resolved to an existing password account: takes that account's password, attaches the identity and returns the session. An issued initial password is refused here with `password_change_required`)
* `POST /auth/login/initial-password` (answers a `/auth/login` result with `password_change_required`: takes its single-use `pending_token` and the holder's own password, replaces the issued initial password, signs out other sessions and continues the sign-in in the `/auth/login` response shape. Retrying is safe: the token is spent once, and a repeat answers `password_change_expired`)
* `POST /auth/logout`, `POST /auth/logout-all`, `GET /auth/me`, `PATCH /auth/me/onboarding`
* `PUT /auth/me/product-tour` (records that the signed-in user finished or skipped the dashboard's product tour, answering `product_tour_completed_at`, which `GET /auth/me` also carries. Naturally retry-safe: a repeat keeps the first time, so it takes no `Idempotency-Key`)
* `POST /auth/me/avatar`, `DELETE /auth/me/avatar`
* `GET /auth/me/identities`, `DELETE /auth/me/identities/:provider` (the Google, Apple and single sign-on identities linked to the signed-in user, and removing one. Removal requires a [fresh confirmation](/guides/security/), ends the other sessions that signed in through that provider, and is refused with `last_sign_in_method` when it would leave no way to sign in. No `Idempotency-Key`: a repeat converges and answers `204`)
* `POST /auth/me/password/link` (emails the signed-in user a single-use link to set a password, on the same budget as `/auth/reset-password`. No `Idempotency-Key`: a repeat mails another link and spends the budget, which is the cap on retries)
* `POST /emails/onboarding/oauth/start`, `POST /emails/onboarding/oauth/finish`, `POST /emails/onboarding/smtp-imap`. `oauth/start` accepts an optional `login_hint` (an email address) that preselects that mailbox in the provider's sign-in, which is how an import's **Sign in** rows open. For Google and Microsoft dashboard flows (`return: "web"`), the browser's `Origin` is validated against exact configured dashboard origins and bound to the OAuth state; an untrusted origin answers `400` `mailbox_oauth_return_origin`. Requests without an `Origin` retain primary-dashboard routing while their state is live
* `POST /emails/onboarding/smtp-imap/bulk` (up to `50` SMTP/IMAP rows in `accounts`, answered `200` with a per-row `status` of `connected`, `skipped` or `failed` and a `code`; rows past the workspace's [mailbox allowance](/guides/mailboxes/#mailbox-allowance) fail with `mailbox_allowance_reached` before any credential is dialled. Naturally retry-safe: an already connected mailbox is `skipped`, so it takes no `Idempotency-Key`)
* The [mailbox import](/guides/mailbox-import/) routes under `/emails/imports`, all with JWT permission `MANAGE_EMAILS`. They carry passwords, so like onboarding they never accept an API key:

  * `POST /emails/imports/preview`: what an import of this input would do, row by row and domain by domain, without doing any of it
  * `POST /emails/imports`: store the rows and connect them in the background; answers `201` with the import
  * `GET /emails/imports`: the workspace's imports, newest first
  * `GET /emails/imports/:id`: one import with its counts and its failures grouped by `cause`
  * `GET /emails/imports/:id/rows`: an import's rows by line, filtered by `status` (comma-separated) and `cause`
  * `PATCH /emails/imports/:id/rows/:line`: correct one failed row (`password`, `app_password`, `username`, `smtp`, `imap`) and queue it again
  * `POST /emails/imports/:id/retry`: requeue failed rows, all of them or those with one `cause` or the listed `lines`, optionally with one new `password` for all
  * `POST /emails/imports/:id/cancel`: stop the rows not yet started; rows already connecting finish
  * `POST /emails/imports/:id/dismiss`: hide the import from `GET /emails/imports` for the workspace, stopping it first when it is still running. Its rows and history stay, and `GET /emails/imports/:id` still reads it. Answers `204`; a repeat changes nothing
  * `GET /emails/imports/:id/failed.csv`: every row that did not connect, as uploaded minus passwords, with `status`, `problem` and `how_to_fix`

  Preview and create take `multipart/form-data`: `file` (CSV, TSV or XLSX up to 10 MB) or `text` (a pasted list), `mapping` (a JSON object of column index to field, such as `{"0":"email","1":"password"}`; omit it to use the automatic mapping) and `options` (JSON: `has_header`, `shared_password`, `on_existing` of `update` or `skip`, `save_mapping`, and `settings` applied to every mailbox). One import takes up to 5,000 rows. The two lists answer `data` plus `pagination` with `next_cursor` and `has_more`; `limit` is at most `100` for imports and `200` for rows, and a cursor that is not one this API issued returns `400` with `invalid_cursor`. None of these takes an `Idempotency-Key`. Repeating a create makes a second import, whose rows find the first one's mailboxes already connected and update (or skip) them, so nothing is connected twice. A retry only requeues rows that failed, and a fix is refused for a row that is not failed, so repeating either connects nothing twice. Cancelling an import twice is a no-op. Progress is published as the realtime event `MAILBOX_IMPORT_PROGRESS`; see [realtime](/api/realtime/). Error codes are under [mailbox import refusals](/api/error-codes/#mailbox-import-refusals)
* [Inbox vendor](/guides/mailbox-import/#import-from-an-inbox-vendor) connections under `/emails/vendors`, JWT permission `MANAGE_EMAILS`. The key is sealed on the server and no response ever returns it:
  * `GET /emails/vendors/catalog`: the supported vendors, with each one's `fields` (key, label, secret, required, help) and `key_help_url`
  * `GET /emails/vendors`: the workspace's vendor connections with `status`, `last_error` and mailbox count
  * `POST /emails/vendors`: `vendor`, `label` and `fields`; the key is checked with the vendor first, answers `201`. Repeating it makes a second connection
  * `PATCH /emails/vendors/:id`: rename, or replace `fields` after checking the new key with the vendor. Safe to repeat
  * `DELETE /emails/vendors/:id`: forget the key; the mailboxes it brought in stay connected. A repeat is a `404`
  * `GET /emails/vendors/:id/mailboxes`: the vendor account's mailboxes, each marked `connected` when already in the workspace
  * `POST /emails/vendors/:id/import`: `mailbox_ids` or `all`, plus the same `options` as a file import; answers `201` with the import. Each call creates a new import; a repeat finds the first run's mailboxes connected and updates (or skips) them
* [Admin grants](/guides/mailbox-import/#connect-a-whole-google-workspace-domain) under `/emails/grants`, JWT permission `MANAGE_EMAILS`. The four routes that record, use or remove a grant also need a [recent confirmation](#actions-that-need-a-recent-confirmation):
  * `GET /emails/grants/config`: whether this instance takes Google and Microsoft grants, the Google `google_client_id` and `google_scopes` an administrator authorizes, and `google_missing` and `microsoft_missing`, the names of the instance settings still unset (empty when that provider is ready)
  * `GET /emails/grants`, `GET /emails/grants/:id`: grants with their `provider`, `tenant`, covered `domains`, `status` and mailbox count
  * `GET /emails/grants/migration`: the workspace's mailboxes on per-mailbox Google sign-in that can [optionally switch connection methods](/guides/mailboxes/#moving-off-per-mailbox-google-sign-in), grouped by domain: `data` is a list of `domain`, `kind` (`workspace`, which moves onto an admin grant, or `personal`, a shared address such as `gmail.com`, which moves to an app password), `grant_id` when the workspace has an active Google grant covering the domain, and `mailboxes` (`id`, `email`, `name`, `status`); `total` counts the mailboxes. Mailboxes whose sign-in AY Inbox Cloud holds are not listed. Read only
  * `POST /emails/grants/google/start`: `domain` and `admin_email` (on that domain). Answers how this workspace proves it controls the domain: `method` `signin` with a Google sign-in `url` and `state` (valid for 15 minutes) when the instance has a Google sign-in app, else `method` `dns`. Both carry `txt_name` (`_ayinbox.<domain>`) and `txt_value` (`ayinbox-verify=...`, unique to the workspace and domain), because the DNS proof always works. Safe to repeat
  * `POST /emails/grants/google/finish` (recent confirmation): either `state` and `code` from the Google sign-in, which must be `admin_email` itself on that domain, or `domain` and `admin_email` once the `TXT` record is published. The directory must list `admin_email` as a super administrator. Answers `201`. A `state` is single-use; repeating a DNS finish re-verifies and updates the same grant, one per domain
  * `POST /emails/grants/microsoft/start`: the admin consent `url` and `state` for a Global Administrator, valid for 15 minutes
  * `POST /emails/grants/microsoft/finish` (recent confirmation): `state` and `code` from the consent callback; answers `201`. The organization recorded is the one the administrator consented for, as the redeemed sign-in reports it. The `state` is single-use, so a repeat is refused with `mailbox_grant_state_invalid`. One grant per tenant: consenting again updates the same grant
  * `POST /emails/grants/:id/check`: verify now, and restart the grant's stopped mailboxes when it passes. Safe to repeat
  * `DELETE /emails/grants/:id` (recent confirmation): remove the grant; every mailbox it connected stops. A repeat is a `404`
  * `GET /emails/grants/:id/users`: the granted directory, each user marked `enabled` and `connected`, with `email_account_id` when connected. `upgrade` is `true` for a user whose mailbox is already here on its own sign-in with the same provider (Google or Microsoft, not held by AY Inbox Cloud): connecting it moves that mailbox onto the grant in place
  * `POST /emails/grants/:id/connect` (recent confirmation): `user_ids` or `all` (every enabled user not yet connected, plus every user marked `upgrade`), plus import `options`; answers `201` with the import. Each call creates a new import; a repeat finds the first run's mailboxes connected and updates (or skips) them. A user whose mailbox is already here as a delegated mailbox is relinked to this grant, and one on its own sign-in is converted onto the grant in place, keeping its history, campaigns and warmup, without counting against the mailbox allowance
* [Sending domains](/guides/sending-domains/) under `/emails/domains`, member permission `MANAGE_EMAILS`; these also take an API key, see [sending domains](#sending-domains). `:domain` is the bare domain:

  * `GET /emails/domains`: every domain the workspace sends from, with mailbox count, mail hosts, SPF, DKIM and DMARC, tracking hosts in use and the redirect. When a connected [inbox vendor](/guides/sending-domains/#domains-held-by-an-inbox-vendor) account holds the domain, `vendor_domain` carries `vendor`, `connection_id`, the current `forwarding` when the vendor reports it, `can_forward`, `can_unforward`, `forwarding_reviewed` (a change is applied later by the vendor's staff), `can_dns` and `dns_types`
  * `POST /emails/domains/bulk`: `domains` (1 to 100), `tracking_label` (one DNS label, such as `link`; each domain gets `<label>.<domain>`) and/or `redirect_url`, plus optional `tracking_hosts` and `redirect_urls` objects keyed by domain that give a listed domain its own host or website in place of the shared one. Each domain takes its vendor's path when the vendor's API can (writing the `CNAME`, forwarding the root) and is saved to wait for DNS otherwise. Optional `served_by` (`instance` or `cloud`) picks where a redirect no vendor forwards is served from; omitted keeps each domain's current choice. Answers `data`, one row per domain with `tracking` (`host`, `via` of `vendor` or `dns`, `verified`, `mailboxes`, `cname_target`, `note` when the vendor refused the record) and `redirect` (`target_url`, `via` of `vendor`, `dns` or `cloud`, `verified`, `reviewed`); a refused domain carries `error` and `code` on its row and never stops the others. Safe to retry: each setting is set, not added
  * `GET /emails/domains/:domain/tracking-suggestion`: the tracking `host` to offer, its `status` (`active`, `found` or `suggested`), the `cname_target`, and `vendor_domain` when a connected vendor account holds the domain
  * `PUT /emails/domains/:domain/tracking`: `host` for every mailbox on the domain (empty clears it); answers the verification result and the number of `mailboxes` changed. Idempotent
  * `PUT /emails/domains/:domain/redirect`: `target_url`, optional `include_www` (default `true`) and optional `served_by` (`instance`, or `cloud` on a self-hosted instance [linked to AY Inbox Cloud](/guides/sending-domains/#serve-it-from-ay-inbox-cloud); omitted keeps the current choice, and a new redirect is served by this instance). `target_url` must be on one of the workspace's own domains, a confirmed website included (`redirect_target_not_own_domain` otherwise; see [redirect destination refusals](/api/error-codes/#redirect-destination-refusals)). Checks it once and answers the redirect with its `records`, `served_by`, `serve_host` (the tracking host that answers, which a proxy routes the domain to) and, once DNS verifies, `reach`: the last visit to the domain, with `status` (`ok`, `not_reaching`, `https_error` or `unreachable`), `hint` (`not_routed`, `host_header`, `wrong_target`, `certificate`, `no_listener`, or `settling` while the tracking service picks up a redirect that has just verified), a `detail` sentence, the `proxy` that answered when it named itself, and `checked_at`. A redirect served by this instance or Cloud for it also carries `target_active`, `false` with a `target_inactive_reason` when the saved `target_url` is no longer on the workspace's own domains and the domain answers as if it had no redirect. When the destination is the workspace's only through a [confirmed website](/guides/sending-domains/#confirm-your-website), the redirect carries `website` with `domain`, `verified` and `record` (the `TXT` record to publish at the website's DNS); while it is not verified, `reach` is absent because the domain is not served. Idempotent: a repeat keeps the same ownership value, so the `TXT` record stays valid
  * `POST /emails/domains/:domain/redirect/verify`: check DNS and visit the domain now; for a redirect AY Inbox Cloud serves, Cloud checks and the answer is its verdict. A destination's website is looked at too. Safe to repeat
  * `POST /emails/domains/:domain/redirect/website`: `target_url`. Looks for the workspace's `TXT` record on the destination's website now and answers `domain`, `verified` and, when the website is not already one of the workspace's mailbox, tracking or forms domains, `record` (`type`, `name`, `value`, `ok`). A found record confirms the website for the workspace. Safe to repeat
  * `DELETE /emails/domains/:domain/redirect`: stop serving it, on AY Inbox Cloud too when Cloud serves it. A repeat is a `404`
  * `PUT /emails/domains/:domain/vendor-forwarding`: `url`; the vendor account holding the domain forwards its root there. An empty `url` removes the forwarding where `can_unforward`. Answers the updated `vendor_domain`. Idempotent
  * `POST /emails/domains/:domain/vendor-tracking`: `host`, a subdomain of the domain; the vendor writes its `CNAME` to this instance's tracking host, then every mailbox on the domain uses the host. Answers like `PUT .../tracking`. Safe to retry: the record is replaced, not added

  None of these takes an `Idempotency-Key`; the retry behavior of each is stated above. Changes to vendor connections, grants and redirects publish `AUDIT_CREATED` with `entity_type` `mailbox_vendor`, `mailbox_grant` or `domain_redirect`. Error codes are under [mailbox source refusals](/api/error-codes/#mailbox-source-refusals)
* `POST /emails/onboarding/oauth/reauth/:id`, `PUT /emails/onboarding/smtp-imap/:id` (reconnect an existing mailbox after a credential change; JWT permission `MANAGE_EMAILS`)
* `POST /emails/onboarding/smtp-imap/:id/certificates/check` and `POST /emails/onboarding/smtp-imap/:id/certificates/confirm` (JWT permission `MANAGE_EMAILS`), for an SMTP/IMAP mailbox that trusts a server on its own certificate. `check` runs a connection test and answers the shape of `GET /emails/:id/certificates` with `observed` filled in for each server; it stores nothing. `confirm` takes `{"smtp_pin": "...", "imap_pin": "..."}`, a SHA-256 fingerprint for each server the mailbox trusts on its own certificate, tests against exactly those, stores them and loads the mailbox back onto its worker, clearing a `TLS_CERTIFICATE_CHANGED` or `TLS_CERTIFICATE_UNCONFIRMED` error. `400` `mailbox_certificate_not_pinned` when neither server is trusted that way, `400` `invalid_tls_pin` for a missing or malformed fingerprint, and the connect errors (`mailbox_tls_failed` and friends) when the test fails. The body is the desired state, so it takes no `Idempotency-Key`. Publishes `AUDIT_CREATED` with `entity_type` `email_account`
* `POST /emails/onboarding/app-password/:id` (JWT permission `MANAGE_EMAILS`): move a mailbox off per-mailbox Google sign-in onto Gmail's IMAP and SMTP with `app_password`, a 16-letter Google app password (spaces are ignored). The password is checked against Gmail's servers before anything is stored, with the same refusals as an SMTP/IMAP connect; on success the mailbox becomes `smtp_imap` in place, keeps its imported mail, campaigns and warmup, and answers `200` with the mailbox. `409` `mailbox_not_google_signin` for any other mailbox, including one already switched, so a repeat changes nothing and takes no `Idempotency-Key`; `400` `app_password_invalid` when the value is not 16 letters. Publishes `AUDIT_CREATED` with `entity_type` `email_account`
* `GET /oauth/authorize/details`, `POST /oauth/authorize` (the consent flow: a human approves a third-party app, and the grant carries only the scopes the member's role covers). `POST /oauth/authorize` requires a recent confirmation (`POST /auth/reauth`), like creating an API key
* `GET /oauth/authorized-apps`, `DELETE /oauth/authorized-apps/:id` (apps the user has authorized)
* `GET /oauth/workspace-authorizations`, `DELETE /oauth/workspace-authorizations/:id/members/:userId` (every member's authorized apps, and revoking one; JWT permission `MANAGE_API_KEYS` or `MANAGE_SETTINGS`). A revoke succeeds when there is nothing to revoke and publishes `AUDIT_CREATED` with `entity_type` `oauth_authorization`
* `POST /getaway` (websocket bootstrap)
* `GET /realtime/info`
* `GET /me/danger-zone`, `POST /me/danger-zone/delete`, `DELETE /me/danger-zone/delete`
* `GET /me/views/:view`, `PUT /me/views/:view`, `DELETE /me/views/:view` (the signed-in member's own column layout and sort for a dashboard list in the current workspace; `view` is `contacts` or `campaign_leads`, or `unibox_rail` for the unibox scope rail, which saves a `layout` of `favorites`, `hidden`, `order` and `section_order` instead of columns. Personal to the session, so no API scope reaches it)
* `GET /invitations`, `POST /invitations/accept`
* Every other `/organization/*` route (create, switch, member and role changes, invitations, transfer ownership, avatar, export, danger zone). Reading the profile, limits, risk, members and roles, and `PATCH /organization/current`, take keys; see [workspace, plan and credits](#workspace-plan-and-credits)
  * `GET /organization/members` includes each member's `access_scope` (`workspace` or `restricted`) and `access` (`scope`, `campaign_folder_ids`, `campaign_ids`, `email_account_ids`); `GET /organization` carries the caller's own `access_scope` per workspace. `GET /organization/invitations` carries each invitation's `access`. It lists pending invitations; with `?include_expired=true` it also lists the 50 most recently expired, each marked `"expired": true`, so they can be sent again by inviting the same address. Any other value returns `400`
  * `GET /organization/members/:id/access` and `PUT /organization/members/:id/access` (JWT permission `MANAGE_TEAM`; the `PUT` also needs a recent confirmation). `:id` is the member's user id. The body is the whole scope, `{"scope": "restricted", "campaign_folder_ids": [], "campaign_ids": [], "email_account_ids": []}`, and replaces what was there, so a retry lands on the same state and takes no `Idempotency-Key`. `scope: "workspace"` clears the grants. Every id must belong to the workspace (`400` `invalid_access_resource`), at most 500 per list, the owner cannot be restricted (`403` `owner_access_unrestricted`), nobody can change their own access, and the caller must hold every permission the member's roles grant. Answers the member with `access`, and publishes `AUDIT_CREATED` with `entity_type` `organization_member`
  * `POST /organization/members/invite` accepts the same object as `access`, so an invitee joins already restricted. Ids are checked on invite and again on acceptance, which drops grants on anything deleted in between
  * `GET /organization/access/suggested-senders?campaign_ids=&campaign_folder_ids=` (JWT permission `MANAGE_TEAM`): the mailboxes those campaigns, and the campaigns in those folders, send from (explicit senders and sending-tag matches), each with the `campaign_ids` it sends for. A suggestion grants nothing
* `GET /website-tracking/settings`, `PATCH /website-tracking/settings`, `POST /website-tracking/settings/rotate-key` (the [website tracking](/guides/website-tracking/) snippet's consent mode, location precision, allowed hosts and retention; JWT permission `MANAGE_SETTINGS`. The rotate is bodyless and safe to repeat, each call issues a new key)
* Every other `/subscription/*` route (checkout, portal, cancel, change-plan, preview-change, enterprise-inquiry, discounts, referrals, etc.). The plan, trial, feature and credit reads take keys; see [workspace, plan and credits](#workspace-plan-and-credits)
* All of `/auth/cli/*` except the two handshake routes below (`GET /auth/cli/codes/:code`, `POST /auth/cli/codes/:code/approve`, `POST /auth/cli/codes/:code/deny`: the browser half of `ayinbox auth login`, where a signed-in member reviews the code a CLI is showing and authorizes it. Approving mints an ordinary API key, so it requires the `MANAGE_API_KEYS` organization permission and a fresh authentication, is session-only (an API key must not be able to mint another one this way), and caps the key to the scopes the approver's role allows. Looking up, approving or denying a code is limited to 30 a minute for each signed-in member, and is refused with a retryable `503` when that budget cannot be checked. `GET /auth/cli/codes/:code?organization_id=` adds `granted_scopes` and `granted_scope_names`: what approving into that workspace would grant)
* All of `/pool-link/*` and `/cloud-link/*`, which this build does not register (they answer `404`; see [AY Inbox Cloud](/guides/ayinbox-cloud/)). Upstream, they are the self-hosted warmup pool link ( approving an instance's code, listing and unlinking instances, reading the pool plan's price and opening its checkout, and on a self-hosted instance the connect flow and mailbox enrollment). `/cloud-link/*` exists only on a self-hosted instance. Connections belong to the selected local workspace, but linking and unlinking them (`/connect`, `/connect/poll`, `DELETE /cloud-link`) and the linked cloud workspace's mailboxes (`/workspace-mailboxes`, `.../adopt`) take a platform admin holding `manage_settings` whose session presented a second factor (`admin_mfa_required` otherwise). Reading the link and putting a workspace's own mailboxes on it (`GET /cloud-link`, `/mailboxes`, enroll, pause, resume and the Google and Microsoft sign-in under `/oauth/*`) take the workspace's `MANAGE_EMAILS`. `GET /cloud-link` includes `legacy_connected`; mailbox rows include `legacy` when they still use the old connection. A normal `DELETE /cloud-link` only disconnects the selected workspace's scoped link. `DELETE /cloud-link?legacy=true` explicitly disconnects the legacy link across all workspaces and uses the same platform-admin permission. New connections and enrollments require workspace scope, and a Cloud workspace accepts only one active link (legacy links count). `/pool-link/codes/:code` and its approve and deny routes share the CLI code's limit of 30 lookups a minute per signed-in member. `POST /pool-link/codes` requires `remote_organization_id` and returns `workspace_scoped: true` for supported clients. `POST /pool-link/codes` and `POST /pool-link/poll` are public and per-IP rate limited: they are the device-code handshake an instance uses before it has a token, and `/pool-link/instance/*` accepts only an instance token. `/pool-link/instance/oauth/*`, `/pool-link/instance/mailboxes/:id/token`, `/pool-link/instance/workspace-mailboxes` and `/pool-link/instance/mailboxes/adopt` are the cloud-managed mailbox surface (Google and Microsoft sign-in on AY Inbox's OAuth apps, brokered access tokens); their instance-side counterparts are `/cloud-link/oauth/*` and `/cloud-link/workspace-mailboxes/*`. `/pool-link/instance/redirects` (list), and `GET`, `PUT`, `DELETE` and `POST .../verify` on `/pool-link/instance/redirects/:domain`, are the root redirects AY Inbox Cloud serves for the calling instance; the instance drives them through `PUT /emails/domains/:domain/redirect` with `served_by: "cloud"`, and `GET /pool-link/instance` carries `redirects` (`available`, `host`, `limit`, `used`)
* All of `/admin/*`. `DELETE /admin/instance/invitations/expired` requires platform admin `manage_organizations` and an MFA-verified session. It removes only expired invitation records across all workspaces, leaves active invitations and membership unchanged, records an admin audit entry, and returns `{ "cleaned": true }`. The admin panel confirms before calling it.
  * `GET /admin/instance/health` requires `view_analytics` and an MFA-verified admin session. Returns configuration `checks`, `summary`, additive `execution_coverage` (currently `unreported`) and `operational_monitoring_path`. Empty findings do not establish execution coverage or operational health.
  * Aggregate PostgreSQL monitoring sources, including sync and worker health samples, use one database reference for SQL thresholds, windows and source/metric observation clocks. That observation can be later than the snapshot's collection-start `checked_at`; cached snapshots keep their original clocks. Database observation is not provider recovery evidence.
  * `GET /admin/instance/monitoring` requires `view_analytics` and an MFA-verified admin session, not an organization API-key scope. Returns the read-only version `"1"` snapshot with `checked_at`, `refresh_after`, `coverage` and permission-shaped `sources`; HTTP responses use `Cache-Control: no-store`, while the backend shares a bounded one-minute collection cache. Each source has availability, coverage, nullable observation/scope measurements and metrics with nullable counts, affected mailboxes/organizations, evidence clocks, conditions and optional investigation paths. Missing permissions return unavailable source placeholders with `permission_denied`, not private metrics. Source bits and interpretation are listed under [operational monitoring](/development/instance-health/#operational-monitoring). Unavailable is not zero or an outage; a positive broker lag/pending observation remains `unknown`/`info`, not proof of failed throughput. This route never subscribes, replays, reconnects, clears holds or changes broker offsets/assignments.
  * `GET /admin/analytics/overview` requires `view_analytics`. `total_emails_sent` and `emails_sent_today` count confirmed warmup and other outbound sends, not dispatches. Today uses the UTC calendar day. A send-count database error fails the request instead of returning zero counters. See [platform admin send counters](/guides/analytics/#platform-admin-send-counters).
  * `GET /admin/organizations/:id/plan-offers` (`view_organizations`) lists the private plans offered to a workspace as `{"offers": [...]}`, lapsed ones included, each with `plan_id`, `reason`, `granted_by`, `granted_at`, `expires_at`, `plan` and `active`. `PUT /admin/organizations/:id/plan-offers/:planId` (`manage_organizations`) offers or renews one with `{"reason": "...", "expires_at": null}`: the reason is required (at most 500 characters), an end date must be in the future, and a public plan or the Warmup plan is refused with `400`. `DELETE` on the same path withdraws it and answers `204`. See [plans offered to a workspace](/guides/billing/#plans-offered-to-a-workspace).
  * `GET /admin/organizations/:id/self-execution` (`view_organizations`) answers `{"grant": {...} | null, "active": bool}` for credential-free Cloud warmup; `PUT` (`manage_organizations`) grants or renews it with the same `reason` and `expires_at` body, and `DELETE` revokes it with `204`. Every offer and grant change writes the admin audit log. All of these routes need an MFA-verified admin session like the rest of `/admin/*`, and repeating one is safe.
  * `GET /admin/organizations/:id/self-execution/mailboxes` (`view_organizations`) lists the workspace's mailboxes whose credential stays on a linked instance, as an array of `{email_account_id, email, trust_state, reported_samples, divergence, evaluated_at, last_leased_at}`. `trust_state` is `trusted`, `probation` or `untrusted`; `divergence` is the absolute difference between the mailbox's reported spam rate and the rate Cloud-run recipients saw from the same senders, as a fraction from 0 to 1, and is null before the first audit with samples. `last_leased_at` is when the instance last leased commands.
  * `GET /admin/organizations` and `GET /admin/organizations/:id` require `view_organizations`. Their acquisition fields include the nullable `referrer_host` saved at signup, alongside UTM source, medium, campaign and landing path. The list accepts an exact `referrer_host` filter as well as its existing acquisition filters. An absent source is unknown, not proof of a direct visit. See [signup source attribution](/guides/analytics/#signup-source-attribution-platform-admins).
  * `GET /admin/warmup-content/overview` includes effective generation controls and requires `view_warmup_pool`; `PUT /admin/warmup-content/settings` writes effective controls with `manage_settings`. These are platform-admin routes, not organization API-key scopes. See [effective generation policy and stopping](/development/warmup-generation/).
  * `POST /admin/organizations/:id/sample-data`: `manage_testers` and an MFA-verified admin session. Adds the [reviewer sample dataset](/development/accounts-and-access/#adding-sample-data) only to an active dedicated Test workspace. No request body. Returns `200` with `organization_id`, `created` (false when already seeded) and `seeded_at`; retries preserve edits and deletions. An ordinary workspace, revoked or expired tester, or missing/expired managed Test grant returns `400`. Inserts and the admin audit marker commit together. No mailbox connection, email delivery, external request or credit spend occurs.
  * `GET /admin/testers`: `view_users` and an MFA-verified admin session. Each tester in `data` may include `test_workspace_id` for its owned dedicated Test workspace and `sample_data_seeded_at` when populated. These fields are absent for a tester merely attached to an ordinary workspace. Every tester carries `created_as_tester`, true for an account the tester flow created.
  * `POST /admin/testers/:id/password`: `manage_testers`, an MFA-verified admin session and a recent confirmation. No request body. Gives an account created as a tester a newly generated [initial password](/development/accounts-and-access/#initial-passwords), valid 48 hours and to be changed at first sign-in. It replaces whatever password the account held and signs out its sessions and remembered devices first. Returns `200` with `user_id`, `password` (shown only in this response), `password_expires_at` and `password_change_required: true`, and writes `reissue_tester_password` to the admin audit log. An account that does not exist or was not created as a tester returns `404` and nothing changes. Not idempotent by design: every call issues a different password, and only the last one works.
  * `PUT /admin/users/:id/login-code-exempt`: `manage_testers`, an MFA-verified admin session and a recent confirmation. Body `{"exempt": true, "reason": "..."}` excuses that account from the [emailed login code](/development/accounts-and-access/#excusing-one-account); the reason is required (at most 500 characters) and the acting admin is recorded as the grantor. `{"exempt": false}` ends the exemption, clears the password of an account created as a tester and signs out every session the account holds. Both write the admin audit log, and setting the same state again is safe. An unknown account returns `404`. `GET /admin/users/:id` and `/preview` carry `login_code_exempt`, `login_code_exempt_reason` and `login_code_exempt_at`
  * `GET /admin/sends/holds`: `view_campaigns` and an MFA-verified admin session. Lists mailboxes held from sending (up to `limit`, default 100, at most 500), switched-on first, with a `summary` of `total`, `active`, `inactive` and `by_reason`. Each row carries the held send's `held_task_id`, `recipients`, `message_id` and timestamps, and `resolvable` with a `blocker` when it is not. An unknown hold younger than ten minutes is a send in progress and is not listed.
  * `POST /admin/sends/holds/:id/resolve` (`:id` is the mailbox): `stop_campaigns` and an MFA-verified admin session. Body `held_task_id`, `held_reason`, `outcome` (`sent` or `not_sent` for an `unknown` hold, `provider_confirmed` for `authentication`, `permanent` or `conflict`), `message_id` (required for `sent`), optional `provider_msg_id`, and a one-line `confirmation_reference`. It applies the [mailbox resolution](/development/send-safety/#from-the-admin-panel) in the mailbox's own organization and writes the admin audit log. An unknown verdict answers `applied_by_consumer: true` and lifts once the result consumer applies it. A repeat is refused rather than applied twice, so retrying is safe. Errors are `send_hold_not_found`, `send_hold_changed` and `send_hold_not_resolvable`.
  * `GET /admin/mailboxes`: `view_users` and an MFA-verified admin session. Optional `mailbox_id` filters by exact UUID; `status=all` includes inactive mailboxes. Node evidence links use `/mailboxes?mailbox_id=UUID`, regardless of loaded worker assignments or earlier browse filters. Deleted identities return an empty result, not a substitute mailbox.

`POST /subscription/checkout`, `POST /subscription/portal`, `POST /subscription/cancel`, `POST /subscription/change-plan`, `POST /subscription/offers/:planId/checkout` and `GET /subscription/preview-change` require the `MANAGE_BILLING` organization permission; reading the subscription, its limits, trial, features and offers needs only membership. `POST /subscription/checkout` and `POST /subscription/change-plan` sell public plans, and a private plan only to a workspace holding an open offer for it; anything else is `403` `plan_offer_required`. `GET /subscription/offers` returns `{"offers": [...]}`, the private plans the workspace has an open offer for, each with `plan_id`, `name`, `currency`, `monthly_usd`, `yearly_usd`, `monthly_available`, `yearly_available`, `available` (false while billing is off or the plan has no Stripe price for any period), `warmup_only` and `expires_at`. `POST /subscription/offers/:planId/checkout` takes `{"interval": "month"}` or `"year"` and resolves the Stripe price on the server: it answers `{"checkout_url": "..."}`, or `{"changed": true}` when the workspace already has an active Stripe subscription, which is switched to the plan in place. Without an open offer it is `403` `plan_offer_required`, and a period with no price is `503`. Opening a checkout twice is harmless, so neither route takes an `Idempotency-Key`. `POST /subscription/portal` requires an existing Stripe billing customer. A workspace without one receives `400` with code `bad_request` and a message to complete checkout first; free and operator-granted plans alone do not create a billing customer.

Internal execution and managed consent [#internal-execution-and-managed-consent]

`POST /api/v1/internal/email-message-map/lookup` is a node-token-only read endpoint for mailbox synchronization, not a customer API-key scope. The JSON body carries `user_id`, `email_id` and at most 200 `message_ids` (at most 1 MiB in total). A successful response is `200` with `{"mappings": [...]}`; missing IDs are omitted and an empty result is an empty array, not `404`. Each returned mapping carries `user_id`, `email_id`, `message_id`, `id` and `thread_id`. Updated IMAP workers batch one envelope window at a time, without caching missing mappings across passes. Older workers keep using the single-message GET endpoint. Updated workers fall back to it only when the batch route returns `404` or `405`, and recheck batch support after ten minutes, so an existing self-host keeps working through a rolling upgrade. Authentication failures, database errors and malformed successful responses remain failures, not evidence that mail is absent. No sync interval, arrival acknowledgment or send authority changes.

<Callout type="warn" title="Not available in this build">
  Every `/cloud-link/*` and `/pool-link/*` route in the paragraphs below, and the Cloud-managed warmup they describe, is switched off in this build (`config.CloudLinkEnabled` and `config.PoolLinkEnabled`). The routes are not registered and answer `404`; the text stays for reference.
</Callout>

`POST /cloud-link/mailboxes/:id/enroll` takes an optional JSON body `{"executor": "cloud" | "instance"}`. `instance` keeps the mailbox's credential on this server: Cloud receives no credential and leases warmup commands that the instance's consumer runs on its own workers. It needs Cloud to advertise `self_execution.protocol` 1 with an active grant on `GET /pool-link/instance`, and is refused with `cloud_link_self_execution_not_granted`, `cloud_link_self_execution_unavailable` or `cloud_link_self_execution_managed` otherwise. Naming the other executor on an enrolled mailbox switches it; an empty body keeps its current executor, and repeating a request is naturally safe. Mailbox rows carry `executor`, `commands_pending` and `commands_failed`. Cloud's instance-token-only `POST /pool-link/instance/commands/lease`, `POST /pool-link/instance/commands/:id/result`, `POST /pool-link/instance/mailboxes/:remoteId/warmup-arrivals` and `POST /pool-link/instance/mailboxes/:remoteId/warmup-observations` carry the commands, their results and the warmup mail the instance reports. See [keep credentials on your server](/guides/ayinbox-cloud/#keep-credentials-on-your-server).

`DELETE /cloud-link/mailboxes/:id` removes the selected workspace's enrollment. On Cloud, instance-token-only `DELETE /pool-link/instance/mailboxes/:remoteId` stops both diagnostic send and receive participation for a managed mailbox before releasing its link, retaining its Cloud mailbox, sign-in and history. A failed stop retains the link for retry; a failed unlink does not restart participation. Unmanaged enrollments delete their Cloud mailbox as before. Whole-instance `DELETE /pool-link/instance` and workspace-authorized instance unlinking preserve managed Cloud participation while releasing links. These removals are naturally retryable; a missing remote enrollment returns `pool_link_mailbox_not_found`, which the self-hosted client treats as already released.

`PATCH /cloud-link/mailboxes/:id/participation` is self-hosted JWT-only with `MANAGE_EMAILS` and organization ownership. It accepts typed `mode` (`diagnostic` or `off`), `send`, `receive` and optional positive shared/rolling limits. `receive` also authorizes probabilistic spam rescue, reads, importance and stars or flags on verified warmup messages where supported; it does not authorize replies or guarantee reputation improvement. Cloud mirrors it through instance-token-only `PATCH /pool-link/instance/mailboxes/:remoteId/participation`, scoped to the calling instance. These dedicated routes prevent an old Cloud silently ignoring additive consent fields; old state without participation is not explicit diagnostic consent. The existing native lifecycle routes select diagnostic send/receive on start/resume and complete off on stop while retaining history. See [participation and send safety](/development/send-safety/).

For a mailbox AY Inbox Cloud warms, `PATCH /emails/:id` sends `warmup_base`, `warmup_max`, `warmup_increase`, `warmup_reply_rate`, `warmup_start_time`, `warmup_end_time`, `warmup_days` and `warmup` (as pause or resume) to AY Inbox Cloud through instance-token-only `PATCH /pool-link/instance/mailboxes/:remoteId`, then stores the values Cloud reports. The rest of the patch is written locally only once Cloud has applied its part, and the timezone travels with it. `test_mode`, `test_send_enabled`, `test_receive_enabled`, `shared_daily_limit` and `rolling_recipient_limit` return `409 cloud_warmup_managed`; use the participation route above. `POST /emails/:id/warmup/start` and `/resume` resume the mailbox on Cloud, `/pause` pauses it there, and `/stop` returns `409 cloud_warmup_managed`. `GET /cloud-link/mailboxes` rows carry `settings_observed_at`, when the local copy last matched Cloud. See [warmup settings](/guides/ayinbox-cloud/#warmup-settings) and [the refusals](/api/error-codes/#ay-inbox-cloud-warmup-refusals).

Cloud-managed OAuth additionally exposes `POST /pool-link/instance/oauth/start-correlated`, `POST /pool-link/instance/oauth/finish-correlated` and `POST /pool-link/instance/mailboxes/adopt-correlated`, all instance-token only and requiring `protocol: 1`. The authenticated instance response advertises `managed_consent_protocol: 1`. Legacy routes remain unchanged; absence of the capability cannot be bypassed with fields an old replica might ignore. See [the Cloud route and recovery contract](/guides/ayinbox-cloud/).

`GET /pool-link/instance/workspace-mailboxes` includes active delegated Google Workspace mailboxes alongside per-mailbox Google and Microsoft OAuth mailboxes, excluding mailboxes already enrolled to an instance. The adoption routes associate the selected Cloud mailbox ID without replacing its grant, history or settings. `GET /pool-link/instance/mailboxes/:id/token` mints delegated Google tokens through Cloud's domain grant, with the same instance, workspace, mailbox activity and pool-standing checks as OAuth token brokerage. Microsoft whole-organization grants are not adoptable. On the self-hosted side, `POST /cloud-link/workspace-mailboxes/:id/adopt` also reuses an existing active SMTP/IMAP mailbox with the same address in the selected workspace, switching it to brokered access and removing local credentials only after Cloud confirms. Local mailbox IDs, campaigns and imported messages remain. An unconfirmed enrollment on that connection can be replaced; an active enrollment, pending removal, vendor-managed mailbox or another connection cannot. This OAuth and delegation path uses the existing managed-consent protocol; the additional SMTP association marker is described below.

New clients request `GET /pool-link/instance/workspace-mailboxes?include_smtp_imap=true` to include existing SMTP/IMAP Cloud mailboxes. The default response keeps its earlier provider types for old clients. The self-hosted picker shows these only alongside an active local SMTP/IMAP copy in the selected workspace. Its same admin- and MFA-gated adoption route then calls instance-token-only `POST /pool-link/instance/mailboxes/associate-smtp` with `remote_id`, the explicitly selected Cloud `email_account_id`, and `email`. The instance response must advertise `smtp_association_protocol: 1`. This route associates records without transferring or comparing credentials, changing Cloud settings, or resuming paused warmup. Both copies keep their independent credentials. It refuses another workspace, a changed address, an inactive mailbox, an active Cloud campaign or an existing association elsewhere. Replay returns the same association and cannot revive a revoked remote ID. Cloud retains this independently owned mailbox and its history on unlink; the local app-password mailbox is also retained. Individual unlink stops Cloud participation, while whole-instance unlink preserves it. Migration `000322` adds the nullable selected Cloud ID to local enrollment rows for durable retries and independent local credential updates. This marker and links do not travel in workspace archives.

`POST /internal/worker/warmup-dispatch` is an execution-plane internal-token route, not a public JWT/API-key scope. It inspects, begins and durably finishes a task/mailbox/worker/nonce-bound command. Start repeats authoritative policy checks; unknown or denied authority cannot permit native execution. Receipt/header/model text supplies no authorization.

`POST /internal/email-message-map/arrival` uses the existing internal node token. Only `204` with `X-Ayinbox-Arrival-Durable: 1` acknowledges durable admission. Authoritative absence of the exact mailbox/user ownership returns `503` with `X-Ayinbox-Arrival-Ownership-Lost` containing the requested mailbox UUID and no durable acknowledgment. Updated workers retire only that mailbox generation; the failed arrival remains unacknowledged, its stored body is retained and its provider cursor is not advanced. Ordinary `503` responses, transport failures, database failures and encryption failures remain retryable. Older workers ignore the new marker and continue holding the arrival; an upgraded backend and worker are both required for retirement. This does not clear send holds, resend messages or alter Kafka offsets.

`POST /internal/worker/outbound-dispatch`, `/internal/worker/warmup-actions`, and `/internal/worker/diagnostic-auth` use the existing execution-plane internal token, never customer API keys. They bind the current mailbox/worker and require protocol 2. Outbound start additionally rechecks task/organization/provider/nonce/recipients and current campaign or diagnostic policy. Diagnostic authorization/result submission binds one exact already-owned warmup receipt and short-lived nonce; it neither ingests unrelated inbox messages nor trusts event/header claims. No public scope or permission was added. Details and limits: [send safety](/development/send-safety/).

Warmup actions for a deleted, unassigned or no-longer-authorized mailbox return no permitted actions. The worker treats that authoritative denial as a no-op without touching the provider. Database or authority failures still return `503` and retry; denial does not authorize a send, clear a send hold, or acknowledge an arrival.

`POST /internal/worker/warmup-actions` optionally accepts `filing_id` for a filing-only action. `filing_pending: true` is returned only for permitted, pure filing work still durably queued for that exact mailbox and its current worker. On a failed filing, this proof lets an updated worker finish the command without a `WarmupFiled` completion event; the durable recovery queue remains responsible for retrying it. Missing proof or an older backend retains the existing command retries. Deploy the backend before workers to enable this behavior. Successful filing still requires a completion event, and publication failures remain retryable. No send, arrival or retention-delete authority is added.

Referrals and discounts [#referrals-and-discounts]

The referral program and billing discount history are part of `/subscription/*`, so they are JWT only and never accept an API key. There is no API permission scope for them; browser callers are gated by the org permission below. The discount validation and checkout endpoints are listed under [Subscription and billing](/api/reference/account-org/) in the reference.

| Method | Path                                  | JWT permission   |
| ------ | ------------------------------------- | ---------------- |
| GET    | `/subscription/referral`              | `manage_billing` |
| POST   | `/subscription/referral`              | `manage_billing` |
| GET    | `/subscription/referral/attributions` | `manage_billing` |
| GET    | `/subscription/referral/earnings`     | `manage_billing` |
| GET    | `/subscription/discounts`             | `manage_billing` |

AI credits [#ai-credits]

Top-up purchases and spend-control changes are JWT only and never accept an API key; the balance, usage, transactions and settings reads also take a key with `READ_WORKSPACE` (see [workspace, plan and credits](#workspace-plan-and-credits)). Purchases are fulfilled only in the Stripe webhook, never in the checkout call. See the [AI credits](/guides/ai-credits/) guide for what each action costs and the pack and reset rules. `GET /subscription/credits` carries `unlimited` (boolean): `true` on a deployment with `BILLING_PROVIDER=none`, where the ledger is bypassed and every balance and allowance field is zero and meaningless.

| Method | Path                                 | JWT permission   |
| ------ | ------------------------------------ | ---------------- |
| GET    | `/subscription/credits`              | `manage_billing` |
| GET    | `/subscription/credits/transactions` | `manage_billing` |
| POST   | `/subscription/credits/checkout`     | `manage_billing` |

AI assistant [#ai-assistant]

Aya, the dashboard AI assistant, is JWT only: sessions are private to the member who started them. The message and approval runs stream over Server-Sent Events. Each tool Aya runs is gated by the member's own organization permission bits, so Aya can never do more than the member could by hand. See the [Aya](/guides/ai-assistant/) guide. (API-key and OAuth callers reach the same tools through the [MCP server](/api/mcp/) or the [REST agent-tools surface](/api/agent-tools/), each tool gated by its own scope.)

| Method | Path                        | JWT permission                    |
| ------ | --------------------------- | --------------------------------- |
| POST   | `/ai/sessions`              | organization member (use AI)      |
| GET    | `/ai/sessions`              | organization member (use AI)      |
| DELETE | `/ai/sessions`              | organization member (use AI)      |
| DELETE | `/ai/sessions/:id`          | organization member (use AI)      |
| GET    | `/ai/sessions/:id/messages` | organization member (use AI)      |
| POST   | `/ai/sessions/:id/messages` | organization member (use AI, SSE) |
| POST   | `/ai/sessions/:id/approve`  | organization member (use AI, SSE) |
| GET    | `/ai/tool-policies`         | `manage_settings`                 |
| DELETE | `/ai/tool-policies/:tool`   | `manage_settings`                 |

`POST /ai/sessions/:id/approve` takes `{"decision": "approve" | "deny" | "always_allow", "tool_call_id": "..."}`. `tool_call_id` is required and must be the call the session is waiting on (the `tool_call_id` of its `approval_required` event, or `pending.tool_call_id` from `GET /ai/sessions/:id/messages`); any other answers `409` `approval_not_pending`, and so does a second decision on the same call. `always_allow` saves a workspace policy only for a member with `manage_settings` and only for a tool that may be always allowed; otherwise it approves this one call. The `approval_required` event and `pending` carry `arguments` (every argument as indented JSON with sorted keys, capped at 16 KiB, with `arguments_truncated`), `preview` for a send (`from`, `to`, `subject`, `body`, `body_html`), and `always_allow_offered`.

`GET /ai/tool-policies` lists the tools the assistant runs without asking (`tool_name`, `created_by`, `created_by_name`, `created_at`); `DELETE /ai/tool-policies/:tool` revokes one. Both audit as `ai_tool_policy`.

Campaign brains [#campaign-brains]

Named context about an offer that every AI reply draft reads (see the [campaign brain](/guides/campaign-brain/) guide). Reads return public fields, `do_not_say`, `has_internal_rules` and source titles, never internal rules, internal material or skill text. Choosing the default, and editing or deleting the brain that is currently the default, also needs `manage_settings`; for a key, its creator must hold it. A restricted member reaches none of these routes.

| Method | Path                                           | Permission                                                                                                |
| ------ | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| GET    | `/ai/brains`                                   | `view_campaigns` / `READ_CAMPAIGNS`                                                                       |
| POST   | `/ai/brains`                                   | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| PUT    | `/ai/brains/default`                           | `manage_settings` / `WRITE_CAMPAIGNS`                                                                     |
| GET    | `/ai/brains/:id`                               | `view_campaigns` / `READ_CAMPAIGNS`                                                                       |
| GET    | `/ai/brains/:id/internal`                      | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| GET    | `/ai/brains/graph`                             | `view_campaigns` / `READ_CAMPAIGNS`                                                                       |
| GET    | `/ai/brains/:id/graph`                         | `view_campaigns` / `READ_CAMPAIGNS`                                                                       |
| GET    | `/ai/brains/:id/pages`                         | `view_campaigns` / `READ_CAMPAIGNS`                                                                       |
| POST   | `/ai/brains/:id/pages`                         | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| GET    | `/ai/brains/:id/pages/:pageId`                 | `view_campaigns` / `READ_CAMPAIGNS`; an internal page also needs `manage_campaigns` / `WRITE_CAMPAIGNS`   |
| PUT    | `/ai/brains/:id/pages/:pageId`                 | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| DELETE | `/ai/brains/:id/pages/:pageId`                 | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| GET    | `/ai/brains/:id/pages/:pageId/revisions`       | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| POST   | `/ai/brains/:id/pages/:pageId/restore`         | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| POST   | `/ai/brains/:id/organize`                      | `manage_campaigns` / `WRITE_CAMPAIGNS` and `use_ai` / `AI_AGENT`                                          |
| GET    | `/ai/brains/:id/vault`                         | `view_campaigns` / `READ_CAMPAIGNS`                                                                       |
| PATCH  | `/ai/brains/:id`                               | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| DELETE | `/ai/brains/:id`                               | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| POST   | `/ai/brains/:id/materials`                     | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| GET    | `/ai/brains/:id/materials/:materialId`         | `view_campaigns` / `READ_CAMPAIGNS`; an internal source also needs `manage_campaigns` / `WRITE_CAMPAIGNS` |
| PATCH  | `/ai/brains/:id/materials/:materialId`         | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| POST   | `/ai/brains/:id/materials/:materialId/refresh` | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| DELETE | `/ai/brains/:id/materials/:materialId`         | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| POST   | `/ai/brains/:id/assemble`                      | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |
| POST   | `/ai/brains/:id/sample-reply`                  | `manage_campaigns` / `WRITE_CAMPAIGNS` and `use_ai` / `AI_AGENT`                                          |
| GET    | `/campaigns/:id/brain`                         | `view_campaigns` / `READ_CAMPAIGNS`                                                                       |
| PUT    | `/campaigns/:id/brain`                         | `manage_campaigns` / `WRITE_CAMPAIGNS`                                                                    |

* `GET /ai/brains` returns `data` plus `pagination` with an opaque cursor; `limit` is 1 to 100 (default 50), and a bad cursor or limit answers `400`.
* `POST /ai/brains` takes `{name, fields?, seed?: "workspace"}` and answers `201`. `seed` copies the voice profile into empty offer, audience and tone fields. Send an `Idempotency-Key`.
* `PATCH /ai/brains/:id` takes `{name?, fields?}`. In `fields` a present key replaces, an absent key stays and `null` clears. A repeat lands on the same document, so it needs no key, and `revision` moves only when the fields change.
* `GET /campaigns/:id/brain` returns the brain that applies (`brain`, public fields only), `source` (`campaign`, `default` or `voice_profile`), its source titles, and `applied`: the merged values a draft reads, since tone, internal rules and do-not-say fall through from the workspace default. `applied.from` names where each value comes from (`campaign`, `default`, `campaign_and_default` or `voice_profile`); `applied` is `null` when no brain applies.
* `PUT /ai/brains/default` and `PUT /campaigns/:id/brain` take `{"brain_id": "<id>"}` or `{"brain_id": null}` to clear. The brain must belong to the workspace.
* `POST /ai/brains/:id/materials` takes JSON `{kind: "text", title, text, visibility?}` or `{kind: "url", url, title?, visibility?}`, or a multipart form with a `file` field and optional `title` and `visibility`. It answers `201`, or `200` with `deduplicated: true` when the same text is already on the brain with the same visibility; with the other visibility it answers `409 brain_material_duplicate` and changes nothing. Send an `Idempotency-Key` with JSON; send none with a file, since a retried upload carries a new multipart boundary and the content dedupe already makes the retry safe. `visibility` defaults to `internal`.
* `POST /ai/brains/:id/assemble` takes `{sample, campaign_id?}` (`sample` up to 12,000 characters) and returns `grounding` and `prompt_block`: what a reply would read, with playbooks named but never quoted. No model call and no charge.
* `POST /ai/brains/:id/sample-reply` takes the same body and returns the `/unibox/reply/draft` shape plus `grounding`. It charges AI credits (reason `brain_sample`) and sends nothing; the `Idempotency-Key` keys the charge.
* `GET /ai/brains/:id/pages` returns `data`: every page's `id`, `path`, `title`, `type`, `visibility`, `pinned`, `revision`, `char_count` and `updated_by_agent`, without bodies. `GET /ai/brains/:id/pages/:pageId` returns `page` with its `body`, `links` (each `[[target]]` it names, with `id`, `path` and `title` when a page answers it) and `backlinks`; an internal page answers `403 brain_internal_restricted` without `manage_campaigns`.
* `POST /ai/brains/:id/pages` takes `{title, body?, type?, visibility?, pinned?, path?}` and answers `201`. `type` is `note` (default), `offer`, `persona`, `case_study`, `objection`, `competitor`, `proof`, `faq` or `process`; without `path` the page lands in its type's folder under a path made from the title. A retry finds the path taken (`409 brain_page_path_taken`) and changes nothing, so it needs no key.
* `PUT /ai/brains/:id/pages/:pageId` states the whole page and takes `revision`, the one you read: a page saved since answers `409 brain_page_conflict`, and a retry of a write that already landed answers `200` with that page. `[[links]]` are read from `body` on every write. `GET .../revisions` lists the last 25 versions; `POST .../restore` takes `{revision}` and writes it back as the newest.
* `POST /ai/brains/:id/organize` takes an empty body and returns `created`, `updated`, `kept` (paths a person edited last, which it never overwrites), `dropped` and the credits. It charges AI credits (reason `brain_organize`) keyed by the `Idempotency-Key`, and refunds them when no page could be written. New pages are internal.
* `GET /ai/brains/:id/vault` answers a zip of Markdown files, one per page under a folder named after the brain, with frontmatter and `[[links]]`. Internal pages are included only for a caller with `manage_campaigns` / `WRITE_CAMPAIGNS`.
* `GET /ai/brains/graph` and `GET /ai/brains/:id/graph` return a graph: `nodes` (`id`, `kind`, `label`, `detail`, `ref`), `links` (`source`, `target`, `rel`), `center` for a single brain, and `truncated`. Nodes are the brains, their filled public fields, their sources and the campaigns that read them (a campaign without its own brain links to the default). Internal rules, do-not-say and source text never appear.
* `GET /contacts/:id/graph` returns the same shape centred on the contact: their company, colleagues at the same company domain, replies, meetings, notes and labels, and company signals. Research findings need `AI_RESEARCH`, deals `READ_CRM`, and campaigns with the brains they read `view_campaigns` / `READ_CAMPAIGNS`; without one, that part is left out rather than refused. A graph holds at most 160 nodes and sets `truncated` when it cut some.

Auto-reply [#auto-reply]

A campaign that opts in answers qualifying replies itself (see the [auto-reply](/guides/auto-reply/) guide). Turning it on, for the workspace or a campaign, and changing what an automatic campaign may send take `SEND_CAMPAIGNS` and, for a signed-in member, a recent confirmation (`POST /v1/auth/reauth`). Stopping it never does. A restricted member reaches only the run routes, filtered to their mailboxes.

| Method | Path                               | Permission                                                              |
| ------ | ---------------------------------- | ----------------------------------------------------------------------- |
| GET    | `/auto-reply/settings`             | `access_unibox` or `view_campaigns` / `READ_UNIBOX`                     |
| POST   | `/auto-reply/enable`               | `send_campaigns` / `SEND_CAMPAIGNS`, fresh sign-in                      |
| POST   | `/auto-reply/pause`                | `access_unibox`, `send_campaigns` or `manage_settings` / `WRITE_UNIBOX` |
| GET    | `/campaigns/:id/auto-reply`        | `view_campaigns` / `READ_CAMPAIGNS`                                     |
| PUT    | `/campaigns/:id/auto-reply`        | `manage_campaigns` / `WRITE_CAMPAIGNS`                                  |
| POST   | `/campaigns/:id/auto-reply/enable` | `send_campaigns` / `SEND_CAMPAIGNS`, fresh sign-in                      |
| GET    | `/auto-reply/runs`                 | `access_unibox` / `READ_UNIBOX`                                         |
| GET    | `/auto-reply/runs/:id`             | `access_unibox` / `READ_UNIBOX`                                         |
| POST   | `/auto-reply/runs/:id/cancel`      | `access_unibox` / `WRITE_UNIBOX`                                        |

* `GET /auto-reply/settings` returns `{available, unavailable_reasons, shadow_only, enabled, changed_at, sent_today}`. `POST /auto-reply/enable` and `/pause` take no body and set absolute state, so a retry is safe without a key.
* `GET /campaigns/:id/auto-reply` returns `settings` (`mode`, `intents`, `max_per_thread`, `allowed_links`, `allowed_figures`, `outside_window`, `instructions`, `disclosure_line`), `preconditions`, `warnings`, `suggested_links` and `suggested_figures` from the brain, `stats` over 30 days, `workspace_enabled` and `availability`.
* `PUT /campaigns/:id/auto-reply` takes the settings with `mode` of `off` or `shadow`. While the stored mode is `auto` it accepts only a lower `mode`; anything else answers `403 auto_reply_send_required`, and so does `mode: "auto"`.
* `POST /campaigns/:id/auto-reply/enable` takes the full settings and writes them together with `mode: "auto"`, after the preconditions pass. A mailbox-limited key needs an explicit sender pool inside its grant. Send an `Idempotency-Key`.
* `GET /auto-reply/runs` filters on `thread_id`, `campaign_id`, `contact_id` and `status`, returns `data` plus `pagination` with an opaque cursor (`limit` 1 to 100, default 50; a bad cursor, limit, id or status answers `400`), and leaves bodies out. `GET /auto-reply/runs/:id` returns one run with its `body` and `check_results`.
* `POST /auto-reply/runs/:id/cancel` takes an optional `{keep_draft, reason}` (`reason` is `member_cancelled` or `member_composing`). `keep_draft: true` turns the answer into a pending draft. Cancelling a cancelled run succeeds; a run already sending answers `409 auto_reply_run_already_sending`.

Calendar booking [#calendar-booking]

[Calendar booking](/guides/booking/) offers open times and books picks through the workspace's Cal.com or Calendly. Every route answers `503 booking_disabled` while `AYINBOX_BOOKING` is off. Anything that returns or is keyed by inbound text needs unibox access. Workspace-wide writes refuse a mailbox-limited key with `api_key_mailbox_limited`; offer routes check the offer's mailbox against the key's grant. A restricted member is refused.

| Method        | Path                                               | Member permission / API scope                                                                                        |
| ------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| GET           | `/booking/settings`                                | `access_unibox` or `manage_settings` / `READ_UNIBOX`                                                                 |
| PUT           | `/booking/settings`                                | `manage_settings` and `use_ai` / `INTEGRATIONS`                                                                      |
| POST          | `/booking/auto`                                    | `send_campaigns` / `SEND_CAMPAIGNS`, fresh sign-in                                                                   |
| GET           | `/booking/summary`                                 | `view_contacts` / `READ_CONTACTS`                                                                                    |
| GET           | `/booking/profiles`                                | `view_contacts` / `READ_CONTACTS`                                                                                    |
| POST          | `/booking/profiles`                                | `manage_settings` / `INTEGRATIONS`                                                                                   |
| PATCH, DELETE | `/booking/profiles/:id`                            | `manage_settings` / `INTEGRATIONS`                                                                                   |
| GET           | `/booking/connections/:id/event-types`             | `use_integrations` / `INTEGRATIONS`                                                                                  |
| GET           | `/booking/availability`                            | `view_contacts` and `access_unibox` for a member; `READ_CONTACTS` for a key, and with `thread_id` also `READ_UNIBOX` |
| GET           | `/booking/offers`                                  | `access_unibox` / `READ_UNIBOX`                                                                                      |
| GET           | `/booking/offers/:id`                              | `access_unibox` / `READ_UNIBOX`                                                                                      |
| POST          | `/booking/offers`                                  | `access_unibox` / `WRITE_UNIBOX`                                                                                     |
| POST          | `/booking/offers/:id/book`                         | `manage_contacts` and `access_unibox` / `WRITE_CONTACTS` and `WRITE_UNIBOX`                                          |
| POST          | `/booking/offers/:id/stop`                         | `access_unibox` / `WRITE_UNIBOX`                                                                                     |
| POST          | `/booking/offers/:id/resume`                       | `access_unibox` / `WRITE_UNIBOX`                                                                                     |
| POST          | `/booking/offers/:id/pick/reject`                  | `access_unibox` / `WRITE_UNIBOX`                                                                                     |
| PUT           | `/integrations/connections/:id/booking-credential` | `manage_settings` / `INTEGRATIONS`                                                                                   |

* `POST /booking/profiles`, `POST /booking/offers` and `POST /booking/offers/:id/book` take an `Idempotency-Key`; a booking is also unique per offer and time, so a repeat never books twice. The other writes set absolute state.
* `GET /booking/profiles` and `GET /booking/offers` return `data` plus `pagination` with an opaque cursor; a bad cursor or limit answers `400`.
* `POST /booking/offers` takes `{thread_id, profile_id?, timezone?, slot_starts?}` and returns `{offer, block, slots}`. It sends nothing and claims nothing: the offer starts when a reply carrying the block is sent, for example through `POST /unibox/reply`.
* `POST /booking/offers/:id/book` takes `{slot_id}` or `{start_at}`, and `send_confirmation` (default `true`). It answers `201 {meeting, offer, confirmation}`, `202 {offer, outcome: "unknown"}` while the provider's answer is being confirmed, `409 booking_slot_taken` with `alternatives`, or `409 booking_new_reply` with `message_id`.
* `PUT /integrations/connections/:id/booking-credential` takes `{api_key, base_url}`; the key is checked with the provider, sealed and never returned, and an empty key removes it. `base_url` is for a self-hosted Cal.com only and must pass the outbound URL rules.

Connected MCP servers [#connected-mcp-servers]

External MCP servers whose tools the assistant can use (see [Connect MCP tools](/guides/connect-mcp-tools/)). JWT-only and `manage_settings`-gated; bearer tokens are sealed with the org key and never returned.

| Method | Path                          | JWT permission    |
| ------ | ----------------------------- | ----------------- |
| GET    | `/ai/connections`             | `manage_settings` |
| POST   | `/ai/connections`             | `manage_settings` |
| PATCH  | `/ai/connections/:id`         | `manage_settings` |
| DELETE | `/ai/connections/:id`         | `manage_settings` |
| POST   | `/ai/connections/:id/refresh` | `manage_settings` |

Slack [#slack]

The [Slack](/guides/slack/) panel's routes. Linking is session-only: it binds a Slack account to the signed-in person, and an API key has no person to bind. Status, channels, settings and removing a member's link also take a key with `INTEGRATIONS`, see [Slack settings](#slack-settings). Settings changes and link removals publish `AUDIT_CREATED` with `entity_type` `integration`.

| Method | Path                              | JWT permission                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| ------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| GET    | `/integrations/slack/status`      | organization member. `app_configured`, `interactive_configured` and `my_link` (always the caller's own) for everyone; `connection`, `settings` and `missing_scopes` only for `manage_settings` or `use_integrations` (otherwise absent, `{}` and `[]`); `links` (every member's link) only for `manage_settings`                                                                                                                                                                                                                                                                                                                                             |
| GET    | `/integrations/slack/channels`    | `manage_settings` or `use_integrations`. Public channels and the private channels the bot is in, filtered by `q`, at most 200, as `data` plus `pagination`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| PUT    | `/integrations/slack/settings`    | `manage_settings`. Default `channel`, per-category `routes`, `assistant_disabled`, `assistant_dm_only`, `inbox_channel` (a channel id from the list; empty turns the [inbox channel](/guides/slack/#inbox-in-slack) off) and `inbox_scope` (`replies`, the default, or `all`). The inbox channel must be one the bot can see and not a Slack Connect channel, else `400`. Idempotent: the whole settings object is replaced                                                                                                                                                                                                                                  |
| GET    | `/integrations/slack/link/:code`  | signed in, any workspace. Previews the link a bot button carries: `organization_id`, `organization_name`, `is_member`, `slack_team_id`, `slack_team_name`, `slack_user_id`, `slack_user_name` and `slack_user_avatar` (as Slack reports them now; either may be empty), `user_email` (the caller's AY Inbox email), `email_matches` (the Slack account's email is the caller's AY Inbox email), `verify_available` (Sign in with Slack can confirm the link) and `expires_at`. An unknown or expired code is `404` `slack_link_invalid`                                                                                                                      |
| POST   | `/integrations/slack/link/verify` | signed in, member of the code's workspace (else `403` `forbidden`). `code` from the bot's button; answers `200` with `url`, a Sign in with Slack page for the code's Slack workspace. Slack returns to the integrations OAuth callback, which hands `code` and `state` to the opener. `503` `slack_verify_unavailable` when the Slack app has no client credentials                                                                                                                                                                                                                                                                                          |
| POST   | `/integrations/slack/link`        | signed in, member of the code's workspace (else `403` `forbidden`). `code` from the bot's button, plus `slack_code` and `state` from Sign in with Slack when the caller's AY Inbox email is not the email on the code's Slack account (without them that is `403` `slack_link_email_mismatch`, and the code stays usable). A Sign in with Slack result must be this caller's, for this code, and for the code's Slack account (`403` `slack_verify_failed` or `slack_verify_wrong_account`). Answers `201` with the link and replaces any earlier link of that Slack account. The code is single-use, so a repeat is refused with `404` `slack_link_invalid` |
| PATCH  | `/integrations/slack/link`        | organization member, own link. `dm_notifications`. `404` `slack_not_linked` when the caller has no link in this workspace                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| DELETE | `/integrations/slack/link`        | organization member, own link. Answers `204`, also when there was no link, so a repeat is safe                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| DELETE | `/integrations/slack/links/:id`   | `manage_settings`. Removes any member's link. Answers `204`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |

The two `/integrations/slack/link` routes that take a code do not need a workspace selected, because the code names one. `GET /integrations/slack/status` reports `app_configured` (the instance has a Slack app) and `interactive_configured` (it also has the signing secret, so the assistant and buttons work), so a client can tell what is available before calling anything else. The settings and channel routes answer `404` `slack_not_connected` until the workspace connects Slack. Error codes are under [404](/api/error-codes/#404-not-found) and [503](/api/error-codes/#slack_not_configured).

MCP server [#mcp-server]

`POST /v1/mcp` exposes the tool registry over the [Model Context Protocol](/api/mcp/) streamable-HTTP transport. It accepts an API key or an OAuth 2.1 access token (an unauthenticated request gets the RFC 9728 discovery challenge); no scope is needed to connect, and each tool needs its own. Each tool is gated by its scope, `tools/list` reflects only what the credential allows, and send-class tools are never exposed. Per-key rate limits apply.

Public [#public]

The dashboard's display-only `POST /v1/dashboard-image` endpoint accepts a session JWT and `{"url":"https://..."}` and returns image bytes. It is not API-key-capable. It reads owned public blobs through the active store, or fetches a public HTTPS image without forwarding browser credentials, cookies or referrer. The read rate limit applies; the body is limited to 16 KiB, the URL to 8,192 characters, the image to 5 MiB, and the request to ten seconds. Non-images, non-public destinations, unsafe redirects and unsupported ports are refused. Nothing is stored or changed, so retries need no idempotency key. Saved and sent emails retain the original URLs.

`POST /v1/dashboard-image/public` has the same body and image limits but accepts only this instance's owned public objects and the Slack sign-in avatar providers (`avatars.slack-edge.com` and `secure.gravatar.com/avatar/`). It is per-IP rate limited and cannot fetch arbitrary customer URLs anonymously.

* `GET /health`
* `POST /auth/cli/code`, `POST /auth/cli/poll` (the [CLI](/api/cli/) device-code handshake, per-IP rate limited. Public by necessity: the CLI has no credential until the flow completes. `POST /auth/cli/code` returns `device_code`, `user_code`, `verification_uri`, `verification_uri_complete`, `expires_in` and `interval`; polling returns `{"status":"pending"}` until a member decides, then `{"status":"approved"}` carrying the minted key exactly once, or `{"status":"denied"}`. An unknown or expired `device_code` is a `404`, so a poller cannot probe for live handshakes)
* `POST /webhook/stripe` (Stripe signature)
* `POST /api/v1/integrations/hubspot/webhooks` (HubSpot app webhooks, verified with the `X-HubSpot-Signature-v3` signature over the app's client secret and refused when older than five minutes. A delivery only makes the next read of the named record come sooner; see [HubSpot](/guides/hubspot/#self-hosting))
* `POST /api/v1/integrations/pipedrive/webhooks/:connectionId` (Pipedrive webhooks AY Inbox registers for one connection when a workspace switches to Pipedrive. Authenticated by HTTP Basic auth with a password derived from the connection and the app's client secret. A delivery only makes the next read of the named record come sooner; a connection no workspace runs its CRM on answers `410`. See [Pipedrive](/guides/pipedrive/#self-hosting))
* `GET` and `POST /api/v1/integrations/pipedrive/app/panel`, `/app/enroll` and `/app/pause` (the AY Inbox JSON panel on Pipedrive person and deal pages and its two JSON modals). Pipedrive signs each call with a JWT over the app's client secret, passed as the `token` query value and pinned to HS256; the user and company it names must match the `userId` and `companyId` query values, and a repeated value is refused. The company selects the one workspace in Pipedrive mode with a live connection to it, and each call acts as the member matched to that Pipedrive user, with its permissions. See [working from inside Pipedrive](/guides/pipedrive/#working-from-inside-pipedrive)
* `POST /api/v1/integrations/hubspot/app/card`, `/app/enroll`, `/app/pause` (the AY Inbox card on HubSpot contact records) and `POST /api/v1/integrations/hubspot/actions/enroll`, `/actions/campaigns` (the "Add to AY Inbox campaign" workflow action). HubSpot signs each request with the app's client secret (`X-HubSpot-Signature-v3`, over the public backend URL). The card routes take the portal and HubSpot user from the query values HubSpot appends, exactly one of each, and act as that user's matched member with its permissions; the webhook and workflow action routes refuse a request carrying those values. The portal selects the one workspace in HubSpot mode with a live connection to it. See [working from inside HubSpot](/guides/hubspot/#working-from-inside-hubspot)
* `POST /webhook/campaign`, `/webhook/email`, `/webhook/user-email` (Google OIDC token from Cloud Tasks)
* `GET /addresses/google/callback`, `GET /addresses/outlook/callback` (OAuth bouncer pages)
* `POST /oauth/token`, `POST /oauth/revoke` (OAuth token endpoints, authenticated by the client's id and secret, or PKCE for public clients)
* `POST /oauth/register` (OAuth dynamic client registration, RFC 7591; open and per-IP rate-limited)
* `GET /agents` (the agent front door: whether agent sign-up is on, the scopes a new key gets, the limits, and the MCP and API addresses) and `POST /agents/register` (agent sign-up: opens an account, a workspace on the free trial and an API key holding the [agent sign-up scopes](/api/permissions/#agent-sign-up-key-17793791), answered `201` once with the key and never stored. No captcha, so it is limited per source address, per /24 or /48 network and per instance, and refuses with `503 agent_signup_unavailable` when it cannot count. Takes an optional `Idempotency-Key`, scoped to the caller's address; a retry answers `409 idempotent_secret_withheld`. Off on a self-host by default. See [For agents](/api/for-agents/) and [its errors](/api/error-codes/#agent-sign-up-errors))
* `GET /.well-known/oauth-authorization-server`, `GET /.well-known/oauth-protected-resource` (OAuth discovery metadata. When agent sign-up is on, the protected-resource document also carries `agent_registration_endpoint` and `agent_guide`)

On the backend's host but outside `/v1`, called by Slack and never by an API key or a session:

* `POST /api/v1/integrations/slack/events`, `POST /api/v1/integrations/slack/interactivity` (Slack signature). Each request must carry a valid Slack `X-Slack-Signature` made with the instance's signing secret over a timestamp within five minutes, and its body is capped at 1 MiB. A bad signature is `401` `unauthorized`, and nothing in the body is parsed before the signature checks out. With no `SLACK_SIGNING_SECRET` set they answer `503` `slack_not_configured`. They answer Slack within its three-second limit and do the work afterwards. See [Slack app](/development/slack-app/)

On the tracking service (the `TRACKING_DOMAIN` host, not the API), also public and rate-limited per source:

* `GET /t/o/:task_id.png` (open pixel), `GET /c/:link_id` (click redirect)
* `GET /tracking.js` (the website tracking snippet), `POST /p` (page-view ingest; JSON body up to 8 KB, `429` over budget, `204` otherwise. Nothing in the request can name a contact)
* A visit to a [sending domain's root](/guides/sending-domains/#root-redirects) (or its `www`) that a workspace pointed here. On a verified domain every path except `/health` answers `302` to its website with `Cache-Control: public, max-age=300`, before any route above; an unknown host is a `404`. The forms service answers a verified redirect domain the same way

Scheduled jobs [#scheduled-jobs]

`GET /admin/jobs` requires admin `view_analytics` and returns registered loops
and retained retired records in `data`. Each row includes its service, interval,
last recorded status and error, execution counters and recorded schedule.
`retired_at`, when present, is the timestamp of explicit retirement
classification, not proof of when the last process stopped. Retired records
remain visible in Jobs with their historical values; they are not current
running, overdue, failed or successful jobs in operational monitoring.

`POST /admin/jobs/:name/run` requires admin `manage_settings`. It requests a
registered loop to run at its next poll; it does not execute the job itself.
Missing and explicitly retired records return `404 not_found`. Retired loops
cannot claim new scheduled slots or take manual requests. Retirement does not
interrupt an already admitted run or erase its completion evidence.

Migration 286 classifies only the backend `fleet_quarantine` loop, which was
removed from the application, as retired. Other old or stalled jobs remain
active and retain their monitoring warnings. There is no age-based automatic
retirement. Registering a loop again reactivates its record and clears any
old pending run request, without resetting its execution counters or errors.

Platform node diagnostics [#platform-node-diagnostics]

Backend-origin routes outside `/v1` require a normal bearer session, live admin
permissions and session MFA. Workspace API keys/OAuth API tokens cannot use them.

| Method and path                     | Existing admin permissions                |
| ----------------------------------- | ----------------------------------------- |
| `POST /admin/auth/device/describe`  | Any nonzero admin permission              |
| `POST /admin/auth/device/decide`    | Any nonzero admin permission + fresh auth |
| `GET /admin/fleet/nodes/:id/logs`   | `VIEW_WORKERS`                            |
| `GET /admin/fleet/nodes/:id/broker` | `VIEW_WORKERS` and `VIEW_ANALYTICS`       |

Public device starts/polls are `POST /v1/auth/cli/admin/code` and `/poll`.
See [exact auth schemas](/api/authentication/#platform-admin-device-sessions).
There is no remote shell, arbitrary execution endpoint or admin API key.
Mutations stay on existing permission-gated admin endpoints and audit trails.

Recent evidence [#recent-evidence]

`GET /admin/fleet/nodes/:id/logs` accepts optional `after`/`before` (inclusive
RFC3339 timestamps, after cannot follow before), `level` (`info`, `warn`,
`error`), `mailbox_id` (mailbox account UUID, not folder/message) and `limit`
(integer 1..200, default 200). Newest-first results contain at most one hour
and 1,000 retained events per node; expired/count-evicted evidence cannot be
recovered. This is allowlisted process evidence, not a full journal export.

Response `200` (`Cache-Control: no-store`):

```json
{
  "node_id": "<node UUID>",
  "availability": "fresh", "coverage": "partial",
  "reason": "allowlisted_evidence_only", "observed_at": "2026-10-10T06:10:00Z",
  "capture": {
    "protocol": 1, "run_id": "<process UUID>", "started_at": "2026-10-10T06:00:00Z",
    "observed_at": "2026-10-10T06:09:58Z", "received_at": "2026-10-10T06:09:59Z",
    "dropped": 2
  },
  "events": [{
    "id": "<event UUID>", "observed_at": "2026-10-10T06:09:50Z",
    "event": "sync_control_plane_held", "level": "warn", "category": "control_plane",
    "mailbox_id": "<mailbox account UUID>", "http_status": 503, "count": 1
  }],
  "oldest_retained_at": "2026-10-10T06:09:50Z", "newest_retained_at": "2026-10-10T06:09:50Z",
  "retained_count": 1, "truncated": false, "retention_seconds": 3600, "max_events": 1000
}
```

`capture` is omitted until observed. Oldest/newest/count describe the entire
retained window, not filtered rows. `truncated` means additional matching
retained rows exist beyond the response limit. `dropped` counts known queue
overflow/exhausted transport batches since that process's start, not lifetime
loss. It resets on `run_id`. Crash loss, events before capture installation and
lost acknowledgements cannot be measured completely: an exhausted batch is
counted as dropped even if ingestion succeeded but its acknowledgement was
lost. TTL/count eviction is intentional retention, not transport loss.

| Availability / reason                        | Meaning                                                                    |
| -------------------------------------------- | -------------------------------------------------------------------------- |
| `unavailable` / `log_credential_unavailable` | Old/manual worker or lost Redis enrollment state; re-enroll                |
| `unavailable` / `capture_not_observed`       | Enrolled but no upgraded capture received; might be an old worker          |
| `unavailable` / `cache_unavailable`          | History cannot be read, not zero errors                                    |
| `stale` / `no_recent_capture`                | Capture observation older than two minutes; retained rows may still appear |
| `fresh` / `no_recent_evidence`               | Recent empty capture, not proof of provider health                         |
| `fresh` / `allowlisted_evidence_only`        | Fresh partial evidence, never complete/healthy inference                   |

Fixed event schema: UUID `id`, RFC3339 `observed_at`, allowlisted `event`,
canonical `level`/`category`, optional mailbox UUID and numeric `count`
(0..1,000,000). Only `sync_control_plane_held` retains numeric HTTP status
(100..599), including actual sync map/control-plane 503 responses.

| Event                               | Category / level           | Observed behavior                                                 |
| ----------------------------------- | -------------------------- | ----------------------------------------------------------------- |
| `sync_control_plane_held`           | `control_plane` / `warn`   | Actual failed sync control-plane call; pass aborted, cursors held |
| `sync_message_deferred`             | `control_plane` / `warn`   | Unpublished map cleanup failed, pass skipped                      |
| `mailbox_provider_unreachable`      | `provider` / `warn`        | Actual sync connection/transport failure                          |
| `mailbox_provider_auth_failed`      | `provider` / `warn`        | Actual sync auth/authorization failure                            |
| `mailbox_provider_throttled`        | `provider` / `warn`        | Actual sync throttle                                              |
| `mailbox_provider_sync_completed`   | `provider` / `info`        | Measured complete pass after prior transport failures             |
| `sync_folder_skipped`               | `sync_policy` / `info`     | Count of folders excluded by sync policy                          |
| `sync_message_deferred_by_policy`   | `sync_policy` / `info`     | Count of messages deferred in a sync pass                         |
| `sync_skipped_folder_search_failed` | `provider` / `warn`        | Actual excluded-folder search failure                             |
| `sync_legacy_arrival`               | `compatibility` / `warn`   | Existing legacy arrival fallback used                             |
| `worker_error`                      | `worker_runtime` / `error` | Worker Zap error severity only, without arbitrary log text        |

History also reports `generic_error_coverage: "unavailable"` and `generic_error_reason: "unhooked_runtime_sources"`. Generic runtime error capture currently hooks worker Zap only. Worker Zerolog and consumer standard-log/Zerolog errors are not captured. Explicit allowlisted sync/provider/control-plane decision hooks remain separate and unchanged. A fresh empty capture, including a consumer capture, never establishes zero runtime errors or health. These fields describe missing source coverage, not a current failure.

No addresses, subjects, bodies, headers, raw errors, folder names or provider
credentials are forwarded. Unknown free-form fields are discarded; categories
and levels are reconstructed before transport and again on ingestion/read.
Unknown events are rejected. Evidence does not change sync leases, cursor
handling, arrival admission or send authority.

Node ingestion [#node-ingestion]

`POST /api/v1/internal/fleet/nodes/:id/logs` accepts only the enrollment-issued
node-specific `Authorization: Bearer <NODE_LOG_TOKEN>`. The existing fleet join
token authorizes issuance in `env_b64`. Shared broker/internal tokens are not
accepted; hashes bind to exactly one registered node and are rechecked atomically
at ingestion. Rejoin rotates; node deletion revokes and removes log state.
This credential grants evidence submission only, not mailbox/command access.

There is no broker-key-to-log-credential refresh endpoint: the existing broker
key authenticates the fleet, not ownership of a particular node UUID. Existing
nodes must upgrade and rerun authenticated enrollment with the same UUID. The
normal join script reads that UUID from the existing `node.env`, retains
`node.local.env` and restarts the service; do not remove the configuration
directory or delete the fleet row. Until capture is received, availability is
unavailable, not healthy. Redis enrollment-hash loss requires the same safe
re-enrollment, and no historical journals are imported. See
[upgrade order and coverage](/development/data-control/#upgrade-order-and-coverage).

Request JSON (max 32 KiB, 32 events; events use the schema above):

```json
{
  "protocol": 1, "batch_id": "<UUID, stable across retries>", "run_id": "<process UUID>",
  "started_at": "2026-10-10T06:00:00Z", "observed_at": "2026-10-10T06:09:58Z",
  "dropped": 2, "events": []
}
```

Empty batches report capture coverage only. Batch time must be within two
minutes of receipt and no more than one minute ahead. Events must be within
one-hour retention and no more than one minute ahead. `started_at` must be
present and not after observation. Duplicate batch IDs are idempotent for one
hour; older batches cannot overwrite newer capture metadata. Cadence is five
seconds, queue 256 events, at most three attempts with bounded backoff and
five-second HTTP deadlines. Capture never waits for network/queue space.
Authenticated requests are capped at 30/node/minute. Success `204`; errors use
the standard envelope: `400 bad_request` (invalid protocol/time/bounds/event),
`401 unauthorized` (missing/wrong-node/revoked credential), `404 not_found`
(absent registered node), `429 rate_limit_exceeded` (`Retry-After: 60`) and
`503 service_unavailable` (dependencies).

Broker committed lag [#broker-committed-lag]

`GET /admin/fleet/nodes/:id/broker` accepts a registered worker UUID, no
topic/group overrides. Existing read-only scopes remain separate:
`commands` observes `worker-<UUID>` / `w.<UUID>`; `results` observes
`consumer-group` / `jobs.worker-events`, shared results, not attributable to
that worker. No extra consumer scopes or offset mutations are created.

Response `200` has `node_id`, RFC3339 `observed_at`, `commands`, `results` and
`note`. Each uses existing `MonitoringSource`: `id`, `availability`, `coverage`,
optional `reason`, `observed_at`, `checked_at`, measured/expected scopes and
`metrics`. Successful metrics are `committed_lag` (messages) and `members`, with
count, observation/evidence timestamps, `condition: "unknown"`, severity and
cautionary note. Only this focused endpoint opts into lag partition detail:

```json
{
  "broker": {
    "consumer_group": "worker-<UUID>", "topics": ["w.<UUID>"],
    "partitions": [{"topic":"w.<UUID>","partition":0,"earliest":10,"committed":16,"latest":22,"committed_lag":6}]
  }
}
```

Every requested topic/partition and committed/earliest/latest offset is
validated; partition IDs must be nonnegative, max 128 partitions/scope, sorted
by topic/partition. Missing commits, offsets outside retention, mismatched
partitions, negative/overflowing lag, permission errors and timeouts are
unavailable, never clamped to zero. Failed metrics have null count, no broker
detail, unknown condition and attempted observation time. Reasons include
`dependency_missing`, `resource_absent`, `permission_denied`, `timeout`,
`collection_failed`; non-Kafka/unsupported buses return `unsupported` with
empty metrics. One scope failure does not hide another successful scope.
Overall collection deadline is eight seconds.

Lag is **not** delivery, provider health, throughput, uncommitted in-flight
work, queue-drain time or an outage. Membership alone is not an outage either.
Errors: `400 bad_request` (UUID/non-worker/invalid log filters),
`401 unauthorized`, `403 admin_mfa_required`/`forbidden`, `404 not_found`,
`503 service_unavailable` (node registry). Log Redis read failure instead
returns `200` with explicit unavailable coverage. Successful diagnostics use
`Cache-Control: no-store`.

Internal warmup recovery [#internal-warmup-recovery]

These control-plane endpoints use the existing node-internal credential, not an API key, dashboard JWT or node logging credential. Their base is `/api/v1/internal`, not the public API base above. They introduce no new node identity or credential exchange.

| Method | Internal path                  | Scope                                                                                                                             |
| ------ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| POST   | `/worker/warmup-actions`       | Check current mailbox assignment and permitted actions; exact pending single-file work can return versioned filing recovery proof |
| POST   | `/worker/warmup-actions/defer` | Commit a monotonic provider retry time for that exact pending filing under current mailbox assignment                             |

Deferral accepts `mailbox_id`, `worker_id`, `filing_id` and an absolute `provider_retry_at` timestamp. Successful `persisted: true` is returned only after commit, with `filing_recovery` proof containing protocol `1`, the same three IDs and the retained timestamp. Requesting a shorter time never shortens an existing provider hold or ordinary retry lease. There is no GET mutation or new filing-success event on deferral.

For exact filing commands, an upgraded backend returns no permitted actions when the pending filing is completed, missing, foreign or has a different action payload. Workers skip provider filing and do not emit a filing-success event for that denial. Untracked actions retain the existing admission behavior.

Invalid identifiers or time return `400`; stale, deleted, foreign or disallowed work returns `403`; unsupported authority or database failure returns `503`. Responses use `Cache-Control: no-store`. Clients reject unmarked, malformed, unsupported-version or incorrectly scoped replies as durable deferral proof. Existing older-backend retry behavior is unchanged; an older worker does not implement the provider floor. See [warmup reconnect recovery](/development/data-control/#warmup-reconnect-recovery) for backend-first upgrade and source-instance data handling.

Notes [#notes]

* When a route has both a JWT permission and an API permission listed, the dual-auth middleware checks the JWT user's organization role for browser callers and the key's permission bitmask for API key callers. They're independent gates: a user's role doesn't constrain what an API key can do beyond what was granted at creation.
* Every API key request is logged to `api_key_usage_logs` with the endpoint pattern (e.g. `/campaigns/:id`), method, IP, user-agent, response status, and elapsed milliseconds. JWT requests are not logged here.
* Rate limit categories (`X-RateLimit-*` headers) are scoped to the underlying user, not the key; every key issued by a user shares the user's quota.

See also [#see-also]

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