# Developer API — same shape as `api.mailkite.dev` `api-local` exposes the same JSON send/receive surface MailKite Cloud does, so SDKs and code written against `api.mailkite.dev` run unchanged against a self-hosted server — point the client's `baseUrl` at this server instead of `https://api.mailkite.dev`. The **JSON surface is the compatibility layer**. Underneath it is this server's own pipeline (`buildMessage` → `deliverOutbound` → the `SMARTHOST` you configured), so "send" means what it always meant here: local delivery plus whatever the smarthost reaches. This document is the surface, not the transport. ## Authentication Every endpoint here is **user-trust**: `Authorization: Bearer ` with a key minted for the account (`mk_local_…`, from `POST /api/admin/keys` or `cli.mjs add-key`). The key's account must own the domain a send uses as its `from`. Browser clients are supported with CORS. The default is `Access-Control-Allow-Origin: *` because authentication is bearer-token-only; set `CORS_ORIGIN` to restrict it. ## Send ### `POST /v1/send` ```jsonc // request { "from": "hello@myapp.ai", // required — a domain this server hosts "to": "ada@example.com", // required (string or array) "subject": "Your invoice", // required "html": "

Thanks!

", // html and/or text required "text": "Thanks!", "cc": ["billing@myapp.ai"], // optional "bcc": ["archive@myapp.ai"], // optional — envelope-only, never in headers "replyTo": "support@myapp.ai", // optional "inReplyTo": "", // optional — threads "headers": { "X-Tag": "receipts" } // optional — extra raw MIME headers } ``` ```jsonc // 202 response { "id": "msg_2Hk9QpVn4tLd", "status": "sent" } ``` The same gates as `/api/relay` and the console compose apply: the `from` domain must be owned by the key's account (`403 from_domain`), recipients and the subject/body are validated, and a send that would have nowhere to go (external recipients with no `SMARTHOST` set) is refused before anything is stored (`400 no_smarthost`). A smarthost failure returns `502` with the Sent copy already stored — the same trade the relay makes. ### Hosted-only fields Fields that only exist on the managed product are **refused with `400 unsupported`**, not silently ignored, so a caller can't think they scheduled or attached something that never happened: `templateId`, `templateData`, `attachments`, `scheduledAt`. Tracking flags (`trackOpens`, `trackClicks`) and `attribution` are accepted but are no-ops — nothing is tracked or attributed here. ## Receive ### `GET /api/messages` — account-wide list, newest first Returns a bare JSON array (like the cloud), across both `INBOX` and `Sent`, newest first. | Query | | |---|---| | `limit` | max rows, 1–200 (default 100) | | `before` | a `received_at` epoch-ms cursor — pass the last row's `received_at` to page | | `search` | case-insensitive substring over sender, recipient, and subject | Each element carries the cloud's `message` shape (list rows, no bodies): ```jsonc { "id": "msg_2Hk9QpVn4tLd", "direction": "inbound", // or "outbound" "from": { "address": "ada@example.com" }, "to": [{ "address": "hello@myapp.ai" }], "from_addr": "ada@example.com", "to_addr": "hello@myapp.ai", "subject": "Invoice question", "received_at": 1769731200000, // epoch ms "size_bytes": 4821 } ``` ### `GET /api/messages/:id` — one message with bodies Returns the cloud `message-detail` envelope. Its `message` member is the list shape plus `text_body`, `html_body`, and `headers_json` (stored MIME headers as a JSON string). `deliveries`, `events`, `deliveryAttempts`, `opens`, and `attachments` are empty arrays: self-hosted has no cloud tracking records to report. `404` is returned for an unknown id. ### `GET /v1/me` — account probe `{ "email": null, "verified": true, "plan": "self-hosted" }`. Honest stubs: a self-hosted server has no account email or plan, but the fields are present so SDK clients that ping this on connect don't fail. ## Reading a mailbox with an app password The mailbox REST routes (`GET /api/mailbox/messages`, `…/raw`, `…/flags`) are the same shape on both sides already — see `app-passwords.md` and the "Mailbox API" section of `contract.md`. That is how an agent reads its own inbox over plain HTTPS. ### `POST /v1/send/batch` Accepts the cloud batch shape with a shared message body and up to 50 `recipients` entries. Each entry is sent independently through the same single-message path, and the response contains per-recipient `results`, `sent`, `scheduled: 0`, and `failed` counts. ## What is deliberately not copied - **Templates, scheduling, attachments, tracking** — hosted product features; refused (`400 unsupported`) rather than faked. - **Delivery guarantees.** The cloud's `status: "sent"` means "on the warmed path"; here it means "stored and handed to your smarthost (or delivered locally)". Same shape, honest meaning. ## Compatibility scope and follow-up This is the first plug-in-compatible slice for applications that use the MailKite library to send and receive mail: point the library's `baseUrl` at the self-hosted server and use an `mk_local_…` key. The core JSON contract, response envelopes, message IDs, batch send, browser CORS, and raw mailbox responses are covered by the OSS and Node SDK regression tests. It is not yet a complete implementation of every MailKite Cloud API method. The remaining scope includes management APIs such as templates, domains, routes, delivery retry, attachments, tracking, realtime, and other hosted product features. Full API parity is a follow-up project; add each endpoint only with an explicit local behavior and tests rather than presenting a hosted-only no-op as compatible.