API overview
Base URL, authentication, scopes, pagination, errors, idempotency and rate limits: what every endpoint has in common.
Every endpoint in this reference follows the conventions on this page. Each entry states its method and path, the scope the key needs, every parameter, what comes back, and the errors specific to it. The same API is described machine-readably at /openapi.json, and most calls are also reachable as tools through the MCP server. Key management is deliberately left out of both: /openapi.json does not describe the API key endpoints, and the MCP server has no tool for them. See API keys.
Base URL
All requests are JSON over HTTPS. Send Content-Type: application/json on any request with a body. Bodies are limited to 15 MB on POST /v1/emails, 25 MB on POST /v1/emails/batch and 5 MB everywhere else; a larger body answers 413 with type payload_too_large, and a body that is not valid JSON answers 400 with type invalid_json. A request to a path or method that does not exist answers 404 with type not_found and the message No such API route, once its credentials have been accepted: under /v1, a request without a valid token answers 401 first, whatever the path. A record that does not exist is also 404 not_found, but its message names the record (No such message, No such webhook endpoint), so a wrong path and a missing record read differently.
Authentication
Every request carries a bearer token:
Two kinds of token are accepted and behave the same way: an API key (sk_live_…) created in the dashboard, and an OAuth access token (srv_oat_…) granted through the consent screen, which is what the MCP server uses when a client signs in. An OAuth token carries the scopes of its grant but none of an API key's guardrails (daily cap, recipient allowlist, approval hold); those belong to a key someone configured. An OAuth token cannot decide approvals: POST /v1/approvals/{id} answers 403 forbidden for it, as it does for a key with any guardrail. Approvals are decided by a person in the dashboard or by an API key without guardrails. See Authentication and Limits for agents.
| Status | Type | When |
|---|---|---|
| 401 | unauthorized | No Authorization: Bearer header, or the key or token is invalid, revoked or expired. |
| 403 | forbidden | The key or token is valid but lacks the scope the endpoint needs. The message names the scope. |
Scopes
Each endpoint below names the one scope it requires.
| Scope | Endpoints that require it |
|---|---|
emails:send | Sending, scheduling and cancelling email; deciding an approval; marking a thread handled; suppression writes; enrolling someone in an automation; recording an event |
emails:read | Sent emails and their metrics; listing approvals; listing suppressions; usage |
threads:read | Inbound messages and attachments; reading threads |
domains:read | Reading sending domains |
domains:write | Adding, verifying and removing sending domains; managing API keys |
contacts:read / contacts:write | Contacts, contact properties, audiences, segments, topics and topic preferences |
broadcasts:read / broadcasts:write | Campaigns and automations |
templates:read / templates:write | Templates |
webhooks:read / webhooks:write | Webhook endpoints |
The table is a summary; the scope printed on each endpoint is the one that is enforced.
Identifiers
Audiences, sending domains, segments, automations, campaigns and contacts are addressed by the id the API returned when they were created, never by their name, domain or email address. The commonest mistake is passing the name instead, so the error says so: when the value matches a record's name in your workspace, the message names the id to use and the call that lists them. A name is never used to route a request, because names are not unique.
| Where the reference is | Status | Type |
|---|---|---|
In the path, for example /v1/audiences/{id} | 404 | not_found |
In the body, for example audience_id | 422 | invalid_request, with a message that names the field or the record not found |
A record that belongs to another workspace is indistinguishable from one that does not exist.
Pagination
Every response that is a collection has exactly four top-level fields, and nothing beside them:
object is always "list", data holds the items, has_more says whether there are more than data holds, and next_cursor is the value to pass back as cursor for the next page, or null. A list never carries a count, a note or an echo of the query. Counts have their own endpoints, such as segment metrics.
There is one exception. POST /v1/emails/batch is not a collection read but a batch result: it answers { "object": "list", "data", "failed" }, where data has one result per entry in input order and failed counts the entries that failed, with no has_more or next_cursor.
| Query parameter | Default | Notes |
|---|---|---|
limit | 50 | At most 100; a larger value is capped at 100 and a fraction is rounded down. Zero, a negative number or a value that is not a number falls back to 50. None of these is an error. |
cursor | none | The next_cursor from the previous page. Cursors are opaque strings whose format differs from one list to another, so pass back exactly what you were given and never build one. On the lists whose cursor is a position in time (every cursor-paged list except an audience's contacts and a campaign's recipients), an unreadable cursor returns the first page. |
Pass next_cursor back as cursor while has_more is true. How each list fills the envelope:
- Cursor-paged, newest first, with the
limitabove: sent emails, threads (by latest message), suppressions, audiences, automations, an automation's enrolments (GET /v1/automations/{id}/enrollments), campaigns, webhook endpoints, API keys and the workspace's contacts (GET /v1/contacts). - Cursor-paged with their own limits or order: a segment's contacts are newest contact first, with
limitdefaulting to200and allowed up to1000; an audience's contacts are in address order, with thelimitabove; a campaign's recipients default to and allow at most200per page. - Not paged: sending domains, segments, topics, templates, approvals, a person's topic preferences (from
GETandPOST /v1/topics/preferences), contact properties, contact tags, a campaign's clicked links, a webhook endpoint's delivery attempts, and the domains created byPOST /v1/domains.cursoris ignored andnext_cursoris alwaysnull.has_moreistrueonly when the list reached the most the endpoint returns, meaning there may be more it cannot return: 500 for segments, topics, templates and topic preferences, 200 for tags, 100 for clicked links, andlimit(at most 200) for delivery attempts. Sending domains and approvals sethas_moreat 500 rows but still return every row, and contact properties and a domain creation return every row withhas_morealwaysfalse.
Each endpoint's page gives its own details.
Resources
A resource has one shape. Retrieving it, listing it, and every call that creates it, changes it or acts on it return the same object with the same fields, so a record you listed, fetched or just changed can be read the same way:
- Update calls and actions return the whole resource as it is stored after the change, not a partial object or a flag. Sending, resuming or deciding the winner of a campaign returns the campaign; changing an automation's status returns the automation; changing a contact's tags returns the contact; verifying a domain returns the domain; revoking an API key returns the key with
revoked_atset. - The only differences are fields that a retrieve carries and a list leaves out because they can be long, and each is documented on its endpoint: a campaign's
html, a thread'smessagesand an email'sevents. - Deleting answers a small confirmation, such as
{ "id", "deleted": true }, since there is no resource left to return, and cancelling a scheduled email answers{ "id", "status": "canceled" }. - Calls whose result is not one resource return a result object documented on their endpoint: sending an email (the accepted send; retrieve the email for its full record), a batch, rendering a template, enrolling someone, recording an event, deciding an approval and the bulk suppression and import calls.
Errors
Every error has the same shape:
type is stable and safe to branch on, and a type means the same thing, with the same status, on every endpoint. message is for people and may change. Two extra fields appear: details, an array listing each problem field, on most validation failures (422 invalid_request), and missing, an array of variable names, on every 422 missing_variables. The full list of types, and whether a retry helps, is on Errors.
| Status | Types | Meaning |
|---|---|---|
| 400 | invalid_json, invalid_request | The request body is not valid JSON, or could not be read at all. |
| 401 | unauthorized | Missing, invalid, revoked or expired credentials. |
| 402 | plan_limit_reached | The workspace's plan does not allow it: its monthly emails or its sending domains. Retrying will not help; upgrading will. |
| 403 | forbidden, recipient_not_allowed | The credential lacks the scope, a key guardrail refused the request, a key tried to edit or revoke itself, or an OAuth token or guarded key tried to decide an approval. recipient_not_allowed is a key's allowlist refusing an address. |
| 404 | not_found | No such record in this workspace, or no such API route. |
| 409 | conflict, invalid_state, idempotency_in_progress, approval_in_progress | An identifier is already taken (conflict), the record's current state does not allow the action (invalid_state), or the same request is still running. |
| 410 | gone | The resource existed but is no longer stored, such as an attachment on received mail older than 30 days. |
| 413 | payload_too_large | The request body is larger than the endpoint accepts. |
| 415 | invalid_request | The request body uses a charset or content encoding the API does not accept. |
| 422 | invalid_request, idempotency_key_reused, missing_variables, unknown_topic, invalid_schedule, no_verified_identity, no_postal_address, warmup_limit, workspace_suspended | Validation failed, the request refers to something that does not exist, or the workspace or sending domain cannot send it now. |
| 429 | rate_limited, daily_limit | Too many requests a minute, or a key's daily cap is spent. |
| 500 | internal_error | Something failed on our side. |
| 502 | ses_error | The email provider refused or failed the request. |
Idempotency
Every POST accepts an Idempotency-Key header. Replaying the same key with the identical request body, on the same path in the same workspace, within 24 hours returns the stored response instead of acting again, with an Idempotent-Replay: true header. Only successful responses are stored, so a request that failed with a 4xx or 5xx can be fixed and retried under the same key. PATCH and DELETE requests are not covered.
A key belongs to one request. The body is part of it: sending a key again with a different body answers 422 with type idempotency_key_reused and does nothing, whether the first request succeeded or is still running. The bodies are compared as JSON, so the order of their fields does not matter. Use a new key for a new request. A request that failed stored nothing, so its key is free again, and a corrected body under the same key runs. The endpoint's scope is checked first: a credential without it gets 403 forbidden, never idempotency_key_reused, so it cannot learn that a key was used with another body.
The key is claimed when the request arrives, before anything is done. A second request with the same key and body on the same path while the first is still running waits for it, up to 8 seconds: if the first succeeds, the second returns its stored response with Idempotent-Replay: true; if the first fails, the second runs itself. Only if the first is still running after the wait does the second answer 409 with type idempotency_in_progress, having done nothing; retry it with the same key after a short pause. This covers the case that matters most: a client that times out and retries while the server is still sending. A claim left by a request that never answered, such as one cut off by a server restart, stops blocking the key after ten minutes.
Details that matter when you rely on it:
- The 24 hours run from the stored response. After that the key is forgotten, and the same key runs the request again.
- A replay checks the scope the original request needed. A credential without that scope gets
403 forbidden, not the stored response. Any credential in the workspace that has the scope and presents the same key and body on the same path does receive it, so use unguessable keys, such as UUIDs. - Responses that carry a secret shown only once are never stored: creating an API key and creating a webhook endpoint. A retry of those after they have answered acts again.
Always send one on POST /v1/emails: a retried send that is not deduplicated is a second copy in someone's inbox.
Rate limits
Each key or OAuth token may make 600 requests per minute by default, counted separately by each API server instance. Beyond that the API answers 429 with type rate_limited; wait and retry. This is separate from how fast mail actually leaves: sends are paced against the account's provider rate in the delivery path, so a burst of accepted sends queues rather than failing.
Conventions
- Every field name is
snake_case: in request bodies, query parameters, responses (including list envelopes, nested objects and responses replayed for anIdempotency-Key) and webhook payloads. The one exception is the inside of a map whose keys you chose yourself, such as a contact'sattributes, a template'svariablesor a message'sheaders: those keys come back exactly as you sent them. - Every documented field is always present. A field with no value is
null, and a list with no items is[]; a field is never left out of one response and present in another, so a created record and the same record fetched later have exactly the same keys. - Responses never carry storage internals such as a database
_idor the workspace id, which the credential already implies. Every record is identified by itsid. - Timestamps are ISO 8601 strings in UTC, for example
2026-09-15T12:00:00.000Z. - Each endpoint's response example shows exactly which fields come back.