mails-ai Domains API
Bring-your-own custom sending domains (records-only DNS verification).
Bring-your-own custom sending domains (records-only DNS verification).
Every API here is available over the APIs.io API and to AI agents over MCP.
One button, every client — Claude, Cursor, VS Code and the rest.
https://apis.io/mcp
find_apisBrowse and filter every API in the catalog.get_api_artifactsOne API's artifacts, grouped by type.get_openapiThe primary OpenAPI for this API.find_similar_apisAPIs that look like this one.apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.resolveTurn a domain, URL or GitHub org into the provider it belongs to.find_cohortsEvery scored population of providers in the catalog.curl "https://apis.io/api/v1/apis/mails-ai:mails-ai-domains-api"
curl "https://apis.io/api/v1/apis?limit=25"
Discovery needs no key. Ratings and market analysis are Pro.
Free tier, no form to fill in. Signing in shares your email address with us — we store it to create your key and to recognise you if you sign in with another provider. See our Privacy Policy and Terms.
A second provider on the same verified email joins the account you already have.
openapi: 3.2.0
info:
title: Mails.ai Domains API
version: 0.2.0
description: 'The Mails.ai API gives AI agents a real email identity: send and receive
transactional email. Mail received within your plan''s limits is scanned for prompt injection and
reaches you as a typed event. With classification turned on (a paid-plan feature), new mail can also
be read for intent; a reply in a thread never is.
## Authentication
All endpoints take a Bearer API key:
```
Authorization: Bearer mk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
Keys carry one or more scopes — `send`, `read`, `draft` (writes drafts, but can neither send nor
schedule one) and `manage` — and a mode: **live** (`mk_live_…`) or **test** (`mk_test_…`).
## Test mode
A `mk_test_` key exercises the entire API — real validation, persisted resources, real
`message.sent`/`*.received` events and webhooks — without sending real email or incurring cost. Build and
test your whole integration on a test key, then flip one character to `mk_live_` to go live.
`POST /v1/test/inbound` (test key only) lets you simulate inbound mail end-to-end.
## Idempotency
Send endpoints accept an `Idempotency-Key` header; a repeated key replays the stored response instead of
sending twice, with an `Idempotent-Replay: true` header.
## Pagination
List endpoints are cursor-paginated: pass `limit` (1–100) and the `next_cursor` from the previous page.
## Errors
Errors use a stable envelope: `{ "error": { "type", "code", "message", "param", "request_id" } }`. Every
response carries an `X-Request-Id` header for support correlation.'
contact:
name: Mails.ai Support
url: https://mails.ai
email: support@mails.ai
license:
name: Proprietary
identifier: LicenseRef-Proprietary
servers:
- url: https://api.mails.ai
description: Production
security:
- bearerAuth: []
tags:
- name: Domains
description: Bring-your-own custom sending domains (records-only DNS verification).
paths:
/v1/domains:
post:
tags:
- Domains
summary: Add a custom send domain
operationId: createDomain
description: '**Requires scope:** `manage`. Registers a bring-your-own sending domain (paid plans). Returns the DNS records to create at your registrar — records-only verification, no nameserver changes. Agents whose address is on a VERIFIED custom domain send under their own identity instead of the shared workspace subdomain. A domain that is already added, to this workspace or to another, is refused with 400 `duplicate_resource`.'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateDomainRequest'
example:
domain: mail.acme.com
responses:
'201':
description: The registered domain with the DNS records to create.
content:
application/json:
schema:
$ref: '#/components/schemas/Domain'
example:
id: dom_01JZXE8S0U2W4Y6A8C0E2G4J6L
domain: mail.acme.com
status: pending
provider: ownmetal
dns_records:
- type: TXT
host: dkim._domainkey.mail.acme.com
value: v=DKIM1; k=rsa; p=MIIBIjANBgkq…
purpose: dkim
required: true
verified: false
instruction: Add this record exactly as shown. This is what signs your mail.
- type: TXT
host: mail.acme.com
value: v=spf1 ip4:15.204.67.226 ~all
purpose: spf
required: false
verified: false
instruction: 'If you already publish an SPF record, do NOT replace it — add ip4:15.204.67.226 to the existing one (before the final ~all/-all). Only publish the value shown if you have no SPF record today. Optional: your mail authenticates via DKIM either way.'
merge_token: ip4:15.204.67.226
- type: TXT
host: _dmarc.mail.acme.com
value: v=DMARC1; p=none; rua=mailto:dmarc@mail.acme.com
purpose: dmarc
required: false
verified: false
instruction: Only if you have no DMARC record today. If you already publish one, KEEP IT — never lower an existing p=quarantine or p=reject to p=none. Point rua at a mailbox you control.
fail_reason: null
last_check_at: null
verified_at: null
created_at: '2026-07-24T10:30:00.000Z'
'400':
description: Invalid request — malformed JSON or a field failed validation. Also `feature_not_enabled` when this server has a feature switched off (custom domains before they are available).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Missing or invalid API key.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'402':
description: 'Payment required: a plan limit (`plan_limit_exceeded`) or a feature the current plan does not include (`feature_not_enabled`). The error object carries `upgrade_url`, the dashboard page where the plan can be changed.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: The API key lacks the required scope (`insufficient_scope`), or the token belongs to an app connected by sign-in and this operation is closed to connected apps (`connected_app_not_allowed`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
x-required-scope: manage
get:
tags:
- Domains
summary: List custom domains
operationId: listDomains
description: '**Requires scope:** `read`.'
responses:
'200':
description: This workspace's custom domains.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Domain'
description: Every custom domain in the workspace.
has_more:
type: boolean
description: 'Always `false`: every domain comes in one response.'
next_cursor:
type: string
description: Pass as `cursor` to fetch the next page. Absent on the last page.
required:
- data
- has_more
'401':
description: Missing or invalid API key.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: The API key lacks the required scope (`insufficient_scope`), or the token belongs to an app connected by sign-in and this operation is closed to connected apps (`connected_app_not_allowed`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
x-required-scope: read
/v1/domains/{id}:
get:
tags:
- Domains
summary: Get a domain
operationId: getDomain
description: '**Requires scope:** `read`.'
parameters:
- name: id
in: path
required: true
schema:
type: string
description: Domain id (`dom_…`).
responses:
'200':
description: Domain status + the DNS records to create.
content:
application/json:
schema:
$ref: '#/components/schemas/Domain'
example:
id: dom_01JZXE8S0U2W4Y6A8C0E2G4J6L
domain: mail.acme.com
status: pending
provider: ownmetal
dns_records:
- type: TXT
host: dkim._domainkey.mail.acme.com
value: v=DKIM1; k=rsa; p=MIIBIjANBgkq…
purpose: dkim
required: true
verified: false
instruction: Add this record exactly as shown. This is what signs your mail.
- type: TXT
host: mail.acme.com
value: v=spf1 ip4:15.204.67.226 ~all
purpose: spf
required: false
verified: false
instruction: 'If you already publish an SPF record, do NOT replace it — add ip4:15.204.67.226 to the existing one (before the final ~all/-all). Only publish the value shown if you have no SPF record today. Optional: your mail authenticates via DKIM either way.'
merge_token: ip4:15.204.67.226
- type: TXT
host: _dmarc.mail.acme.com
value: v=DMARC1; p=none; rua=mailto:dmarc@mail.acme.com
purpose: dmarc
required: false
verified: false
instruction: Only if you have no DMARC record today. If you already publish one, KEEP IT — never lower an existing p=quarantine or p=reject to p=none. Point rua at a mailbox you control.
fail_reason: null
last_check_at: null
verified_at: null
created_at: '2026-07-24T10:30:00.000Z'
'401':
description: Missing or invalid API key.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: The API key lacks the required scope (`insufficient_scope`), or the token belongs to an app connected by sign-in and this operation is closed to connected apps (`connected_app_not_allowed`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Resource not found in this workspace, or the agent a send names is archived (`agent_archived`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
x-required-scope: read
delete:
tags:
- Domains
summary: Remove a domain
operationId: deleteDomain
description: '**Requires scope:** `manage`. Agents with addresses on this domain stop sending: every send from them is refused with 422 `domain_not_verified` until the domain is added and verified again, or the agent is recreated on your workspace''s mails.ai subdomain. It is never sent from another domain instead.'
parameters:
- name: id
in: path
required: true
schema:
type: string
description: Domain id (`dom_…`).
responses:
'200':
description: Deletion confirmation.
content:
application/json:
schema:
type: object
properties:
id:
type: string
description: 'Id of the removed domain (`dom_…`): the id in the path.'
deleted:
type: boolean
description: Always `true`. A domain that is not in this workspace answers 404 instead.
required:
- id
- deleted
'401':
description: Missing or invalid API key.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: The API key lacks the required scope (`insufficient_scope`), or the token belongs to an app connected by sign-in and this operation is closed to connected apps (`connected_app_not_allowed`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Resource not found in this workspace, or the agent a send names is archived (`agent_archived`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
x-required-scope: manage
/v1/domains/{id}/verify:
post:
tags:
- Domains
summary: Verify a domain now
operationId: verifyDomain
description: '**Requires scope:** `manage`. Re-checks every DNS record and updates per-record + overall status. Each call is an attempt: one that does not pass counts toward `failed`, 20 in a row mark the domain `failed`, and the count starts over on success or after a day without a call. Polling is rarely needed. We re-check an unverified domain every 15 minutes and a `failed` one at least daily, without counting, whatever its age or the workspace''s plan, and it verifies at the first check after its records resolve.'
parameters:
- name: id
in: path
required: true
schema:
type: string
description: Domain id (`dom_…`).
responses:
'200':
description: The domain with refreshed per-record verification state.
content:
application/json:
schema:
$ref: '#/components/schemas/Domain'
'401':
description: Missing or invalid API key.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: The API key lacks the required scope (`insufficient_scope`), or the token belongs to an app connected by sign-in and this operation is closed to connected apps (`connected_app_not_allowed`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Resource not found in this workspace, or the agent a send names is archived (`agent_archived`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
x-required-scope: manage
components:
schemas:
Error:
type: object
properties:
error:
type: object
properties:
type:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- resource_error
- payment_error
- abuse_error
- api_error
description: High-level error category.
code:
type: string
enum:
- invalid_field
- missing_field
- missing_param
- invalid_param_value
- method_not_allowed
- invalid_idempotency_key
- duplicate_resource
- slug_invalid
- slug_reserved
- slug_taken
- agent_name_conflict
- body_too_large
- missing_authorization
- missing_api_key
- invalid_api_key
- expired_api_key
- revoked_api_key
- invalid_access_token
- email_not_verified
- insufficient_scope
- connected_app_not_allowed
- sso_required
- workspace_mismatch
- agent_not_owned
- rate_limit_exceeded
- daily_limit_exceeded
- hourly_limit_exceeded
- monthly_limit_exceeded
- agent_not_found
- agent_paused
- agent_archived
- resource_not_found
- recipient_suppressed
- recipient_not_allowed
- domain_not_verified
- ses_role_not_assumable
- ses_role_external_id_not_required
- ses_permission_denied
- ses_setup_failed
- ses_message_rejected
- ses_events_not_confirmed
- payment_required
- payment_method_failed
- subscription_canceled
- billing_state_inconsistent
- free_tier_exceeded
- plan_limit_exceeded
- cold_email_prohibited
- agent_suspended
- new_workspace_fanout_exceeded
- resource_cap_exceeded
- workspace_cap_exceeded
- feature_not_enabled
- test_mode_required
- live_mode_required
- unknown_test_address
- workspace_not_approved
- spam_pattern_detected
- classifier_rejected
- high_injection_score
- complaint_threshold_exceeded
- bounce_threshold_exceeded
- internal_error
- internal_server_error
- upstream_error
- upstream_unavailable
- service_unavailable
description: Stable machine-readable error code.
message:
type: string
description: Human-readable explanation.
param:
type:
- string
- 'null'
description: The offending request field, when applicable.
request_id:
type: string
description: Echo of the X-Request-Id header for support correlation.
errors:
type: array
items:
$ref: '#/components/schemas/FieldError'
description: On a validation failure, EVERY offending field (not just the first). The top-level code/message/param mirror errors[0] for backward compatibility.
resets_at:
type:
- string
- 'null'
format: date-time
description: 'On every 429: when the limit lets the request through again (UTC). Null when waiting will not help, for a cap that only an upgrade or a deletion lifts.'
retry_after_seconds:
type:
- integer
- 'null'
description: 'On every 429: the seconds until `resets_at`, null when it is null. On any other error that says how long to wait, such as a 503 `upstream_unavailable`: the seconds its `Retry-After` header gives.'
upgrade_url:
type: string
format: uri
description: 'On every 402: the dashboard page where the plan can be changed, `https://app.mails.ai/billing`.'
required:
- type
- code
- message
description: 'The error, as every error response carries it: `type`, `code`, `message`, `param` and `request_id`, plus `errors` on a validation failure, `resets_at` and `retry_after_seconds` on a 429, `retry_after_seconds` on any other error with a `Retry-After`, and `upgrade_url` on a 402. A failed item in a batch send is the same error, and `retry_after_seconds` is how it says how long to wait, since an item has no headers of its own.'
required:
- error
CreateDomainRequest:
type: object
properties:
domain:
type: string
minLength: 4
maxLength: 253
description: The domain to send from, such as `mail.acme.com`, 4 to 253 characters, lowercased and without a trailing dot. A name that is not a valid hostname, is under `mails.ai`, or is already added to any workspace is refused with 400.
required:
- domain
additionalProperties: false
Domain:
type: object
properties:
id:
type: string
description: Domain id (`dom_…`).
domain:
type: string
description: The custom sending domain, lowercased.
status:
type: string
enum:
- pending
- verifying
- verified
- failed
description: 'pending → records issued; verifying → checks in progress; verified → agents on this domain send under their own identity; failed → 20 verify calls in a row did not pass. Not final: a failed domain is still re-checked automatically (less often the longer it fails, at most a day apart) and verifies by itself once its records resolve; verify still checks at once, and the count starts over on success or after a day without a verify call.'
provider:
type: string
description: Backing sending infrastructure for this domain.
dns_records:
type: array
items:
$ref: '#/components/schemas/DnsRecord'
description: The records to create at your DNS host. Each record's `verified` says whether the last check found it, and every `required` one must be found for the domain to verify.
fail_reason:
type:
- string
- 'null'
description: Why the last verification did not pass, when it didn't.
last_check_at:
type:
- string
- 'null'
format: date-time
description: 'When the records were last checked: by a verify call, by the automatic re-checks of unverified domains, or by the daily re-check of verified ones. Null before the first check.'
verified_at:
type:
- string
- 'null'
format: date-time
description: When the domain first verified. Null until then, and cleared again when the daily re-check of verified domains (06:40 UTC) finds a required record gone.
created_at:
type: string
format: date-time
description: When the domain was added (UTC).
required:
- id
- domain
- status
- dns_records
- created_at
DnsRecord:
type: object
properties:
type:
type: string
enum:
- TXT
- CNAME
- MX
description: DNS record type to create.
host:
type: string
description: Fully-qualified record host/name to create at your DNS provider.
value:
type: string
description: Exact record value. Copy verbatim.
purpose:
type: string
enum:
- spf
- dkim
- dmarc
- return_path
- tracking
- ownership
- outlook_relay
description: 'What the record is for: `dkim` (the key that signs your mail), `return_path` (the bounce address), `spf` and `dmarc` (your domain''s sender policy), `tracking` (link tracking), `outlook_relay` (lets your mail reach personal Outlook, Hotmail and Live addresses), or `ownership` (any other record the sending provider asks for, such as proof you control the domain).'
required:
type: boolean
description: Required records gate verification; optional ones improve deliverability/reporting.
verified:
type: boolean
description: Whether the record was observed in DNS on the last check.
instruction:
type: string
description: How to create this record, word for word from the provider. For SPF and DMARC it says when to merge the value into a record the domain already publishes instead of replacing it, because replacing one can break the domain's existing mail.
merge_token:
type: string
description: The part to add to an existing record when merging (for SPF, e.g. `ip4:…`). A merged SPF record that contains it verifies.
required:
- type
- host
- value
- purpose
- required
- verified
FieldError:
type: object
properties:
code:
type: string
enum:
- invalid_field
- missing_field
- missing_param
- invalid_param_value
- method_not_allowed
- invalid_idempotency_key
- duplicate_resource
- slug_invalid
- slug_reserved
- slug_taken
- agent_name_conflict
- body_too_large
- missing_authorization
- missing_api_key
- invalid_api_key
- expired_api_key
- revoked_api_key
- invalid_access_token
- email_not_verified
- insufficient_scope
- connected_app_not_allowed
- sso_required
- workspace_mismatch
- agent_not_owned
- rate_limit_exceeded
- daily_limit_exceeded
- hourly_limit_exceeded
- monthly_limit_exceeded
- agent_not_found
- agent_paused
- agent_archived
- resource_not_found
- recipient_suppressed
- recipient_not_allowed
- domain_not_verified
- ses_role_not_assumable
- ses_role_external_id_not_required
- ses_permission_denied
- ses_setup_failed
- ses_message_rejected
- ses_events_not_confirmed
- payment_required
- payment_method_failed
- subscription_canceled
- billing_state_inconsistent
- free_tier_exceeded
- plan_limit_exceeded
- cold_email_prohibited
- agent_suspended
- new_workspace_fanout_exceeded
- resource_cap_exceeded
- workspace_cap_exceeded
- feature_not_enabled
- test_mode_required
- live_mode_required
- unknown_test_address
- workspace_not_approved
- spam_pattern_detected
- classifier_rejected
- high_injection_score
- complaint_threshold_exceeded
- bounce_threshold_exceeded
- internal_error
- internal_server_error
- upstream_error
- upstream_unavailable
- service_unavailable
description: Stable machine-readable code for this field.
message:
type: string
description: Human-readable explanation for this field.
param:
type:
- string
- 'null'
description: The offending request field (dot-path), or null.
required:
- code
- message
- param
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: mk_live_<32 hex>, mk_test_<32 hex>, or moa_<48 hex>
description: 'A Mails.ai API key, or an OAuth access token (`moa_…`, valid for 1 hour) from signing in through an app such as the CLI, Claude or ChatGPT. OAuth discovery: https://api.mails.ai/.well-known/oauth-authorization-server and https://api.mails.ai/.well-known/oauth-protected-resource. An access token''s OAuth scope maps onto these API scopes: `full_access` = `send` + `read` + `manage` (not `draft`: the draft routes take those three too), `send` = `send` + `read`, `read` = `read`. Scopes (`send`/`read`/`manage`/`draft`) are noted per operation via `x-required-scope`; when more than one scope opens an operation (the draft routes also take `draft`), `x-accepted-scopes` lists them all. `inbox` appears there too, on the triage routes (changing and trashing threads, labels, cancelling a scheduled send): only a signed-in dashboard session holds it (a workspace Member''s), never an API key or OAuth token, and `x-session-scopes` names it.'