---
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` |
| PATCH | `/emails/:id/track` | `WRITE_EMAILS` |
| DELETE | `/emails/:id` | `WRITE_EMAILS` |
| POST | `/emails/:id/send` | `SEND_CAMPAIGNS` |

### Campaigns and sequences

| Method | Path | API Permission |
|--------|------|----------------|
| GET | `/campaigns` | `READ_CAMPAIGNS` |
| GET | `/campaigns-overview` | `READ_CAMPAIGNS` |
| POST | `/campaigns` | `WRITE_CAMPAIGNS` |
| GET | `/campaigns/:id` | `READ_CAMPAIGNS` |
| PATCH | `/campaigns/:id` | `WRITE_CAMPAIGNS` |
| DELETE | `/campaigns/:id` | `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/logs` | `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` |

### Contacts

| Method | Path | API Permission |
|--------|------|----------------|
| POST | `/contacts/search` | `READ_CONTACTS` |
| GET | `/contacts/custom-fields` | `READ_CONTACTS` |
| POST | `/contacts` | `WRITE_CONTACTS` |
| DELETE | `/contacts` | `BULK_CONTACTS` |
| PATCH | `/contacts` | `BULK_CONTACTS` |
| PATCH | `/contacts/:id` | `WRITE_CONTACTS` |
| DELETE | `/contacts/:id` | `WRITE_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. It returns a flat string array under `data`, capped at 200 keys.

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.

### 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` |
| POST | `/unibox/reply` | `WRITE_UNIBOX` |
| 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` |

`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 /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/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` |

### Analytics and audit

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

### 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` |
| GET | `/api-keys/permissions` | `API_KEYS` |
| GET | `/api-keys/:id` | `API_KEYS` |
| PATCH | `/api-keys/:id` | `API_KEYS` |
| DELETE | `/api-keys/:id` | `API_KEYS` |

### 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 | `/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.

### 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`
- `GET /auth/providers`, `POST /auth/apple`, `POST /auth/google` (native-app social sign-in)
- `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`
- `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 /invitations`, `POST /invitations/accept`
- All of `/organization/*` (create, switch, members, invitations, transfer ownership, avatar, danger zone)
- All of `/subscription/*` (checkout, portal, cancel, change-plan, preview-change, enterprise-inquiry, discounts, referrals, etc.)
- All of `/admin/*`

### 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.

| 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/), gated by the `AI_AGENT` 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` |

### 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 /webhooks/github/releases` (HMAC-SHA256 signature)
- `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)

## 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/)
