# Build on Abnormal

The management API and MCP server of an Abnormal workspace: manage domains, mailboxes, aliases, lists and webhooks from your own code or from an AI agent, with scoped keys and signed events.

Last reviewed: 2026-11-12 · API version: 1.8.0

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

1. **Get a workspace.** The API belongs to a workspace in the admin console. [Apply for a workspace](https://console.abnormalhq.com/signup); 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.
2. **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.
3. **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.

```sh
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 add `pagination`. A create answers `201`.
- `{id}` in a path is the record ID the API returned, not an address or a domain name.
- `POST /batch` runs 1 to 25 operations in order and needs an `Idempotency-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:

```json
{
  "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 /capabilities` reports the limit in force as `limits.requestsPerMinute`.
- Every answer carries `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy`.
- Over the limit the answer is `429` with `Retry-After` in 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:

```js
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 `2xx` within 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}/test` sends 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 as `botSecret`): 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}/sending` creates 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 /emails` sends one message (`POST /emails/batch` up to 50); an `Idempotency-Key` makes 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 as `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced` and `email.failed` events.
- `PUT /mailboxes/{id}/inbound` turns on inbound routing: each message the mailbox receives is POSTed as `message.received`, with its plain text; read the whole message and its attachments with `GET /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 gets `complainedAt` and an `email.complained` event, 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_paused` and an `email.sending_paused` event) 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 no `GET /emails` record 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-mailboxes` with a `domain` and a `name` makes one; `GET /agent-mailboxes/{id}/messages` reads its inbox, `POST …/send` sends a new message and `POST …/messages/{emailId}/reply` replies in the thread.
- `POST /agent-mailboxes/{id}/keys` returns 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.received` events, so it need not poll. `PATCH` and `DELETE …/messages/{emailId}` mark a message read or delete it, and `GET …/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.send` and `mail.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/token` for 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/messages` and `/messages/{id}` read and search the mailbox (and `…/attachments/{index}` its attachments), `POST /api/mail/v1/send` sends as it, with attachments and a copy in Sent, and with `mail.modify` the 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:

```sh
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 `v1` is announced first, marked deprecated in the specification and signalled with `Deprecation` and `Sunset` headers 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](https://developers.abnormalhq.com/openapi.json) for code generators and API tools.
- The [status page](https://console.abnormalhq.com/status) 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 example `shop.acme.test`); `GET /capabilities` reports it as `sandbox.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 as `message.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_unsupported` for 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](mailto: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-After` when 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

- `body_too_large` (413): The request body is over 16 KiB (8 MiB for POST /emails and /emails/batch). What to do: Send less per request; use several requests or a batch. Retry: No.
- `endpoint_retired` (410, Reserved): The operation was retired after its announced sunset date. What to do: Use the replacement in `details.replacement`; see the API changelog. Retry: No.
- `field_retired` (422, Reserved): The request sent a field, query parameter or enum value retired after its sunset date. What to do: Drop it or use `details.replacement`; `details.field` names it. Retry: No.
- `forbidden` (403): The key lacks a scope the operation needs, or its owner's current role no longer allows it. What to do: Create a key with the scope listed for the operation in the reference, owned by an account whose role allows it. Retry: No.
- `invalid_arguments` (400): An MCP tool was called with arguments that are not a JSON object of the tool's own fields. What to do: Pass exactly the fields in the tool's input schema. Retry: No.
- `invalid_id` (400): An ID in the path or body is not in the format of a record ID. What to do: Use the IDs the API returns. Retry: No.
- `invalid_json` (400): The body is not valid JSON, or not a JSON object. What to do: Send a JSON object. Retry: No.
- `invalid_query` (400, 422): A query parameter is unknown, malformed or out of range. What to do: Fix the parameter `details` names; the reference lists each operation's parameters and their ranges. Retry: No.
- `method_not_allowed` (405): The path exists but not with this HTTP method. What to do: Use one of the methods in the `Allow` header. Retry: No.
- `not_found` (404): The record in the path does not exist in this workspace (or is in the recycle bin, for mailboxes). What to do: Check the ID: paths take the record IDs the API returns, not addresses or domain names. Retry: No.
- `rate_limited` (429): The key's (or the address's) rate limit is used up for this window; nothing was done. What to do: Wait `Retry-After` seconds, then send the same request again. Use batches for bulk changes. Retry: Later.
- `unauthorized` (401): No Bearer key was sent, or the key is unknown, expired, revoked or no longer valid for its owner. What to do: Send a working key in `Authorization: Bearer`. Repeated invalid keys get the sending address rate-limited. Retry: No.
- `unknown_field` (400): The body has a field the operation does not take. What to do: Remove it; `details` names it. Retry: No.
- `unsupported_media_type` (415): A request body was sent without `Content-Type: application/json`. What to do: Send JSON with that content type. Retry: No.

### Domains, onboarding and alias domains

- `alias_changed` (409): The alias domain changed while the request ran. What to do: Read it again, then decide whether to repeat the request. Retry: After checking.
- `alias_limit` (409): The domain already has the most alias domains allowed. What to do: Remove one before adding another. Retry: No.
- `already_exists` (409): The domain, alias domain or address already exists (here, or claimed by another workspace). What to do: Use the existing record, or choose another name. Retry: No.
- `dns_lookup_failed` (502): The DNS lookup for the ownership check failed; nothing was changed. What to do: Try again later. Retry: Later.
- `domain_binding_conflict` (409): The domain's server binding is ambiguous or changed while the request ran. What to do: Have an admin review the domain's binding, then try again. Retry: After checking.
- `domain_changed` (409): The domain's binding changed while the request ran. What to do: Read the domain again, then decide whether to repeat the request. Retry: After checking.
- `domain_has_aliases` (409): The domain still has alias domains. What to do: Remove its alias domains first. Retry: No.
- `domain_not_empty` (409): The domain still has local mailboxes, so its record cannot be removed. What to do: Delete or move its mailboxes first. Retry: No.
- `domain_not_provisioned` (409): The domain to import is not on the chosen server. What to do: Create it on that server first, or onboard it with `/domain-onboardings`. Retry: No.
- `domain_onboarding_conflict` (409): The domain's onboarding has not finished, or its state does not allow this step. What to do: Read the onboarding's state and DNS status; never submit the remote provisioning twice. Retry: After checking.
- `domain_ownership_required` (403): The domain cannot be imported without a verified ownership onboarding in this workspace. What to do: Onboard it with `/domain-onboardings` instead. Retry: No.
- `domain_unbound` (409): The domain is not bound to a valid mail server, so nothing on it can be changed remotely. What to do: Bind the domain to a server in the admin console. Retry: No.
- `invalid_domain` (400): The domain name is missing or not a valid name. What to do: Send a lowercase domain name such as example.com. Retry: No.
- `invalid_domain_update` (400): The status change does not say whether to enable or disable the domain. What to do: Send `{ "enabled": true }` or `{ "enabled": false }`. Retry: No.
- `ownership_unverified` (409): The ownership TXT record was not found with the expected value. What to do: Publish the record the connection details show, wait for DNS, then verify again. Retry: Later.
- `remote_domain_delete_unavailable` (422): Deleting a domain on the mail server is not offered. What to do: Disable the domain, then remove only the local record; the server keeps the domain and its data. Retry: No.
- `subscription_not_found` (404): The mail server in `subscriptionId` does not exist or is not available to this workspace. What to do: Use an ID from `GET /servers`. Retry: No.
- `subscription_required` (400): Several mail servers are configured and the request does not say which one to use. What to do: Send `subscriptionId` (one of `GET /servers`). Retry: No.
- `verification_expired` (409): The alias domain's ownership value expired before it was verified. What to do: Remove the alias domain, add it again and publish the new value. Retry: No.

### Mailboxes and sending limits

- `invalid_daily_limit` (400): The daily sending limit is not an integer in range. What to do: Send an integer from 0 to 1000000. Retry: No.
- `invalid_mailbox` (422): The mailbox fields failed validation. What to do: Fix the fields listed in `details.fields`. Retry: No.
- `invalid_mailbox_ids` (400): The bulk sending-limit request does not name 1–100 distinct mailbox IDs. What to do: Send between 1 and 100 distinct mailbox record IDs. Retry: No.
- `invalid_note` (400, 422): The note (of a mailbox or a suppression) is too long or not text. What to do: Send at most the documented number of characters. Retry: No.
- `invalid_password` (400): The password is not 8–128 characters without whitespace. What to do: Send a password that meets the rule. Retry: No.
- `invalid_quota` (400): The quota is not a whole number of MB in range. What to do: Send an integer from 0 (no limit) to the documented maximum. Retry: No.
- `invalid_status` (400): The status is not one of `active`, `disabled` or `pending`. What to do: Send one of those values. Retry: No.
- `invalid_username` (400): The username (the part before @) is not valid. What to do: Use 1–64 lowercase letters, digits, `.`, `_` or `-`, starting and ending with a letter or digit. Retry: No.
- `provisioning_uncertain` (409): The mailbox write was sent to the server but its result is not confirmed; a pending record was kept. What to do: Look the mailbox up with `details.mailboxId` and verify it; do not create it again. Retry: After checking.
- `sending_limit_unavailable` (422): A sending limit cannot be set for this mailbox (not created on the server yet, or not supported). What to do: Create the mailbox first, or leave the limit to the server's default. Retry: No.
- `sending_limit_uncertain` (409): The sending-limit write may or may not have been applied. What to do: Read the limit back (GET); repeat only the same target value, never a different one, if it is not set. Retry: After checking.

### Aliases, distribution lists, forwarding and catch-all

- `address_taken` (409): The address is already used by a mailbox, service mailbox, alias, list, forwarder or a mailbox in the recycle bin. What to do: Choose another address. Retry: No.
- `external_member` (422): The list has members outside the workspace's domains, which this workspace does not allow. What to do: Remove the outside addresses, or ask an admin to allow external members. Retry: No.
- `forwarding_loop` (409): The rule forwards to itself or would close a loop with existing forwarding. What to do: Choose a different destination. Retry: No.
- `invalid_alias` (400): The alias fields are invalid. What to do: Fix the username, target or description. Retry: No.
- `invalid_alias_target` (409): The alias target is not an existing managed mailbox of the same domain. What to do: Point the alias at a mailbox of its own domain. Retry: No.
- `invalid_catch_all` (400): The catch-all setting is invalid. What to do: Send a mailbox address of the domain, or null to reject mail to unknown addresses. Retry: No.
- `invalid_catch_all_destination` (409): The catch-all destination is not an existing managed mailbox of this domain. What to do: Use one of the domain's own mailboxes. Retry: No.
- `invalid_forwarder` (400): The forwarding rule's source or destination is invalid. What to do: Send one source address of the domain and one destination address. Retry: No.
- `invalid_list` (400): The distribution list fields are invalid. What to do: Fix the username, description or members. Retry: No.
- `list_limit` (409): The domain already has the most distribution lists allowed. What to do: Delete a list before creating another. Retry: No.
- `readonly_alias` (409): This alias is a special one managed on the mail server (or a user's masked address) and cannot be changed here. What to do: Leave it as it is. Retry: No.
- `readonly_forwarder` (409): This rule is not managed here (a list, alias, catch-all or custom Sieve rule). What to do: Change it where it is managed. Retry: No.
- `readonly_list` (409): This list is managed on the mail server and cannot be changed here. What to do: Leave it as it is. Retry: No.
- `unsupported` (422): The domain's mail server does not support this kind of address management. What to do: Use a Stalwart 1.0 or later server for the domain. Retry: No.

### Batches

- `batch_stopped` (424): An item of a batch that was not run because an earlier item's outcome is unconfirmed (item `error.code`). What to do: Verify the earlier item, then send this one again in a new batch. Retry: After checking.
- `idempotency_conflict` (409): This idempotency key was already used, with this key, for a different batch (or follow-up task, or messages sent through /emails). What to do: Use a new idempotency key for different content. Retry: No.
- `idempotency_running` (409): A batch with this idempotency key is still running. What to do: Read its state with `GET /batch/{requestId}`; do not resend under another key. Retry: After checking.
- `idempotency_uncertain` (409): A batch with this idempotency key stopped with an unconfirmed write. What to do: Verify the affected records, then send only what is confirmed missing, in a new batch with a new key. Retry: After checking.
- `invalid_batch` (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. What to do: Fix the batch; nothing in it was run or sent. Retry: No.
- `invalid_idempotency_key` (400): The `Idempotency-Key` header is missing or not 8–128 letters, digits, `-` or `_`. What to do: Send a valid key, unique per batch. Retry: No.
- `request_budget_exceeded` (422): The batch reached its request budget on the mail server and the remaining operations were stopped. What to do: Check the results, then send the rest in smaller batches. Retry: After checking.

### Webhooks

- `delivery_in_progress` (409): The delivery is still waiting for its own retry. What to do: Only a delivered or dead-lettered delivery can be sent again; wait for it to settle. Retry: Later.
- `invalid_description` (400): The description is longer than 200 characters. What to do: Shorten it. Retry: No.
- `invalid_enabled` (400): `enabled` is not a boolean. What to do: Send true or false. Retry: No.
- `invalid_events` (400): The events list names an unknown event type. What to do: Use the event types in the reference, or `["*"]`. Retry: No.
- `invalid_webhook` (400): A webhook update changes nothing. What to do: Send at least one of `url`, `description`, `events` and `enabled`. Retry: No.
- `invalid_webhook_format` (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). What to do: Send one of the formats, and a bot's signing secret only with a bot format; to change the format, make a new webhook. Retry: No.
- `invalid_webhook_url` (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. What to do: Use a public https address without credentials, IP literal or internal name. Retry: No.
- `webhook_limit` (409): The workspace already has the most webhooks allowed (10). What to do: Delete one before creating another. Retry: No.

### Legal holds and suppressions

- `hold_not_active` (409): The legal hold was already released or has ended. What to do: Nothing to release; place a new hold if needed. Retry: No.
- `invalid_address` (422): The suppression list address is not a valid email address. What to do: Send one valid address. Retry: No.
- `invalid_ends_at` (400): The hold's end date is not a future ISO 8601 time. What to do: Send a future date, or none. Retry: No.
- `invalid_reason` (400): The required reason is missing or too long. What to do: Send a reason. Retry: No.

### Message trace

- `missing_criteria` (400): A trace search names none of sender, recipient, Message-ID or queue ID. What to do: Give at least one of them. Retry: No.
- `trace_unavailable` (409): No mail server whose logs can be read is configured. What to do: Ask an admin to connect a mail server. Retry: No.

### Follow-up tasks

- `already_decided` (409): This email awaiting approval was already approved or rejected. What to do: Read the task's approvals again. Retry: No.
- `auto_send_disabled` (409): An admin switched automatic sending off, so every email of a task needs approval. What to do: Create the task with approval required, or ask an admin. Retry: No.
- `invalid_agent_task` (400, 422): The follow-up task's fields are invalid. What to do: Fix what the message describes. Retry: No.
- `invalid_state` (409): The task is not in a state that allows this (only a running task can be paused, a paused one resumed). What to do: Read the task's state first. Retry: After checking.
- `mailbox_unavailable` (409): The task's mailbox cannot be used (disabled, no stored password, or its domain has no mail server). What to do: Fix the mailbox, or use another one. Retry: No.
- `task_finished` (409): The task has already ended. What to do: Create a new task if more follow-up is needed. Retry: No.

### Sending, inbound routing, agent mailboxes and mail apps

- `attachments_too_large` (413): The attachments are over 5 MiB in all, decoded. What to do: Send smaller files, or a link to them. Retry: No.
- `billing_required` (402): The workspace's plan bills sending through the API, and the workspace has no subscription that may send (none, canceled, unpaid). What to do: An admin subscribes, or fixes the payment, under Settings → Developers → Billing. `details.status` is the subscription's Stripe status. Retry: No.
- `domain_not_sending` (409): The domain does not send through the API, so it has no sending account to make SMTP relay credentials for. What to do: Turn sending on for the domain (`PUT /domains/{id}/sending`) first. Retry: No.
- `email_api_not_approved` (403): Sending through the API is not turned on for this workspace: a platform admin has not approved it. What to do: Ask the platform's operator to approve it (with a daily limit). Test keys can send to the sandbox meanwhile. Retry: No.
- `email_api_paused` (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. What to do: 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. Retry: No.
- `email_daily_limit` (429): The workspace's daily allowance of messages is used up (a batch counts all of its messages). What to do: Send again after `details.resetAt` (midnight UTC), or ask for a higher limit. Nothing of the request was sent. Retry: Later.
- `from_domain_not_sending` (422): The From address is not on a domain of this workspace that sends through the API. What to do: 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. Retry: No.
- `invalid_agent_name` (400): An agent mailbox's `name` is not a single line of at most 200 characters without quotes or angle brackets. What to do: Fix it. Retry: No.
- `invalid_attachments` (400): An attachment is malformed: no filename, content that is not base64, a bad content type, or more than 10 attachments. What to do: Fix the attachments. Retry: No.
- `invalid_body` (400): The message has neither `text` nor `html`, or one of them is too long. What to do: Send `text`, `html` or both, within the documented sizes. Retry: No.
- `invalid_from` (400): `from` is not an address or `Name <address>`. What to do: Fix it. Retry: No.
- `invalid_headers` (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. What to do: Fix the headers. Retry: No.
- `invalid_mail_app` (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. What to do: Fix the app. Retry: No.
- `invalid_message_change` (400): A change to an agent mailbox's message needs `read` and/or `flagged`, each true or false, and nothing else. What to do: Send `{ "read": true }`, `{ "flagged": false }` or both. Retry: No.
- `invalid_recipients` (400): `to`, `cc`, `bcc` or `replyTo` holds something that is not an address, or the message has no recipient or more than 50. What to do: Fix the lists; send to more people in several messages or a batch. Retry: No.
- `invalid_smtp_credential` (400): An SMTP relay credential needs a name: one line of at most 100 characters. What to do: Name what the credential is for (the application that will use it). Retry: No.
- `invalid_subject` (400): `subject` is missing, empty, over 500 characters or not a single line. What to do: Fix it. Retry: No.
- `invalid_tags` (400): `tags` is not an object of up to 10 names with string values. What to do: Fix the tags. Retry: No.
- `key_mailbox_restricted` (403): A key restricted to one agent mailbox called something other than that mailbox's own paths. What to do: Use the key only for `/agent-mailboxes/{its id}` and the paths under it (not `/keys`, not DELETE); use another key for the rest. Retry: No.
- `mail_app_limit` (409): The workspace already has 20 mail apps that are not revoked. What to do: Revoke one first (DELETE /mail-apps/{id}). Retry: No.
- `mailbox_unreadable` (409): The mailbox cannot be read for inbound routing: it is not active, or has no stored password. What to do: Use an active mailbox with a stored password (created through the console or the API). Retry: No.
- `nothing_to_reply_to` (422): The message has no Reply-To or From address other than the agent's own. What to do: Send a new message with POST /agent-mailboxes/{id}/send instead. Retry: No.
- `smtp_credential_limit` (409): The domain already has 10 SMTP relay credentials in use. What to do: Revoke one you no longer need (`DELETE /domains/{id}/smtp-credentials/{credentialId}`). Retry: No.

### Test mode

- `sandbox_domain_zone` (422): A test key asked for a domain outside its sandbox's zone (<workspace slug>.test). What to do: Use a name under the zone `GET /capabilities` reports in `sandbox.domainZone`. Retry: No.
- `test_mode_unsupported` (403): A test key called an endpoint that needs real mail (message trace, follow-up tasks, transport security). What to do: Use a live key for it. `GET /capabilities` lists these paths under `sandbox.unsupportedPaths`. Retry: No.

### Mail server and service availability

- `service_unavailable` (503): The service or its mail server configuration is unavailable. What to do: Try again later; read the current state before repeating a write. Retry: After checking.
- `upstream_error` (502): The mail server refused or failed the request. What to do: Read the current state before repeating a write; try reads again later. Retry: 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.

- `GET /capabilities`: Get API capabilities (`domains:read`)

### Domain onboarding

Provision a domain after verifying ownership, or look up / cancel pending requests.

- `GET /servers`: List available mail servers (`domains:read`)
- `GET /domain-onboardings`: List domain onboarding requests (`domains:read`)
- `POST /domain-onboardings`: Request verification and provisioning of a domain (`domains:write`)
- `GET /domain-onboardings/{id}`: Get a domain onboarding request (`domains:read`)
- `DELETE /domain-onboardings/{id}`: Cancel an unsubmitted domain request (`domains:write`)
- `POST /domain-onboardings/{id}/verify`: Verify TXT and provision the domain (`domains:write`)
- `POST /domain-onboardings/{id}/refresh`: Regenerate the ownership verification record (`domains:write`)

### Domains

Import existing Stalwart domains and manage local records.

- `GET /domains`: List domains (`domains:read`)
- `POST /domains`: Import an existing domain (`domains:write`)
- `GET /domains/{id}`: Get a domain (`domains:read`)
- `DELETE /domains/{id}`: Remove the local domain record (`domains:write`)
- `GET /domains/{id}/connection`: Get domain connection and DNS status (`domains:read`)
- `PUT /domains/{id}/status`: Enable or disable a domain (`domains:write`)
- `GET /domains/{id}/catch-all`: Get a domain's catch-all (`domains:read`)
- `PUT /domains/{id}/catch-all`: Set or clear a domain's catch-all (`domains:write`)
- `GET /domains/{id}/domain-aliases`: List a domain's alias domains (`domains:read`)
- `POST /domains/{id}/domain-aliases`: Request an alias domain (`domains:write`)
- `GET /domains/{id}/domain-aliases/{aliasId}`: Get an alias domain and its DNS status (`domains:read`)
- `DELETE /domains/{id}/domain-aliases/{aliasId}`: Remove an alias domain (`domains:write`)
- `POST /domains/{id}/domain-aliases/{aliasId}/verify`: Verify ownership and add the alias domain (`domains:write`)
- `POST /domains/{id}/transport-security`: Verify and enable domain transport security (`domains:write`)

### Mailboxes

Manage mailboxes; passwords and redemption-code links are never returned.

- `GET /mailboxes`: List mailboxes (`mailboxes:read`)
- `POST /mailboxes`: Create a mailbox (`mailboxes:write`)
- `GET /mailboxes/{id}`: Get a mailbox (`mailboxes:read`)
- `DELETE /mailboxes/{id}`: Delete a mailbox (`mailboxes:write`)
- `GET /mailboxes/deleted`: List deleted mailboxes (`mailboxes:read`)
- `POST /mailboxes/{id}/restore`: Restore a deleted mailbox (`mailboxes:write`)
- `PUT /mailboxes/{id}/password`: Change mailbox password (`mailboxes:write`)
- `PUT /mailboxes/{id}/status`: Change mailbox status (`mailboxes:write`)
- `PUT /mailboxes/{id}/quota`: Change mailbox quota (`mailboxes:write`)
- `POST /mailboxes/sending-limits`: Set daily sending limits for multiple mailboxes (`mailboxes:write`)
- `GET /mailboxes/{id}/sending-limit`: Get a mailbox's daily sending limit (`mailboxes:read`)
- `PUT /mailboxes/{id}/sending-limit`: Set a mailbox's daily sending limit (`mailboxes:write`)
- `PUT /mailboxes/{id}/note`: Change mailbox note (`mailboxes:write`)

### Forwarders

Forwarding rules between single regular email addresses.

- `GET /domains/{id}/forwarders`: List forwarding rules (`forwarders:read`)
- `POST /domains/{id}/forwarders`: Create a forwarding rule (`forwarders:write`)
- `DELETE /domains/{id}/forwarders/{forwarderId}`: Delete a forwarding rule (`forwarders:write`)

### Aliases

Regular aliases delivering to managed mailboxes on the same domain.

- `GET /domains/{id}/aliases`: List regular aliases (`aliases:read`)
- `POST /domains/{id}/aliases`: Create a regular alias (`aliases:write`)
- `GET /domains/{id}/aliases/{aliasId}`: Get a regular alias (`aliases:read`)
- `PATCH /domains/{id}/aliases/{aliasId}`: Update a regular alias (`aliases:write`)
- `DELETE /domains/{id}/aliases/{aliasId}`: Delete a regular alias (`aliases:write`)

### Lists

Distribution lists: an address of a domain that delivers to all of its members.

- `GET /lists`: List distribution lists (`lists:read`)
- `POST /lists`: Create a distribution list (`lists:write`)
- `GET /lists/{listId}`: Get a distribution list (`lists:read`)
- `PATCH /lists/{listId}`: Update a distribution list (`lists:write`)
- `DELETE /lists/{listId}`: 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.

- `GET /holds`: List legal holds (`holds:read`)
- `POST /holds`: Place a legal hold (`holds:write`)
- `GET /holds/{id}`: Get a legal hold (`holds:read`)
- `POST /holds/{id}/release`: Release a legal hold (`holds:write`)

### Reports

Usage reports: inactive mailboxes, storage per domain, top senders. Computed from this app's own records.

- `GET /reports/inactive-mailboxes`: Inactive mailboxes (`mailboxes:read`)
- `GET /reports/storage`: Storage per domain (`mailboxes:read`)
- `GET /reports/top-senders`: Top senders by daily sending-limit usage (`mailboxes:read`)

### Batch

Manage domains, mailboxes and aliases in ordered batches with persisted idempotent results.

- `POST /batch`: Batch-manage domains, mailboxes and aliases (Per operation: `mailboxes:write`, `domains:write`, `aliases:write`)
- `GET /batch/{requestId}`: 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.

- `GET /agent-tasks`: List follow-up tasks (`agent_tasks:read`)
- `POST /agent-tasks`: Create a follow-up task (`agent_tasks:write`)
- `POST /agent-tasks/batch`: Create a follow-up task for each of several mailboxes (`agent_tasks:write`)
- `GET /agent-tasks/{id}`: Get a follow-up task (`agent_tasks:read`)
- `PATCH /agent-tasks/{id}`: Change a follow-up task's limits (`agent_tasks:write`)
- `GET /agent-tasks/{id}/events`: Fetch the task timeline (`agent_tasks:read`)
- `GET /agent-tasks/{id}/threads`: List the conversations a task follows (`agent_tasks:read`)
- `POST /agent-tasks/{id}/threads`: Hand a conversation to a task (`agent_tasks:write`)
- `POST /agent-tasks/{id}/messages`: Message the agent (`agent_tasks:write`)
- `POST /agent-tasks/{id}/pause`: Pause a follow-up task (`agent_tasks:write`)
- `POST /agent-tasks/{id}/resume`: Resume a follow-up task (`agent_tasks:write`)
- `POST /agent-tasks/{id}/cancel`: Cancel a follow-up task (`agent_tasks:write`)
- `GET /agent-tasks/{id}/approvals`: List emails awaiting approval (`agent_tasks:read`)
- `POST /agent-tasks/{id}/approvals/{approvalId}`: 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.

- `GET /webhooks`: List webhooks (`webhooks:read`)
- `POST /webhooks`: Create a webhook (`webhooks:write`)
- `GET /webhooks/{id}`: Get a webhook (`webhooks:read`)
- `PATCH /webhooks/{id}`: Update a webhook (`webhooks:write`)
- `DELETE /webhooks/{id}`: Delete a webhook (`webhooks:write`)
- `GET /webhooks/{id}/deliveries`: List a webhook's recent delivery attempts (`webhooks:read`)
- `POST /webhooks/{id}/deliveries/{deliveryId}/redeliver`: Send a delivery's event again (`webhooks:write`)
- `POST /webhooks/{id}/test`: 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.

- `GET /trace`: 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.

- `GET /emails`: List sent messages (`emails:read`)
- `POST /emails`: Send a message (`emails:write`)
- `POST /emails/batch`: Send several messages (`emails:write`)
- `GET /emails/{id}`: Get a message's delivery status (`emails:read`)
- `GET /sending`: Get the workspace's sending state (`emails:read`)
- `GET /domains/{id}/sending`: Get a domain's sending state (`emails:read`)
- `PUT /domains/{id}/sending`: Turn sending on for a domain (`emails:write`)
- `DELETE /domains/{id}/sending`: Turn sending off for a domain (`emails:write`)
- `GET /domains/{id}/smtp-credentials`: List a domain's SMTP relay credentials (`emails:read`)
- `POST /domains/{id}/smtp-credentials`: Make an SMTP relay credential (`emails:write`)
- `DELETE /domains/{id}/smtp-credentials/{credentialId}`: 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.

- `GET /inbound-routes`: List inbound routes (`inbound:read`)
- `GET /mailboxes/{id}/inbound`: Get a mailbox's inbound route (`inbound:read`)
- `PUT /mailboxes/{id}/inbound`: Turn inbound routing on for a mailbox (`inbound:write`)
- `DELETE /mailboxes/{id}/inbound`: Turn inbound routing off for a mailbox (`inbound:write`)
- `GET /mailboxes/{id}/messages/{emailId}`: Read a received message (`inbound:read`)
- `GET /mailboxes/{id}/messages/{emailId}/attachments/{index}`: 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.

- `GET /agent-mailboxes`: List agent mailboxes (`agent_mailboxes:read`)
- `POST /agent-mailboxes`: Create an agent mailbox (`agent_mailboxes:write`)
- `GET /agent-mailboxes/{id}`: Get an agent mailbox (`agent_mailboxes:read`)
- `DELETE /agent-mailboxes/{id}`: Delete an agent mailbox (`agent_mailboxes:write`)
- `GET /agent-mailboxes/{id}/messages`: List an agent's inbox (`agent_mailboxes:read`)
- `GET /agent-mailboxes/{id}/messages/{emailId}`: Read a message an agent received (`agent_mailboxes:read`)
- `PATCH /agent-mailboxes/{id}/messages/{emailId}`: Mark a message an agent received read or flagged (`agent_mailboxes:write`)
- `DELETE /agent-mailboxes/{id}/messages/{emailId}`: Delete a message an agent received (`agent_mailboxes:write`)
- `GET /agent-mailboxes/{id}/messages/{emailId}/attachments/{index}`: Download an attachment of a received message (`agent_mailboxes:read`)
- `POST /agent-mailboxes/{id}/messages/{emailId}/reply`: Reply to a message as the agent (`agent_mailboxes:write`)
- `POST /agent-mailboxes/{id}/send`: Send a message as the agent (`agent_mailboxes:write`)
- `POST /agent-mailboxes/{id}/keys`: 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.

- `GET /mail-apps`: List mail apps (`mail_apps:read`)
- `POST /mail-apps`: Register a mail app (`mail_apps:write`)
- `GET /mail-apps/{id}`: Get a mail app (`mail_apps:read`)
- `DELETE /mail-apps/{id}`: 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.

- `GET /suppressions`: List suppressed recipients (`suppressions:read`)
- `POST /suppressions`: Suppress a recipient (`suppressions:write`)
- `GET /suppressions/{address}`: Check whether a recipient is suppressed (`suppressions:read`)
- `DELETE /suppressions/{address}`: Remove a recipient from the suppression list (`suppressions:write`)

- [Download the OpenAPI specification](https://developers.abnormalhq.com/openapi.json)
- [Service status](https://console.abnormalhq.com/status)
