mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 16:02:02 +00:00
653 lines
66 KiB
Plaintext
653 lines
66 KiB
Plaintext
---
|
|
title: Endpoint scope map
|
|
description: Every endpoint, which auth types it accepts, and which permission it requires.
|
|
---
|
|
|
|
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.
|
|
|
|
All paths below are relative to the versioned base URL `https://api.warmbly.com/v1` (for example `/campaigns` is `https://api.warmbly.com/v1/campaigns`). See [versioning](/api/) for details.
|
|
|
|
## API key accepted
|
|
|
|
### Emails
|
|
|
|
| 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/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, 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/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 `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/).
|
|
|
|
`/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
|
|
|
|
| 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` |
|
|
| GET | `/contacts/:id/segments` | `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/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.
|
|
|
|
### 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.
|
|
|
|
### 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/). 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. 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
|
|
|
|
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
|
|
|
|
| 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 Warmbly's own: the copy at the mail provider stays where it is, and the next sync will not undo it, 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.
|
|
|
|
`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, and your [voice profile](/guides/unibox/#ai-reply-drafts). It charges AI credits; see [AI credits](/guides/ai-credits/).
|
|
|
|
`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. 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). 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-a-new-email).
|
|
|
|
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.
|
|
|
|
`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. `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).
|
|
|
|
`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).
|
|
|
|
`DELETE /emails/:id` releases the mailbox on Warmbly 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. Warmbly 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"`. Warmbly 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; `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.
|
|
|
|
### 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` |
|
|
|
|
### 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.
|
|
|
|
`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`.
|
|
|
|
### 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
|
|
|
|
Recommendations about deliverability, mailbox configuration, warmup, campaign performance, copy, and list quality. See the [Advisor guide](/guides/advisor/).
|
|
|
|
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` | permission of the underlying change |
|
|
| POST | `/advisor/recommendations/:id/undo` | permission of the underlying change |
|
|
|
|
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
|
|
|
|
| Method | Path | API Permission |
|
|
|--------|------|----------------|
|
|
| GET | `/api-keys` | `API_KEYS` |
|
|
| POST | `/api-keys` | `API_KEYS`, JWT only |
|
|
| GET | `/api-keys/permissions` | `API_KEYS` |
|
|
| GET | `/api-keys/:id` | `API_KEYS` |
|
|
| PATCH | `/api-keys/:id` | `API_KEYS` |
|
|
| DELETE | `/api-keys/:id` | `API_KEYS` |
|
|
| DELETE | `/api-keys/:id/permanent` | `API_KEYS` |
|
|
| 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` requires 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. An API key or OAuth token calling it 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.
|
|
|
|
`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 [`warmbly 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
|
|
|
|
Registering and managing the OAuth apps your workspace owns. The flow itself (authorize, token, revoke) is listed under JWT only and Public below.
|
|
|
|
| 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` |
|
|
| 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` |
|
|
|
|
### Operations
|
|
|
|
| Method | Path | API Permission |
|
|
|--------|------|----------------|
|
|
| GET/PATCH | `/outreach/settings` | `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/*` | `INTEGRATIONS` |
|
|
| GET/POST/PATCH/DELETE | `/automations[/:id]` | `INTEGRATIONS` |
|
|
| PATCH | `/automations/:id/layout` | `INTEGRATIONS` |
|
|
| GET/POST/PATCH/DELETE | `/warmup/routing[/:id]` | `WARMUP_ROUTING` |
|
|
|
|
### Retry safety
|
|
|
|
Mutating API requests may include an `Idempotency-Key` header. Warmbly stores the completed response for 24 hours per organization and key. Reusing the same key with the same method, route, query, and body replays the original response with `X-Idempotent-Replayed: true`; reusing the key with a different request returns `409 Conflict`.
|
|
|
|
### 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
|
|
|
|
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, and reads it from `GET /auth/me`, which returns `folders`, `tags` and `categories` for the session's selected workspace; there is no separate list endpoint and no read scope of its own. Changing a registry is gated like the records it labels, so it takes the API permission in the table below, or the matching JWT permission `MANAGE_CAMPAIGNS`, `MANAGE_EMAILS` or `MANAGE_CONTACTS`. Membership alone is read-only.
|
|
|
|
`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 |
|
|
|--------|------|----------------|
|
|
| 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` |
|
|
|
|
### Reference data
|
|
|
|
| Method | Path | API Permission |
|
|
|--------|------|----------------|
|
|
| GET | `/plans` | (any authenticated key) |
|
|
| GET | `/timezones` | (any authenticated key) |
|
|
|
|
## 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.
|
|
|
|
- `POST /auth/login`, `/auth/login/confirm`, `/auth/register`, `/auth/register/confirm`, `/auth/refresh`, `/auth/reset-password`, `/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/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)
|
|
- `GET /auth/instance` (JWT only: the running Warmbly 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
|
|
|
|
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 an API key | `POST /api-keys` |
|
|
| Add a passkey | `POST /auth/passkey/register/begin`, `/finish` |
|
|
| Remove a passkey | `DELETE /auth/passkey/credentials/:id` |
|
|
| Transfer a workspace | `POST /organization/transfer-ownership` |
|
|
| Schedule a workspace or account for deletion | `POST /organization/current/danger-zone/delete`, `POST /me/danger-zone/delete` |
|
|
| 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` |
|
|
|
|
Each either hands out a credential that outlives the session that created it, 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 with `POST /auth/reauth` (password or a current two-factor code) and retry. See [error codes](/api/error-codes/#confirmation-required).
|
|
|
|
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.
|
|
|
|
`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 `warmbly events tail` and `warmbly browse`.
|
|
|
|
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. It is `false` unless the deployment sets `BOX_GOOGLE_OAUTH_CONNECT=true`, and a client should then 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`. Mailboxes already connected with Google sign-in are unaffected either way.
|
|
|
|
`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)
|
|
- `POST /auth/logout`, `POST /auth/logout-all`, `GET /auth/me`, `PATCH /auth/me/onboarding`
|
|
- `POST /auth/me/avatar`, `DELETE /auth/me/avatar`
|
|
- `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
|
|
- `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 still on per-mailbox Google sign-in, which is [being retired](/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 Warmbly 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` (`_warmbly.<domain>`) and `txt_value` (`warmbly-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 Warmbly 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`, JWT permission `MANAGE_EMAILS`. `: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. 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`, `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` and optional `include_www` (default `true`); checks DNS once and answers the redirect with its `records`. Idempotent: a repeat keeps the same ownership value, so the `TXT` record stays valid
|
|
- `POST /emails/domains/:domain/redirect/verify`: check DNS now. Safe to repeat
|
|
- `DELETE /emails/domains/:domain/redirect`: stop serving 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/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)
|
|
- `GET /oauth/authorized-apps`, `DELETE /oauth/authorized-apps/:id` (apps the user has authorized)
|
|
- `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`. Personal to the session, so no API scope reaches it)
|
|
- `GET /invitations`, `POST /invitations/accept`
|
|
- All of `/organization/*` (create, switch, members, invitations, transfer ownership, avatar, danger zone)
|
|
- `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)
|
|
- All of `/subscription/*` (checkout, portal, cancel, change-plan, preview-change, enterprise-inquiry, discounts, referrals, etc.)
|
|
- 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 `warmbly 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 is session-only: an API key must not be able to mint another one this way)
|
|
- All of `/pool-link/*` and `/cloud-link/*` (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). `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 Warmbly's OAuth apps, brokered access tokens); their instance-side counterparts are `/cloud-link/oauth/*` and `/cloud-link/workspace-mailboxes/*`
|
|
- All of `/admin/*`
|
|
|
|
`POST /subscription/checkout`, `POST /subscription/portal`, `POST /subscription/cancel`, `POST /subscription/change-plan` and `GET /subscription/preview-change` require the `MANAGE_BILLING` organization permission; reading the subscription, its limits, trial and features needs only membership. `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.
|
|
|
|
### 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
|
|
|
|
Credit balance, top-up purchases, and the transaction log are part of `/subscription/*`, so they are JWT only and never accept an API key. 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
|
|
|
|
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 the assistant runs is gated by the member's own organization permission bits, so the assistant can never do more than the member could by hand. See the [AI assistant](/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) |
|
|
|
|
### 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.
|
|
|
|
| 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)
|
|
|
|
The AI tool registry over plain HTTP for function-calling agents that do not speak MCP (see [Agent tools](/api/agent-tools/)). Like the advisor apply path and the MCP endpoint, there is no route-level scope on purpose: each tool enforces its own permission, the list reflects only what the caller may use, and send-class tools are never exposed.
|
|
|
|
| Method | Path | API Permission |
|
|
|--------|------|----------------|
|
|
| GET | `/ai/tools` | permission of each listed tool |
|
|
| POST | `/ai/tools/:name/call` | permission of the tool being called |
|
|
|
|
### 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` |
|
|
|
|
## 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). 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
|
|
|
|
- `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 /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 /.well-known/oauth-authorization-server`, `GET /.well-known/oauth-protected-resource` (OAuth discovery metadata)
|
|
|
|
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
|
|
|
|
## 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
|
|
|
|
- [Authentication](/api/authentication/)
|
|
- [Permissions Reference](/api/permissions/)
|
|
- [Error Codes](/api/error-codes/)
|