mila
PricingGuidesMCPSDKAbout
Sign inSign up

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:

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:

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):

  1. Create it: POST /api/v1/domains with { "name": "example.com" }. The domain comes back in pending state. (Creation is throttled system-wide to one new domain per 30 seconds -- a 429 means wait half a minute and retry.)
  2. Get the DNS records to publish: GET /api/v1/domains/<domain-id>/dns-records returns everything to add at your DNS host -- the vennyx-mila-verify= verification TXT, MX, SPF, DKIM, DMARC, and the client-autoconfig records.
  3. Check when they've landed: GET /api/v1/domains/<domain-id>/diagnostics runs a live DNS check and reports each block as ok or failing, with expected-vs-found detail.
  4. 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:

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:

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