What the API does
The API manages a workspace's mail service. Everything it does, an admin can also do in the admin console; the API lets a script, an integration or an agent do it instead.
- Domains: verify ownership, connect a domain, check its DNS, set a catch-all, add alias domains.
- Mailboxes: create, delete and restore them, and change passwords, status, quotas, notes and daily sending limits.
- Addresses: forwarding rules, plain aliases and distribution lists.
- Compliance and reports: legal holds, inactive mailboxes, storage per domain and top senders.
- Events: signed webhooks for mailbox, domain, order, bulk job and security events.
- Sending and receiving: send from your domains, follow each message's delivery, and receive mail as webhooks (after approval).
- Agent mailboxes: a mailbox for an AI agent in one call, with its inbox, threaded replies and a key for that mailbox alone.
- Mail apps: let your mailbox users authorize your app, with OAuth, to read their mail and send as them.
- Delivery: the suppression list, message trace, and late bounces and spam complaints read back for you.
- Clients and connectors: SDKs for TypeScript and Python, a command-line tool, n8n and Zapier, and Feishu and DingTalk group bots (see the app directory below).
- Batches: many changes in one ordered, idempotent request.
Get a workspace and a key
- Get a workspace. The API belongs to a workspace in the admin console. Apply for a workspace; a person reviews each application, and you are invited as its admin once it is approved. Where the sign-up page offers a developer workspace, choose it to get one as soon as you confirm your email, to try the API with test keys.
- Create a key. In the admin console open Settings → API keys, create a key and choose only the scopes your code needs. Only admins can create keys.
- Save it at once. The full key is shown only when it is created. Keep it in a secret store or an environment variable, never in a repository, a URL or a log.
A key never does more than its owner's current role allows in the console, and a revoked key stops working at its next request. Every admin of the workspace can see and revoke the keys of the other accounts.
Your first request
Every request goes to https://console.abnormalhq.com/api/v1 and carries the key as a bearer token. GET /capabilities is a good first call: it returns the scopes, the features and the limits in force.
API_BASE='https://console.abnormalhq.com/api/v1'
printf 'API key: '; stty -echo; IFS= read -r API_KEY; stty echo; printf '\n'
curl --fail-with-body "$API_BASE/capabilities" \
-H "Authorization: Bearer $API_KEY"Reading the key with hidden input keeps it out of your shell history. Run unset API_KEY when you are done.
Scopes
Each operation needs one scope, listed next to it in the reference below. A write scope does not include the matching read scope. Two operations differ: POST /batch checks the scope of every operation it carries, and GET /batch/{requestId} answers only the key that submitted the batch.
domains:read,domains:write: domains, onboarding, DNS and connection status, catch-all, alias domains.mailboxes:read,mailboxes:write: mailboxes, the recycle bin, sending limits and the usage reports.forwarders:*,aliases:*,lists:*: forwarding rules, plain aliases and distribution lists.holds:*,webhooks:*,suppressions:*,trace:read: legal holds, webhooks, the suppression list and message trace.agent_tasks:*: follow-up tasks, the agent that answers mail as a mailbox.emails:*,inbound:*: sending through the API and inbound routing (choose them: a new key does not start with them).agent_mailboxes:*,mail_apps:*: agent mailboxes, and the apps your mailbox users can authorize (also chosen, not preset).
Domains, forwarding, aliases, lists, holds and webhooks need the admin role even to read; mailbox and follow-up task scopes also work for operators, and their read scopes for viewers.
Requests, responses and errors
- Request bodies are JSON (
Content-Type: application/json) and at most 16 KiB. - A result is
{ "data": … }, and lists addpagination. A create answers201. {id}in a path is the record ID the API returned, not an address or a domain name.POST /batchruns 1 to 25 operations in order and needs anIdempotency-Key: sending the same batch again with the same key returns the saved result instead of running it twice.
Every error has the same shape. Branch on code, never on message, which is for people and may change:
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Try again in 42 seconds.",
"details": { "retryAfter": 42, "limit": 300 }
}
}After a timeout, a dropped connection or a 502, read the current state before you repeat a write. A 409 with provisioning_uncertain means a mailbox may or may not exist yet: look it up with details.mailboxId instead of creating it again.
Rate limits
- Requests are counted per key, REST and MCP together. The default is 300 a minute;
GET /capabilitiesreports the limit in force aslimits.requestsPerMinute. - Every answer carries
RateLimit-Limit,RateLimit-Remaining,RateLimit-ResetandRateLimit-Policy. - Over the limit the answer is
429withRetry-Afterin seconds. A refused request did nothing, so it is safe to repeat after that wait. - Too many invalid keys from one address block that address for a while, valid keys included. Fix the client that sends the wrong key.
Webhooks
A webhook is an https address the workspace POSTs to when something happens, so you do not have to poll. Create one with POST /webhooks; the response includes the signing secret (whsec_…) once and never again. A workspace can have up to 10.
Each delivery is signed in the X-Mailhq-Signature header as t=<milliseconds>,v1=<hex HMAC-SHA256> over the timestamp, a dot and the raw body. Check it before you trust the body:
import { createHmac, timingSafeEqual } from "node:crypto";
/** rawBody: the body exactly as received; header: the X-Mailhq-Signature value. */
export function verifyMailhqWebhook(secret, rawBody, header, toleranceMs = 5 * 60_000) {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
if (!match) return false;
const timestamp = Number(match[1]);
if (Math.abs(Date.now() - timestamp) > toleranceMs) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
const given = Buffer.from(match[2], "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}- Answer
2xxwithin 10 seconds and do the work afterwards. - Delivery is at least once and not ordered: drop repeats by the event
id. - A failed delivery is retried with growing pauses for about 17 hours. A webhook that has failed for 3 days is switched off until you switch it on again.
POST /webhooks/{id}/testsends a test event to check your receiver. Settings → Developers lists every webhook's latest deliveries with the receiver's answer and can redeliver one; the status page shows whether webhook delivery is on time.- To get events in a group chat without a receiver of your own, create the webhook with
"format": "feishu"or"dingtalk"and the group bot's address (and its signing secret asbotSecret): each event arrives as a short message.
Sending and receiving mail
Send mail from your own domains through their own mail server, follow each message's delivery, and receive what chosen mailboxes get as webhooks. Sending is off until we approve your workspace with a daily limit; write to us to ask. Test keys can send to the sandbox right away.
PUT /domains/{id}/sendingcreates the domain's sending account: mail goes out signed with the domain's DKIM key, its bounces return to that account, and From can be any address on the domain.POST /emailssends one message (POST /emails/batchup to 50); anIdempotency-Keymakes a retry safe. Recipients on the suppression list are skipped.GET /emails/{id}shows each recipient's outcome: sent, delivered, delivery delayed or bounced. The same changes arrive asemail.sent,email.delivered,email.delivery_delayed,email.bouncedandemail.failedevents.PUT /mailboxes/{id}/inboundturns on inbound routing: each message the mailbox receives is POSTed asmessage.received, with its plain text; read the whole message and its attachments withGET /mailboxes/{id}/messages/{emailId}. Aliases or a catch-all bring many addresses into one routed mailbox.- If your plan bills sending by volume, subscribe under Settings → Developers → Billing first: until then live keys get
402 billing_required. Each message handed to the mail server counts once, whatever its number of recipients. - Settings → Developers in the admin console shows the day's sending allowance, the latest API calls with their error codes, each message sent and every recipient's outcome.
- Late bounces and spam complaints (feedback loop reports) that reach the domain's sending account are read back: the recipient becomes
bounced, or getscomplainedAtand anemail.complainedevent, and goes on the suppression list. - A newly approved workspace warms up: 50 messages on its first day, twice as many each day after, up to its limit. Live sending pauses by itself when too many recipients bounce or complain (
403 email_api_pausedand anemail.sending_pausedevent) until we look into it. - Software that only speaks SMTP can send through the domain's own server with
POST /domains/{id}/smtp-credentials: a password that may only sign in and send, made and revoked apart from API keys. That mail is not an API message: it has noGET /emailsrecord and no delivery events, and it counts against the sending account's own daily limit on the server, not the workspace's allowance.
Agent mailboxes
An agent mailbox is a real mailbox on your domain, made for an AI agent in one call, with inbound routing and sending already on.
POST /agent-mailboxeswith adomainand anamemakes one;GET /agent-mailboxes/{id}/messagesreads its inbox,POST …/sendsends a new message andPOST …/messages/{emailId}/replyreplies in the thread.POST /agent-mailboxes/{id}/keysreturns a key that works for that one mailbox only: give it to the agent, and it cannot touch anything else in the workspace.- What the agent receives also arrives as
message.receivedevents, so it need not poll.PATCHandDELETE …/messages/{emailId}mark a message read or delete it, andGET …/attachments/{index}downloads an attachment.
Apps your mailbox users authorize
Your application can read a mailbox's mail and send as it with that user's consent, without ever seeing their password: OAuth 2.0's authorization code flow with PKCE, on Abnormal's webmail.
- Register the app with
POST /mail-apps: a name, its redirect URIs, and the scopes it may ask for:mail.read,mail.sendandmail.modify. Every app is a public client: there is no client secret, and PKCE (S256) is required. - Send the user to the webmail's
/oauth/authorize; they sign in, see your app's name and what it asks for, and approve. Trade the code at/api/oauth/tokenfor an access token (one hour) and a refresh token that rotates on every use. - Call the Mail API with the token:
GET /api/mail/v1/messagesand/messages/{id}read and search the mailbox (and…/attachments/{index}its attachments),POST /api/mail/v1/sendsends as it, with attachments and a copy in Sent, and withmail.modifythe app can mark, move and delete messages. - The user can revoke your app in webmail settings at any time, and changing the mailbox's password ends every authorization;
DELETE /mail-apps/{id}ends all of your app's at once.
MCP for AI agents
The same operations are tools on an MCP server at https://console.abnormalhq.com/api/mcp (Streamable HTTP), with the same keys, scopes and rate limit. Give an agent a key with only the scopes it needs. In Claude Code, for example:
claude mcp add --transport http abnormal https://console.abnormalhq.com/api/mcp \
--header "Authorization: Bearer $ABNORMAL_API_KEY"Clients that sign in with OAuth, such as Claude on claude.ai, Claude Desktop and Claude Code, need only the address: they register themselves, an admin approves the scopes on a consent page, and their tokens renew on their own. Leave out the header and the client opens the sign-in page.
App directory
Every way to connect other software to Abnormal, besides calling the API yourself. The console lists the same under Settings → Developers → Integrations.
- Claude and other MCP clients (available): the MCP server above, with OAuth sign-in.
- Your own app, for your mailbox users (available): mail apps with OAuth, see above.
- Feishu / Lark and DingTalk (available): webhook events, or your console notifications, posted to a group bot.
- Slack and Telegram (available): your console notifications in a channel or chat.
- TypeScript SDK, Python SDK and the `abnormal` CLI (not published yet): typed calls, retries, idempotency keys and webhook verification; the CLI will install with
brew install abnormalhq/tap/abnormal. - n8n and Zapier (not published yet): a node and a trigger for n8n, and a trigger, an action and a search for Zapier.
Versions and changes
- The version is in the path:
/api/v1. Within it, changes only add things: new endpoints, new optional fields, new response fields, new enum values and new event types. Your code should accept fields and values it does not know. - Removing or changing something in
v1is announced first, marked deprecated in the specification and signalled withDeprecationandSunsetheaders on every response: at least 6 months ahead for anything your code sends, 3 months for a field or value it only reads. - The OpenAPI 3.1 specification behind the reference below can be downloaded as JSON for code generators and API tools.
- The status page shows the management API's availability over the last 30 days, checked every minute, with outages and maintenance. Your own monitors can poll
GET https://console.abnormalhq.com/api/health, which needs no key.
Test mode
Create a key with Test key turned on to build an integration without touching your real domains and mailboxes. Its token starts with mhq_test_, and every call it makes happens in your workspace's sandbox, whose mail server is simulated: nothing is delivered and no DNS is looked up.
- Domains must end with your workspace's zone,
<slug>.test(for exampleshop.acme.test);GET /capabilitiesreports it assandbox.domainZone. Their ownership check passes without DNS. - Mailboxes, aliases, lists, forwarding, legal holds, batches and the suppression list work as in your workspace, and webhooks are delivered for real.
- Sending works without approval: every recipient is delivered at once except a local part starting with
bounce, which bounces, and mail to a routed sandbox mailbox arrives asmessage.received. - Message trace, follow-up tasks, transport security, reading received messages (an agent mailbox's included) and mail apps need real mail, so a test key gets
403 test_mode_unsupportedfor them. - A sandbox holds at most 5 domains and 100 mailboxes.
Not offered yet
- Reading a mailbox without its user's consent, unless it is routed.
- Open and click tracking, on purpose: it reads what people do with their mail, which our webmail does not do either.
- The SDK packages, the CLI and the n8n and Zapier listings are built but not published yet: this page will link each one once it is.
Mailboxes still send and receive their own mail over webmail, IMAP and SMTP. Questions about the API go to support@abnormalhq.com.
Error codes
Every error has a stable code. The table lists them all, with the statuses they come with, what they mean, what to do and whether sending the same request again can succeed. Codes may be added within v1, so handle one you do not know by its HTTP status.
- No: the same request fails the same way until you change it.
- Later: nothing happened; it may succeed after a wait. Honour
Retry-Afterwhen there is one. - After checking: the outcome is unknown or the state moved. Read the current state first, because a blind repeat could do the work twice.
Authentication, request format and limits
| Code | Status | Meaning | What to do | Retry |
|---|---|---|---|---|
| body_ | 413 | The request body is over 16 KiB (8 MiB for POST /emails and /emails/batch). | Send less per request; use several requests or a batch. | No |
| endpoint_ | 410 | The operation was retired after its announced sunset date. | Use the replacement in details.replacement; see the API changelog. | No |
| field_ | 422 | The request sent a field, query parameter or enum value retired after its sunset date. | Drop it or use details.replacement; details.field names it. | No |
| forbidden | 403 | The key lacks a scope the operation needs, or its owner's current role no longer allows it. | Create a key with the scope listed for the operation in the reference, owned by an account whose role allows it. | No |
| invalid_ | 400 | An MCP tool was called with arguments that are not a JSON object of the tool's own fields. | Pass exactly the fields in the tool's input schema. | No |
| invalid_ | 400 | An ID in the path or body is not in the format of a record ID. | Use the IDs the API returns. | No |
| invalid_ | 400 | The body is not valid JSON, or not a JSON object. | Send a JSON object. | No |
| invalid_ | 400, 422 | A query parameter is unknown, malformed or out of range. | Fix the parameter details names; the reference lists each operation's parameters and their ranges. | No |
| method_ | 405 | The path exists but not with this HTTP method. | Use one of the methods in the Allow header. | No |
| not_ | 404 | The record in the path does not exist in this workspace (or is in the recycle bin, for mailboxes). | Check the ID: paths take the record IDs the API returns, not addresses or domain names. | No |
| rate_ | 429 | The key's (or the address's) rate limit is used up for this window; nothing was done. | Wait Retry-After seconds, then send the same request again. Use batches for bulk changes. | Later |
| unauthorized | 401 | No Bearer key was sent, or the key is unknown, expired, revoked or no longer valid for its owner. | Send a working key in Authorization: Bearer. Repeated invalid keys get the sending address rate-limited. | No |
| unknown_ | 400 | The body has a field the operation does not take. | Remove it; details names it. | No |
| unsupported_ | 415 | A request body was sent without Content-Type: application/json. | Send JSON with that content type. | No |
Domains, onboarding and alias domains
| Code | Status | Meaning | What to do | Retry |
|---|---|---|---|---|
| alias_ | 409 | The alias domain changed while the request ran. | Read it again, then decide whether to repeat the request. | After checking |
| alias_ | 409 | The domain already has the most alias domains allowed. | Remove one before adding another. | No |
| already_ | 409 | The domain, alias domain or address already exists (here, or claimed by another workspace). | Use the existing record, or choose another name. | No |
| dns_ | 502 | The DNS lookup for the ownership check failed; nothing was changed. | Try again later. | Later |
| domain_ | 409 | The domain's server binding is ambiguous or changed while the request ran. | Have an admin review the domain's binding, then try again. | After checking |
| domain_ | 409 | The domain's binding changed while the request ran. | Read the domain again, then decide whether to repeat the request. | After checking |
| domain_ | 409 | The domain still has alias domains. | Remove its alias domains first. | No |
| domain_ | 409 | The domain still has local mailboxes, so its record cannot be removed. | Delete or move its mailboxes first. | No |
| domain_ | 409 | The domain to import is not on the chosen server. | Create it on that server first, or onboard it with /domain-onboardings. | No |
| domain_ | 409 | The domain's onboarding has not finished, or its state does not allow this step. | Read the onboarding's state and DNS status; never submit the remote provisioning twice. | After checking |
| domain_ | 403 | The domain cannot be imported without a verified ownership onboarding in this workspace. | Onboard it with /domain-onboardings instead. | No |
| domain_ | 409 | The domain is not bound to a valid mail server, so nothing on it can be changed remotely. | Bind the domain to a server in the admin console. | No |
| invalid_ | 400 | The domain name is missing or not a valid name. | Send a lowercase domain name such as example.com. | No |
| invalid_ | 400 | The status change does not say whether to enable or disable the domain. | Send { "enabled": true } or { "enabled": false }. | No |
| ownership_ | 409 | The ownership TXT record was not found with the expected value. | Publish the record the connection details show, wait for DNS, then verify again. | Later |
| remote_ | 422 | Deleting a domain on the mail server is not offered. | Disable the domain, then remove only the local record; the server keeps the domain and its data. | No |
| subscription_ | 404 | The mail server in subscriptionId does not exist or is not available to this workspace. | Use an ID from GET /servers. | No |
| subscription_ | 400 | Several mail servers are configured and the request does not say which one to use. | Send subscriptionId (one of GET /servers). | No |
| verification_ | 409 | The alias domain's ownership value expired before it was verified. | Remove the alias domain, add it again and publish the new value. | No |
Mailboxes and sending limits
| Code | Status | Meaning | What to do | Retry |
|---|---|---|---|---|
| invalid_ | 400 | The daily sending limit is not an integer in range. | Send an integer from 0 to 1000000. | No |
| invalid_ | 422 | The mailbox fields failed validation. | Fix the fields listed in details.fields. | No |
| invalid_ | 400 | The bulk sending-limit request does not name 1–100 distinct mailbox IDs. | Send between 1 and 100 distinct mailbox record IDs. | No |
| invalid_ | 400, 422 | The note (of a mailbox or a suppression) is too long or not text. | Send at most the documented number of characters. | No |
| invalid_ | 400 | The password is not 8–128 characters without whitespace. | Send a password that meets the rule. | No |
| invalid_ | 400 | The quota is not a whole number of MB in range. | Send an integer from 0 (no limit) to the documented maximum. | No |
| invalid_ | 400 | The status is not one of active, disabled or pending. | Send one of those values. | No |
| invalid_ | 400 | The username (the part before @) is not valid. | Use 1–64 lowercase letters, digits, ., _ or -, starting and ending with a letter or digit. | No |
| provisioning_ | 409 | The mailbox write was sent to the server but its result is not confirmed; a pending record was kept. | Look the mailbox up with details.mailboxId and verify it; do not create it again. | After checking |
| sending_ | 422 | A sending limit cannot be set for this mailbox (not created on the server yet, or not supported). | Create the mailbox first, or leave the limit to the server's default. | No |
| sending_ | 409 | The sending-limit write may or may not have been applied. | Read the limit back (GET); repeat only the same target value, never a different one, if it is not set. | After checking |
Aliases, distribution lists, forwarding and catch-all
| Code | Status | Meaning | What to do | Retry |
|---|---|---|---|---|
| address_ | 409 | The address is already used by a mailbox, service mailbox, alias, list, forwarder or a mailbox in the recycle bin. | Choose another address. | No |
| external_ | 422 | The list has members outside the workspace's domains, which this workspace does not allow. | Remove the outside addresses, or ask an admin to allow external members. | No |
| forwarding_ | 409 | The rule forwards to itself or would close a loop with existing forwarding. | Choose a different destination. | No |
| invalid_ | 400 | The alias fields are invalid. | Fix the username, target or description. | No |
| invalid_ | 409 | The alias target is not an existing managed mailbox of the same domain. | Point the alias at a mailbox of its own domain. | No |
| invalid_ | 400 | The catch-all setting is invalid. | Send a mailbox address of the domain, or null to reject mail to unknown addresses. | No |
| invalid_ | 409 | The catch-all destination is not an existing managed mailbox of this domain. | Use one of the domain's own mailboxes. | No |
| invalid_ | 400 | The forwarding rule's source or destination is invalid. | Send one source address of the domain and one destination address. | No |
| invalid_ | 400 | The distribution list fields are invalid. | Fix the username, description or members. | No |
| list_ | 409 | The domain already has the most distribution lists allowed. | Delete a list before creating another. | No |
| readonly_ | 409 | This alias is a special one managed on the mail server (or a user's masked address) and cannot be changed here. | Leave it as it is. | No |
| readonly_ | 409 | This rule is not managed here (a list, alias, catch-all or custom Sieve rule). | Change it where it is managed. | No |
| readonly_ | 409 | This list is managed on the mail server and cannot be changed here. | Leave it as it is. | No |
| unsupported | 422 | The domain's mail server does not support this kind of address management. | Use a Stalwart 1.0 or later server for the domain. | No |
Batches
| Code | Status | Meaning | What to do | Retry |
|---|---|---|---|---|
| batch_ | 424 | An item of a batch that was not run because an earlier item's outcome is unconfirmed (item error.code). | Verify the earlier item, then send this one again in a new batch. | After checking |
| idempotency_ | 409 | This idempotency key was already used, with this key, for a different batch (or follow-up task, or messages sent through /emails). | Use a new idempotency key for different content. | No |
| idempotency_ | 409 | A batch with this idempotency key is still running. | Read its state with GET /batch/{requestId}; do not resend under another key. | After checking |
| idempotency_ | 409 | A batch with this idempotency key stopped with an unconfirmed write. | Verify the affected records, then send only what is confirmed missing, in a new batch with a new key. | After checking |
| invalid_ | 400 | The batch body is invalid: not 1–25 operations, duplicate item IDs, an unknown operation or bad input; for POST /emails/batch, emails is not a list of 1 to 50 messages. | Fix the batch; nothing in it was run or sent. | No |
| invalid_ | 400 | The Idempotency-Key header is missing or not 8–128 letters, digits, - or _. | Send a valid key, unique per batch. | No |
| request_ | 422 | The batch reached its request budget on the mail server and the remaining operations were stopped. | Check the results, then send the rest in smaller batches. | After checking |
Webhooks
| Code | Status | Meaning | What to do | Retry |
|---|---|---|---|---|
| delivery_ | 409 | The delivery is still waiting for its own retry. | Only a delivered or dead-lettered delivery can be sent again; wait for it to settle. | Later |
| invalid_ | 400 | The description is longer than 200 characters. | Shorten it. | No |
| invalid_ | 400 | enabled is not a boolean. | Send true or false. | No |
| invalid_ | 400 | The events list names an unknown event type. | Use the event types in the reference, or ["*"]. | No |
| invalid_ | 400 | A webhook update changes nothing. | Send at least one of url, description, events and enabled. | No |
| invalid_ | 400 | format is not standard, feishu or dingtalk, botSecret came without a bot format or is not one, or a PATCH tried to change either (they are set when the webhook is made). | Send one of the formats, and a bot's signing secret only with a bot format; to change the format, make a new webhook. | No |
| invalid_ | 400 | The address is not https on the default port with a public host name, or, for a bot format, not that bot platform's webhook address. | Use a public https address without credentials, IP literal or internal name. | No |
| webhook_ | 409 | The workspace already has the most webhooks allowed (10). | Delete one before creating another. | No |
Legal holds and suppressions
| Code | Status | Meaning | What to do | Retry |
|---|---|---|---|---|
| hold_ | 409 | The legal hold was already released or has ended. | Nothing to release; place a new hold if needed. | No |
| invalid_ | 422 | The suppression list address is not a valid email address. | Send one valid address. | No |
| invalid_ | 400 | The hold's end date is not a future ISO 8601 time. | Send a future date, or none. | No |
| invalid_ | 400 | The required reason is missing or too long. | Send a reason. | No |
Message trace
| Code | Status | Meaning | What to do | Retry |
|---|---|---|---|---|
| missing_ | 400 | A trace search names none of sender, recipient, Message-ID or queue ID. | Give at least one of them. | No |
| trace_ | 409 | No mail server whose logs can be read is configured. | Ask an admin to connect a mail server. | No |
Follow-up tasks
| Code | Status | Meaning | What to do | Retry |
|---|---|---|---|---|
| already_ | 409 | This email awaiting approval was already approved or rejected. | Read the task's approvals again. | No |
| auto_ | 409 | An admin switched automatic sending off, so every email of a task needs approval. | Create the task with approval required, or ask an admin. | No |
| invalid_ | 400, 422 | The follow-up task's fields are invalid. | Fix what the message describes. | No |
| invalid_ | 409 | The task is not in a state that allows this (only a running task can be paused, a paused one resumed). | Read the task's state first. | After checking |
| mailbox_ | 409 | The task's mailbox cannot be used (disabled, no stored password, or its domain has no mail server). | Fix the mailbox, or use another one. | No |
| task_ | 409 | The task has already ended. | Create a new task if more follow-up is needed. | No |
Sending, inbound routing, agent mailboxes and mail apps
| Code | Status | Meaning | What to do | Retry |
|---|---|---|---|---|
| attachments_ | 413 | The attachments are over 5 MiB in all, decoded. | Send smaller files, or a link to them. | No |
| billing_ | 402 | The workspace's plan bills sending through the API, and the workspace has no subscription that may send (none, canceled, unpaid). | An admin subscribes, or fixes the payment, under Settings → Developers → Billing. details.status is the subscription's Stripe status. | No |
| domain_ | 409 | The domain does not send through the API, so it has no sending account to make SMTP relay credentials for. | Turn sending on for the domain (PUT /domains/{id}/sending) first. | No |
| email_ | 403 | Sending through the API is not turned on for this workspace: a platform admin has not approved it. | Ask the platform's operator to approve it (with a daily limit). Test keys can send to the sandbox meanwhile. | No |
| email_ | 403 | Live sending is paused for the workspace: in the last 7 days (or since it was last resumed) too many recipients bounced or complained. details.reason says which, details.pausedAt since when. | Find out why (a stale list, a missing unsubscribe, a stolen key) and fix it, then ask the platform's operator to resume sending. Test keys still send to the sandbox. | No |
| email_ | 429 | The workspace's daily allowance of messages is used up (a batch counts all of its messages). | Send again after details.resetAt (midnight UTC), or ask for a higher limit. Nothing of the request was sent. | Later |
| from_ | 422 | The From address is not on a domain of this workspace that sends through the API. | Turn sending on for the domain (PUT /domains/{id}/sending), or send from a domain that has it. details.index names the message in a batch. | No |
| invalid_ | 400 | An agent mailbox's name is not a single line of at most 200 characters without quotes or angle brackets. | Fix it. | No |
| invalid_ | 400 | An attachment is malformed: no filename, content that is not base64, a bad content type, or more than 10 attachments. | Fix the attachments. | No |
| invalid_ | 400 | The message has neither text nor html, or one of them is too long. | Send text, html or both, within the documented sizes. | No |
| invalid_ | 400 | from is not an address or Name <address>. | Fix it. | No |
| invalid_ | 400 | headers holds a header that is not allowed (only X- headers, List-Unsubscribe and List-Unsubscribe-Post), repeats one, or has a multi-line value. | Fix the headers. | No |
| invalid_ | 400 | A mail app needs a name, 1 to 5 redirect URIs (https on a public host, or http on the loopback) and scopes from mail.read, mail.send and mail.modify. | Fix the app. | No |
| invalid_ | 400 | A change to an agent mailbox's message needs read and/or flagged, each true or false, and nothing else. | Send { "read": true }, { "flagged": false } or both. | No |
| invalid_ | 400 | to, cc, bcc or replyTo holds something that is not an address, or the message has no recipient or more than 50. | Fix the lists; send to more people in several messages or a batch. | No |
| invalid_ | 400 | An SMTP relay credential needs a name: one line of at most 100 characters. | Name what the credential is for (the application that will use it). | No |
| invalid_ | 400 | subject is missing, empty, over 500 characters or not a single line. | Fix it. | No |
| invalid_ | 400 | tags is not an object of up to 10 names with string values. | Fix the tags. | No |
| key_ | 403 | A key restricted to one agent mailbox called something other than that mailbox's own paths. | Use the key only for /agent-mailboxes/{its id} and the paths under it (not /keys, not DELETE); use another key for the rest. | No |
| mail_ | 409 | The workspace already has 20 mail apps that are not revoked. | Revoke one first (DELETE /mail-apps/{id}). | No |
| mailbox_ | 409 | The mailbox cannot be read for inbound routing: it is not active, or has no stored password. | Use an active mailbox with a stored password (created through the console or the API). | No |
| nothing_ | 422 | The message has no Reply-To or From address other than the agent's own. | Send a new message with POST /agent-mailboxes/{id}/send instead. | No |
| smtp_ | 409 | The domain already has 10 SMTP relay credentials in use. | Revoke one you no longer need (DELETE /domains/{id}/smtp-credentials/{credentialId}). | No |
Test mode
| Code | Status | Meaning | What to do | Retry |
|---|---|---|---|---|
| sandbox_ | 422 | A test key asked for a domain outside its sandbox's zone (<workspace slug>.test). | Use a name under the zone GET /capabilities reports in sandbox.domainZone. | No |
| test_ | 403 | A test key called an endpoint that needs real mail (message trace, follow-up tasks, transport security). | Use a live key for it. GET /capabilities lists these paths under sandbox.unsupportedPaths. | No |
Mail server and service availability
| Code | Status | Meaning | What to do | Retry |
|---|---|---|---|---|
| service_ | 503 | The service or its mail server configuration is unavailable. | Try again later; read the current state before repeating a write. | After checking |
| upstream_ | 502 | The mail server refused or failed the request. | Read the current state before repeating a write; try reads again later. | After checking |
API reference
Every operation of v1, grouped by area, with the scope it needs. Paths are relative to the API base; the request and response schemas are in the specification.
Capabilities
Boundaries of the current API.
| Operation | What it does | Scope |
|---|---|---|
GET/ | Get API capabilities | domains:read |
Domain onboarding
Provision a domain after verifying ownership, or look up / cancel pending requests.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List available mail servers | domains:read |
GET/ | List domain onboarding requests | domains:read |
POST/ | Request verification and provisioning of a domain | domains:write |
GET/ | Get a domain onboarding request | domains:read |
DELETE/ | Cancel an unsubmitted domain request | domains:write |
POST/ | Verify TXT and provision the domain | domains:write |
POST/ | Regenerate the ownership verification record | domains:write |
Domains
Import existing Stalwart domains and manage local records.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List domains | domains:read |
POST/ | Import an existing domain | domains:write |
GET/ | Get a domain | domains:read |
DELETE/ | Remove the local domain record | domains:write |
GET/ | Get domain connection and DNS status | domains:read |
PUT/ | Enable or disable a domain | domains:write |
GET/ | Get a domain's catch-all | domains:read |
PUT/ | Set or clear a domain's catch-all | domains:write |
GET/ | List a domain's alias domains | domains:read |
POST/ | Request an alias domain | domains:write |
GET/ | Get an alias domain and its DNS status | domains:read |
DELETE/ | Remove an alias domain | domains:write |
POST/ | Verify ownership and add the alias domain | domains:write |
POST/ | Verify and enable domain transport security | domains:write |
Mailboxes
Manage mailboxes; passwords and redemption-code links are never returned.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List mailboxes | mailboxes:read |
POST/ | Create a mailbox | mailboxes:write |
GET/ | Get a mailbox | mailboxes:read |
DELETE/ | Delete a mailbox | mailboxes:write |
GET/ | List deleted mailboxes | mailboxes:read |
POST/ | Restore a deleted mailbox | mailboxes:write |
PUT/ | Change mailbox password | mailboxes:write |
PUT/ | Change mailbox status | mailboxes:write |
PUT/ | Change mailbox quota | mailboxes:write |
POST/ | Set daily sending limits for multiple mailboxes | mailboxes:write |
GET/ | Get a mailbox's daily sending limit | mailboxes:read |
PUT/ | Set a mailbox's daily sending limit | mailboxes:write |
PUT/ | Change mailbox note | mailboxes:write |
Forwarders
Forwarding rules between single regular email addresses.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List forwarding rules | forwarders:read |
POST/ | Create a forwarding rule | forwarders:write |
DELETE/ | Delete a forwarding rule | forwarders:write |
Aliases
Regular aliases delivering to managed mailboxes on the same domain.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List regular aliases | aliases:read |
POST/ | Create a regular alias | aliases:write |
GET/ | Get a regular alias | aliases:read |
PATCH/ | Update a regular alias | aliases:write |
DELETE/ | Delete a regular alias | aliases:write |
Lists
Distribution lists: an address of a domain that delivers to all of its members.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List distribution lists | lists:read |
POST/ | Create a distribution list | lists:write |
GET/ | Get a distribution list | lists:read |
PATCH/ | Update a distribution list | lists:write |
DELETE/ | Delete a distribution list | lists:write |
Legal holds
Legal holds: a held mailbox cannot be deleted permanently or purged. A hold preserves the mailbox, not individual messages.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List legal holds | holds:read |
POST/ | Place a legal hold | holds:write |
GET/ | Get a legal hold | holds:read |
POST/ | Release a legal hold | holds:write |
Reports
Usage reports: inactive mailboxes, storage per domain, top senders. Computed from this app's own records.
| Operation | What it does | Scope |
|---|---|---|
GET/ | Inactive mailboxes | mailboxes:read |
GET/ | Storage per domain | mailboxes:read |
GET/ | Top senders by daily sending-limit usage | mailboxes:read |
Batch
Manage domains, mailboxes and aliases in ordered batches with persisted idempotent results.
| Operation | What it does | Scope |
|---|---|---|
POST/ | Batch-manage domains, mailboxes and aliases | Per operationmailboxes:write, domains:write, aliases:write |
GET/ | Get batch execution status | Only the key that created the batch |
Agent tasks
An agent follows up email as a given mailbox: it starts from a prompt, sends fully autonomously by default, and continues when replies arrive.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List follow-up tasks | agent_tasks:read |
POST/ | Create a follow-up task | agent_tasks:write |
POST/ | Create a follow-up task for each of several mailboxes | agent_tasks:write |
GET/ | Get a follow-up task | agent_tasks:read |
PATCH/ | Change a follow-up task's limits | agent_tasks:write |
GET/ | Fetch the task timeline | agent_tasks:read |
GET/ | List the conversations a task follows | agent_tasks:read |
POST/ | Hand a conversation to a task | agent_tasks:write |
POST/ | Message the agent | agent_tasks:write |
POST/ | Pause a follow-up task | agent_tasks:write |
POST/ | Resume a follow-up task | agent_tasks:write |
POST/ | Cancel a follow-up task | agent_tasks:write |
GET/ | List emails awaiting approval | agent_tasks:read |
POST/ | Approve or reject an email | agent_tasks:write |
Webhooks
Signed event webhooks: mailbox, domain, order, bulk job and security events POSTed to your https address, with retries and a delivery log.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List webhooks | webhooks:read |
POST/ | Create a webhook | webhooks:write |
GET/ | Get a webhook | webhooks:read |
PATCH/ | Update a webhook | webhooks:write |
DELETE/ | Delete a webhook | webhooks:write |
GET/ | List a webhook's recent delivery attempts | webhooks:read |
POST/ | Send a delivery's event again | webhooks:write |
POST/ | Send a test event | webhooks:write |
Trace
Message trace: the delivery timeline of messages sent or received by the workspace's domains, read from the mail server's logs and queue. Every call is recorded in the activity log.
| Operation | What it does | Scope |
|---|---|---|
GET/ | Trace messages | trace:read |
Emails
Send mail from the workspace's domains through their own mail server, and follow each message's delivery. Off until a platform admin approves the workspace; test keys send to the sandbox.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List sent messages | emails:read |
POST/ | Send a message | emails:write |
POST/ | Send several messages | emails:write |
GET/ | Get a message's delivery status | emails:read |
GET/ | Get the workspace's sending state | emails:read |
GET/ | Get a domain's sending state | emails:read |
PUT/ | Turn sending on for a domain | emails:write |
DELETE/ | Turn sending off for a domain | emails:write |
GET/ | List a domain's SMTP relay credentials | emails:read |
POST/ | Make an SMTP relay credential | emails:write |
DELETE/ | Revoke an SMTP relay credential | emails:write |
Inbound
Inbound routing: mail that chosen mailboxes receive is POSTed to webhooks as message.received, and can be read through the API.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List inbound routes | inbound:read |
GET/ | Get a mailbox's inbound route | inbound:read |
PUT/ | Turn inbound routing on for a mailbox | inbound:write |
DELETE/ | Turn inbound routing off for a mailbox | inbound:write |
GET/ | Read a received message | inbound:read |
GET/ | Download an attachment of a received message | inbound:read |
Agent mailboxes
A mailbox an AI agent reads and sends from, made in one call, and a key restricted to just that mailbox for the agent itself.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List agent mailboxes | agent_mailboxes:read |
POST/ | Create an agent mailbox | agent_mailboxes:write |
GET/ | Get an agent mailbox | agent_mailboxes:read |
DELETE/ | Delete an agent mailbox | agent_mailboxes:write |
GET/ | List an agent's inbox | agent_mailboxes:read |
GET/ | Read a message an agent received | agent_mailboxes:read |
PATCH/ | Mark a message an agent received read or flagged | agent_mailboxes:write |
DELETE/ | Delete a message an agent received | agent_mailboxes:write |
GET/ | Download an attachment of a received message | agent_mailboxes:read |
POST/ | Reply to a message as the agent | agent_mailboxes:write |
POST/ | Send a message as the agent | agent_mailboxes:write |
POST/ | Create a key for one agent mailbox | agent_mailboxes:write |
Mail apps
Third-party apps a workspace registers so its mailbox users can authorize them, with OAuth on WebMail, to read their mail or send as them through WebMail's Mail API.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List mail apps | mail_apps:read |
POST/ | Register a mail app | mail_apps:write |
GET/ | Get a mail app | mail_apps:read |
DELETE/ | Revoke a mail app | mail_apps:write |
Suppressions
The workspace's suppression list: recipients that hard-bounced repeatedly or were added by hand. Advisory: nothing is refused at submission; check an address before sending to it.
| Operation | What it does | Scope |
|---|---|---|
GET/ | List suppressed recipients | suppressions:read |
POST/ | Suppress a recipient | suppressions:write |
GET/ | Check whether a recipient is suppressed | suppressions:read |
DELETE/ | Remove a recipient from the suppression list | suppressions:write |