# anymail developers: full documentation # anymail developer documentation > Source: https://developers.anymail.watch/ The anymail REST API reads and writes the data of one workspace: prospects, campaigns with their steps and statistics, the inbox, companies, offers, suppressions, webhook subscriptions, mailboxes and domains. Every response is JSON. An MCP server exposes the same operations to LLM clients such as Claude and ChatGPT, and a [CLI](/cli/) wraps them for scripts. ## Base URL ``` https://app.anymail.watch/api/v1 ``` The version is the `/api/v1/` path prefix. There is no other versioning mechanism. The API has no CORS headers, so call it from a server or a script. ## What you can do | Resource | Reads | Writes | |---|---|---| | [Prospects](/prospects/) | List, search, get | Create, update, delete, anonymise | | [Campaigns](/campaigns/) | List, get, list enrolments | Create, update, delete a draft, start, pause, resume, stop, enrol prospects | | [Steps and variants](/campaign-steps/) | List | Add, edit, delete steps and A/B variants | | [Statistics](/campaign-stats/) | Totals, funnel, per step and variant | None | | [Companies](/companies/) | List, get with account temperature | None | | [Replies](/replies/) | List classified replies | Correct a classification | | [Inbox](/inbox/) | List messages with the full text | Answer a message | | [Offers](/offers/) | List, get with visits and reading time | None | | [Suppressions](/suppressions/) | List | Create, delete | | [Webhook subscriptions](/webhook-subscriptions/) | List | Subscribe, unsubscribe | | [Mailboxes](/mailboxes/) | List, get | Change limit, sender name, signature, pause | | [Domains](/domains/) | SPF, DKIM, DMARC, tracking domain | None | | [Workspaces](/workspaces/) | The workspace of the token | None | The API has 43 operations, all in the [OpenAPI document](/openapi/). The [MCP server](/mcp-server/) offers 44 tools over them. ## Conventions - **Authentication.** Send a workspace API token as `Authorization: Bearer sgn_…`. A token has the `read` scope, or `read` and `write`. MCP clients can sign in with OAuth. See [Authentication](/authentication/). - **Isolation.** A token sees only its own workspace. A resource from another workspace returns `404`. - **Rate limit.** 60 requests per minute per token. See [Rate limits](/rate-limits/). - **Lists.** Every list returns `{"data": [...], "next_cursor": "..."}`. See [Pagination](/pagination/). - **Errors.** Every error returns JSON with an `error` message. See [Errors](/errors/). - **Timestamps.** ISO 8601 with a time zone, for example `2026-09-03T10:00:00.123456+00:00`. - **Unknown input.** Unknown query parameters are ignored. The campaign, step and mailbox writes reject unknown body fields with `400`. The older endpoints ignore them. A known parameter with an invalid value returns `400`. ## Where to start 1. Follow the [Quick start](/quick-start/) to generate a token and make a first call. 2. Read [Idempotency keys](/idempotency-keys/) before you write from a script that retries. 3. Subscribe to events with [Webhooks](/webhooks/), or connect [Claude](/mcp-claude/), [ChatGPT](/mcp-chatgpt/), [Claude Code](/mcp-claude-code/) or [Cursor](/mcp-cursor/) to the MCP server. 4. Import the [OpenAPI document or the Postman collection](/openapi/) to explore every endpoint. The [changelog](/changelog/) lists every change to the API. --- # Quick start > Source: https://developers.anymail.watch/quick-start/ After this page you have a working token and a successful call to the API. You need an anymail workspace and access to its settings. ## 1. Generate a token 1. In the app, open **Settings**, then **Integrations**, then the **API keys** tab. 2. In the **API tokens** section, type a name that says what the token is for, for example `Zapier`. 3. Pick the **Access**: **Read only** for a first look, **Read & write** to create and change data. 4. Select **Generate token**. 5. Copy the token. It starts with `sgn_` and the app shows it once. anymail stores only a hash of it. If you lose a token, revoke it in the same list and generate a new one. ## 2. Store the token Keep the token in an environment variable so it stays out of your shell history and your code. ```bash export ANYMAIL_TOKEN="sgn_…" ``` ## 3. Make the first call List the campaigns in the workspace. ```bash curl "https://app.anymail.watch/api/v1/campaigns?limit=2" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` The response is `200` with a JSON body. ```json { "data": [ { "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Q3 outbound", "status": "active", "display_status": "running", "pause_reason": null, "campaign_type": "outbound", "folder": { "id": "8d1c0e2a-7b3f-4c5d-9e6a-1f2b3c4d5e6f", "name": "Sales" }, "steps": 4, "progress": { "planned": 400, "done": 312, "enrolled": 100, "finished": 61, "pct": 78 }, "created_at": "2026-08-10T09:00:00.000+00:00", "started_at": "2026-08-11T07:30:00.000+00:00", "archived_at": null } ], "next_cursor": null } ``` `next_cursor` is `null` when there is no further page. ## 4. Create a prospect ```bash curl -X POST "https://app.anymail.watch/api/v1/prospects" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"email":"ada@example.com","first_name":"Ada","company_name":"Example Ltd"}' ``` The response is `201`. ```json { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "email": "ada@example.com", "company_id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427" } ``` ## If the call fails | Status | Meaning | |---|---| | `401` | The header is missing or the token is invalid or revoked. See [Authentication](/authentication/). | | `400` | A parameter or the body is invalid. The `error` field names the field. See [Errors](/errors/). | | `429` | You sent more than 60 requests in a minute. Wait for `Retry-After` seconds. See [Rate limits](/rate-limits/). | ## Related - [Authentication](/authentication/) - [Prospects](/prospects/) - [Idempotency keys](/idempotency-keys/) --- # Authentication > Source: https://developers.anymail.watch/authentication/ Every request to `/api/v1/` and to the MCP server carries a token in the `Authorization` header. ``` Authorization: Bearer sgn_… ``` There are two kinds of token. Both work on the REST API and on the MCP server, with the same scopes, the same [rate limit](/rate-limits/) and the same workspace isolation. | | API token | OAuth token | |---|---|---| | Prefix | `sgn_` | `amo_` | | Made by | You, in **Settings** | An MCP client, after you allow it in the browser | | Lifetime | Until you revoke it | One hour, renewed by the client with a refresh token | | Use it for | Scripts, servers, Zapier, the [CLI](/cli/) | Claude, ChatGPT, Claude Code, Cursor. See [MCP server](/mcp-server/). | ## Generate and revoke API tokens Open **Settings**, then **Integrations**, then the **API keys** tab. The **API tokens** section lists your tokens and has the **Generate token** button. Pick the **Access** of the token before you generate it: **Read & write** or **Read only**. - A token is `sgn_` followed by 43 characters. - The app shows the full token once, when you generate it. anymail stores only a hash of it, so nobody can show it again. - **Revoke** disables a token. A revoked token returns `401`. - The token list shows the name, the first characters of each token, the access, the creation date and the date of last use. Use the last use date to find tokens that are safe to revoke. ## Scopes A token has the `read` scope, or the `read` and `write` scopes. | Scope | Allows | |---|---| | `read` | `GET` requests: lists, single objects, statistics. | | `write` | `POST`, `PATCH` and `DELETE` requests: create, change, delete, start and stop campaigns, send replies. | The method decides the scope, so every endpoint is covered without a list to keep up to date. A read only token is the right choice for reporting, a dashboard or an LLM client that should look but not touch. A write with a read only token returns `403` and changes nothing: ``` HTTP/1.1 403 Forbidden WWW-Authenticate: Bearer realm="anymail API", error="insufficient_scope", scope="write" Content-Type: application/json {"error":"insufficient_scope: this token lacks the \"write\" scope.","code":"insufficient_scope"} ``` On the MCP server every request is a `POST`, so connecting needs only `read`. A write tool called with a read only token returns the same `403` as a tool error. Tokens generated before scopes existed have both scopes. ## Workspace A token gives access to one workspace. Everything the API returns belongs to that workspace. An id from another workspace behaves like an id that does not exist and returns `404`. To make a request fail when it would reach the wrong workspace, send the `X-Workspace` header with the workspace id. A mismatch returns `403` with `code: "workspace_forbidden"`. See [Workspaces](/workspaces/#the-x-workspace-header). ## OAuth for MCP clients anymail runs an OAuth 2.1 authorization server for the MCP endpoint, so a client such as Claude or ChatGPT connects without a pasted token. You add the server URL in the client, the client opens anymail in the browser, you sign in and press **Allow**. On the consent screen you choose: - **Workspace.** The workspace this connection may reach, when you are a member of more than one. - **Access.** **Read only** (campaigns, prospects, companies, replies and stats) or **Read and write** (also add prospects, enrol them and change campaigns). This becomes the scope of the token. **Deny** sends the client away without a token. | Item | Value | |---|---| | Authorization server metadata | `https://app.anymail.watch/.well-known/oauth-authorization-server` | | Protected resource metadata | `https://app.anymail.watch/.well-known/oauth-protected-resource` | | Authorization endpoint | `https://app.anymail.watch/api/oauth/authorize` | | Token endpoint | `https://app.anymail.watch/api/oauth/token` | | Client registration | `https://app.anymail.watch/api/oauth/register` (dynamic client registration, RFC 7591) | | Resource | `https://app.anymail.watch/api/mcp` | | Grants | `authorization_code` with PKCE `S256` (required), `refresh_token` | | Client authentication | None. Clients are public. | | Scopes | `read`, `write` | | Access token | `amo_…`, valid for one hour | | Refresh token | `amr_…`, valid for 30 days, replaced on every use | Redirect URIs must use `https`, `http` on a loopback address (`localhost`, `127.0.0.1`, `[::1]`, any port) or a private app scheme. A `401` from the MCP endpoint carries `WWW-Authenticate: Bearer resource_metadata="https://app.anymail.watch/.well-known/oauth-protected-resource"`, which is how a client finds the authorization server. A refresh token used twice ends every token of that client, user and workspace, because reuse means a copy leaked. A person removed from the workspace loses access at once, even with a valid token. ## Where the token goes The API accepts a token only in the `Authorization` header. A token in a query string is ignored, because URLs end up in access logs and browser history. ## Responses | Situation | Status | Body | |---|---|---| | No `Authorization` header, or not a `Bearer` scheme | `401` | `{"error":"Missing bearer token."}` | | Malformed, unknown, expired or revoked token | `401` | `{"error":"Invalid API token."}` | | A write with a read only token | `403` | `{"error":"insufficient_scope: …","code":"insufficient_scope"}` | | `X-Workspace` names another workspace | `403` | `{"error":"This token does not have access to the workspace named in X-Workspace.","code":"workspace_forbidden"}` | A `401` response from the REST API carries `WWW-Authenticate: Bearer realm="anymail API"`. The message is the same for an unknown token and a revoked one, so a response never confirms that a token exists. A request with a wrong token never produces a `500`. ## Keep tokens safe - Store tokens in a secret manager or an environment variable. - Create one token per integration, so you can revoke one without touching the others. - Give an integration a read only token when it does not write. - The `sgn_` prefix lets secret scanners find a token that leaks into a repository. Revoke it at once if that happens. ## Related - [Quick start](/quick-start/) - [MCP server](/mcp-server/) - [Workspaces](/workspaces/) - [Rate limits](/rate-limits/) --- # CLI > Source: https://developers.anymail.watch/cli/ `anymail` is a command line client for the REST API. It has no dependencies beyond Node.js 20 or later, prints tables for people and JSON for scripts, and works with the same [API tokens](/authentication/) as the API. ## Install Install it from npm: ```bash npm install -g anymail-cli anymail --help ``` ## Log in ```bash anymail login --token sgn_… ``` The CLI checks the token with `GET /workspaces` before it saves it to `~/.config/anymail/config.json` with permissions `600`. In CI, skip the file and set the token in the environment: | Variable | Meaning | |---|---| | `ANYMAIL_API_TOKEN` | The token. It takes precedence over the saved one. | | `ANYMAIL_API_URL` | The base URL. Default `https://app.anymail.watch`. | A read only token is enough for `list`, `search` and `stats`. ## Commands | Command | What it does | |---|---| | `anymail campaigns list [--status ]` | Campaigns with status and progress. `` is `draft`, `active`, `paused` or `completed`. | | `anymail campaigns create --name "Q4 outbound" [--mailbox ]…` | A new draft. Repeat `--mailbox` for several mailboxes. | | `anymail campaigns start ` | Start a draft or resume a paused campaign. This sends email. | | `anymail campaigns stop ` | End a campaign for good. | | `anymail prospects add [--first-name] [--last-name] [--company] [--campaign ]` | Create a prospect, and enrol it when `--campaign` is set. | | `anymail prospects search ` | Search by email, name or company. | | `anymail stats ` | The totals of a campaign. | | `anymail api [--data '']` | Any endpoint, for example `anymail api GET /v1/domains`. | Add `--json` to print the raw response. For lists, `--json` prints only the `data` array, so `anymail campaigns list --json | jq length` counts campaigns. ```bash anymail prospects add ada@example.com --first-name Ada --company "Example Ltd" --campaign 3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d anymail stats 3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d --json ``` An API error prints `HTTP : ` and exits with code `1`. The token never appears in the output. ## Related - [Authentication](/authentication/) - [Campaigns](/campaigns/) - [OpenAPI and Postman](/openapi/) --- # Rate limits > Source: https://developers.anymail.watch/rate-limits/ The API allows 60 requests per minute per token. The window is fixed and the budget is shared by all endpoints. ## How the limit works - The count is per token and does not depend on the IP address. For OAuth, each connection of a client has its own budget. - The count starts after authentication. A request with a wrong token does not use any budget. - Every request counts, including a replayed [idempotent](/idempotency-keys/) request. - The limit is one budget for the whole API. It is not a separate budget for each endpoint. ## Rate limit headers Every response to an authenticated request carries three headers. This includes `2xx` and `204` responses, `4xx` responses from an endpoint, the `403` of a missing scope and `429` responses. | Header | Meaning | |---|---| | `X-RateLimit-Limit` | The budget of the window: `60`. | | `X-RateLimit-Remaining` | How many requests are left in the current window after this one. It is `0` on a `429`. | | `X-RateLimit-Reset` | When the window resets, as a Unix timestamp in seconds. | `X-RateLimit-Reset` is a point in time. The number of seconds to wait is in `Retry-After`. A `401` response has no rate limit headers, because there is no token to count against. The values are a snapshot taken at authentication. Two parallel requests can see the same `X-RateLimit-Remaining`. Use the value as a hint for backoff. It is not a transactional counter. ## When you exceed the limit ``` HTTP/1.1 429 Too Many Requests Retry-After: 23 X-RateLimit-Limit: 60 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1788517380 Content-Type: application/json {"error":"Rate limit exceeded (60 requests/minute)."} ``` `Retry-After` is the number of seconds to the end of the window. ## Handle a 429 ```python import time import requests def call(url, token): while True: response = requests.get(url, headers={"Authorization": f"Bearer {token}"}) if response.status_code != 429: return response time.sleep(int(response.headers.get("Retry-After", "1"))) ``` ## MCP A tool call on the [MCP server](/mcp-server/) uses two requests from the same budget. See that page for the effect on throughput. ## Related - [Limits and good practice](/limits-and-good-practice/) - [Errors](/errors/) - [Pagination](/pagination/) --- # Errors > Source: https://developers.anymail.watch/errors/ Every error response is JSON with an `error` message. Some errors add a `code`. ## Status codes | Status | Body | When | |---|---|---| | `400` | `{"error":""}` | Invalid JSON or an invalid query parameter. The message names the field. | | `400`, `404`, `409`, `500`, `502` | `{"error":"","code":""}` | Errors from the prospect write layer. See the codes below. | | `401` | `{"error":""}` | Missing or invalid token. See [Authentication](/authentication/). | | `403` | `{"error":"","code":"insufficient_scope"}` | A `POST`, `PATCH` or `DELETE` with a read only token. | | `403` | `{"error":"","code":"workspace_forbidden"}` | `X-Workspace` names another workspace than the one of the token. | | `404` | `{"error":" not found."}` | The resource does not exist in this workspace, the id belongs to another workspace, or the id is not a UUID. | | `409` | `{"error":""}` | The resource is in a state that does not allow the action. Some add details, such as `issues` or `conflicts` on a campaign start. | | `422` | `{"error":""}` | An [idempotency key](/idempotency-keys/) was reused with a different request. | | `429` | `{"error":"Rate limit exceeded (60 requests/minute)."}` | See [Rate limits](/rate-limits/). | | `500` | `{"error":"Couldn't read this resource: "}` | A database failure on a read. The fault is on the anymail side. | | `500` | `{"error":"Couldn't apply this change: "}` | A failure on a write. | | `502` | `{"error":"Couldn't send: "}` | The mail server refused an [inbox answer](/inbox/#answer-a-message). | Unknown query parameters and unknown body fields are ignored. A known parameter with an invalid value returns `400`. ## Prospect error codes The prospect endpoints and the campaign enrolment call return a `code` next to the message. | Code | Status | Meaning | |---|---|---| | `invalid-email` | `400` | The address failed validation. The message is `Enter a valid email address.` | | `duplicate` | `409` | The address already exists in the workspace. Use `PATCH /prospects/{id}` to change it. | | `not-found` | `404` | The prospect does not exist in this workspace. | | `company-failed` | `502` | Grouping the prospect into a company failed. | | `db-error` | `500` | A database failure on the anymail side. | `POST /suppressions` returns `409` with `code: "duplicate"` and also `kind` and `value` when the target is already excluded. ## Body errors Endpoints that take a JSON body return these `400` messages. | Message | Cause | |---|---| | `Request body too large.` | The body exceeds the limit of the endpoint. See [Limits and good practice](/limits-and-good-practice/). | | `Expected a JSON body.` | The body is empty. | | `Body is not valid JSON.` | The body does not parse. | | `Body must be a JSON object.` | The body is an array or a scalar. | Send `Content-Type: application/json` with every body. ## Handle errors in code - `4xx` means the request needs a change. Do not retry it unchanged. The exceptions are `409` with the message `A request with this Idempotency-Key is still being processed.` and `429`. - `5xx` means a fault on the anymail side. Retry with backoff. Reuse the same [idempotency key](/idempotency-keys/) so the retry cannot create a duplicate. ## Related - [Pagination](/pagination/) - [Idempotency keys](/idempotency-keys/) - [Rate limits](/rate-limits/) --- # Pagination > Source: https://developers.anymail.watch/pagination/ Every list endpoint takes the same two query parameters and returns the same envelope. ``` GET /api/v1/?limit=50&cursor= ``` | Parameter | Type | Notes | |---|---|---| | `limit` | integer | 1 to 100. The default is 50. Any other value returns `400`. | | `cursor` | string | The `next_cursor` value of the previous response. A cursor that does not decode returns `400`. | ```json { "data": [], "next_cursor": "eyJrIjoiMjAyNi0wOC0xOVQxMjowMDowMC4wMDArMDA6MDAiLCJpIjoiN2M5ZTY2NzktNzQyNS00MGRlLTk0NGItZTA3ZmMxZjkwYWU3In0" } ``` `next_cursor` is `null` on the last page. ## Cursors are opaque Pass the cursor back as you received it. Do not build or edit a cursor. ## Order Lists run from newest to oldest, by `created_at`, `received_at` or `sent_at` descending, and then by `id`. Cursors are keyset cursors on that pair. A row added while you page through a list does not shift the pages and does not cause duplicates. ## Walk every page ```bash cursor="" while :; do page=$(curl -s "https://app.anymail.watch/api/v1/prospects?limit=100&cursor=$cursor" \ -H "Authorization: Bearer $ANYMAIL_TOKEN") echo "$page" | jq -c '.data[]' cursor=$(echo "$page" | jq -r '.next_cursor // empty') [ -z "$cursor" ] && break done ``` Each page is one request against your [rate limit](/rate-limits/). ## Exceptions - **Companies** use an offset cursor. The cursor is still opaque. The list is assembled in memory because the `temperature` filter works on a value computed from events. If companies are imported while you page, a page can skip a row or repeat one at a boundary. - **Mailboxes** are not paginated. `next_cursor` is always `null`. - **Offers** apply the `status` filter to the page after it is fetched. A page can hold fewer than `limit` rows while `next_cursor` is not `null`. Keep paging until `next_cursor` is `null`. ## Date filters Date parameters such as `created_since` and `since` accept any ISO 8601 value, for example `2026-09-01` or `2026-09-01T00:00:00Z`. ## Related - [Rate limits](/rate-limits/) - [Errors](/errors/) - [Prospects](/prospects/) --- # Idempotency keys > Source: https://developers.anymail.watch/idempotency-keys/ Every `POST`, `PATCH` and `DELETE` accepts an optional `Idempotency-Key` header, except `PATCH /prospects/{id}` and `DELETE /hooks/{id}`, which are idempotent by themselves. With a key, you can retry a request after a timeout and be sure it runs once. ```bash curl -X POST "https://app.anymail.watch/api/v1/prospects" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 5f1c0e1a-2b7d-4a4e-9c1a-0f1e2d3c4b5a" \ -d '{"email":"ada@example.com"}' ``` The key is a string of 1 to 255 characters. A UUID v4 is a good choice. ## What happens | Situation | Result | |---|---| | First request with the key | It runs as usual. The status and JSON body are stored for the workspace for 24 hours. | | Same key, same method, same path with query, same body byte for byte | The stored status and body return without running the request. The response has `Idempotent-Replayed: true`. | | Same key, a different request | `422` with `{"error":"Idempotency-Key was already used with a different request."}` | | Same key while the first request is still running | `409` with `{"error":"A request with this Idempotency-Key is still being processed."}`. Retry shortly. | | Empty key, or longer than 255 characters | `400` with `{"error":"Idempotency-Key must be between 1 and 255 characters."}` | ## Details that matter - The API stores `2xx` and `4xx` responses. A `409` duplicate is stored too, so a replay returns what the first call returned. - The API does not store `5xx` responses. After a server fault, a retry with the same key runs again. - The key belongs to the workspace. Two tokens of one workspace share one key space. - The API compares the body as raw bytes. The same JSON with the keys in another order is a different request and returns `422`. Resend an identical payload. - A replay uses a request of your [rate limit](/rate-limits/). - Without the header, nothing changes. A repeated `POST /prospects` without a key returns `409` with `code: "duplicate"`. ## Endpoints that ignore the header `PATCH /prospects/{id}` and `DELETE /hooks/{id}` ignore the header, because repeating them gives the same result. The same applies to `POST /campaigns/{id}/pause`, `POST /campaigns/{id}/resume`, `PATCH /replies/{id}` and `DELETE /suppressions/{id}`. A repeated `POST /campaigns/{id}/prospects` returns `already-enrolled` for each person who is already enrolled. ## Related - [Errors](/errors/) - [Prospects](/prospects/) - [Rate limits](/rate-limits/) --- # Prospects > Source: https://developers.anymail.watch/prospects/ A prospect is a person you email. A prospect belongs to a company, which anymail derives from the email domain or from `company_name`. | Method | Path | Action | |---|---|---| | `POST` | `/api/v1/prospects` | Create a prospect, optionally enrol it in a campaign | | `GET` | `/api/v1/prospects` | List prospects | | `GET` | `/api/v1/prospects/{id}` | Get one prospect | | `PATCH` | `/api/v1/prospects/{id}` | Update a prospect | | `DELETE` | `/api/v1/prospects/{id}` | Delete a prospect, or anonymise it for a GDPR erasure | ## Create a prospect `POST /api/v1/prospects` runs the same function as the form in the app: the same address validation, the same grouping into a company and the same duplicate handling. The endpoint honours an [`Idempotency-Key`](/idempotency-keys/). **Query** | Parameter | Type | Notes | |---|---|---| | `campaign` | UUID | Optional. After the prospect is created, anymail enrols it in this campaign with the rules of **Add prospects** in the app. The campaign must be `draft` or `active`. An unknown or unusable campaign returns `400` with `Unknown or unusable campaign.` and no prospect is created. | The response does not say whether the enrolment succeeded. Check the run history in **Settings**, **Integrations**, **History**. To enrol existing prospects with a result for each person, use [`POST /campaigns/{id}/prospects`](/campaigns/#enrol-prospects). **Body** (JSON, up to 64 KiB) | Field | Type | Required | Notes | |---|---|---|---| | `email` | string | Yes | Trimmed and lower-cased. The last segment of the domain must start with a letter. | | `first_name` | string or null | No | | | `last_name` | string or null | No | | | `timezone` | string or null | No | Not checked against a time zone database. | | `company_name` | string or null | No | The company name you give. It takes precedence over the email domain when anymail groups prospects into companies. | | `custom_fields` | object | No | Keys map to a string, number or boolean. Numbers and booleans are stored as strings. An object, an array or `null` as a value returns `400`. | ```bash curl -X POST "https://app.anymail.watch/api/v1/prospects" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"email":"ada@example.com","first_name":"Ada","company_name":"Example Ltd","custom_fields":{"role":"CTO"}}' ``` **Response `201`** ```json { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "email": "ada@example.com", "company_id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427" } ``` `company_id` is `null` for an address on a public mail provider, such as gmail.com, when you give no `company_name`. That response is a valid `201`. **Errors** | Status | Code | Message | |---|---|---| | `400` | | `Request body too large.`, `Expected a JSON body.`, `Body is not valid JSON.`, `Body must be a JSON object.` | | `400` | | `` `email` must be a string. ``, `` `` must be a string or null. ``, `` `custom_fields` must be an object of string values. ``, `` `custom_fields.` must be a string, number or boolean. `` | | `400` | `invalid-email` | `Enter a valid email address.` | | `409` | `duplicate` | ` is already in your workspace.` | | `500` | `db-error` | A database failure. | | `502` | `company-failed` | Grouping into a company failed. | A repeated call without an idempotency key returns `409`, and nothing is updated. Use `PATCH` to change an existing prospect. ## List prospects `GET /api/v1/prospects` returns prospects with the activity columns of the **Prospects** screen. | Parameter | Type | Notes | |---|---|---| | `q` | string | Search, up to 200 characters. Matches part of the email, the first name, the last name, or the name or domain of the company, case-insensitive. The same search as the **Search** box of the prospect list. | | `email` | string | Exact address, case-insensitive. | | `company_id` | UUID | Prospects of one company. | | `status` | string | One of `blocked`, `bounced`, `replied`, `active`, `contacted`, `not_contacted`. The definition and the precedence match the **Status** column in the app: suppressed, then bounced, then replied, then active in a campaign, then contacted, then not contacted. | | `created_since` | ISO 8601 | Prospects created at or after this time. | | `limit` | integer | 1 to 100. Default 50. | | `cursor` | string | See [Pagination](/pagination/). | `updated_since` does not exist, because prospects carry no modification timestamp. The parameter returns `400` so that a script does not rely on a filter that is not there. ```bash curl "https://app.anymail.watch/api/v1/prospects?status=replied&limit=2" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [ { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "email": "ada@example.com", "first_name": "Ada", "last_name": null, "company": { "id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427", "name": "Example Ltd", "domain": "example.com" }, "timezone": null, "custom_fields": { "role": "CTO" }, "tags": ["q3"], "source": "API", "verification_status": null, "verification_checked_at": null, "status": "replied", "campaign_count": 1, "emails_sent": 3, "date_contacted": "2026-08-20T08:02:11.512+00:00", "date_responded": "2026-08-25T16:19:38.123+00:00", "created_at": "2026-08-19T12:00:00.000+00:00" } ], "next_cursor": null } ``` Search for a person by any part of the name, address or company: ```bash curl "https://app.anymail.watch/api/v1/prospects?q=example.com&limit=20" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` `q` combines with the other filters. With `q=acme&status=not_contacted`, the list holds people of Acme who were never emailed. ## Get a prospect `GET /api/v1/prospects/{id}` returns one prospect in the shape above, without the `data` wrapper. An unknown id, an id from another workspace and an id that is not a UUID all return `404` with `{"error":"Prospect not found."}`. ```bash curl "https://app.anymail.watch/api/v1/prospects/7c9e6679-7425-40de-944b-e07fc1f90ae7" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` ## Update a prospect `PATCH /api/v1/prospects/{id}` takes the same fields and the same validation as the create call. `email` is required, because it is the natural key. Send the full state you want: - An omitted `first_name`, `last_name` or `timezone` is set to `null`. - An omitted `custom_fields` becomes `{}`. - An omitted `company_name` leaves the company as it is. Send `"company_name": null` to clear it. A change of email domain or of `company_name` moves the prospect to another company, which anymail creates when needed. `source` does not change. ```bash curl -X PATCH "https://app.anymail.watch/api/v1/prospects/7c9e6679-7425-40de-944b-e07fc1f90ae7" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"email":"ada@example.com","first_name":"Ada","last_name":"Lovelace","company_name":"Example Ltd"}' ``` **Response `200`** ```json { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "email": "ada@example.com", "company_id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427" } ``` **Errors** | Status | Code | Message | |---|---|---| | `400` | | Body errors as for create. | | `400` | `invalid-email` | `Enter a valid email address.` | | `404` | `not-found` | `Prospect not found.` This includes an id from another workspace and an id that is not a UUID. | | `409` | `duplicate` | ` is already used by another prospect in your workspace.` | | `500` | `db-error` | A database failure. | | `502` | `company-failed` | Grouping into a company failed. | ## Delete a prospect `DELETE /api/v1/prospects/{id}` runs one of the two bulk actions of the prospect list in the app, for one prospect. Choose with `mode`. | `mode` | What happens | |---|---| | `delete` (default) | **Delete.** The prospect is removed with its enrolments and events. The address can be added again later. | | `anonymize` | **Anonymize (GDPR).** Personal data is erased, the address goes on the suppression list of the workspace, active enrolments stop and the audit log records the action with counts only. Use this for an erasure request: the person cannot be emailed again by mistake. | ```bash curl -X DELETE "https://app.anymail.watch/api/v1/prospects/7c9e6679-7425-40de-944b-e07fc1f90ae7?mode=anonymize" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** for `mode=delete` ```json { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "deleted": true } ``` **Response `200`** for `mode=anonymize` ```json { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "anonymized": true, "suppressions_added": 1, "enrollments_stopped": 1 } ``` `anonymized` is also `true` for a prospect that was anonymised before. Both modes cannot be undone. **Errors:** `400` with `` `mode` must be delete or anonymize. ``, `404` with `Prospect not found.` for an unknown id, an id from another workspace and an id that is not a UUID. ## Related - [Campaigns](/campaigns/) - [Companies](/companies/) - [Idempotency keys](/idempotency-keys/) --- # Campaigns > Source: https://developers.anymail.watch/campaigns/ A campaign is a sequence, a list of prospects and the mailboxes it sends from. The API covers the whole life of a campaign: create a draft, set its mailboxes, audience and schedule, write the [steps](/campaign-steps/), start it, read the [statistics](/campaign-stats/), pause, resume and stop it. Each call runs the same function as the matching button in the app, with the same checks. | Method | Path | Action | |---|---|---| | `GET` | `/api/v1/campaigns` | List campaigns with progress | | `POST` | `/api/v1/campaigns` | Create a draft | | `GET` | `/api/v1/campaigns/{id}` | Get a campaign with settings, steps and statistics | | `PATCH` | `/api/v1/campaigns/{id}` | Change the name, mailboxes, audience, limits and schedule | | `DELETE` | `/api/v1/campaigns/{id}` | Delete a draft | | `POST` | `/api/v1/campaigns/{id}/start` | Start a draft or resume a paused campaign | | `POST` | `/api/v1/campaigns/{id}/stop` | End a campaign for good | | `GET` | `/api/v1/campaigns/{id}/prospects` | List the enrolments of a campaign | | `POST` | `/api/v1/campaigns/{id}/prospects` | Enrol existing prospects | | `POST` | `/api/v1/campaigns/{id}/pause` | Pause a running campaign | | `POST` | `/api/v1/campaigns/{id}/resume` | Resume a paused campaign | Write calls need a token with the `write` scope. See [Authentication](/authentication/#scopes). ## From zero to a running campaign 1. `GET /mailboxes` and `GET /domains` to pick mailboxes whose domain passes SPF, DKIM and DMARC. See [Domains](/domains/). 2. `GET /prospects?q=…` to find the audience. See [Prospects](/prospects/). 3. `POST /campaigns` with `name`, `mailbox_ids` and `prospect_ids`. 4. `POST /campaigns/{id}/steps` once for each email. See [Steps and variants](/campaign-steps/). 5. `POST /campaigns/{id}/start`. This sends real email. 6. `GET /campaigns/{id}/stats` to follow the results. See [Statistics](/campaign-stats/). ## List campaigns | Parameter | Type | Notes | |---|---|---| | `status` | string | One of `draft`, `active`, `paused`, `completed`. This is the raw status column. | | `folder_id` | UUID | Campaigns in one folder. | | `archived` | string | `true` or `false`. | | `limit` | integer | 1 to 100. Default 50. | | `cursor` | string | See [Pagination](/pagination/). | ```bash curl "https://app.anymail.watch/api/v1/campaigns?status=active" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [ { "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Q3 outbound", "status": "active", "display_status": "running", "pause_reason": null, "campaign_type": "outbound", "folder": { "id": "8d1c0e2a-7b3f-4c5d-9e6a-1f2b3c4d5e6f", "name": "Sales" }, "steps": 4, "progress": { "planned": 400, "done": 312, "enrolled": 100, "finished": 61, "pct": 78 }, "created_at": "2026-08-10T09:00:00.000+00:00", "started_at": "2026-08-11T07:30:00.000+00:00", "archived_at": null } ], "next_cursor": null } ``` - `status` is the raw column. `display_status` is one of `draft`, `running`, `paused`, `done`, `archived` or `other`, and matches the badge in the app. An `active` campaign at 100% progress with at least one enrolment is `done`. `archived_at` takes precedence. - `progress.planned` is the number of enrolled prospects multiplied by the number of email steps. `progress.done` counts the steps passed by active enrolments plus all steps of stopped and completed enrolments. `pct` runs from 0 to 100. - Campaigns have no `updated_at`. The timestamps are `created_at`, `started_at` and `archived_at`. **Errors:** `400` for an invalid `status`, `folder_id`, `archived`, `limit` or `cursor`. ## Create a draft `POST /api/v1/campaigns` creates a campaign with status `draft`, like **New campaign** in the app. All fields are optional. An unknown field returns `400`. | Field | Type | Notes | |---|---|---| | `name` | string | Up to 200 characters. Default `Untitled campaign`. | | `mailbox_ids` | array of UUID | Sending mailboxes, in rotation order. Up to 1000. A draft may have none. | | `prospect_ids` | array of UUID | The audience. Up to 1000. Duplicates are merged. Nobody is enrolled until the start. | | `campaign_type` | string or null | One of `cold_outreach`, `newsletter`, `webinar_invite`, `offer`. | ```bash curl -X POST "https://app.anymail.watch/api/v1/campaigns" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Q4 outbound","mailbox_ids":["b2c3d4e5-0000-4000-8000-000000000001"],"prospect_ids":["7c9e6679-7425-40de-944b-e07fc1f90ae7"]}' ``` **Response `201`** returns the campaign in the list shape, with `"status": "draft"`, `"steps": 0` and a new `id`. New campaigns get the default settings of the app. **Errors:** `400` with `` Unknown field(s): … ``, `` `mailbox_ids` must be an array of up to 1000 UUIDs. ``, `One of the picked mailboxes isn't a mailbox of this workspace.` or `Pick one of the listed campaign types, or leave it empty for no type.` ## Update a campaign `PATCH /api/v1/campaigns/{id}` changes only the fields you send. Send at least one. | Field | Type | Notes | |---|---|---| | `name` | string | Not empty. Up to 200 characters. | | `mailbox_ids` | array of UUID | Replaces the mailboxes and their order. Not allowed while the campaign is running: pause it first. A paused or completed campaign needs at least one mailbox. | | `prospect_ids` | array of UUID | Replaces the audience. Draft only. To add people to a running campaign, use [Enrol prospects](#enrol-prospects). | | `campaign_type` | string or null | As for create. | | `daily_enroll_limit` | integer or null | New enrolments per day, 1 or more. `null` means no limit. | | `timezone` | string or null | IANA time zone of the send window, for example `Europe/Warsaw`. `null` uses the workspace value. | | `use_prospect_timezone` | boolean | Send in the time zone of each prospect when it is known. | | `schedule` | object or null | `{ "start_hour": 9, "end_hour": 17, "send_days": [1,2,3,4,5] }`. Hours 0 to 24, start before end. Days 0 (Sunday) to 6 (Saturday), at least one. `null` inherits the schedule of the workspace. | ```bash curl -X PATCH "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"daily_enroll_limit":30,"schedule":{"start_hour":9,"end_hour":17,"send_days":[1,2,3,4,5]}}' ``` **Response `200`** returns the campaign in the list shape. Read the settings with `GET /campaigns/{id}`. **Errors** | Status | Message | |---|---| | `400` | `` Send at least one of: … ``, `` Unknown field(s): … ``, ``` `name` can't be empty. ```, `Daily enroll has to be a whole number of 1 or more, or null for no limit.`, ` is not a timezone this server knows.`, a schedule message such as `Sending days have to be whole numbers between 0 (Sunday) and 6 (Saturday).` | | `404` | `Campaign not found.` | | `409` | `The audience can only be set on a draft; enrol more people with POST /campaigns/{id}/prospects.` | | `409` | `Pause the campaign before changing its sending mailboxes.` or `Pick at least one sending mailbox: a campaign with none can't send.` | ## Delete a draft `DELETE /api/v1/campaigns/{id}` deletes a campaign with status `draft`. The response is `204` with no body. A campaign that has sent anything is a record of what went out, so the API does not delete it. Stop it instead. ```bash curl -X DELETE "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Errors:** `404` with `Campaign not found.`, `409` with `Only a draft can be deleted; stop the campaign instead.` ## Start a campaign `POST /api/v1/campaigns/{id}/start` has no body. It runs the **Start** button of the app. On a draft it checks the campaign, enrols the audience and schedules step 1. On a paused campaign it works like [Resume](#resume-a-campaign). **This sends real email.** The checks, in this order: 1. The campaign is not already running and not completed. 2. It has at least one sending mailbox. 3. No step is empty. A step needs a subject or a body. 4. The audience has prospects. 5. Every prospect has every field used in the steps and in the active A/B variants. 6. On the first start: no prospect is active in another campaign of the workspace, unless the campaign allows duplicates in the app. When a check fails, nothing is enrolled and the campaign keeps its status. ```bash curl -X POST "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/start" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "campaign": { "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Q4 outbound", "status": "active", "display_status": "running" }, "enrolled": 48 } ``` `campaign` is the list shape, shortened here. `enrolled` is the number of enrolments this call created. It is `0` on a resume. **Errors** | Status | Message | |---|---| | `404` | `Campaign not found.` | | `409` | `Campaign is already running.`, `A completed campaign can't be started again; duplicate it in the app.` | | `409` | `This campaign has no sending mailbox.`, `Add at least one step first.`, `This campaign's audience has no prospects.` or a message about an empty step. | | `409` | ` prospect(s) are missing a field used in the copy. Fix them or narrow the audience: nothing was enrolled.` The body adds `issues`, as in [Resume](#resume-a-campaign). | | `409` | ` prospect(s) are already active in another campaign. …` The body adds `conflicts`. | ```json { "error": "1 prospect(s) are already active in another campaign. Two sequences to the same person at once is what gets a domain blocked: remove them here or stop them there. Nothing was enrolled.", "conflicts": [ { "prospectId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "email": "ada@example.com", "campaigns": [{ "id": "8a1b2c3d-0000-4000-8000-000000000009", "name": "Q3 outbound", "nextStep": 3 }] } ] } ``` ## Stop a campaign `POST /api/v1/campaigns/{id}/stop` has no body. It does what **Stop** does in the app: every active enrolment becomes `stopped` with the reason `Stopped: the campaign was ended by the operator.`, and the campaign becomes `completed`. This cannot be undone. To stop sending for a while, [pause](#pause-a-campaign) instead. ```bash curl -X POST "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/stop" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** returns the campaign in the list shape with `"status": "completed"`. **Errors:** `404` with `Campaign not found.`, `409` with `A draft has nothing to stop; delete it instead.` or `Campaign is already completed.` ## Get a campaign `GET /api/v1/campaigns/{id}` returns the row above plus settings, steps, mailboxes and statistics. ```bash curl "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** (the fields of the list row are shortened) ```json { "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Q3 outbound", "status": "active", "display_status": "running", "settings": { "daily_enroll_limit": 20, "timezone": null, "use_prospect_timezone": true, "window_start_hour": 9, "window_end_hour": 17, "send_on_weekends": false, "send_days": null, "business_days_only": false }, "step_list": [ { "id": "a1b2c3d4-0000-4000-8000-000000000001", "position": 1, "delay_days": 0, "subject": "Quick question" } ], "mailboxes": [ { "id": "b2c3d4e5-0000-4000-8000-000000000001", "email": "sales@example.com" } ], "stats": { "sent": 312, "delivered": 305, "bounced": 7, "replied": 19, "unsubscribed": 2, "opened": null, "clicked": null, "reply_rate": 0.0609, "replies": { "total": 19, "interested": 6, "neutral": 9, "notInterested": 4, "autoreplied": 3 }, "prospects": { "enrolled": 100, "queued": 31, "finished": 61, "invalid": null }, "tracking_enabled": false } } ``` The statistics come from the same source as the **Stats** tab in the app. - `delivered` is `sent` minus `bounced`. - `replies` leaves out autoresponders and reports them separately as `autoreplied`. - `queued` is the number of active enrolments whose next step is in the future. `finished` is the number of completed enrolments. - `invalid` counts prospects with an `invalid` verification result. It is `null` when nobody in this campaign has been verified. - `opened` and `clicked` are `null` until the tracking domain of the workspace is verified, and `tracking_enabled` is `false`. `null` means the value is not measured. It does not mean zero. - `settings.window_start_hour` and `settings.window_end_hour` can be `null`, which means they inherit the workspace value. **Errors:** `404` with `{"error":"Campaign not found."}`. This includes a campaign from another workspace and an id that is not a UUID. ## List enrolments `GET /api/v1/campaigns/{id}/prospects` returns one row for each person enrolled in the campaign. | Parameter | Type | Notes | |---|---|---| | `status` | string | One of `active`, `stopped`, `completed`. | | `limit` | integer | 1 to 100. Default 50. | | `cursor` | string | See [Pagination](/pagination/). | ```bash curl "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/prospects?status=stopped" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [ { "id": "e0a1c2d3-0000-4000-8000-000000000001", "prospect": { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "email": "ada@example.com", "first_name": "Ada", "last_name": null, "company_id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427" }, "status": "stopped", "current_step": 3, "next_step_at": null, "outcome": { "label": "Stopped", "negative": true, "reason": "Bounced: 550 5.1.1 user unknown" }, "enrolled_at": "2026-08-11T07:30:00.000+00:00" } ], "next_cursor": null } ``` - `id` is the id of the enrolment. The id of the prospect is in `prospect.id`. - `current_step` is the number of the step that goes out next. `1` means nothing has gone out yet. - `outcome.label` is `Active`, `Stopped` or `Completed`, the word on the badge in the app. `outcome.negative` is `true` when the enrolment stopped because of a bounce, an unsubscribe or a suppression. `outcome.reason` is the last error recorded on the enrolment. **Errors:** `404` with `Campaign not found.` The API checks the campaign before it reads enrolments. `400` for an invalid `status`, `limit` or `cursor`. ## Enrol prospects `POST /api/v1/campaigns/{id}/prospects` enrols existing prospects of the workspace in a `draft` or `active` campaign. It runs the same function as **Add prospects** on the campaign page, so the rules apply to each person separately. The call does not fail as a whole. **Body** (JSON, up to 64 KiB). Send exactly one of these fields, with 1 to 200 items. Duplicates in the list are merged and the first one wins. Addresses are compared trimmed and lower-cased. | Field | Type | Notes | |---|---|---| | `prospect_ids` | array of UUID | Prospect ids. | | `emails` | array of string | Email addresses of existing prospects. | ```bash curl -X POST "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/prospects" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"emails":["ada@example.com","bob@example.com","nobody@example.com"]}' ``` **Response `200`** ```json { "campaign": { "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Q3 outbound", "status": "active" }, "data": [ { "prospect_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "email": "ada@example.com", "result": "enrolled" }, { "prospect_id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427", "email": null, "result": "already-enrolled" }, { "prospect_id": null, "email": "nobody@example.com", "result": "not-found" } ], "summary": { "requested": 3, "enrolled": 1, "already-enrolled": 1, "suppressed": 0, "conflict": 0, "missing-field": 0, "anonymized": 0, "not-found": 1 } } ``` | `result` | Meaning | |---|---| | `enrolled` | The prospect entered the campaign. Step 1 is scheduled for now, with a mailbox from the rotation of the campaign. | | `already-enrolled` | The prospect is already in this campaign. | | `suppressed` | The address is on the suppression list of the workspace or on the global list. | | `conflict` | The prospect is active in another campaign of the workspace. | | `missing-field` | A snippet used in a subject or body has no value for this prospect. | | `anonymized` | The prospect was anonymised after an erasure request, so it cannot be enrolled. | | `not-found` | No such prospect in this workspace. This includes the id of another workspace. | `data` keeps the order of the request after duplicates are merged. For `not-found`, `prospect_id` is `null` when you sent `emails`, and `email` is `null` when you sent `prospect_ids`. `email` is also `null` for `already-enrolled`. The status is `200` even when nobody was enrolled. Read the numbers in `summary`. **Errors** | Status | Message | |---|---| | `400` | The body errors listed on [Errors](/errors/), `` Provide exactly one of `prospect_ids` or `emails`. ``, `` `prospect_ids` must be a non-empty array of up to 200 UUIDs. ``, `` `emails` must be a non-empty array of up to 200 email addresses. ``, `` `prospect_ids[3]` must be a UUID. ``, `` `emails[2]` must be a non-empty string. `` | | `404` | `Campaign not found.` | | `409` | `This campaign can't take new prospects in its current state (paused).`, `Add step 1 before enrolling anyone.` or `This campaign has no sending mailbox.` | To create a prospect and enrol it in one call, use `POST /prospects?campaign=`. See [Prospects](/prospects/). ## Pause a campaign `POST /api/v1/campaigns/{id}/pause` has no body. It does what **Pause** does in the app: `status` becomes `paused` and `pause_reason` becomes `Paused by the operator.` Enrolments stay in place. The engine sends nothing from a paused campaign, and a resume continues each sequence where it stopped. ```bash curl -X POST "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/pause" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** returns the campaign in the list shape, without the `data` wrapper, with `"status": "paused"` and `"display_status": "paused"`. **Errors:** `404` with `Campaign not found.` A `409` explains why the campaign cannot be paused. Only a running campaign can be paused, so a draft, an already paused campaign and a completed campaign each return `409`. ## Resume a campaign `POST /api/v1/campaigns/{id}/resume` has no body. It follows the **Resume campaign** path of the app, with the same checks: - The campaign has a sending mailbox. - The campaign has at least one step. - The audience has prospects. - Every prospect of the audience has all the fields used in the steps and in the active A/B variants. The write sets `status` to `active`, sets `started_at` to the current time and clears `pause_reason`. It does not create or undo enrolments. Only a paused campaign can be resumed. To start a draft, use [Start](#start-a-campaign). A completed campaign cannot run again. Duplicate it in the app. ```bash curl -X POST "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/resume" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** returns the campaign in the list shape with `"status": "active"`. **Errors** | Status | Message | |---|---| | `404` | `Campaign not found.` | | `409` | `Campaign is already running.` | | `409` | `Only a paused campaign can be resumed` followed by the reason: the campaign is a draft (start it from the app) or completed (run it again from the app). | | `409` | `This campaign has no sending mailbox.`, `Add at least one step first.` or `This campaign's audience has no prospects.` | | `409` | A message that starts with the number of prospects missing a field used in the copy. The campaign stays paused. The body adds an `issues` array. | ```json { "error": "2 prospect(s) are missing a field used in the copy. Fix them or narrow the audience — the campaign stays paused.", "issues": [ { "email": "ada@example.com", "missing": ["first_name"] } ] } ``` ## Related - [Steps and variants](/campaign-steps/) - [Statistics](/campaign-stats/) - [Prospects](/prospects/) - [Mailboxes](/mailboxes/) --- # Steps and variants > Source: https://developers.anymail.watch/campaign-steps/ A step is one email of the sequence. Each step has one to four variants, labelled `A` to `D`, for A/B tests. The content of a step is its variant `A`. | Method | Path | Action | |---|---|---| | `GET` | `/api/v1/campaigns/{id}/steps` | List the steps with their variants | | `POST` | `/api/v1/campaigns/{id}/steps` | Add a step at the end | | `PATCH` | `/api/v1/campaigns/{id}/steps/{stepId}` | Edit a step | | `DELETE` | `/api/v1/campaigns/{id}/steps/{stepId}` | Delete a step | | `POST` | `/api/v1/campaigns/{id}/steps/{stepId}/variants` | Add a variant | | `PATCH` | `/api/v1/campaigns/{id}/steps/{stepId}/variants/{label}` | Edit a variant | | `DELETE` | `/api/v1/campaigns/{id}/steps/{stepId}/variants/{label}` | Delete a variant | ## How writes work Every write reads the whole sequence, changes one element and saves the whole sequence with the function of the sequence editor in the app. The validation of variants, the cleaning of HTML, the step schedules and the other nodes of the sequence (tasks and conditions made in the app) follow the editor rules. The API has no content rules of its own. - Writes are allowed on a `draft` or a `paused` campaign. A running campaign returns `409`. [Pause](/campaigns/#pause-a-campaign) it first. - Every write returns the whole sequence in the shape of `GET /steps`. - A step is identified by its position, as in the editor. After you delete step 2, the old step 3 becomes step 2 and takes the `id` of the old step 2. Read the ids again from the response after a delete. - Snippets such as `{{first_name}}` and `{{company}}` are filled from the prospect when the email goes out. A missing value blocks the [start](/campaigns/#start-a-campaign). ## List steps ```bash curl "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/steps" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [ { "id": "a1b2c3d4-0000-4000-8000-000000000001", "position": 1, "name": null, "delay_days": 0, "subject": "Quick question, {{first_name}}", "body": "Hi {{first_name}},\n\nAre you the right person to talk to about outbound at {{company}}?", "content_format": "plain", "append_signature": true, "preview_text": null, "schedule": null, "variants": [ { "label": "A", "subject": "Quick question, {{first_name}}", "body": "Hi {{first_name}},\n\nAre you the right person to talk to about outbound at {{company}}?", "content_format": "plain", "append_signature": true, "active": true, "preview_text": null }, { "label": "B", "subject": "{{first_name}}, a question about {{company}}", "body": "Hi {{first_name}},\n\nAre you the right person to talk to about outbound at {{company}}?", "content_format": "plain", "append_signature": true, "active": true, "preview_text": null } ] } ] } ``` | Field | Notes | |---|---| | `delay_days` | Days after the previous step. Step 1 usually has `0`. | | `subject` | An empty subject on a follow-up keeps the email in the thread of the previous one. | | `content_format` | `plain` or `html`. HTML is cleaned when it is saved. | | `append_signature` | Add the signature of the sending mailbox. | | `schedule` | The send window of this step, or `null` to follow the campaign schedule. | | `variants[].active` | An inactive variant is kept but not sent. | **Errors:** `404` with `Campaign not found.` ## Add a step `POST /api/v1/campaigns/{id}/steps` adds a step at the end of the sequence. Without `delay_days`, the first step gets `0` and later steps get `2`. **Body** (JSON, up to 64 KiB). All fields are optional. An unknown field returns `400`. | Field | Type | Notes | |---|---|---| | `subject` | string | | | `body` | string | | | `name` | string or null | A label for the step in the app. | | `delay_days` | integer | 0 or more. | | `content_format` | string | `plain` or `html`. | | `append_signature` | boolean | | | `preview_text` | string or null | The preview line of the inbox. | | `schedule` | object or null | A step schedule in the shape that `GET` returns. | ```bash curl -X POST "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/steps" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"subject":"","body":"Hi {{first_name}}, a short follow-up on my last note.","delay_days":3}' ``` **Response `201`** returns the whole sequence. **Errors** | Status | Message | |---|---| | `400` | `` Unknown field(s): … ``, `` `delay_days` must be a number. ``, `` `content_format` must be plain or html. `` | | `404` | `Campaign not found.` | | `409` | `` Steps can be edited only on a draft or a paused campaign — this one is active. Pause it first. `` | | `409` | A rule of the sequence editor, for example `Add at least one email step with a subject and a body.` or `Step 2 schedule: …` | ## Edit a step `PATCH /api/v1/campaigns/{id}/steps/{stepId}` takes the fields of the add call. `subject`, `body`, `content_format`, `append_signature` and `preview_text` change variant `A`. Variants `B` to `D` stay as they are. `name`, `delay_days` and `schedule` change the step. ```bash curl -X PATCH "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/steps/a1b2c3d4-0000-4000-8000-000000000002" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"delay_days":4}' ``` **Response `200`** returns the whole sequence. **Errors:** as for add, plus `404` with `Step not found.` ## Delete a step `DELETE /api/v1/campaigns/{id}/steps/{stepId}` removes a step. The steps after it move up one position. The response is `200` with the whole sequence. ```bash curl -X DELETE "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/steps/a1b2c3d4-0000-4000-8000-000000000002" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Errors:** `404` with `Campaign not found.` or `Step not found.`, `409` with `A campaign needs at least one step; edit this one instead.` ## Add a variant `POST /api/v1/campaigns/{id}/steps/{stepId}/variants` adds the next free label: `B`, then `C`, then `D`. Fields you omit are copied from the first variant. A new variant is active unless you send `"active": false`. | Field | Type | |---|---| | `subject` | string | | `body` | string | | `content_format` | `plain` or `html` | | `append_signature` | boolean | | `preview_text` | string or null | | `active` | boolean | ```bash curl -X POST "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/steps/a1b2c3d4-0000-4000-8000-000000000001/variants" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"subject":"{{first_name}}, a question about {{company}}"}' ``` **Response `201`** returns the whole sequence. **Errors:** `404` with `Step not found.`, `409` with `A step can have at most 4 variants.` ## Edit or delete a variant `PATCH /api/v1/campaigns/{id}/steps/{stepId}/variants/{label}` takes the fields of the add call. `{label}` is `A` to `D` and is not case-sensitive. To stop sending a variant without losing it, send `{"active": false}`. `DELETE /api/v1/campaigns/{id}/steps/{stepId}/variants/{label}` removes a variant. The last variant of a step cannot be deleted. Delete the step instead. ```bash curl -X PATCH "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/steps/a1b2c3d4-0000-4000-8000-000000000001/variants/B" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"active":false}' ``` Both return `200` with the whole sequence. **Errors:** `404` with `Variant not found.`, `409` with `A step needs at least one variant; delete the step instead.` ## Related - [Campaigns](/campaigns/) - [Statistics](/campaign-stats/) - [MCP server](/mcp-server/) --- # Statistics > Source: https://developers.anymail.watch/campaign-stats/ `GET /api/v1/campaigns/{id}/stats` returns the numbers of one campaign in three parts: totals, the funnel from the campaign list and a breakdown by step and A/B variant. No number is computed differently from the app. Totals match the **Stats** tab, the funnel matches the campaign list. | Method | Path | Action | |---|---|---| | `GET` | `/api/v1/campaigns/{id}/stats` | Statistics of a campaign | ```bash curl "https://app.anymail.watch/api/v1/campaigns/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/stats" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "campaign_id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "totals": { "sent": 312, "delivered": 305, "opened": null, "clicked": null, "replied": 19, "interested": 6, "maybe_later": 9, "not_interested": 4, "autoreplied": 3, "bounced": 7, "unsubscribed": 2, "reply_rate": 0.0609, "enrolled": 100, "tracking_enabled": false }, "funnel": { "contacted": 96, "enrolled": 100, "contactedPct": 96, "delivered": 305, "deliveredPct": 98, "replyRatio": 0.0609, "interested": 6, "neutral": 9, "negative": 4 }, "steps": [ { "position": 1, "subject": "Quick question, {{first_name}}", "sent": 96, "delivered": 93, "bounced": 3, "replied": 11, "interested": 4, "maybe_later": 5, "opened": null, "opened_unique": null, "queued": 4, "branch": null, "variants": [ { "label": "A", "subject": "Quick question, {{first_name}}", "active": true, "deleted": false, "sent": 48, "delivered": 47, "bounced": 1, "replied": 7, "interested": 3, "maybe_later": 2, "opened": null }, { "label": "B", "subject": "{{first_name}}, a question about {{company}}", "active": true, "deleted": false, "sent": 48, "delivered": 46, "bounced": 2, "replied": 4, "interested": 1, "maybe_later": 3, "opened": null } ] } ] } ``` ## Totals | Field | Notes | |---|---| | `sent`, `delivered`, `bounced` | Messages. `delivered` is `sent` minus `bounced`. SMTP does not report delivery. | | `replied` | Replies, without autoresponders. | | `interested`, `maybe_later`, `not_interested` | Replies by classification. `maybe_later` is the `neutral` class. | | `autoreplied` | Autoresponders, counted apart from replies. | | `reply_rate` | `replied / sent` as a fraction from 0 to 1. | | `enrolled` | People enrolled in the campaign. | | `opened`, `clicked` | `null` until the tracking domain of the workspace is verified. `null` means not measured. It does not mean zero. | ## Funnel The funnel mixes two units, as in the app. `contacted` and `enrolled` count people. `delivered` and `replyRatio` count messages. | Field | Notes | |---|---| | `contacted` | People who received at least one message. | | `contactedPct` | `contacted` as a rounded percentage of `enrolled`. `null` when nobody is enrolled. | | `deliveredPct` | `delivered` as a rounded percentage of sent messages. | | `replyRatio` | Replies per sent message, 0 to 1. `null` when nothing was sent. | | `interested`, `neutral`, `negative` | Replies by classification. | The funnel keys are in camelCase, unlike the rest of the API. They are the same keys the app uses. ## Steps and variants `steps` has one row per email step, in order. | Field | Notes | |---|---| | `queued` | Active enrolments that wait for this step. | | `opened` | Open events for messages of this step, with repeats. `null` without tracking. | | `opened_unique` | Messages of this step opened at least once, which is the number of people. `null` without tracking. | | `branch` | `{ "yes": n, "no": n }` when a condition built in the app gates this step, otherwise `null`. | | `variants` | One row per A/B variant. `deleted: true` marks a variant that was removed after it sent messages, so its history stays visible. | Unsubscribes have no per step number, because the unsubscribe link is the same on every step. Treat opens as a weak signal. Apple Mail Privacy Protection opens the tracking pixel without a person. **Errors:** `404` with `Campaign not found.`, including a campaign from another workspace and an id that is not a UUID. ## Related - [Campaigns](/campaigns/) - [Steps and variants](/campaign-steps/) - [Replies](/replies/) --- # Companies > Source: https://developers.anymail.watch/companies/ A company groups the prospects that share an email domain or a `company_name`. Each company has an account temperature that anymail computes from the events of its people. Companies are read-only in the API. | Method | Path | Action | |---|---|---| | `GET` | `/api/v1/companies` | List companies with temperature | | `GET` | `/api/v1/companies/{id}` | Get a company card | ## List companies | Parameter | Type | Notes | |---|---|---| | `search` | string | Matches the name, the name given by the operator and the domain. A leading `www.` is ignored. | | `temperature` | string | One of `hot`, `warm`, `cold`, `quiet`. | | `limit` | integer | 1 to 100. Default 50. | | `cursor` | string | An offset cursor. See [Pagination](/pagination/). | ```bash curl "https://app.anymail.watch/api/v1/companies?temperature=hot&limit=2" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [ { "id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427", "display_name": "Example Ltd", "name": "Example Ltd", "group_name": null, "domain": "example.com", "tags": ["enterprise"], "people": 3, "temperature": { "band": "hot", "score": 8, "replies": 1, "event_count": 5, "window_days": 7, "trend": { "direction": "up", "change_percent": 60, "previous_score": 5 } }, "last_activity_at": "2026-09-03T10:00:00.000+00:00", "created_at": "2026-08-19T12:00:00.000+00:00" } ], "next_cursor": "eyJvIjo1MH0" } ``` ### Account temperature The temperature is the value that the **Companies** screen draws as a bar. It sums the events of the last 7 days, weighted by type. | Event | Weight | |---|---| | Reply with positive intent | 5 | | Reply | 3 | | Return to an offer | 2.5 | | Click | 2 | | Offer open | 2 | | Delivery | 0.5 | | Sent | 0 | | Bounce or unsubscribe | −2 | | Band | Score | |---|---| | `hot` | 6 or more | | `warm` | 2 or more | | `cold` | below 2 | | `quiet` | no events in the window. The app shows this as "No signal". | `trend` compares the current 7 days with the previous 7 days. `direction: "unknown"` and `change_percent: null` mean there is no earlier period to compare with. `last_activity_at` is the latest event in the whole history of the company, which can be older than the 7 day window. `display_name` is the name, then the name given by the operator, then the domain, then `Unnamed company`. **Errors:** `400` for an invalid `temperature`, a `search` value that is too long, or an invalid `limit` or `cursor`. ## Get a company `GET /api/v1/companies/{id}` returns the row above, with the same temperature value as the list, plus the company card. ```bash curl "https://app.anymail.watch/api/v1/companies/1b4e28ba-2fa1-11d2-883f-0016d3cca427" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** (the fields of the list row are shortened) ```json { "id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427", "display_name": "Example Ltd", "domain": "example.com", "people": 3, "people_list": [ { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "email": "ada@example.com", "name": "Ada Lovelace", "position": "CTO", "timezone": null, "enrollments": [ { "campaignId": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "campaignName": "Q3 outbound", "status": "stopped", "lastError": "replied" } ], "last_activity_at": "2026-09-03T10:00:00.000+00:00", "latest_reply": { "classification": "interested", "receivedAt": "2026-09-03T10:00:00.000+00:00", "campaignId": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "campaignName": "Q3 outbound" }, "has_responded": true } ], "campaigns": [ { "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Q3 outbound", "status": "active", "people": 3, "replies": 1 } ], "recent_events": [ { "id": "c4d5e6f7-0000-4000-8000-000000000001", "type": "replied", "occurred_at": "2026-09-03T10:00:00.000+00:00", "prospect_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "campaign_id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" } ] } ``` - `people_list` follows the order of the company view in the app: people who replied first, then by recent activity, then alphabetically. - `position` is a heuristic from custom fields and snippet slots. It is `null` when there is nothing to show. - The API returns the fact and the classification of a reply. The text of a reply is not returned. - `campaigns[].people` is the number of enrolled people from this company. `campaigns[].replies` is the number of replies from this company in that campaign. `status` is the status of the campaign. - `recent_events` holds up to 50 of the latest events of the people of this company, of every type. **Errors:** `404` with `{"error":"Company not found."}`. ## Related - [Prospects](/prospects/) - [Replies](/replies/) - [Offers](/offers/) --- # Replies > Source: https://developers.anymail.watch/replies/ A reply is a message from a prospect that arrived in one of the mailboxes of the workspace. anymail classifies each reply. The API returns the fact, the classification and a short snippet. It does not return the full text of a reply. | Method | Path | Action | |---|---|---| | `GET` | `/api/v1/replies` | List classified replies | | `PATCH` | `/api/v1/replies/{id}` | Correct the classification of a reply | ## List replies | Parameter | Type | Notes | |---|---|---| | `classification` | string | One of `interested`, `neutral`, `not_interested`, `unclassified`, `autoresponder`. | | `since` | ISO 8601 | Replies received at or after this time. | | `campaign_id` | UUID | Replies to one campaign. | | `prospect_id` | UUID | Replies from one prospect. | | `limit` | integer | 1 to 100. Default 50. | | `cursor` | string | See [Pagination](/pagination/). | Without `classification`, the list leaves out autoresponders, like the **All replies** folder in the app. `classification=autoresponder` returns only autoresponders. `unclassified` returns replies that are not classified yet, for example when the workspace has no model key. ```bash curl "https://app.anymail.watch/api/v1/replies?classification=interested&since=2026-09-01" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [ { "id": "20000000-0000-0000-0000-000000000001", "prospect": { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "email": "ada@example.com", "first_name": "Ada", "last_name": null }, "company": { "id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427", "name": "Example Ltd", "domain": "example.com" }, "campaign": { "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Q3 outbound" }, "mailbox_id": "b2c3d4e5-0000-4000-8000-000000000001", "from_email": "ada@example.com", "subject": "Re: Quick question", "classification": "interested", "classification_overridden": false, "is_autoresponder": false, "is_read": false, "received_at": "2026-09-03T10:00:00.000+00:00", "snippet": "Sounds good, let's talk next week. Does Tuesday 10:00 work?" } ], "next_cursor": null } ``` - `snippet` is the first 160 characters of the text on one line. - `prospect.email` can be `null` when the prospect was deleted. `company` is `null` for an address without a company. - `classification_overridden` is `true` after a human correction. **Errors:** `400` for an invalid `classification`, `since`, UUID, `limit` or `cursor`. ## Correct a classification `PATCH /api/v1/replies/{id}` sets the classification by hand. It writes the same fields as the menu in the **Inbox**: `classification`, `classification_overridden = true`, `classified_by = "human"`, a new `classified_at` and no `classification_error`. The classifier does not touch the row again, even if a classification is in progress. The call also works on a reply that is not classified yet. **Body** (JSON, up to 4 KiB) | Field | Type | Required | Notes | |---|---|---|---| | `classification` | string | Yes | One of `interested`, `neutral`, `not_interested`. | ```bash curl -X PATCH "https://app.anymail.watch/api/v1/replies/20000000-0000-0000-0000-000000000001" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"classification":"not_interested"}' ``` **Response `200`** returns the reply in the list shape, without the `data` wrapper, with `"classification_overridden": true`. **Errors** | Status | Message | |---|---| | `400` | The body errors listed on [Errors](/errors/), or `` `classification` must be one of: interested, neutral, not_interested. `` | | `404` | `Reply not found.` This includes a reply from another workspace and an id that is not a UUID. | ## Related - [Webhook subscriptions](/webhook-subscriptions/) - [Campaigns](/campaigns/) - [Companies](/companies/) --- # Inbox > Source: https://developers.anymail.watch/inbox/ The inbox endpoints read the messages that arrived in the mailboxes of the workspace, with the full text, and send an answer. [Replies](/replies/) returns the same messages with a snippet and is the place to correct a classification. | Method | Path | Action | |---|---|---| | `GET` | `/api/v1/inbox/messages` | List messages with the full text | | `POST` | `/api/v1/inbox/messages/{id}/reply` | Answer a message | ## List messages | Parameter | Type | Notes | |---|---|---| | `category` | string | One of `interested`, `neutral`, `not_interested`, `unclassified`, `autoresponder`. | | `unread` | string | `true` for unread messages only, `false` for read ones. | | `since` | ISO 8601 | Messages received at or after this time. | | `campaign_id` | UUID | Messages in reply to one campaign. | | `prospect_id` | UUID | Messages from one prospect. | | `limit` | integer | 1 to 100. Default 50. | | `cursor` | string | See [Pagination](/pagination/). | Without `category`, the list leaves out autoresponders, like the **All replies** folder in the app. Newest messages come first. ```bash curl "https://app.anymail.watch/api/v1/inbox/messages?unread=true&category=interested" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [ { "id": "20000000-0000-0000-0000-000000000001", "prospect": { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "email": "ada@example.com", "first_name": "Ada", "last_name": null }, "company": { "id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427", "name": "Example Ltd", "domain": "example.com" }, "campaign": { "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Q3 outbound" }, "mailbox_id": "b2c3d4e5-0000-4000-8000-000000000001", "from_email": "ada@example.com", "subject": "Re: Quick question", "classification": "interested", "classification_overridden": false, "is_autoresponder": false, "is_read": false, "received_at": "2026-09-03T10:00:00.000+00:00", "snippet": "Sounds good, let's talk next week. Does Tuesday 10:00 work?", "body": "Sounds good, let's talk next week. Does Tuesday 10:00 work?\n\nAda" } ], "next_cursor": null } ``` The fields are those of [Replies](/replies/#list-replies) plus `body`, the full text of the message. Reading the list does not mark a message as read. **Errors:** `400` for an invalid `category`, `unread`, `since`, UUID, `limit` or `cursor`. ## Answer a message `POST /api/v1/inbox/messages/{id}/reply` sends an answer from the mailbox the message arrived in, in the same thread, to the sender of the message. It runs the **Reply** action of the inbox in the app. **This sends real email.** **Body** (JSON, up to 64 KiB) | Field | Type | Required | Notes | |---|---|---|---| | `body` | string | Yes | Plain text, up to 20,000 characters after trimming. | ```bash curl -X POST "https://app.anymail.watch/api/v1/inbox/messages/20000000-0000-0000-0000-000000000001/reply" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 1d7c2f4e-8a90-4b3c-9d1e-2f3a4b5c6d7e" \ -d '{"body":"Thanks Ada, Tuesday 10:00 works. I will send an invite."}' ``` **Response `200`** ```json { "sent": true, "to": "ada@example.com" } ``` When the message went out but anymail could not record it, the response is still `200`, with `warning` in place of `to`. Do not retry in that case, because a retry sends a second email. An [`Idempotency-Key`](/idempotency-keys/) protects a retry after a timeout. **Errors** | Status | Message | |---|---| | `400` | `` `body` must be a non-empty string. ``, `` `body` is limited to 20000 characters. `` | | `404` | `Message not found.` This includes a message from another workspace and an id that is not a UUID. | | `409` | `The mailbox this reply arrived in no longer exists.` or `This reply has no sender address to answer.` | | `502` | `Couldn't send: `. The mail server refused the message. | ## Related - [Replies](/replies/) - [Webhooks](/webhooks/) - [Mailboxes](/mailboxes/) --- # Offers > Source: https://developers.anymail.watch/offers/ An offer is a document you send with a tracked link. anymail records when the recipient opens it, how long they read and how far they get. The API creates an offer from HTML, which is the way to send an offer written by an AI assistant, and reads the statistics. Reading returns metadata and statistics. The content of the document is not returned. | Method | Path | Action | |---|---|---| | `POST` | `/api/v1/offers` | Create an offer from HTML and send it | | `GET` | `/api/v1/team` | List the people who can be the contact on an offer | | `GET` | `/api/v1/offers` | List sent offers | | `GET` | `/api/v1/offers/{id}` | Get an offer with its visits | ## Create an offer `POST /api/v1/offers` turns an HTML document into a tracked offer page for one prospect. With `send` true, the default, it also emails the link. The mailbox is chosen automatically, with the same rotation, pauses and daily limits as the app, and the email counts toward the mailbox limit. The HTML is sanitised on the server: scripts and event handlers are removed. Use plain HTML with inline CSS. | Field | Type | Notes | |---|---|---| | `prospect_id` | UUID | Required. | | `html` | string | Required. The offer document. The whole request can be up to about 500 KB. | | `subject` | string | Required when `send` is true. | | `message` | string | The cover message in the email. Empty means the email contains only the link. | | `message_format` | string | `plain` (default) or `html`. | | `contact` | string | The salesperson shown on the offer page. An `id` from `GET /api/v1/team`. | | `send` | boolean | Default `true`. `false` creates the link and sends nothing. | | `email_gate` | boolean | Ask the reader for an email before showing the offer. Default `false`. | ```bash curl -X POST "https://app.anymail.watch/api/v1/offers" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"prospect_id":"4c0f0f9e-0000-4000-8000-000000000001","html":"

