Skip to main content
abnormal.Developers

Developer docs

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: · 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; 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.

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:

{
  "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:

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:

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

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

CodeStatusMeaningWhat to doRetry
body_too_large413The 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_retiredReserved410The operation was retired after its announced sunset date.Use the replacement in details.replacement; see the API changelog.No
field_retiredReserved422The 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
forbidden403The 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_arguments400An 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_id400An ID in the path or body is not in the format of a record ID.Use the IDs the API returns.No
invalid_json400The body is not valid JSON, or not a JSON object.Send a JSON object.No
invalid_query400, 422A 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_not_allowed405The path exists but not with this HTTP method.Use one of the methods in the Allow header.No
not_found404The 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_limited429The 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
unauthorized401No 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_field400The body has a field the operation does not take.Remove it; details names it.No
unsupported_media_type415A request body was sent without Content-Type: application/json.Send JSON with that content type.No

Domains, onboarding and alias domains

CodeStatusMeaningWhat to doRetry
alias_changed409The alias domain changed while the request ran.Read it again, then decide whether to repeat the request.After checking
alias_limit409The domain already has the most alias domains allowed.Remove one before adding another.No
already_exists409The domain, alias domain or address already exists (here, or claimed by another workspace).Use the existing record, or choose another name.No
dns_lookup_failed502The DNS lookup for the ownership check failed; nothing was changed.Try again later.Later
domain_binding_conflict409The 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_changed409The domain's binding changed while the request ran.Read the domain again, then decide whether to repeat the request.After checking
domain_has_aliases409The domain still has alias domains.Remove its alias domains first.No
domain_not_empty409The domain still has local mailboxes, so its record cannot be removed.Delete or move its mailboxes first.No
domain_not_provisioned409The domain to import is not on the chosen server.Create it on that server first, or onboard it with /domain-onboardings.No
domain_onboarding_conflict409The 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_ownership_required403The domain cannot be imported without a verified ownership onboarding in this workspace.Onboard it with /domain-onboardings instead.No
domain_unbound409The 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_domain400The domain name is missing or not a valid name.Send a lowercase domain name such as example.com.No
invalid_domain_update400The status change does not say whether to enable or disable the domain.Send { "enabled": true } or { "enabled": false }.No
ownership_unverified409The 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_domain_delete_unavailable422Deleting 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_not_found404The mail server in subscriptionId does not exist or is not available to this workspace.Use an ID from GET /servers.No
subscription_required400Several mail servers are configured and the request does not say which one to use.Send subscriptionId (one of GET /servers).No
verification_expired409The 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

CodeStatusMeaningWhat to doRetry
invalid_daily_limit400The daily sending limit is not an integer in range.Send an integer from 0 to 1000000.No
invalid_mailbox422The mailbox fields failed validation.Fix the fields listed in details.fields.No
invalid_mailbox_ids400The bulk sending-limit request does not name 1–100 distinct mailbox IDs.Send between 1 and 100 distinct mailbox record IDs.No
invalid_note400, 422The note (of a mailbox or a suppression) is too long or not text.Send at most the documented number of characters.No
invalid_password400The password is not 8–128 characters without whitespace.Send a password that meets the rule.No
invalid_quota400The quota is not a whole number of MB in range.Send an integer from 0 (no limit) to the documented maximum.No
invalid_status400The status is not one of active, disabled or pending.Send one of those values.No
invalid_username400The username (the part before @) is not valid.Use 1–64 lowercase letters, digits, ., _ or -, starting and ending with a letter or digit.No
provisioning_uncertain409The 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_limit_unavailable422A 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_limit_uncertain409The 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

CodeStatusMeaningWhat to doRetry
address_taken409The address is already used by a mailbox, service mailbox, alias, list, forwarder or a mailbox in the recycle bin.Choose another address.No
external_member422The 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_loop409The rule forwards to itself or would close a loop with existing forwarding.Choose a different destination.No
invalid_alias400The alias fields are invalid.Fix the username, target or description.No
invalid_alias_target409The alias target is not an existing managed mailbox of the same domain.Point the alias at a mailbox of its own domain.No
invalid_catch_all400The catch-all setting is invalid.Send a mailbox address of the domain, or null to reject mail to unknown addresses.No
invalid_catch_all_destination409The catch-all destination is not an existing managed mailbox of this domain.Use one of the domain's own mailboxes.No
invalid_forwarder400The forwarding rule's source or destination is invalid.Send one source address of the domain and one destination address.No
invalid_list400The distribution list fields are invalid.Fix the username, description or members.No
list_limit409The domain already has the most distribution lists allowed.Delete a list before creating another.No
readonly_alias409This 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_forwarder409This rule is not managed here (a list, alias, catch-all or custom Sieve rule).Change it where it is managed.No
readonly_list409This list is managed on the mail server and cannot be changed here.Leave it as it is.No
unsupported422The 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

CodeStatusMeaningWhat to doRetry
batch_stopped424An 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_conflict409This 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_running409A batch with this idempotency key is still running.Read its state with GET /batch/{requestId}; do not resend under another key.After checking
idempotency_uncertain409A 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_batch400The 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_idempotency_key400The Idempotency-Key header is missing or not 8–128 letters, digits, - or _.Send a valid key, unique per batch.No
request_budget_exceeded422The 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

CodeStatusMeaningWhat to doRetry
delivery_in_progress409The 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_description400The description is longer than 200 characters.Shorten it.No
invalid_enabled400enabled is not a boolean.Send true or false.No
invalid_events400The events list names an unknown event type.Use the event types in the reference, or ["*"].No
invalid_webhook400A webhook update changes nothing.Send at least one of url, description, events and enabled.No
invalid_webhook_format400format 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_webhook_url400The 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_limit409The workspace already has the most webhooks allowed (10).Delete one before creating another.No

Legal holds and suppressions

CodeStatusMeaningWhat to doRetry
hold_not_active409The legal hold was already released or has ended.Nothing to release; place a new hold if needed.No
invalid_address422The suppression list address is not a valid email address.Send one valid address.No
invalid_ends_at400The hold's end date is not a future ISO 8601 time.Send a future date, or none.No
invalid_reason400The required reason is missing or too long.Send a reason.No

Message trace

CodeStatusMeaningWhat to doRetry
missing_criteria400A trace search names none of sender, recipient, Message-ID or queue ID.Give at least one of them.No
trace_unavailable409No mail server whose logs can be read is configured.Ask an admin to connect a mail server.No

Follow-up tasks

CodeStatusMeaningWhat to doRetry
already_decided409This email awaiting approval was already approved or rejected.Read the task's approvals again.No
auto_send_disabled409An 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_agent_task400, 422The follow-up task's fields are invalid.Fix what the message describes.No
invalid_state409The 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_unavailable409The 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_finished409The task has already ended.Create a new task if more follow-up is needed.No

Sending, inbound routing, agent mailboxes and mail apps

CodeStatusMeaningWhat to doRetry
attachments_too_large413The attachments are over 5 MiB in all, decoded.Send smaller files, or a link to them.No
billing_required402The 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_not_sending409The 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_api_not_approved403Sending 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_api_paused403Live 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_daily_limit429The 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_domain_not_sending422The 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_agent_name400An agent mailbox's name is not a single line of at most 200 characters without quotes or angle brackets.Fix it.No
invalid_attachments400An attachment is malformed: no filename, content that is not base64, a bad content type, or more than 10 attachments.Fix the attachments.No
invalid_body400The message has neither text nor html, or one of them is too long.Send text, html or both, within the documented sizes.No
invalid_from400from is not an address or Name <address>.Fix it.No
invalid_headers400headers 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_mail_app400A 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_message_change400A 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_recipients400to, 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_smtp_credential400An 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_subject400subject is missing, empty, over 500 characters or not a single line.Fix it.No
invalid_tags400tags is not an object of up to 10 names with string values.Fix the tags.No
key_mailbox_restricted403A 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_app_limit409The workspace already has 20 mail apps that are not revoked.Revoke one first (DELETE /mail-apps/{id}).No
mailbox_unreadable409The 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_to_reply_to422The 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_credential_limit409The domain already has 10 SMTP relay credentials in use.Revoke one you no longer need (DELETE /domains/{id}/smtp-credentials/{credentialId}).No

Test mode

CodeStatusMeaningWhat to doRetry
sandbox_domain_zone422A 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_mode_unsupported403A 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

CodeStatusMeaningWhat to doRetry
service_unavailable503The service or its mail server configuration is unavailable.Try again later; read the current state before repeating a write.After checking
upstream_error502The 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.

OperationWhat it doesScope
GET/capabilities
Get API capabilitiesdomains:read

Domain onboarding

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

OperationWhat it doesScope
GET/servers
List available mail serversdomains:read
GET/domain-onboardings
List domain onboarding requestsdomains:read
POST/domain-onboardings
Request verification and provisioning of a domaindomains:write
GET/domain-onboardings/{id}
Get a domain onboarding requestdomains:read
DELETE/domain-onboardings/{id}
Cancel an unsubmitted domain requestdomains:write
POST/domain-onboardings/{id}/verify
Verify TXT and provision the domaindomains:write
POST/domain-onboardings/{id}/refresh
Regenerate the ownership verification recorddomains:write

Domains

Import existing Stalwart domains and manage local records.

OperationWhat it doesScope
GET/domains
List domainsdomains:read
POST/domains
Import an existing domaindomains:write
GET/domains/{id}
Get a domaindomains:read
DELETE/domains/{id}
Remove the local domain recorddomains:write
GET/domains/{id}/connection
Get domain connection and DNS statusdomains:read
PUT/domains/{id}/status
Enable or disable a domaindomains:write
GET/domains/{id}/catch-all
Get a domain's catch-alldomains:read
PUT/domains/{id}/catch-all
Set or clear a domain's catch-alldomains:write
GET/domains/{id}/domain-aliases
List a domain's alias domainsdomains:read
POST/domains/{id}/domain-aliases
Request an alias domaindomains:write
GET/domains/{id}/domain-aliases/{aliasId}
Get an alias domain and its DNS statusdomains:read
DELETE/domains/{id}/domain-aliases/{aliasId}
Remove an alias domaindomains:write
POST/domains/{id}/domain-aliases/{aliasId}/verify
Verify ownership and add the alias domaindomains:write
POST/domains/{id}/transport-security
Verify and enable domain transport securitydomains:write

Mailboxes

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

OperationWhat it doesScope
GET/mailboxes
List mailboxesmailboxes:read
POST/mailboxes
Create a mailboxmailboxes:write
GET/mailboxes/{id}
Get a mailboxmailboxes:read
DELETE/mailboxes/{id}
Delete a mailboxmailboxes:write
GET/mailboxes/deleted
List deleted mailboxesmailboxes:read
POST/mailboxes/{id}/restore
Restore a deleted mailboxmailboxes:write
PUT/mailboxes/{id}/password
Change mailbox passwordmailboxes:write
PUT/mailboxes/{id}/status
Change mailbox statusmailboxes:write
PUT/mailboxes/{id}/quota
Change mailbox quotamailboxes:write
POST/mailboxes/sending-limits
Set daily sending limits for multiple mailboxesmailboxes:write
GET/mailboxes/{id}/sending-limit
Get a mailbox's daily sending limitmailboxes:read
PUT/mailboxes/{id}/sending-limit
Set a mailbox's daily sending limitmailboxes:write
PUT/mailboxes/{id}/note
Change mailbox notemailboxes:write

Forwarders

Forwarding rules between single regular email addresses.

OperationWhat it doesScope
GET/domains/{id}/forwarders
List forwarding rulesforwarders:read
POST/domains/{id}/forwarders
Create a forwarding ruleforwarders:write
DELETE/domains/{id}/forwarders/{forwarderId}
Delete a forwarding ruleforwarders:write

Aliases

Regular aliases delivering to managed mailboxes on the same domain.

OperationWhat it doesScope
GET/domains/{id}/aliases
List regular aliasesaliases:read
POST/domains/{id}/aliases
Create a regular aliasaliases:write
GET/domains/{id}/aliases/{aliasId}
Get a regular aliasaliases:read
PATCH/domains/{id}/aliases/{aliasId}
Update a regular aliasaliases:write
DELETE/domains/{id}/aliases/{aliasId}
Delete a regular aliasaliases:write

Lists

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

OperationWhat it doesScope
GET/lists
List distribution listslists:read
POST/lists
Create a distribution listlists:write
GET/lists/{listId}
Get a distribution listlists:read
PATCH/lists/{listId}
Update a distribution listlists:write
DELETE/lists/{listId}
Delete a distribution listlists:write

Legal holds: a held mailbox cannot be deleted permanently or purged. A hold preserves the mailbox, not individual messages.

OperationWhat it doesScope
GET/holds
List legal holdsholds:read
POST/holds
Place a legal holdholds:write
GET/holds/{id}
Get a legal holdholds:read
POST/holds/{id}/release
Release a legal holdholds:write

Reports

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

OperationWhat it doesScope
GET/reports/inactive-mailboxes
Inactive mailboxesmailboxes:read
GET/reports/storage
Storage per domainmailboxes:read
GET/reports/top-senders
Top senders by daily sending-limit usagemailboxes:read

Batch

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

OperationWhat it doesScope
POST/batch
Batch-manage domains, mailboxes and aliasesPer operationmailboxes:write, domains:write, aliases:write
GET/batch/{requestId}
Get batch execution statusOnly 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.

OperationWhat it doesScope
GET/agent-tasks
List follow-up tasksagent_tasks:read
POST/agent-tasks
Create a follow-up taskagent_tasks:write
POST/agent-tasks/batch
Create a follow-up task for each of several mailboxesagent_tasks:write
GET/agent-tasks/{id}
Get a follow-up taskagent_tasks:read
PATCH/agent-tasks/{id}
Change a follow-up task's limitsagent_tasks:write
GET/agent-tasks/{id}/events
Fetch the task timelineagent_tasks:read
GET/agent-tasks/{id}/threads
List the conversations a task followsagent_tasks:read
POST/agent-tasks/{id}/threads
Hand a conversation to a taskagent_tasks:write
POST/agent-tasks/{id}/messages
Message the agentagent_tasks:write
POST/agent-tasks/{id}/pause
Pause a follow-up taskagent_tasks:write
POST/agent-tasks/{id}/resume
Resume a follow-up taskagent_tasks:write
POST/agent-tasks/{id}/cancel
Cancel a follow-up taskagent_tasks:write
GET/agent-tasks/{id}/approvals
List emails awaiting approvalagent_tasks:read
POST/agent-tasks/{id}/approvals/{approvalId}
Approve or reject an emailagent_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.

OperationWhat it doesScope
GET/webhooks
List webhookswebhooks:read
POST/webhooks
Create a webhookwebhooks:write
GET/webhooks/{id}
Get a webhookwebhooks:read
PATCH/webhooks/{id}
Update a webhookwebhooks:write
DELETE/webhooks/{id}
Delete a webhookwebhooks:write
GET/webhooks/{id}/deliveries
List a webhook's recent delivery attemptswebhooks:read
POST/webhooks/{id}/deliveries/{deliveryId}/redeliver
Send a delivery's event againwebhooks:write
POST/webhooks/{id}/test
Send a test eventwebhooks: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.

OperationWhat it doesScope
GET/trace
Trace messagestrace: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.

OperationWhat it doesScope
GET/emails
List sent messagesemails:read
POST/emails
Send a messageemails:write
POST/emails/batch
Send several messagesemails:write
GET/emails/{id}
Get a message's delivery statusemails:read
GET/sending
Get the workspace's sending stateemails:read
GET/domains/{id}/sending
Get a domain's sending stateemails:read
PUT/domains/{id}/sending
Turn sending on for a domainemails:write
DELETE/domains/{id}/sending
Turn sending off for a domainemails:write
GET/domains/{id}/smtp-credentials
List a domain's SMTP relay credentialsemails:read
POST/domains/{id}/smtp-credentials
Make an SMTP relay credentialemails:write
DELETE/domains/{id}/smtp-credentials/{credentialId}
Revoke an SMTP relay credentialemails:write

Inbound

Inbound routing: mail that chosen mailboxes receive is POSTed to webhooks as message.received, and can be read through the API.

OperationWhat it doesScope
GET/inbound-routes
List inbound routesinbound:read
GET/mailboxes/{id}/inbound
Get a mailbox's inbound routeinbound:read
PUT/mailboxes/{id}/inbound
Turn inbound routing on for a mailboxinbound:write
DELETE/mailboxes/{id}/inbound
Turn inbound routing off for a mailboxinbound:write
GET/mailboxes/{id}/messages/{emailId}
Read a received messageinbound:read
GET/mailboxes/{id}/messages/{emailId}/attachments/{index}
Download an attachment of a received messageinbound: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.

OperationWhat it doesScope
GET/agent-mailboxes
List agent mailboxesagent_mailboxes:read
POST/agent-mailboxes
Create an agent mailboxagent_mailboxes:write
GET/agent-mailboxes/{id}
Get an agent mailboxagent_mailboxes:read
DELETE/agent-mailboxes/{id}
Delete an agent mailboxagent_mailboxes:write
GET/agent-mailboxes/{id}/messages
List an agent's inboxagent_mailboxes:read
GET/agent-mailboxes/{id}/messages/{emailId}
Read a message an agent receivedagent_mailboxes:read
PATCH/agent-mailboxes/{id}/messages/{emailId}
Mark a message an agent received read or flaggedagent_mailboxes:write
DELETE/agent-mailboxes/{id}/messages/{emailId}
Delete a message an agent receivedagent_mailboxes:write
GET/agent-mailboxes/{id}/messages/{emailId}/attachments/{index}
Download an attachment of a received messageagent_mailboxes:read
POST/agent-mailboxes/{id}/messages/{emailId}/reply
Reply to a message as the agentagent_mailboxes:write
POST/agent-mailboxes/{id}/send
Send a message as the agentagent_mailboxes:write
POST/agent-mailboxes/{id}/keys
Create a key for one agent mailboxagent_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.

OperationWhat it doesScope
GET/mail-apps
List mail appsmail_apps:read
POST/mail-apps
Register a mail appmail_apps:write
GET/mail-apps/{id}
Get a mail appmail_apps:read
DELETE/mail-apps/{id}
Revoke a mail appmail_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.

OperationWhat it doesScope
GET/suppressions
List suppressed recipientssuppressions:read
POST/suppressions
Suppress a recipientsuppressions:write
GET/suppressions/{address}
Check whether a recipient is suppressedsuppressions:read
DELETE/suppressions/{address}
Remove a recipient from the suppression listsuppressions:write