Feature guides
Integrating with mila's API and MCP server
REST API base URL, Bearer API-key auth, the v1 resources, and how to connect an MCP client to mila's hosted MCP server.
What this guide covers
Everything in the mila panel is backed by a documented REST API, and mila also runs a hosted Model Context Protocol (MCP) server -- so an AI assistant or agent can read mila's documentation (and, in the future, act on your account) without you copy-pasting anything into it. This guide covers both: calling the REST API directly, and pointing an MCP client at mila.
REST API: base URL and authentication
The API lives at https://api.mila.cx, versioned under /api/v1/.... Every
request is authenticated with an API key, sent as a bearer token:
Authorization: Bearer <your-api-key>
Create a key from Account > API Keys in the panel. The plaintext key is shown exactly once, right after you create it -- copy it somewhere safe immediately, since mila only stores a hash of it afterward and can't show it to you again (you can always revoke a key and mint a new one, though).
Key permissions (scopes)
When you create a key you choose between two things:
- Full access -- the key can do anything its owner's organization role allows. Transferring a domain is not included; that is a separate permission you must grant explicitly, described below.
- Only selected permissions -- the key can do only what you tick. They are
named
resource:action, such asmailboxes:write,domains:readormail:send; the full list is in the panel and atGET /api/account/api-keys/scopes(a panel/session route, not an API-key one).
Permissions do not replace the role, they narrow it: an operation needs BOTH the owner's role and the key's permission. So a narrow key sitting on a server cannot do everything its owner can do in the panel, even if it leaks.
Keys created before permissions existed keep working and keep the capabilities
they had then. They never gain permissions added later, such as
domains:transfer -- to grant one you create a new key. The panel lists these
keys as "Legacy key (pre-permissions)".
What you can do with it
The v1 API covers the same resources the panel itself manages, each scoped under a domain:
account-- who this key belongs to (top-levelGET /api/v1/account). Ask it FIRST in any setup flow, see "Check the key before you trust it" belowdomains-- list, read, and ADD domains (top-level); adding is a four-step self-serve lifecycle, see "Adding a domain via the API" belowmailboxes-- create, read, update, and delete mailboxes on a domainaliases-- extra addresses that route to one or more mailboxesidentities-- send-as/reply-as identities on a mailbox, including cross-domain identities for a unified inboxforwardings-- copy mail from a mailbox out to an external addressrewrites-- pattern-based local-part routing rulescatchall-- the domain-wide fallback destination for unmatched addressesemails-- SEND email (top-levelPOST /api/v1/emails, not domain-nested): transactional and notification mail withfrom/to/subject/text/htmland base64 attachments, no SMTP setup needed -- thefromaddress just has to be a mailbox or identity your API key's organization can use
A quick example -- listing your domains:
curl "https://api.mila.cx/api/v1/domains" \
-H "Authorization: Bearer $MILA_API_KEY"
And creating a mailbox:
curl -X POST "https://api.mila.cx/api/v1/domains/<domain-id>/mailboxes" \
-H "Authorization: Bearer $MILA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"local_part": "sales",
"password_method": "password",
"password": "a-strong-unique-password",
"may_send": true,
"may_receive": true
}'
For every other resource -- exact endpoints, methods, and parameters -- see the API reference; for runnable snippets in curl, JavaScript, and Python that show the exact request-body fields for every write operation, see the code examples below (or ask an MCP-connected assistant, which can fetch both on demand -- see the next section).
Check the key before you trust it
A key from the wrong account does not look wrong. It authenticates, and
GET /api/v1/domains answers 200 with a full list — somebody else's. Nothing
fails until the first real send, which for a shop is the first order's
confirmation email.
So ask first:
curl "https://api.mila.cx/api/v1/account" \
-H "Authorization: Bearer $MILA_API_KEY"
{
"account": { "id": "org_...", "name": "INKLEDER", "slug": "inkleder" },
"api_key": {
"id": "key_...",
"name": "Storefront",
"prefix": "mila_ab12",
"role": "admin",
"scopes": ["mail:send"],
"domain_access": "all"
}
}
If you are building a setup screen, show account.name and have the person
confirm it. "The key is valid" means nothing to someone who is not a developer;
their company's name means everything. prefix lets you show which key was
pasted without ever handling the secret again, and scopes tells you up front
whether this key can do what your integration needs — a key without mail:send
will authenticate happily and then refuse every send.
This route needs no particular scope. Whatever key the user pasted can ask it, including keys minted before scopes existed (those report the full set they really carry, not an empty list).
When a send is refused
If POST /api/v1/emails refuses the from address, the response body carries
a code when it can safely say why:
{
"statusCode": 404,
"code": "SENDER_DOMAIN_NOT_OWNED",
"message": "This API key's account does not hold the domain of \"hallo@example.com\"..."
}
That one means exactly what it says: the domain is not in this account. It is the wrong-key mistake, caught at send time — and it is worth surfacing to the user in your own words rather than as a generic failure.
Every other reason a from is refused returns the same 404 without a
code, and deliberately so: whether a particular mailbox exists is not
something we will confirm to anyone holding any valid key.
Adding a domain via the API
Adding a domain is a four-step lifecycle -- in this order (an admin-role API key is required for steps 1 and 4):
- Create it:
POST /api/v1/domainswith{ "name": "example.com" }. The domain comes back inpendingstate. (Creation is throttled system-wide to one new domain per 30 seconds -- a 429 means wait half a minute and retry.) - Get the DNS records to publish:
GET /api/v1/domains/<domain-id>/dns-recordsreturns everything to add at your DNS host -- thevennyx-mila-verify=verification TXT, MX, SPF, DKIM, DMARC, and the client-autoconfig records. - Check when they've landed:
GET /api/v1/domains/<domain-id>/diagnosticsruns a live DNS check and reports each block as ok or failing, with expected-vs-found detail. - Activate:
POST /api/v1/domains/<domain-id>/activate. The server re-checks DNS itself and answers 422 (listing the failing blocks) until the verification TXT and MX records pass -- the other records improve deliverability but don't block activation.
The same lifecycle is available in the SDK
(client.domains.create/dnsRecords/diagnostics/activate) and, over MCP with
the domains:write scope, as the create_domain, check_domain_dns, and
activate_domain tools.
Moving a domain to another organization
If a domain ended up in the wrong organization, move it with
POST /api/v1/domains/<domain-id>/transfer. The body carries the target
organization's transfer code:
{ "target_transfer_code": "<the-target-organizations-transfer-code>" }
The transfer code is shown in the target organization's own panel and is a secret only that organization can hand out -- so even a stolen key cannot send a domain somewhere the thief does not already control.
The endpoint requires three things: the domains:transfer permission on the
key, the owner role for the key's owner, and the domain belonging to the
key's organization. A transfer cannot be undone; the domain moves with its
mailboxes and their mail.
Deleting a domain (and undoing it)
DELETE /api/v1/domains/<domain-id> schedules a deletion rather than
performing one. Mail stops immediately; the data is permanently deleted seven
days later. Until then POST /api/v1/domains/<domain-id>/cancel-deletion calls
it off and the mailboxes come back untouched, contents and all.
For a domain that has mailboxes you must pass its name back, exactly as in the
panel: ?confirm=example.com. That way a bare DELETE against a guessed id
cannot take somebody's mail with it.
Deleting needs the domains:delete permission — domains:write does not
cover it, and a key minted before permissions existed can never hold it.
Cancelling needs only domains:write: stopping a mistake must not be harder
than making it.
Cancelling does not bring the domain back online; activating it is a separate step.
Using the @vennyx/mila SDK
Prefer not to hand-build requests? mila also publishes an official TypeScript/JavaScript client:
npm install @vennyx/mila
import { MilaClient } from '@vennyx/mila';
const client = new MilaClient({ apiKey: process.env.MILA_API_KEY! });
const domains = await client.domains.list();
Every request and response is fully typed, errors come back as a single
MilaApiError instead of a status code to parse by hand, and sending an
attachment is just a Buffer -- the SDK handles the base64 encoding the
API expects on the wire. See mila's SDK page for the full resource
list and a walkthrough.
Sending real HTML email? Pair it with @vennyx/mila-react-template
Writing HTML by hand that survives Outlook, Gmail, and Apple Mail is its
own project. @vennyx/mila-react-template is a separate, bulletproof
React component library built for exactly that -- pair it with the SDK's
own emails.send():
npm install @vennyx/mila-react-template
import { MilaEmailLayout, MilaButtonFluid, renderMilaEmail } from '@vennyx/mila-react-template';
function InviteEmail({ inviteUrl }: { inviteUrl: string }) {
return (
<MilaEmailLayout locale="en" previewText="You've been invited">
<h1>You've been invited</h1>
<MilaButtonFluid href={inviteUrl} label="Accept invitation" />
</MilaEmailLayout>
);
}
const { html, text } = await renderMilaEmail(<InviteEmail inviteUrl="https://example.com/accept/abc" />);
await client.emails.send({
from: 'hello@yourdomain.com',
to: ['someone@example.com'],
subject: "You've been invited",
html,
text,
});
Every component renders real, tested-against-every-major-client HTML --
Outlook-safe buttons (VML under the hood), correct dark-mode handling, and
a matching plain-text version generated for you. It's bilingual (Turkish/
English) out of the box; every component takes a locale prop.
Connecting mila's MCP server
mila runs a hosted MCP server at two addresses, for two different purposes:
https://docs-mcp.mila.cx-- documentation only (search mila's guides, fetch one by slug, the API reference, runnable code examples, this same integration overview). No authentication at all -- any MCP client can call these tools immediately.https://mcp.mila.cx-- the same documentation tools, plus full account access (domains, mailboxes, aliases, identities, forwardings, rewrites) and mail read/send, gated by OAuth. The first authenticated tool call opens mila's sign-in and consent screen, where you choose exactly which domains, addresses, and permissions to grant.
Both speak the Streamable HTTP transport (JSON-RPC over POST) and are
stateless -- no session setup beyond the connection itself. Most MCP clients
expect a config entry along these lines (the exact shape varies by client --
check yours):
{
"mcpServers": {
"mila": {
"type": "streamable-http",
"url": "https://docs-mcp.mila.cx"
}
}
}
For a fuller walkthrough, sample prompts, and the full tool catalog, see mila's MCP page.
A domain can disappear from the API without being deleted
GET /api/v1/domains lists only domains your account currently holds, and
GET /api/v1/domains/{id} returns 404 for anything else. There are two ways a
domain leaves that set, and they look identical over the wire:
- You deleted it. The response was preceded by your own
DELETE, and by the grace period it schedules. - Somebody claimed it. A person who proved control of the domain's DNS took the name over -- see DNS setup for the mechanism and what it does to the previous holder's mail. Your account's owners and admins are emailed when this happens, so the notification is the signal; the API is not.
So treat a sudden 404 on a domain id you were successfully polling as
"no longer yours", not as "deleted by me". Integrations that reconcile state on a
schedule should surface it for a human rather than recreating the domain -- the
recreate will fail, because the name now belongs to somebody else.
Where to go next
- DNS setup for mail and Email client settings cover the non-API side of getting a domain and mailbox running.
- Forwarding and
Aliases & catch-all explain the concepts
behind the
forwardings/aliases/catchallresources above in plain panel terms, if you'd rather read the concept before scripting it.