Offer

…

","subject":"Our offer","contact":"b2c3d4e5-0000-4000-8000-000000000009"}' ``` **Response `201`:** `{"id":"…","url":"https://…/offer/…","status":"sent","recipient":"ada@acme.com","mailbox_id":"…"}`. With `send: false`, `status` is `link_only` and `mailbox_id` is `null`. | Status | Message | |---|---| | `400` | `` Unknown field(s): … ``, `` `prospect_id` must be a prospect id (uuid). ``, `` `html` is required … ``, `` `subject` is required when `send` is true. ``, `` `contact` is not a person in this workspace. … `` | | `404` | `Prospect not found.` | | `409` | The prospect is on the suppression list, or no mailbox can send now. Nothing was sent. | | `502` | The mail server refused the message. The offer link was saved. | Once the recipient opens the link, the signals `offer_opened`, `offer_read` and `offer_temperature_changed` reach your [webhooks](/webhooks/#events). To let a person review the page before it goes out, create the offer with `send: false`, open the `url`, and then send it. ## List the contacts `GET /api/v1/team` returns `data`, a list of `{id, kind, name, title, email, phone, role}`. `kind` is `member` for a workspace member and `profile` for a salesperson without a login. Pass the `id` as `contact` when you create an offer. ## List offers | Parameter | Type | Notes | |---|---|---| | `status` | string | One of `sent`, `opened`, `read`. The API applies it to the fetched page. See [Pagination](/pagination/). | | `prospect_id` | UUID | Offers sent to one prospect. | | `campaign_id` | UUID | Offers sent in one campaign. | | `limit` | integer | 1 to 100. Default 50. | | `cursor` | string | See [Pagination](/pagination/). | ```bash curl "https://app.anymail.watch/api/v1/offers?status=read" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [ { "id": "9a1f0b2c-0000-4000-8000-000000000001", "prospect": { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "email": "ada@example.com", "first_name": "Ada", "last_name": null }, "company": { "id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427", "name": "Example Ltd", "domain": "example.com" }, "campaign": null, "document": { "format": "html_pasted", "label": "Proposal v2", "url": null, "path": "offers/example/proposal-v2.html" }, "url": "https://app.anymail.watch/offer/Qm9…", "sent_at": "2026-09-01T10:00:00.000+00:00", "status": "opened", "visit_status": "returned", "visits": 2, "returns": 1, "total_active_seconds": 247, "progress": { "max_page_reached": 3, "total_pages": 6, "completed": false }, "first_viewed_at": "2026-09-01T11:02:00.000+00:00", "last_viewed_at": "2026-09-02T09:15:00.000+00:00" } ], "next_cursor": null } ``` ### Two status fields | Field | Values | Answers | |---|---|---| | `status` | `sent`, `opened`, `read` | Did the recipient read it? `read` means they reached the end (`completed`) or the last known page. | | `visit_status` | `sent`, `viewed`, `returned` | How many times did they come back? 0, 1, or 2 or more visits. | - `total_active_seconds` is the sum over all visits. - `progress.total_pages` is `null` for a PDF without sections. For such a document `completed` is the only signal for `read`. - `url` is the public address of the offer, the one the app shows under **Copy link**. It uses the white-label host when the workspace sets one. - `campaign` is `null` when the offer was not sent from a campaign. **Errors:** `400` for an invalid `status`, UUID, `limit` or `cursor`. ## Get an offer `GET /api/v1/offers/{id}` returns the row above plus the visits and the reading time per page. ```bash curl "https://app.anymail.watch/api/v1/offers/9a1f0b2c-0000-4000-8000-000000000001" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** (the fields of the list row are shortened) ```json { "id": "9a1f0b2c-0000-4000-8000-000000000001", "status": "opened", "visits": 2, "visit_list": [ { "session_id": "s2", "started_at": "2026-09-02T09:15:00.000+00:00", "active_seconds": 40, "device": "iPhone · Safari", "max_page_reached": 3, "completed": false, "visit_index": 2 }, { "session_id": "s1", "started_at": "2026-09-01T11:02:00.000+00:00", "active_seconds": 207, "device": "Mac · Chrome", "max_page_reached": 2, "completed": false, "visit_index": 1 } ], "pages": [ { "id": "sec1", "position": 0, "label": "Intro", "page_type": "cover", "visits_read": 2, "total_active_seconds": 70, "last_read_at": "2026-09-02T09:16:00.000+00:00" }, { "id": "sec2", "position": 1, "label": "Pricing", "page_type": "pricing", "visits_read": 1, "total_active_seconds": 30, "last_read_at": null } ] } ``` - `visit_list` holds up to the 200 latest visits, newest first. `visit_index` counts in time order, so `1` is the first visit. - `device` is approximate, for example `Mac · Chrome`, `Unknown device` or `Automated client`. - `pages` sums the reading time per page or section over all visits. It is empty for a PDF without sections. **Errors:** `404` with `{"error":"Offer not found."}`. ## Related - [Companies](/companies/) - [Prospects](/prospects/) - [Campaigns](/campaigns/) --- # Suppressions > Source: https://developers.anymail.watch/suppressions/ The suppression list holds the addresses and domains that anymail never emails. The one-click unsubscribe link writes to the same list. The engine checks the list on every send attempt, without a cache. | Method | Path | Action | |---|---|---| | `POST` | `/api/v1/suppressions` | Exclude an address or a domain | | `GET` | `/api/v1/suppressions` | List the suppression list of the workspace | | `DELETE` | `/api/v1/suppressions/{id}` | Remove an entry | ## Exclude an address or a domain The endpoint honours an [`Idempotency-Key`](/idempotency-keys/). **Body** (JSON, up to 4 KiB) | Field | Type | Required | Notes | |---|---|---|---| | `target` | string | Yes | An email address such as `jane@example.com`, or a bare domain such as `example.com`. | ```bash curl -X POST "https://app.anymail.watch/api/v1/suppressions" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"target":"example.com"}' ``` **Response `201`** ```json { "id": "d5e6f7a8-0000-4000-8000-000000000001", "kind": "domain", "value": "example.com" } ``` `kind` is `email` or `domain`. `value` is the normalised target. **Errors** | Status | Message | |---|---| | `400` | The body errors listed on [Errors](/errors/). | | `400` | `` `target` must be a non-empty string — an email address or a domain. `` | | `400` | `` `target` is not a valid email address or domain (e.g. acme.com). `` | | `409` | ` is already excluded.` The body has `"code": "duplicate"`, `kind` and `value`. The target is already unreachable, so the goal is met. | | `500` | `Couldn't exclude it: ` | ## List suppressions | Parameter | Type | Notes | |---|---|---| | `kind` | string | `email` or `domain`. | | `limit` | integer | 1 to 100. Default 50. | | `cursor` | string | See [Pagination](/pagination/). | ```bash curl "https://app.anymail.watch/api/v1/suppressions?kind=email" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [ { "id": "d5e6f7a8-0000-4000-8000-000000000002", "kind": "email", "value": "jane@example.com", "reason": "Excluded via the API.", "created_at": "2026-09-03T00:00:00.000+00:00" } ], "next_cursor": null } ``` The list shows only the suppressions of the workspace. The global anymail list, which also applies when you send, is not visible through the API. ## Remove a suppression `DELETE /api/v1/suppressions/{id}` does what **Remove** does in **Settings**, **Suppressions**. The response is `204` with no body. ```bash curl -X DELETE "https://app.anymail.watch/api/v1/suppressions/d5e6f7a8-0000-4000-8000-000000000001" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` The engine reads the list on every send attempt, so the address is reachable from the next attempt. Enrolments that stopped earlier because of the suppression are not revived, as after a click in the app. **Errors:** `404` with `{"error":"Suppression not found."}` for an unknown id, an id from another workspace, an id that is not a UUID and an id from the global list. ## Related - [Prospects](/prospects/) - [Campaigns](/campaigns/) - [Errors](/errors/) --- # Webhook subscriptions (hooks) > Source: https://developers.anymail.watch/webhook-subscriptions/ A subscription tells anymail to send a signed `POST` to a URL when one kind of event happens in the workspace. The endpoints follow the REST Hooks contract that Zapier uses for instant triggers, and any client can call them. For the delivery format, the signature and the retries, see [Webhooks](/webhooks/). | Method | Path | Action | |---|---|---| | `POST` | `/api/v1/hooks` | Subscribe a URL to an event | | `GET` | `/api/v1/hooks` | List subscriptions | | `GET` | `/api/v1/hooks/samples` | Recent payloads of one event | | `DELETE` | `/api/v1/hooks/{id}` | Unsubscribe | ## Events | `event` | Fires when | |---|---| | `new_reply` | A prospect replied. It fires before the reply is classified. | | `positive_reply` | The classifier marks a reply as interested. It fires once the classification is saved. | | `bounce` | A message to a prospect bounced. | | `unsubscribe` | A prospect used the one-click unsubscribe link. | | `email_opened` | A tracked message was opened. | | `link_clicked` | A prospect clicked a tracked link. | | `prospect_interested` | A reply was classified as interested, by the classifier or by a person. | | `prospect_maybe_later` | A reply was classified as neutral. | | `prospect_not_interested` | A reply was classified as not interested. | | `prospect_autoreplied` | A reply was recognised as an autoresponder. | | `campaign_paused` | A campaign was paused. | | `campaign_completed` | A campaign ended. | | `prospect_added` | A prospect was created. | | `offer_opened` | A recipient opened an offer for the first time. | | `offer_returned` | A recipient came back to an offer for another visit. | | `offer_read` | A recipient read an offer to the end. It fires once per offer. | | `offer_temperature_changed` | An offer moved between `hot`, `warm`, `neutral`, `cooling` and `cold`. | The payload of each event is described on [Webhooks](/webhooks/#events). There is no subscription for `sent`. The single workspace webhook in **Settings**, **Integrations**, **Webhooks** delivers `sent`. ## Subscribe The endpoint honours an [`Idempotency-Key`](/idempotency-keys/). The call is also idempotent by itself. The same pair of `event` and `target_url` returns the existing subscription with `200`. **Body** (JSON, up to 4 KiB) | Field | Type | Required | Notes | |---|---|---|---| | `event` | string | Yes | One of the events in the table above. | | `target_url` | string | Yes | An `https://` URL on a public host. Local and private addresses are rejected after a DNS lookup, and so is a URL with `user:pass`. | ```bash curl -X POST "https://app.anymail.watch/api/v1/hooks" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"event":"new_reply","target_url":"https://hooks.example.com/anymail"}' ``` **Response `201`** for a new subscription, `200` for an existing one ```json { "id": "e6f7a8b9-0000-4000-8000-000000000001", "event": "new_reply", "target_url": "https://hooks.example.com/anymail" } ``` The first subscription also creates the signing secret of the workspace. Read it in **Settings**, **Integrations**, **Webhooks**. The API does not return it. **Errors** | Status | Message | |---|---| | `400` | The body errors listed on [Errors](/errors/). | | `400` | `` `event` must be one of: new_reply, positive_reply, bounce, unsubscribe, email_opened, … `` | | `400` | `` `target_url` must be a non-empty string. `` | | `400` | The reason the URL was rejected, for example `The webhook URL must start with https://`. | | `500` | A database failure. | ## List subscriptions | Parameter | Type | Notes | |---|---|---| | `limit` | integer | 1 to 100. Default 50. | | `cursor` | string | See [Pagination](/pagination/). | ```bash curl "https://app.anymail.watch/api/v1/hooks" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [ { "id": "e6f7a8b9-0000-4000-8000-000000000001", "event": "new_reply", "target_url": "https://hooks.example.com/anymail", "created_at": "2026-09-01T00:00:00.000+00:00" } ], "next_cursor": null } ``` The list holds no secrets. The workspace webhook URL and its signing secret belong to the app. The API neither returns nor changes them. ## Unsubscribe `DELETE /api/v1/hooks/{id}` removes a subscription. The response is `204` with no body. Deliveries that are already queued for the URL are not cancelled. ```bash curl -X DELETE "https://app.anymail.watch/api/v1/hooks/e6f7a8b9-0000-4000-8000-000000000001" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Errors:** `404` with `{"error":"No subscription with this id."}` for an unknown id, an id from another workspace and an id that is not a UUID. ## Recent payloads `GET /api/v1/hooks/samples?event=` returns up to three latest payloads delivered for one event, newest first, in the [payload format](/webhooks/#payload). The list is empty until a subscription to that event has received a delivery. The [Zapier app](/zapier/) uses it for the **Test trigger** step. ```bash curl "https://app.anymail.watch/api/v1/hooks/samples?event=new_reply" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [] } ``` An unknown `event` returns `400`. ## Related - [Webhooks](/webhooks/) - [Replies](/replies/) - [Idempotency keys](/idempotency-keys/) --- # Mailboxes > Source: https://developers.anymail.watch/mailboxes/ A mailbox is a sending account of the workspace. The API connects a mailbox with SMTP and IMAP, reads mailboxes and changes four settings of an existing mailbox. Connecting with Google or Microsoft sign-in needs a browser and stays in the app. | Method | Path | Action | |---|---|---| | `GET` | `/api/v1/mailboxes` | List mailboxes | | `POST` | `/api/v1/mailboxes` | Connect a mailbox with SMTP and IMAP | | `GET` | `/api/v1/mailboxes/{id}` | Get a mailbox | | `PATCH` | `/api/v1/mailboxes/{id}` | Change the daily limit, sender name, signature or pause | ## Connect a mailbox `POST /api/v1/mailboxes` connects a mailbox with SMTP and IMAP details. The server tests both connections first, as the app does, and saves nothing when the test fails. The password is encrypted and never returned. | Field | Type | Notes | |---|---|---| | `email` | string | Required. The mailbox address. | | `password` | string | Required. Use an app password. Gmail and Microsoft 365 need one, and the main password must not be used. | | `host` | string | One host for SMTP and IMAP. Otherwise send `smtp_host` and `imap_host`. | | `smtp_port`, `imap_port` | integer | Defaults 587 and 993. | | `username` | string | Default is the address. | | `from_name` | string | The sender name. | | `daily_limit` | integer | Default is the default limit of the workspace. | ```bash curl -X POST "https://app.anymail.watch/api/v1/mailboxes" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"email":"ada@acme.com","password":"app-password","host":"mail.acme.com","from_name":"Ada Lovelace"}' ``` **Response `201`** returns the mailbox in the shape above. | Status | Message | |---|---| | `400` | `` Unknown field(s): … ``, `Enter the mailbox address in `email`.`, `` `password` is required … ``, ``Send `host`, or both `smtp_host` and `imap_host`.``, `Host … is not allowed: …` for a host on a private network, `Couldn't connect, nothing was saved. …` with the message of the mail server | | `409` | `This mailbox is already connected to the workspace.` | The password travels in the request. When an AI assistant makes the call, it also passes through the conversation, so create an app password only for this and revoke it if the chat is shared. A new mailbox needs a gradual warm-up before cold email: start at 20 to 30 messages a day. ## List mailboxes The list is not paginated. `next_cursor` is always `null`. ```bash curl "https://app.anymail.watch/api/v1/mailboxes" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [ { "id": "b2c3d4e5-0000-4000-8000-000000000001", "email": "sales@example.com", "provider": "smtp_imap", "status": "active", "active": true, "daily_limit": 50, "sent_today": 12, "timezone": "Europe/Warsaw", "from_name": "Ada Lovelace", "last_poll_error": null, "last_polled_at": "2026-09-04T08:55:00.000+00:00", "paused_from": null, "paused_to": null, "domain_health": { "domain": "example.com", "checked_at": "2026-09-04T03:00:00.000+00:00", "spf": "ok", "dkim": "unknown", "dmarc": "missing" }, "created_at": "2026-08-01T00:00:00.000+00:00" } ], "next_cursor": null } ``` | Field | Values and notes | |---|---| | `provider` | `smtp_imap`, `m365` or `gmail`. | | `status` | `error`, `paused`, `frozen` or `active`. The precedence matches **Settings**, **Accounts**: an IMAP read error, then disabled, then frozen for the period `paused_from` to `paused_to`, then active. | | `sent_today` | Messages sent today in the time zone of the mailbox. | | `domain_health` | `spf`, `dkim` and `dmarc` are each `ok`, `missing`, `conflicting` or `unknown`. The values come from the latest hourly check of the domain monitor. `unknown` means the check did not run or the resolver did not answer. It does not mean that the record is missing. `checked_at: null` means the domain was never checked. | The response never contains SMTP, IMAP or OAuth credentials, signatures or BCC addresses. **Errors:** `401`, `403`, `429` and `500` only. ## Get a mailbox `GET /api/v1/mailboxes/{id}` returns one mailbox in the shape above, without the `data` wrapper. ```bash curl "https://app.anymail.watch/api/v1/mailboxes/b2c3d4e5-0000-4000-8000-000000000001" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Errors:** `404` with `Mailbox not found.`, including a mailbox from another workspace and an id that is not a UUID. ## Update a mailbox `PATCH /api/v1/mailboxes/{id}` changes only the fields you send, with the rules of the mailbox profile in the app. Send at least one. An unknown field returns `400`. | Field | Type | Notes | |---|---|---| | `daily_limit` | integer | Messages per day, 1 or more. | | `from_name` | string or null | The sender name. Control characters, line breaks included, become spaces. Empty or `null` clears it. | | `signature` | string or null | The signature of this mailbox. Empty or `null` uses the signature template of the workspace. The API writes the signature but never returns it. | | `active` | boolean | `false` pauses the mailbox without deleting it. Campaigns skip it until it is active again. | ```bash curl -X PATCH "https://app.anymail.watch/api/v1/mailboxes/b2c3d4e5-0000-4000-8000-000000000001" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"daily_limit":40,"from_name":"Ada Lovelace"}' ``` **Response `200`** returns the mailbox after the change. **Errors** | Status | Message | |---|---| | `400` | `` Send at least one of: daily_limit, from_name, signature, active. ``, `` Unknown field(s): … ``, `Enter a whole number of at least 1 for the daily limit.`, `` `from_name` must be a string or null. ``, `` `signature` must be a string or null. ``, `` `active` must be a boolean. `` | | `404` | `Mailbox not found.` | Raise the limit of a new mailbox slowly. A jump from 20 to 200 messages a day on a young domain is a common path to the spam folder. ## Related - [Domains](/domains/) - [Campaigns](/campaigns/) - [Rate limits](/rate-limits/) --- # Domains > Source: https://developers.anymail.watch/domains/ A sending domain is the domain part of one or more mailboxes. The endpoint shows each domain as the **Domains** view of **Settings**, **Accounts** does: DNS health, capacity and mailboxes with errors. It also returns the tracking domain of the workspace. | Method | Path | Action | |---|---|---| | `GET` | `/api/v1/domains` | List sending domains | The API does not buy domains or change DNS records. ## List domains The list is not paginated. `next_cursor` is always `null`. ```bash curl "https://app.anymail.watch/api/v1/domains" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [ { "domain": "example.com", "mailboxes": [ { "id": "b2c3d4e5-0000-4000-8000-000000000001", "email": "sales@example.com" }, { "id": "b2c3d4e5-0000-4000-8000-000000000002", "email": "ada@example.com" } ], "daily_limit": 100, "sent_today": 37, "mailboxes_with_errors": 0, "checked_at": "2026-10-27T03:00:00.000+00:00", "spf": "ok", "dkim": "ok", "dmarc": "missing", "has_problem": true } ], "tracking_domain": { "domain": "track.example.com", "verified": true, "verified_at": "2026-10-01T09:00:00.000+00:00", "error": null }, "next_cursor": null } ``` | Field | Notes | |---|---| | `daily_limit` | The sum of the daily limits of the mailboxes on this domain. | | `sent_today` | Messages sent today from these mailboxes, each in its own time zone. | | `mailboxes_with_errors` | Mailboxes whose last IMAP read failed. | | `spf`, `dkim`, `dmarc` | `ok`, `missing`, `conflicting` or `unknown`, from the latest hourly check. `unknown` means the check did not run or the resolver did not answer. It does not mean the record is missing. | | `checked_at` | The time of that check. `null` when the domain was never checked. | | `has_problem` | `true` when a record needs a change: `missing` or `conflicting`. `unknown` does not count. | | `tracking_domain` | The custom domain for open and click tracking, or `null` when the workspace has none. Opens and clicks are measured only when `verified` is `true`. | Before you start a campaign, pick mailboxes on domains with `has_problem: false`. A missing DMARC record is the most common reason for mail in spam. **Errors:** `401`, `403`, `429` and `500` only. ## Related - [Mailboxes](/mailboxes/) - [Campaigns](/campaigns/) - [Statistics](/campaign-stats/) --- # Workspaces > Source: https://developers.anymail.watch/workspaces/ A workspace holds prospects, campaigns, mailboxes and settings. Every token belongs to exactly one workspace. An API token belongs to the workspace it was generated in. An OAuth token belongs to the workspace picked on the consent screen. | Method | Path | Action | |---|---|---| | `GET` | `/api/v1/workspaces` | The workspace of this token | ## List workspaces ```bash curl "https://app.anymail.watch/api/v1/workspaces" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" ``` **Response `200`** ```json { "data": [ { "id": "00000000-0000-0000-0000-000000000001", "name": "Example Ltd", "created_at": "2026-08-01T00:00:00.000+00:00", "current": true } ], "next_cursor": null } ``` The list has one row today, the workspace of the token, with `current: true`. It is a list so that a token for several workspaces can use the same shape later. This call needs only the `read` scope, so it is a cheap way to check a token. The [CLI](/cli/) uses it on `anymail login`. ## The X-Workspace header Send `X-Workspace: ` on any request to state which workspace you mean. When it names another workspace than the one of the token, the request fails before it does anything. ```bash curl "https://app.anymail.watch/api/v1/campaigns" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "X-Workspace: 00000000-0000-0000-0000-000000000001" ``` **Response `403`** when the id does not match ```json { "error": "This token does not have access to the workspace named in X-Workspace.", "code": "workspace_forbidden" } ``` The header is optional. Without it, the request uses the workspace of the token. Use it in a script that holds tokens for several workspaces, so that a mixed-up token fails loudly and does not write to the wrong workspace. ## Related - [Authentication](/authentication/) - [Domains](/domains/) - [Errors](/errors/) --- # Webhooks > Source: https://developers.anymail.watch/webhooks/ anymail sends a signed `POST` request with a JSON body to your URL when an event happens in your workspace. After this page you can receive an event, verify its signature and handle retries. ## Two ways to receive events | | Workspace webhook | Subscriptions | |---|---|---| | Set up in | **Settings**, **Integrations**, **Webhooks** | [`POST /api/v1/hooks`](/webhook-subscriptions/) | | Number of URLs | One for the workspace | Many, each for one event | | Choice of events | None. It receives every event in its column below. | One event per subscription. | Both use the same delivery format, the same signature and the same retries. The workspace webhook also has a **Send test event** button that delivers an event of type `test`. ## Events The `type` field of the payload and the `X-Seegnals-Event` header carry the event name. The header names are part of the protocol and keep their original prefix. | Event | Fires when | Workspace webhook | Subscription | |---|---|---|---| | `sent` | anymail sent a message. | Yes | | | `replied` | A prospect replied. | Yes | as `new_reply` | | `bounced` | A message bounced. The enrolment of the prospect stopped. | Yes | as `bounce` | | `unsubscribed` | A prospect used the one-click unsubscribe link. | Yes | as `unsubscribe` | | `clicked` | A prospect clicked a tracked link. | Yes | as `link_clicked` | | `positive_reply` | A reply was classified as interested. | | Yes | | `prospect_interested` | A reply was classified as interested, by the classifier or by a person. | Yes | Yes | | `prospect_maybe_later` | A reply was classified as neutral (maybe later). | Yes | Yes | | `prospect_not_interested` | A reply was classified as not interested. | Yes | Yes | | `prospect_autoreplied` | A reply was recognised as an autoresponder, such as an out of office message. | Yes | Yes | | `campaign_paused` | A campaign changed to `paused`, by a person, the API or the engine. | Yes | Yes | | `campaign_completed` | A campaign changed to `completed`. | Yes | Yes | | `email_opened` | A tracked message was opened. | | Yes | | `prospect_added` | A prospect was created, in the app, by an import or through the API. | | Yes | | `offer_opened` | A recipient opened an offer for the first time. Visits from known bots and link scanners are filtered out. | Yes | Yes | | `offer_returned` | A recipient came back to an offer for a second or later visit. | Yes | Yes | | `offer_read` | A recipient read an offer to the end, the `read` status of [offers](/offers/). It fires once per offer. | Yes | Yes | | `offer_temperature_changed` | The engagement level of an offer changed. The first calculation for a new offer sends nothing. | Yes | Yes | The classification events fire on every change of the class, so a correction by a person sends a new event. `positive_reply` and `prospect_interested` overlap. Subscribe to one of them. `email_opened` and `prospect_added` are subscription only. Opens are unreliable, because Apple Mail Privacy Protection loads the tracking pixel without a person opening the message, and an import of 10,000 prospects would mean 10,000 deliveries. Subscribe to them only when you want them. There is no event for a delivery, because SMTP does not report one. Opens and clicks are tracked only when the tracking domain of the workspace is verified. See [Domains](/domains/). ## Request Each delivery is a `POST` with these headers. | Header | Value | |---|---| | `Content-Type` | `application/json` | | `User-Agent` | `Seegnals-Webhook/1` | | `X-Seegnals-Event` | The event name. | | `X-Seegnals-Delivery` | The id of this delivery. A retry of the same delivery keeps the same id. | | `X-Seegnals-Signature` | `t=,v1=` | ## Payload ```json { "id": "10000000-0000-0000-0000-000000000002", "type": "replied", "occurred_at": "2026-09-03T10:00:00Z", "workspace_id": "00000000-0000-0000-0000-000000000001", "prospect_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "prospect_email": "ada@example.com", "campaign_id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "campaign_name": "Q3 outbound", "metadata": { "replyId": "20000000-0000-0000-0000-000000000001", "from": "ada@example.com" } } ``` | Field | Notes | |---|---| | `id` | The id of the event. | | `type` | The event name. | | `occurred_at` | ISO 8601 in UTC with a `Z` suffix. | | `workspace_id` | The workspace. | | `prospect_id`, `prospect_email` | The prospect. | | `campaign_id`, `campaign_name` | The campaign. Both can be `null`. | For `campaign_paused` and `campaign_completed`, `prospect_id` and `prospect_email` are `null`. | `metadata` | Event details. The keys depend on the type. | Keys you can find in `metadata`: | Type | Keys | |---|---| | `sent` | `step`, `messageId` | | `replied` | `replyId`, `from` | | `bounced` | `step`, `error` | | `unsubscribed` | `via` | | `clicked`, `link_clicked` | `url`, `messageId`, `via` | | `email_opened` | `messageId` and the details of the tracking event | | `positive_reply`, `prospect_interested`, `prospect_maybe_later`, `prospect_not_interested`, `prospect_autoreplied` | `replyId` | | `campaign_paused`, `campaign_completed` | `status`, `reason` (the pause reason, or `null`) | | `prospect_added` | None. `campaign_id` is `null`. | | `offer_opened`, `offer_returned` | `offerSendId`, `visitIndex`, `via` | | `offer_read` | `offerSendId`, `offerName`, `totalActiveSeconds`, `sections` | | `offer_temperature_changed` | `offerSendId`, `offerName`, `from`, `to` (`hot`, `warm`, `neutral`, `cooling` or `cold`), `reason` | The `clicked` payload never contains the tracking token. ## Signing secret anymail signs each delivery with the signing secret of the workspace. It has the form `whsec_…`. - In **Settings**, **Integrations**, **Webhooks**, select **Reveal signing secret**. **Rotate secret** creates a new one. - A workspace that starts with a subscription from the API gets a secret with the first subscription. - anymail never sends an unsigned delivery. Without a secret, the delivery is rejected. ## Verify the signature The signature is an HMAC-SHA256 of the string `.`, where `t` is the timestamp from the header and the body is the exact bytes of the request. The key is the signing secret. The header carries the result as lower-case hex after `v1=`. 1. Read the raw request body. Do not parse it and serialise it again, because that can change key order and number formatting. 2. Split the header into `t` and `v1`, compute the HMAC of `t + "." + body` and compare it with `v1` in constant time. 3. Reject the request when `t` is more than 300 seconds away from your clock. This is your protection against a replayed request, because anymail cannot know that somebody sent a captured request again. For a longer walkthrough, read [Verify a webhook HMAC signature](https://anymail.watch/blog/verify-a-webhook-hmac-signature). ### Node ```javascript import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_SECONDS = 300; export function verifyAnymailSignature(secret, header, rawBody) { const parts = Object.fromEntries( header.split(",").map((part) => part.trim().split("=", 2)) ); const timestamp = Number(parts.t); const given = parts.v1; if (!Number.isInteger(timestamp) || !given) return false; const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp); if (age > TOLERANCE_SECONDS) return false; const expected = createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex"); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(given.toLowerCase(), "utf8"); return a.length === b.length && timingSafeEqual(a, b); } ``` With Express, take the raw body with `express.raw({ type: "application/json" })` and pass `req.body.toString("utf8")` as `rawBody`. ### Python ```python import hashlib import hmac import time TOLERANCE_SECONDS = 300 def verify_anymail_signature(secret: str, header: str, raw_body: bytes) -> bool: parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p) try: timestamp = int(parts["t"]) given = parts["v1"].lower() except (KeyError, ValueError): return False if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS: return False expected = hmac.new( secret.encode("utf-8"), f"{timestamp}.".encode("utf-8") + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, given) ``` ## Delivery and retries | Rule | Value | |---|---| | Attempts | 3 in total: the first one and two retries. | | Spacing | About one minute between attempts. | | Response time | Your endpoint has 10 seconds to answer. | | Success | Any `2xx` status. anymail does not read the response body. | | Redirects | anymail does not follow a `3xx` response. It counts as a failure. Give anymail the final URL. | | Failure | A non-`2xx` status, a timeout or a connection error. | After the third failed attempt, anymail stops. The status of each delivery, the number of tries and the last error are in **Settings**, **Integrations**, **Webhooks**. The event stays recorded in anymail. ## Handle deliveries idempotently A delivery can arrive more than once, for example when your endpoint processed it but the answer timed out. Store the `X-Seegnals-Delivery` value and ignore a repeat of an id you have already processed. Answer `2xx` for the repeat. Answer quickly. Accept the request, put the work in a queue and return `200`. The 10 second limit covers your whole handler. ## Related - [Webhook subscriptions](/webhook-subscriptions/) - [Inbox](/inbox/) - [Limits and good practice](/limits-and-good-practice/) --- # Zapier > Source: https://developers.anymail.watch/zapier/ The anymail app on Zapier sends events from your workspace to other tools the moment they happen, and lets other tools add prospects to anymail. It uses the [REST API](/) and [webhook subscriptions](/webhook-subscriptions/) underneath, so it does the same things you could build yourself, without code. ## Connect your account 1. In anymail open **Settings**, **Integrations**, **API keys** and create a key with the `read` and `write` scopes. It starts with `sgn_`. 2. In Zapier create a Zap, pick **anymail** as the app and choose a trigger or an action. 3. When Zapier asks you to sign in, paste the key. Zapier checks it with `GET /api/v1/workspaces` and names the connection after your workspace. One key belongs to one workspace. To connect a second workspace, add a second connection with that workspace's key. To disconnect, delete the key in anymail. Zaps that use it stop with an authentication error. ## Triggers All triggers are instant. When you turn a Zap on, Zapier subscribes a URL with `POST /api/v1/hooks`. When you turn it off, Zapier removes the subscription with `DELETE /api/v1/hooks/{id}`. | Trigger | Fires when | |---|---| | New Reply | A prospect replies. It fires before the reply is classified. | | Prospect Interested | A reply is classified as interested, by the classifier or by a person. | | Prospect Maybe Later | A reply is classified as neutral. | | Prospect Not Interested | A reply is classified as not interested. | | Auto-Reply Received | A reply is recognised as an autoresponder. | | Email Bounced | A message to a prospect bounces. | | Prospect Unsubscribed | A prospect uses the one-click unsubscribe link. | | Link Clicked | A prospect clicks a tracked link. | | Email Opened | A tracked message is opened. Apple Mail loads the pixel without a person, so treat it as a weak signal. | | New Prospect | A prospect is created in the app, by an import or through the API. | | Campaign Paused | A campaign is paused, by a person or automatically. | | Campaign Completed | A campaign ends. | Every trigger returns the [webhook payload](/webhooks/#payload): `id`, `type`, `occurred_at`, `prospect_id`, `prospect_email`, `campaign_id`, `campaign_name` and `metadata`. In the Zap editor the **Test trigger** step shows the three latest real events of that kind from `GET /api/v1/hooks/samples?event=`. Before the first event, it shows a sample. ## Actions | Action | What it does | |---|---| | Create or Update Prospect | Looks the email up. A new address is created with `POST /api/v1/prospects`. An existing one is updated with `PATCH /api/v1/prospects/{id}`, and fields you leave empty keep their current value. Custom fields are merged. With a campaign selected, the prospect is also enrolled. | | Add Prospect to Campaign | Enrols an existing prospect by email. The `result` field says `enrolled`, `already-enrolled`, `suppressed` or another outcome from [enrolment](/campaigns/). An address that is not a prospect stops the Zap with an error. | | Add to Suppression List | Adds an email address or a whole domain to the [suppression list](/suppressions/). anymail never emails it again. An address that is already suppressed counts as done, not as an error. | ## Examples - **Interested reply to a CRM deal.** Trigger: Prospect Interested. Action: create a deal in your CRM with `prospect_email` and `campaign_name`. - **Form to campaign.** Trigger: a new form submission (Typeform, Tally, HubSpot). Action: Create or Update Prospect with the campaign selected. - **Unsubscribe everywhere.** Trigger: Prospect Unsubscribed. Action: mark the contact as unsubscribed in your newsletter tool. - **Bounces to a sheet.** Trigger: Email Bounced. Action: add a row to Google Sheets for list cleaning. ## Limits Zapier calls count toward the [rate limit](/rate-limits/) of the key: 60 requests per minute. Create or Update Prospect makes up to four calls. Deliveries to Zapier are retried as described in [Webhooks](/webhooks/#delivery-and-retries). ## Troubleshooting | Message | What to do | |---|---| | `anymail rejected the API key` | The key was deleted or mistyped. Create a new key and reconnect. | | `insufficient_scope` | The key is read-only. Create a key with `write`. | | ` is not a prospect in this workspace` | Use Create or Update Prospect before Add Prospect to Campaign, or use only the first with a campaign selected. | | A trigger does not fire | Check that the Zap is on and that **Settings**, **Integrations** lists the subscription. Tracked opens and clicks need a verified [tracking domain](/domains/). | --- # Clay > Source: https://developers.anymail.watch/clay/ Clay finds and enriches the people. anymail emails them. The link between the two is Clay's **HTTP API** column: for each row it calls the [REST API](/) and the person lands in an anymail campaign. There is nothing to install and nothing to connect on the anymail side. The campaign travels in the URL. ## What you need - An API key with the `write` scope. In anymail open **Settings**, **Integrations**, **API keys**. It starts with `sgn_`. - A campaign in `draft` or `active` status. Its id is in the address bar of the campaign page. **Settings**, **Integrations**, **Clay** shows the ready URL for each campaign. - A Clay table with at least an email column. ## Column 1: create the prospect and enrol it Add a column, choose **HTTP API**, and fill it in: | Field | Value | |---|---| | Method | `POST` | | Endpoint | `https://app.anymail.watch/api/v1/prospects?campaign=` | | Headers | `Authorization`: `Bearer sgn_…`, `Content-Type`: `application/json` | | Body | the JSON below, with Clay columns inserted where the `/` menu puts them | ```json { "email": "{{Email}}", "first_name": "{{First Name}}", "last_name": "{{Last Name}}", "company_name": "{{Company}}", "custom_fields": { "title": "{{Job Title}}", "icebreaker": "{{Icebreaker}}" } } ``` Only `email` is required. `custom_fields` is a flat map of strings. Every key becomes a snippet in your sequence, so `{{icebreaker}}` in an anymail step prints that row's Clay column. Numbers and booleans are turned into text. Nested objects are rejected with `400`. anymail answers: | Status | Meaning | |---|---| | `201` | The prospect was created and enrolled. The body has `id`, `email` and `company_id`. | | `409` | The address is already in your workspace (`"code": "duplicate"`). It was **not** enrolled. Column 2 handles it. | | `400` | The address is not valid, the body is not JSON, or the campaign id is unknown or the campaign is not `draft`/`active`. An unknown campaign rejects the row before anything is created. | | `401` / `403` | The key is wrong, deleted or read-only. | | `429` | Too fast. See [Limits](#limits). | ## Column 2: enrol people who already exist A `409` row needs one more call. Add a second **HTTP API** column: | Field | Value | |---|---| | Method | `POST` | | Endpoint | `https://app.anymail.watch/api/v1/campaigns//prospects` | | Headers | the same as column 1 | | Body | `{"emails": ["{{Email}}"]}` | The answer is always `200` with a per-address `result`: `enrolled`, `already-enrolled`, `suppressed`, `missing-field`, `not-found` and the other outcomes of [enrolment](/campaigns/). Running it on a row that column 1 already enrolled is harmless and returns `already-enrolled`. To save requests, set the column's run condition to rows where column 1 did not return an `id`. ## Test it Run both columns on one row first. Then check: - **Prospects** in anymail lists the address with source `API`. - The campaign's prospect list shows it as enrolled. `GET /api/v1/campaigns//prospects` returns the same. - The integration history (**Settings**, **Integrations**, **History**) has a Clay line for every call that carried `?campaign=`, accepted or rejected, with the reason. The same request from a terminal: ```bash curl -X POST "https://app.anymail.watch/api/v1/prospects?campaign=" \ -H "Authorization: Bearer $ANYMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"email":"ada@example.com","first_name":"Ada","company_name":"Example Inc"}' ``` ## Receiving signals back Clay can receive anymail events through a webhook source. In Clay, create a table with a **Webhook** source and copy its URL. Then subscribe that URL to the events you want: ```bash curl -X POST "https://app.anymail.watch/api/v1/hooks" \ -H "Authorization: Bearer $ANYMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event":"offer_read","target_url":""}' ``` Each delivery becomes a row with `prospect_email`, `campaign_name` and `metadata`. The events that fit Clay best are `new_reply`, `link_clicked`, `offer_opened`, `offer_read` and `offer_temperature_changed`. A column can then act on `metadata.to`, for example only when an offer reaches `hot`. See [Webhooks](/webhooks/#events) for every event and payload. ## Limits One key allows 60 requests per minute ([rate limits](/rate-limits/)). Column 1 and column 2 together take up to two requests per row. Set the HTTP API column's rate limit in Clay to 60 per minute, or 30 per minute if column 2 runs on every row. A `429` row can be rerun from Clay. Adding a prospect does not send anything. A `draft` campaign sends nothing until you start it; an `active` one emails new people on its schedule and sending windows. ## Troubleshooting | What you see | What to do | |---|---| | `Unknown or unusable campaign.` | The id in the URL is wrong, belongs to another workspace, or the campaign is paused or finished. Copy the URL again from **Settings**, **Integrations**, **Clay**. | | `… is already in your workspace.` | Expected for known people. Make sure column 2 runs. | | `` `custom_fields` … `` error | A value is an object or a list. Map a single text column instead. | | Rows succeed but nobody is emailed | The campaign is still `draft`, or the person is on the [suppression list](/suppressions/) (column 2 returns `suppressed`). | --- # n8n > Source: https://developers.anymail.watch/n8n/ The `n8n-nodes-anymail` community node gives n8n two nodes: **anymail** for actions and **anymail Trigger** for instant events. The source is on [GitHub](https://github.com/creathinkone/n8n-nodes-anymail) and the package is on [npm](https://www.npmjs.com/package/n8n-nodes-anymail), published with provenance. ## Install In n8n open **Settings**, **Community nodes**, **Install** and enter `n8n-nodes-anymail`. ## Connect 1. In anymail open **Settings**, **Integrations**, **API keys** and create a key with the `read` and `write` scopes. It starts with `sgn_`. 2. In n8n add the **Anymail API** credential and paste the key. Leave **API URL** as it is. n8n tests the credential with `GET /api/v1/workspaces`. ## The anymail node | Resource | Operation | What it does | |---|---|---| | Prospect | Create or Update | Creates a prospect, or updates the one with the same email. Fields left empty keep their current value. Can also enrol it in a campaign. | | Prospect | Get by Email | Returns the prospect, or no item when there is none. | | Prospect | Search | Lists prospects. The email filter matches the whole address. | | Campaign | Add Prospect | Enrols an existing prospect by email. The result is `enrolled`, `already-enrolled` or `suppressed`. An unknown email is an error. | | Campaign | Start, Pause, Resume, Stop | Changes the campaign state. **Start sends real email.** | | Suppression | Add | Never emails an address or a domain. An address already on the list is not an error. | A campaign that uses `{{first_name}}` does not enrol a prospect without a first name. Fill the field in Create or Update. ## The anymail Trigger node Pick one event. n8n subscribes through [`POST /hooks`](/webhook-subscriptions/) when the workflow is activated and removes the subscription when it is deactivated. | Event | Fires when | |---|---| | New Reply | a prospect replies, before classification | | Prospect Interested, Maybe Later, Not Interested | a reply is classified | | Auto-Reply Received | a reply is an autoresponder | | Email Bounced | a message bounces | | Prospect Unsubscribed | a prospect uses the unsubscribe link | | Link Clicked | a prospect clicks a tracked link | | Email Opened | a tracked message is opened. Apple Mail opens it without a person. | | New Prospect | a prospect is created | | Campaign Paused, Campaign Completed | the campaign changes state | anymail only accepts a public HTTPS webhook address that resolves. Set `WEBHOOK_URL` in a self-hosted n8n. A local n8n without a public address cannot subscribe. ## Limits The node uses the same [rate limits](/limits-and-good-practice/) as the API. Do not put **Campaign, Start** in an unattended workflow without a human check. --- # MCP server > Source: https://developers.anymail.watch/mcp-server/ anymail runs a remote Model Context Protocol server. An LLM client such as Claude, ChatGPT, Claude Code or Cursor can read your workspace, build and start campaigns, read the inbox and answer replies through tools. After this page you know what the server offers. The client pages show how to connect. | | | |---|---| | Endpoint | `https://app.anymail.watch/api/mcp` | | Transport | MCP Streamable HTTP, stateless, with JSON responses | | Methods | `POST` for JSON-RPC, `DELETE` (accepted, no effect). `GET` returns `405`. | | Sign-in | OAuth 2.1 in the browser, or `Authorization: Bearer sgn_…` with an [API token](/authentication/) | | Server info | name `anymail`, version `1.0.0` | | Offers | 44 tools, 3 prompts | Every tool calls one endpoint of the REST API on the same origin with the token the client signed in with. The server can do exactly what the REST API allows. The validation, the [scopes](/authentication/#scopes), the workspace isolation and the [rate limit](/rate-limits/) are the same. ## Connect a client | Client | Sign-in | Page | |---|---|---| | Claude (web, desktop, mobile) | OAuth, no key | [Claude](/mcp-claude/) | | Claude Code | OAuth or API token | [Claude Code](/mcp-claude-code/) | | ChatGPT | OAuth, no key | [ChatGPT](/mcp-chatgpt/) | | Cursor | OAuth or API token | [Cursor](/mcp-cursor/) | ### Another client A client that supports Streamable HTTP and OAuth discovery needs only the URL. It finds the authorization server from the `401` response and registers itself. A client without OAuth sends an API token as a header. ```json { "mcpServers": { "anymail": { "type": "http", "url": "https://app.anymail.watch/api/mcp", "headers": { "Authorization": "Bearer sgn_…" } } } } ``` A client that supports only local stdio servers can reach the server through the `mcp-remote` bridge: `npx -y mcp-remote https://app.anymail.watch/api/mcp`. ### Sign-in with OAuth The client opens anymail in the browser. Sign in, pick the **Workspace** and the **Access**, and press **Allow**. **Read only** lets the client call read tools only. **Read and write** also lets it create prospects, change campaigns and send email. The details of the flow are on [Authentication](/authentication/#oauth-for-mcp-clients). ### Raw protocol To debug, call the server with curl. The transport requires the `Accept` header. ```bash curl -s "https://app.anymail.watch/api/mcp" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` Call a tool with `tools/call`. ```bash curl -s "https://app.anymail.watch/api/mcp" \ -H "Authorization: Bearer $ANYMAIL_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_domains","arguments":{}}}' ``` The server is stateless. A client does not need to call `initialize` first, the server issues no `Mcp-Session-Id` and each HTTP request is complete on its own. ## Tools Each tool has a `title` and the annotations `readOnlyHint`, `destructiveHint`, `idempotentHint` and `openWorldHint`, so a client can ask for confirmation before a change. Each tool returns the JSON of the REST API as formatted text in `content[0].text`. When the response is a JSON object, the tool also returns it as `structuredContent`. The server validates the arguments (required fields, enum values, types) before it makes any HTTP call. `openWorldHint: true` marks the tools that reach outside anymail: `start_campaign` and `reply_to_message` send email to real people, `create_webhook` sends events to a URL. ### Campaigns | Tool | Calls | Annotations | |---|---|---| | `list_campaigns` | `GET /campaigns` | read only | | `get_campaign` | `GET /campaigns/{id}` | read only | | `get_campaign_stats` | `GET /campaigns/{id}/stats` | read only | | `list_campaign_prospects` | `GET /campaigns/{id}/prospects` | read only | | `create_campaign` | `POST /campaigns` | write | | `update_campaign` | `PATCH /campaigns/{id}` | write, idempotent | | `start_campaign` | `POST /campaigns/{id}/start` | write, open world | | `pause_campaign` | `POST /campaigns/{id}/pause` | write, idempotent | | `resume_campaign` | `POST /campaigns/{id}/resume` | write, idempotent | | `stop_campaign` | `POST /campaigns/{id}/stop` | destructive | | `delete_campaign` | `DELETE /campaigns/{id}` | destructive | | `enrol_prospects` | `POST /campaigns/{id}/prospects` | write, idempotent | ### Steps and variants | Tool | Calls | Annotations | |---|---|---| | `list_steps` | `GET /campaigns/{id}/steps` | read only | | `add_step` | `POST /campaigns/{id}/steps` | write | | `update_step` | `PATCH /campaigns/{id}/steps/{step_id}` | write, idempotent | | `delete_step` | `DELETE /campaigns/{id}/steps/{step_id}` | destructive | | `add_variant` | `POST …/steps/{step_id}/variants` | write | | `update_variant` | `PATCH …/variants/{label}` | write, idempotent | | `delete_variant` | `DELETE …/variants/{label}` | destructive | ### Prospects and companies | Tool | Calls | Annotations | |---|---|---| | `list_prospects` | `GET /prospects` | read only | | `search_prospects` | `GET /prospects?q=` | read only | | `get_prospect` | `GET /prospects/{id}` | read only | | `create_prospect` | `POST /prospects` | write | | `update_prospect` | `PATCH /prospects/{id}` | write, idempotent | | `delete_prospect` | `DELETE /prospects/{id}` with `mode` `delete` or `anonymize` | destructive | | `list_companies` | `GET /companies` | read only | | `get_company` | `GET /companies/{id}` | read only | ### Inbox and replies | Tool | Calls | Annotations | |---|---|---| | `list_inbox_messages` | `GET /inbox/messages` | read only | | `reply_to_message` | `POST /inbox/messages/{id}/reply` | write, open world | | `list_replies` | `GET /replies` | read only | | `classify_reply` | `PATCH /replies/{id}` | write, idempotent | ### Mailboxes, domains and workspace | Tool | Calls | Annotations | |---|---|---| | `list_mailboxes` | `GET /mailboxes` | read only | | `get_mailbox` | `GET /mailboxes/{id}` | read only | | `create_mailbox` | `POST /mailboxes` | write, open world | | `update_mailbox` | `PATCH /mailboxes/{id}` | write, idempotent | | `list_domains` | `GET /domains` | read only | | `list_workspaces` | `GET /workspaces` | read only | ### Suppressions, webhooks and offers | Tool | Calls | Annotations | |---|---|---| | `list_suppressions` | `GET /suppressions` | read only | | `add_suppression` | `POST /suppressions` | write, idempotent | | `delete_suppression` | `DELETE /suppressions/{id}` | destructive | | `list_webhooks` | `GET /hooks` | read only | | `create_webhook` | `POST /hooks` | write, open world | | `delete_webhook` | `DELETE /hooks/{id}` | destructive | | `create_offer` | `POST /offers` | write, open world | | `list_team` | `GET /team` | read only | | `list_offers` | `GET /offers` | read only | | `get_offer` | `GET /offers/{id}` | read only | The arguments of a tool are the parameters and body fields of its endpoint. Path ids are `id`, and `step_id` and `label` for steps and variants. `tools/list` returns the full JSON Schema with a description of each argument. A tool that returns `204` from the API, such as `delete_webhook` or `delete_campaign`, returns the text `Done (HTTP 204, no content).` with `structuredContent` of `{"ok": true, "status": 204}`. ## Prompts Prompts are ready-made tasks that a client shows as commands, for example `/mcp__anymail__build_campaign` in Claude Code. Each prompt is text built from the tools above. | Prompt | Arguments | What it does | |---|---|---| | `build_campaign` | `goal` (required), `prospects` | Checks mailboxes and domains, finds the audience, creates a draft with three plain-text steps, offers an A/B subject test and shows the sequence. It does not start the campaign until you say so. | | `review_replies` | `campaign_id` | Goes through unread messages, checks the classifications, drafts answers to interested people and sends only the ones you approve. | | `campaign_report` | `campaign_id` (required) | Reports the funnel, the results per step and variant, and up to three changes worth making. | | `connect_mailbox` | `email` (required) | Asks for the provider details and an app password, connects the mailbox after a connection test, then checks SPF, DKIM and DMARC of its domain and reminds you to warm it up. | | `send_offer` | `prospect` (required), `brief` | Finds the prospect, asks which salesperson the offer comes from, writes the offer as HTML, creates the link with `send` false for your review and sends only after you say so. | ## Errors | Situation | What the client sees | |---|---| | No header, or a malformed, expired or revoked token | HTTP `401` with JSON `{"error":"…"}` and `WWW-Authenticate: Bearer resource_metadata="https://app.anymail.watch/.well-known/oauth-protected-resource"`. An OAuth client starts the sign-in from here. | | Rate limit exceeded | HTTP `429` with `Retry-After` in seconds. | | Token check impossible because of a database outage | HTTP `500` with `{"error":"Authentication is temporarily unavailable."}`. | | `GET /api/mcp` | HTTP `405` with `Allow: POST, DELETE`. | | Missing or invalid tool arguments | A tool result with `isError: true` that explains the problem. The server makes no HTTP call. | | A write tool with a read only token | A tool result with `isError: true` and `HTTP 403 … insufficient_scope`. | | The REST API answers `4xx` or `5xx` | A tool result with `isError: true` and the text `HTTP from : (code: )`. A `429` adds `Retry after N seconds.` | | The REST API cannot be reached, or it times out | A tool result with `isError: true` that says the API could not be reached. | A tool failure is always a tool result and never a JSON-RPC protocol error, so the model can read the message and correct its input. ## Limits - **Rate limit.** 60 requests per minute per token. One tool call uses two requests: the MCP request is authenticated with the same limiter, and the proxied REST call is the second. Plan for about 30 tool calls per minute per token. `tools/list` costs one request. - **Request bodies.** Up to 64 KiB, as in the REST API. Suppressions, hooks and reply classifications up to 4 KiB. - **Timeout.** A proxied REST call stops after 20 seconds and returns a tool error. - **No sessions.** The server has no sessions, no resumability and no server-initiated notifications. - **No resources.** The server offers tools and prompts. - **Same reach as the REST API.** See [What the API does not do](/what-the-api-does-not-do/). ## Related - [Authentication](/authentication/) - [Rate limits](/rate-limits/) - [What the API does not do](/what-the-api-does-not-do/) --- # Connect Claude > Source: https://developers.anymail.watch/mcp-claude/ Claude connects to anymail as a custom connector. You give it the URL, sign in to anymail in the browser and pick what Claude may do. No API key is involved. You need a Claude plan with custom connectors: Pro, Max, Team or Enterprise. ## Pro and Max 1. In Claude, open **Customize**, then **Connectors**. 2. Select **+ Add**, then **Add custom connector**. 3. Enter the name `anymail` and the URL: ``` https://app.anymail.watch/api/mcp ``` 4. Continue. Claude detects the OAuth settings of anymail. Leave **Authentication** on a sign-in option and add no request headers. 5. Select **Add**, then **Connect**. A browser window opens anymail. 6. Sign in to anymail. On the consent screen pick the **Workspace** and the **Access**, then select **Allow**. The connector shows as connected. Claude can now use the anymail tools in any chat where the connector is turned on. ## Team and Enterprise An Owner adds the connector once for the organisation: 1. Open **Organization settings**, then **Connectors**. 2. Select **Add**, then **Custom**, then **Web**. 3. Enter the name `anymail` and the URL `https://app.anymail.watch/api/mcp`, keep the detected OAuth settings and select **Add**. Each member then opens **Customize**, **Connectors**, finds anymail and selects **Connect**. Every member signs in with their own anymail account, so each person reaches only the workspaces they belong to. ## Choose the access | Access on the consent screen | What Claude can do | |---|---| | **Read only** | Read campaigns, statistics, prospects, companies, replies, mailboxes and domains. Every write tool fails with `insufficient_scope`. | | **Read and write** | Everything above, and create campaigns, write steps, enrol prospects, start and stop campaigns, answer replies. | Start with **Read only** if you want reports and answers first. Connect again to change it. The anymail tools carry annotations that mark reads, writes and destructive calls, so Claude can ask for your approval before a change. `start_campaign` and `reply_to_message` are also marked as reaching outside anymail, because they send email. ## Try it Ask Claude: - "Which of my anymail campaigns has the best reply rate this month?" - "Show me unread interested replies and draft answers." - "Build a three-step campaign for the prospects at example.com, but do not start it." The [prompts](/mcp-server/#prompts) of the server appear in the prompt menu of Claude as well. ## Disconnect Remove or disconnect the connector in **Customize**, **Connectors**. Removing a person from the anymail workspace also ends their access at once. ## Related - [MCP server](/mcp-server/) - [Authentication](/authentication/#oauth-for-mcp-clients) - [Connect ChatGPT](/mcp-chatgpt/) --- # Connect Claude Code > Source: https://developers.anymail.watch/mcp-claude-code/ Claude Code connects to the anymail MCP server over HTTP. Sign in with OAuth in the browser, or send an API token as a header for a machine without a browser. ## With OAuth 1. Add the server: ```bash claude mcp add --transport http anymail https://app.anymail.watch/api/mcp ``` 2. Start Claude Code and run `/mcp`. 3. Select `anymail`, then **Authenticate**. A browser window opens anymail. 4. Sign in, pick the **Workspace** and the **Access**, and select **Allow**. Claude Code stores the tokens and refreshes them. To sign in from the shell without opening a session, run `claude mcp login anymail`. To sign out, use **Clear authentication** in the `/mcp` menu. ## With an API token Generate a token in **Settings**, **Integrations**, **API keys**. See [Authentication](/authentication/). A read only token is enough for reports. ```bash claude mcp add --transport http anymail https://app.anymail.watch/api/mcp \ --header "Authorization: Bearer $ANYMAIL_TOKEN" ``` With a header, Claude Code does not use OAuth. When the token is revoked, the connection fails until you add the server again with a new token. ## Scope of the configuration `claude mcp add` saves the server for you, in the current project, by default. Add `--scope user` to use anymail in every project, or `--scope project` to share the server through `.mcp.json` with your team. Do not put a token in a shared `.mcp.json`. Use OAuth there, so that each person signs in. ```bash claude mcp add --transport http anymail https://app.anymail.watch/api/mcp --scope user ``` ## Check the connection Run `/mcp` in Claude Code. It lists `anymail` as connected, with 44 tools. Then ask, for example: "List my anymail campaigns with their reply rate." The [prompts](/mcp-server/#prompts) of the server are commands in Claude Code: ``` /mcp__anymail__campaign_report /mcp__anymail__review_replies /mcp__anymail__build_campaign ``` ## Related - [MCP server](/mcp-server/) - [CLI](/cli/) - [Connect Cursor](/mcp-cursor/) --- # Connect ChatGPT > Source: https://developers.anymail.watch/mcp-chatgpt/ ChatGPT connects to anymail as a custom MCP server and signs in with OAuth. ChatGPT cannot send an API key, so OAuth is the only way. Custom MCP servers depend on your ChatGPT plan and, on a business plan, on the policy of your workspace admin. ## Add the server 1. Open the plugins page of ChatGPT at `https://chatgpt.com/plugins`. 2. Select the plus button, then **Add custom MCP server**. 3. Enter the name `anymail` and a short description, for example "Cold email campaigns, prospects and replies". 4. Under **Connection**, enter the URL: ``` https://app.anymail.watch/api/mcp ``` 5. Under authentication, choose OAuth. ChatGPT reads the anymail metadata and registers itself. No client id or secret is needed. 6. Read the warning about custom servers and confirm. 7. Select **Create as a plugin**. ChatGPT shows the tools it found: there are 44. 8. Sign in to anymail when ChatGPT asks. On the consent screen pick the **Workspace** and the **Access**, then select **Allow**. ## Choose the access **Read only** gives ChatGPT the read tools: campaigns, statistics, prospects, replies, mailboxes and domains. **Read and write** also lets it create campaigns, enrol prospects, start campaigns and answer replies, which sends real email. The tool annotations mark every write, so ChatGPT can ask for your confirmation first. ## Try it - "In anymail, which campaign got the most interested replies last week?" - "List unread replies from anymail and summarise what each person wants." ## Related - [MCP server](/mcp-server/) - [Connect Claude](/mcp-claude/) - [Authentication](/authentication/#oauth-for-mcp-clients) --- # Connect Cursor > Source: https://developers.anymail.watch/mcp-cursor/ Cursor reads MCP servers from `mcp.json`: `.cursor/mcp.json` in a project, or `~/.cursor/mcp.json` for all projects. ## With OAuth Give Cursor only the URL. Cursor registers itself with anymail and signs in with the authorization code flow and PKCE. ```json { "mcpServers": { "anymail": { "url": "https://app.anymail.watch/api/mcp" } } } ``` Open **Cursor Settings**, then **MCP**. Cursor lists `anymail` and asks you to sign in. Sign in to anymail in the browser, pick the **Workspace** and the **Access**, and select **Allow**. ## With an API token Keep the token out of the file. Cursor reads environment variables with `${env:NAME}`. ```json { "mcpServers": { "anymail": { "url": "https://app.anymail.watch/api/mcp", "headers": { "Authorization": "Bearer ${env:ANYMAIL_TOKEN}" } } } } ``` Set `ANYMAIL_TOKEN` in the environment Cursor starts from. See [Authentication](/authentication/) for how to generate a token. ## Check the connection **Cursor Settings**, **MCP** shows `anymail` with 44 tools. In the agent chat, ask: "List my anymail mailboxes and tell me which domains have a DNS problem." ## Related - [MCP server](/mcp-server/) - [Connect Claude Code](/mcp-claude-code/) - [Domains](/domains/) --- # Limits and good practice > Source: https://developers.anymail.watch/limits-and-good-practice/ This page collects the limits of the API and the habits that keep an integration reliable. ## Limits | Area | Limit | |---|---| | Request rate | 60 requests per minute per token, fixed window, one budget for all endpoints | | Page size | `limit` from 1 to 100, default 50 | | Body of `POST` and `PATCH` on prospects, campaigns, steps, variants, mailboxes and inbox answers | 64 KiB | | Body of suppressions, hooks and reply classification | 4 KiB | | Items in `prospect_ids` or `emails` of an enrolment | 1 to 200 for each call | | Items in `prospect_ids` or `mailbox_ids` of a campaign | Up to 1000 | | A/B variants of a step | 4 (`A` to `D`) | | Text of an inbox answer | 20,000 characters | | `q` search on prospects | 200 characters | | OAuth access token | 1 hour | | OAuth refresh token | 30 days, replaced on every use | | Idempotency key length | 1 to 255 characters | | Idempotency key retention | 24 hours | | `search` on companies | 200 characters | | `email` filter on prospects | 320 characters | | `visit_list` of an offer | The 200 latest visits | | `recent_events` of a company | The 50 latest events | | Reply `snippet` | 160 characters | | Webhook response time | 10 seconds | | Webhook attempts | 3 | | Webhook timestamp tolerance for your verification | 300 seconds | | MCP proxied call | 20 seconds | The rate limit counter lives in the memory of the application process. It resets when the service restarts, which happens on a release. The limit works as a brake on runaway loops. ## Good practice ### Authenticate and store tokens well - Use one token per integration and revoke the one that leaks. - Give a token the `read` scope only, unless the integration writes. - Keep tokens in environment variables or a secret manager. ### Retry safely - Send an [`Idempotency-Key`](/idempotency-keys/) on every `POST` that creates something, and reuse the key on each retry of the same request. - Retry `5xx` and `429` with backoff. Use `Retry-After` for `429`. - Do not retry `4xx` unchanged. ### Stay inside the rate limit - Read `X-RateLimit-Remaining` and slow down before it reaches `0`. - Use `limit=100` on lists to need fewer requests. - Filter on the server with `status`, `since` or `created_since` and do not download a whole list to filter it yourself. - Poll with `since` or `created_since` for new rows, or subscribe to a [webhook](/webhooks/) instead. ### Write complete state on updates `PATCH /prospects/{id}` clears the fields you omit. Read the prospect, change the fields and send the full state. ### Start campaigns on purpose `POST /campaigns/{id}/start` and `POST /inbox/messages/{id}/reply` send real email. In an agent or a script, ask a person before these calls, and use `GET /domains` to avoid starting from a domain with a DNS problem. ### Treat enrolment as a per-person result `POST /campaigns/{id}/prospects` returns `200` even when nobody was enrolled. Read `summary` and the `result` of each item. ### Receive webhooks reliably - Verify the [signature](/webhooks/#verify-the-signature) on the raw body. - Answer with `2xx` within 10 seconds and process the event afterwards. - Store `X-Seegnals-Delivery` and ignore a delivery you have seen. - Point the webhook at the final URL. anymail does not follow redirects. ### Read null as not measured `opened` and `clicked` on a campaign are `null` when tracking is not enabled. Do not convert `null` to zero. ## Related - [Rate limits](/rate-limits/) - [Errors](/errors/) - [Webhooks](/webhooks/) --- # What the API does not do > Source: https://developers.anymail.watch/what-the-api-does-not-do/ This page lists what is not in the API. Each item is a fact about the current version. ## Writes The API covers campaigns from the first draft to the statistics. It does not offer: - Connecting a mailbox with Google or Microsoft sign-in. It needs a browser and stays in the app. [`POST /mailboxes`](/mailboxes/) connects SMTP and IMAP mailboxes with an app password. - Inviting people to the workspace or creating salespeople. - Buying domains or changing DNS records. [`GET /domains`](/domains/) reports SPF, DKIM and DMARC. - Deleting a campaign that has sent anything, or running a completed campaign again. Duplicate it in the app. - Changing the audience of a campaign after the start. Enrol more people with `POST /campaigns/{id}/prospects`. - Tasks and conditions in a sequence. They are kept when the API saves steps, but the API cannot create or edit them. - Uploading or creating offer documents. - Managing API tokens, the workspace webhook URL or its signing secret. - Revoking an OAuth connection from the app. Access ends when the refresh token goes unused for 30 days, or at once when the person leaves the workspace. - Marking an inbox message as read. ## Data - Prospects and campaigns have no `updated_at` and no `updated_since` filter. Their tables carry no modification timestamp. - The API does not return the content of an offer document. It returns metadata and reading statistics. - The API never returns mailbox credentials or signatures. - The global suppression list of anymail is not visible. The list endpoint shows the suppressions of your workspace. ## Protocol - No bulk endpoints. The enrolment call takes up to 200 items and is the closest equivalent. - No ETag. - No CORS, so a browser page cannot call the API. - One workspace per token. A token cannot switch workspaces. `X-Workspace` only confirms the workspace. - Two scopes, `read` and `write`. There are no scopes per resource. ## Events - There is no event for a delivery, because SMTP does not report one. - `email_opened` and `prospect_added` are available only as subscriptions. The workspace webhook does not send them. ## MCP - The MCP server has no resources. - It has no sessions and sends no notifications. - It does what the REST API does. Anything in the lists above is also missing from MCP. ## Related - [Overview](/) - [MCP server](/mcp-server/) - [Changelog](/changelog/) --- # Changelog > Source: https://developers.anymail.watch/changelog/ Each entry gives the date, the type and what changed. The API has one version, `v1`. ## 2026-10-29 **Added.** `POST /offers` creates a tracked offer from HTML and emails it, or with `send: false` returns only the link. `GET /team` lists the salespeople to put on the offer. The MCP server has the tools `create_offer` and `list_team`. See [Offers](/offers/). **Added.** `POST /mailboxes` connects a mailbox with SMTP and IMAP after a connection test. The MCP tool is `create_mailbox`. See [Mailboxes](/mailboxes/). **Added.** MCP prompts `connect_mailbox` and `send_offer`. See [MCP server](/mcp-server/#prompts). **Added.** Four webhook events for offers: `offer_opened`, `offer_returned`, `offer_read` and `offer_temperature_changed`. They reach the workspace webhook and can be subscribed to through `POST /hooks` and the MCP `create_webhook` tool. Use `offer_temperature_changed` to start an action when an offer goes from `cold` to `hot`. See [Webhooks](/webhooks/#events). ## 2026-10-28 **Added.** Campaign writes: `POST /campaigns`, `PATCH /campaigns/{id}`, `DELETE /campaigns/{id}` for a draft, `POST /campaigns/{id}/start` and `POST /campaigns/{id}/stop`. A campaign can now go from a draft to running without the app. See [Campaigns](/campaigns/). **Added.** Steps and A/B variants: `GET` and `POST /campaigns/{id}/steps`, `PATCH` and `DELETE /campaigns/{id}/steps/{stepId}`, `POST /campaigns/{id}/steps/{stepId}/variants`, `PATCH` and `DELETE /campaigns/{id}/steps/{stepId}/variants/{label}`. See [Steps and variants](/campaign-steps/). **Added.** `GET /campaigns/{id}/stats` with totals, the funnel and metrics per step and variant. See [Statistics](/campaign-stats/). **Added.** `GET /inbox/messages` with the full text of each message, and `POST /inbox/messages/{id}/reply`. See [Inbox](/inbox/). **Added.** `DELETE /prospects/{id}` with `mode=delete` or `mode=anonymize`, and the `q` search parameter on `GET /prospects`. See [Prospects](/prospects/). **Added.** `GET /mailboxes/{id}`, `PATCH /mailboxes/{id}`, `GET /domains` and `GET /workspaces`. See [Mailboxes](/mailboxes/), [Domains](/domains/) and [Workspaces](/workspaces/). **Added.** Token scopes `read` and `write`. A write with a read only token returns `403` with `code: "insufficient_scope"`. Existing tokens keep both scopes. No action needed. See [Authentication](/authentication/#scopes). **Added.** The `X-Workspace` header. A request that names another workspace returns `403` with `code: "workspace_forbidden"`. **Added.** Nine webhook events: `email_opened`, `link_clicked`, `prospect_interested`, `prospect_maybe_later`, `prospect_not_interested`, `prospect_autoreplied`, `campaign_paused`, `campaign_completed` and `prospect_added`. Six of them also reach the workspace webhook. See [Webhooks](/webhooks/#events). **Added.** OAuth 2.1 sign-in for the MCP server, with dynamic client registration and PKCE. Claude and ChatGPT connect with the server URL alone. See [Authentication](/authentication/#oauth-for-mcp-clients). **Added.** 21 MCP tools, 44 in total, with titles and annotations, and 3 prompts. See [MCP server](/mcp-server/). **Changed.** `Idempotency-Key` works on every `POST`, `PATCH` and `DELETE` except `PATCH /prospects/{id}` and `DELETE /hooks/{id}`. Requests without the header behave as before. **Changed.** New write endpoints reject unknown body fields with `400`. Earlier endpoints still ignore them. **Added.** The [OpenAPI 3.1 document and a Postman collection](/openapi/), the [CLI](/cli/), connection guides for [Claude](/mcp-claude/), [Claude Code](/mcp-claude-code/), [ChatGPT](/mcp-chatgpt/) and [Cursor](/mcp-cursor/), every page as Markdown, `llms-full.txt`, and curl, Node and Python for every example. ## 2026-09-04 **Added.** Rate limit headers `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` on every authenticated response. See [Rate limits](/rate-limits/). No action needed. **Added.** `Idempotency-Key` on `POST /prospects`, `POST /suppressions` and `POST /hooks`. See [Idempotency keys](/idempotency-keys/). Requests without the header behave as before. **Added.** Five write endpoints: `POST /campaigns/{id}/prospects`, `POST /campaigns/{id}/pause`, `POST /campaigns/{id}/resume`, `PATCH /replies/{id}` and `DELETE /suppressions/{id}`. See [Campaigns](/campaigns/), [Replies](/replies/) and [Suppressions](/suppressions/). **Added.** Thirteen `GET` endpoints for prospects, campaigns, companies, replies, offers, suppressions, hooks and mailboxes. **Added.** The [MCP server](/mcp-server/) at `/api/mcp`, with one tool for each endpoint. ## 2026-09-03 **Changed.** The last segment of an email domain must start with a letter. An address that does not meet this rule returns `invalid-email`. ## 2026-08-29 **Added.** `company_name` on prospects. **Added.** The `campaign` query parameter on `POST /prospects`, which enrols the new prospect in a campaign. **Added.** `POST /suppressions`, `POST /hooks` and `DELETE /hooks/{id}`. ## 2026-08-23 **Added.** The `clicked` event in the workspace webhook. ## 2026-08-22 **Added.** `POST /prospects` and `PATCH /prospects/{id}`, workspace API tokens, a rate limit of 60 requests per minute and the workspace webhook. --- # OpenAPI and Postman > Source: https://developers.anymail.watch/openapi/ The REST API is described in an OpenAPI 3.1 document. A Postman collection is generated from it on every build of this site, so the two never disagree. | File | URL | |---|---| | OpenAPI 3.1 (YAML) | [`/openapi.yaml`](/openapi.yaml) | | Postman collection v2.1 | [`/anymail.postman_collection.json`](/anymail.postman_collection.json) | The document covers every endpoint of `/api/v1`: paths, parameters, request bodies with an example, response schemas, the `bearerAuth` and `oauth2` security schemes with the `read` and `write` scopes, and the shared `X-Workspace` and `Idempotency-Key` headers. A check on every build compares it with the routes of the application, in both directions, and validates it against the OpenAPI 3.1 schema. ## Postman 1. In Postman, select **Import** and paste the collection URL `https://developers.anymail.watch/anymail.postman_collection.json`. 2. Open the collection, then **Variables**. Set `token` to your API token. `baseUrl` is already `https://app.anymail.watch/api/v1`. 3. Requests are grouped by resource. Path parameters such as `:id` are in the **Params** tab. Optional query parameters and headers are listed but turned off. The collection uses Bearer authentication at the collection level, so every request carries the token. ## Generate a client Any OpenAPI 3.1 generator can read the document. For example, TypeScript types with `openapi-typescript`: ```bash npx openapi-typescript https://developers.anymail.watch/openapi.yaml -o anymail.d.ts ``` Or a Python client with `openapi-python-client`: ```bash pipx run openapi-python-client generate --url https://developers.anymail.watch/openapi.yaml ``` The pages of this site give the details that a schema cannot hold: the exact error messages, the order of checks and the meaning of each status. ## For LLMs and agents - [`/llms.txt`](/llms.txt) lists every page with a one-line description. - [`/llms-full.txt`](/llms-full.txt) holds the text of every page in one file. - Every page is also plain Markdown at its path with `.md`, for example [`/campaigns.md`](/campaigns.md). The **Markdown** link at the top of each page points there. An agent that should act on anymail can use the [MCP server](/mcp-server/). ## Related - [Overview](/) - [Changelog](/changelog/) - [MCP server](/mcp-server/) ---