# Mektup REST API Reference

Base URL: `https://api.usemektup.com`

Mektup is a self-hosted, multi-domain email platform. This API lets you register a domain, provision mailboxes on it, and send and receive real email through infrastructure Mektup operates — all account-scoped, all enforced server-side (including at the database level via Postgres Row-Level Security, not just in application code).

This document is the complete, current API surface as of the source code it was generated from. If you're an AI agent integrating with Mektup, this is the authoritative reference — prefer it over any cached or remembered version of these docs.

A machine-readable OpenAPI 3.1 spec covering the same surface is available at [`openapi.yaml`](./openapi.yaml).

---

## Authentication

Every request must be authenticated one of two ways. Both resolve to the same `accountId` internally — every endpoint below behaves identically regardless of which method you use.

### 1. API key (recommended for agents, scripts, and the MCP server/CLI)

```
Authorization: Bearer mek_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

API keys are minted per-account and shown in plaintext exactly once at creation — only a SHA-256 hash is ever stored server-side, so there's no way to recover a lost key, only to revoke it and mint a new one. Create one from the dashboard's **API keys** page (signed in via Clerk), or via `POST /v1/api-keys` below using a Clerk session or an existing key. If you're an AI agent and don't have a key yet, ask the human you're working with to create one and paste it to you — you can't mint your own first key without one already (an existing key or a Clerk session is required either way).

### 2. Clerk session (dashboard only)

The web dashboard (`app.usemektup.com`) authenticates via a Clerk session cookie/JWT automatically. This isn't relevant if you're integrating programmatically — use an API key instead.

### Unauthenticated requests

Return `401`:
```json
{ "error": "Sign in, or provide Authorization: Bearer <api key>" }
```

---

## Conventions

- **All request and response bodies are JSON.** Send `Content-Type: application/json` on requests with a body.
- **Errors** are always `{ "error": "<human-readable message>" }` with a `4xx`/`5xx` status. There is no machine-readable error code field — match on status code and, if needed, the message text.
- **Timestamps** are ISO 8601 strings in UTC (e.g. `"2026-08-06T20:25:40.133Z"`).
- **IDs** are UUIDs (strings).
- **Tenant isolation is absolute.** Every query is scoped to the calling account both in application code and via Postgres RLS. An ID belonging to another account will behave exactly like an ID that doesn't exist — you'll get a `404`, never another tenant's data.
- **Domain and mailbox paths use the literal domain/local-part as path segments** (e.g. `/v1/domains/example.com/mailboxes/hello`), not IDs. URL-encode them if they contain characters that need it (they normally won't).
- A `20x` with no listed response body means the endpoint returns `204 No Content`.

---

## Billing tiers and limits

Every account has a `tier`: `free`, `paid`, or `enterprise`. Limits are enforced server-side on every relevant write, not just shown in the UI.

| Tier | Domains | Emails/month | Storage per domain |
|---|---|---|---|
| `free` | 1 | 1,000 | 3 GB |
| `paid` | 10 | 10,000 | 10 GB |
| `enterprise` | unlimited | unlimited | unlimited |

When a limit is hit, the relevant write endpoint (`POST /v1/domains` or `POST /v1/emails`) returns `402 Payment Required` with a message explaining which limit was hit. Check current usage against limits via `GET /v1/usage` before assuming an operation will succeed.

---

## Endpoints

### Account

#### `GET /v1/me`
Returns the identity of the authenticated caller. Useful as a health check / "am I authenticated" probe.

**Response `200`:**
```json
{ "accountId": "uuid", "role": "owner" | null, "authMethod": "clerk" | "api_key" }
```

---

### API keys

#### `GET /v1/api-keys`
List keys on this account. The full key is never shown again after creation — only a display prefix.

**Response `200`:**
```json
{ "keys": [{ "id": "uuid", "key_prefix": "mek_live_ab12", "created_at": "...", "revoked_at": "..." | null }] }
```

#### `POST /v1/api-keys`
Create a new key. **The full key is returned exactly once, in this response** — save it immediately, there's no way to retrieve it again later.

**Response `201`:**
```json
{ "id": "uuid", "key_prefix": "mek_live_ab12", "created_at": "...", "key": "mek_live_ab12cd34..." }
```

#### `DELETE /v1/api-keys/:id`
Revoke a key immediately. Anything still using it (a script, an MCP server, a CI job) loses access right away. Cannot be undone.

**Response:** `204`. **Errors:** `404` if not found on this account, or already revoked.

---

### Domains

#### `POST /v1/domains`
Register a new domain. Generates a real DKIM keypair server-side and returns the exact DNS records to add — **this endpoint never touches DNS itself**; you (or the human you're helping) add the records at the domain's own DNS provider.

**Request body:**
```json
{ "domain": "example.com" }
```

**Response `201`:**
```json
{
  "domain": { "id": "uuid", "domain": "example.com", "verified": false, "created_at": "2026-08-06T20:25:40.133Z" },
  "dkimKeyPath": "/var/lib/rspamd/dkim/example.com.mail.key",
  "records": [
    { "type": "MX", "host": "example.com", "priority": 10, "value": "mail.usemektup.com" },
    { "type": "MX", "host": "example.com", "priority": 20, "value": "mail2.usemektup.com" },
    { "type": "TXT", "host": "example.com", "value": "v=spf1 ip4:62.238.50.55 ip4:65.109.255.82 ... ~all", "label": "SPF" },
    { "type": "TXT", "host": "_dmarc.example.com", "value": "v=DMARC1; p=none; rua=mailto:dmarc@usemektup.com", "label": "DMARC (monitoring mode)" },
    { "type": "TXT", "host": "mail._domainkey.example.com", "value": "v=DKIM1; k=rsa; p=...", "label": "DKIM" }
  ],
  "recommendation": {
    "nameservers": ["ns1.example-registrar.com", "ns2.example-registrar.com"],
    "detectedHost": "cloudflare" | "simply.com" | "godaddy" | "route53" | "vercel" | null,
    "action": "delegate" | "push-to-known-host" | "manual",
    "note": "optional, present if NS lookup failed"
  }
}
```
`recommendation.action` tells you *how* to add the records: `delegate` means point the domain's nameservers at Mektup (rare, only for `cloudflare`), `push-to-known-host` means add the records directly at a recognized registrar, `manual` means we couldn't identify the host and you should add the records however that provider's DNS panel works.

**Errors:** `402` if the account's domain limit is reached. `400` for an invalid or already-registered domain.

#### `GET /v1/domains`
List every domain on this account.

**Response `200`:**
```json
{ "domains": [{ "domain": "example.com", "verified": true, "created_at": "..." }] }
```

#### `GET /v1/domains/:domain/records`
Re-fetch the DNS records for an already-registered domain (the same set `POST /v1/domains` returned, re-derived from the existing DKIM key — never regenerates it). Use this any time after creation; the records aren't only shown once.

**Response `200`:** `{ "records": [...], "recommendation": {...} }` — same shapes as above.

**Errors:** `404` if the domain isn't registered to this account.

#### `POST /v1/domains/:domain/verify`
Actively re-checks the domain's live MX records against what Mektup expects and flips `verified` to `true` if they match. Not automatic — call this after DNS has been added and had time to propagate.

**Response `200`:**
```json
{ "verified": true, "mx": ["mail.usemektup.com", "mail2.usemektup.com"] }
```
or, on failure: `{ "verified": false, "mx": [], "error": "ENOTFOUND" }` (the `error` field is a DNS resolver error code, not an HTTP error).

**Errors:** `404` if the domain isn't registered to this account.

#### `DELETE /v1/domains/:domain`
Deletes the domain and, via cascade, every mailbox and message under it. Also removes its DKIM private key. **Does not delete already-delivered mail bodies from Object Storage** or the DKIM signing capability instantly (mail in flight may still reference the old records briefly) — this is a destructive, hard-to-reverse operation.

**Response:** `204`. **Errors:** `404` if not found on this account.

---

### Mailboxes

#### `POST /v1/domains/:domain/mailboxes`
Create a mailbox on an already-registered domain. Returns real IMAP/SMTP-AUTH login credentials usable in any real mail client (Outlook, Apple Mail, Thunderbird) — `IMAP: mail.usemektup.com:993`, `SMTP: mail.usemektup.com:465` or `:587`.

**Request body:**
```json
{ "localPart": "hello", "password": "optional, min 12 chars — omit to auto-generate" }
```

**Response `201`:**
```json
{
  "id": "uuid",
  "local_part": "hello",
  "created_at": "...",
  "domain": "example.com",
  "address": "hello@example.com",
  "password": "shown once if auto-generated, omitted if you supplied your own"
}
```
The `password` field is **not recoverable later** — if lost, use `PUT .../password` to reset it (this invalidates the old one).

**Errors:** `404` if the domain isn't registered to this account. `400` for an invalid local part or a password under 12 characters.

#### `GET /v1/domains/:domain/mailboxes`
List mailboxes on a domain.

**Response `200`:** `{ "mailboxes": [{ "local_part": "hello", "created_at": "..." }] }`

#### `PUT /v1/domains/:domain/mailboxes/:localPart/password`
Reset a mailbox's IMAP/SMTP-AUTH password. Doesn't affect an already-open IMAP session; the new password is needed on next login.

**Request body:** `{ "password": "optional, min 12 chars — omit to auto-generate" }`

**Response `200`:** `{ "address": "hello@example.com", "password": "shown once if auto-generated" }`

#### `DELETE /v1/domains/:domain/mailboxes/:localPart`
Deletes the mailbox record. Postfix stops accepting mail for it immediately. **Does not delete already-delivered mail** from the underlying mail store (a separate filesystem boundary Mektup's API deliberately doesn't have access to) — only the mailbox's ability to receive new mail and its Mektup-visible metadata.

**Response:** `204`. **Errors:** `404` if not found on this account.

---

### Forwarding

Forwarding delivers a copy of every incoming message to another address, in addition to keeping it in the mailbox (not instead of).

#### `GET /v1/domains/:domain/mailboxes/:localPart/forwards`
**Response `200`:** `{ "forwards": [{ "id": "uuid", "forward_to": "you@example.org" }] }`

#### `POST /v1/domains/:domain/mailboxes/:localPart/forwards`
**Request body:** `{ "forwardTo": "you@example.org" }`
**Response `201`:** `{ "forwards": [...] }` (the full updated list). **Errors:** `400` invalid email, `404` mailbox not found.

#### `DELETE /v1/domains/:domain/mailboxes/:localPart/forwards/:id`
**Response:** `204`. **Errors:** `404` if the forward doesn't exist on this account.

---

### Identity & signature

#### `GET /v1/domains/:domain/mailboxes/:localPart/identity`
**Response `200`:** `{ "display_name": string | null, "signature_text": string | null, "signature_html": string | null }`

#### `PUT /v1/domains/:domain/mailboxes/:localPart/identity`
Sets the display name and signature applied automatically to every outgoing message from this mailbox.

**Request body:** `{ "displayName": string | null, "signatureText": string | null, "signatureHtml": string | null }`
**Response:** `204`. **Errors:** `404` if the mailbox doesn't exist on this account.

---

### Vacation / auto-reply

#### `GET /v1/domains/:domain/mailboxes/:localPart/vacation`
**Response `200`:** `{ "vacation_enabled": boolean, "vacation_subject": string | null, "vacation_message": string | null }`

#### `PUT /v1/domains/:domain/mailboxes/:localPart/vacation`
**Request body:** `{ "enabled": boolean, "subject": string | null, "message": string | null }` — `message` is required if `enabled` is `true`.
**Response:** `204`. **Errors:** `400` if enabling without a message. `404` if the mailbox doesn't exist on this account.

Auto-reply fires at most once per sender within a rolling window (standard RFC 5230 vacation semantics — no separate configuration needed).

---

### Folders

One folder per message (Outlook-style), not Gmail-style multi-label. Deleting a folder does not delete the mail in it — messages fall back to their normal Inbox/Sent view.

#### `GET /v1/domains/:domain/mailboxes/:localPart/folders`
**Response `200`:** `{ "folders": [{ "id": "uuid", "name": "Receipts", "created_at": "..." }] }`

#### `POST /v1/domains/:domain/mailboxes/:localPart/folders`
**Request body:** `{ "name": "Receipts" }`
**Response `201`:** `{ "id": "uuid", "name": "Receipts", "created_at": "..." }`
**Errors:** `400` empty name. `409` a folder with that name already exists in this mailbox. `404` mailbox not found.

#### `DELETE /v1/domains/:domain/mailboxes/:localPart/folders/:id`
**Response:** `204`. **Errors:** `404` if not found on this account.

---

### Contacts

Account-level, not per-mailbox — a contact is someone you email from any mailbox you own.

#### `GET /v1/contacts`
**Response `200`:** `{ "contacts": [{ "id": "uuid", "name": string | null, "email": "a@b.com", "created_at": "..." }] }`

#### `POST /v1/contacts`
**Request body:** `{ "name": string | null, "email": "a@b.com" }`
**Response `201`:** the created contact. **Errors:** `400` invalid email. `409` already in contacts.

#### `PUT /v1/contacts/:id`
Partial update — only send the fields you want to change.
**Request body:** `{ "name"?: string | null, "email"?: "a@b.com" }`
**Response:** `204`. **Errors:** `400` if the body has neither field, or an invalid email. `404` not found on this account.

#### `DELETE /v1/contacts/:id`
**Response:** `204`. **Errors:** `404` not found on this account.

---

### Drafts

A draft is a distinct row from a real message — it never went through mail transport. Sending (`POST /v1/emails`) can take an optional `draftId` to delete the draft as part of the same successful send.

#### `GET /v1/domains/:domain/mailboxes/:localPart/drafts`
List view — metadata only, no body (same "don't ship the body until asked for" pattern as the message list).
**Response `200`:** `{ "drafts": [{ "id": "uuid", "to_address": string | null, "subject": string | null, "updated_at": "..." }] }`

#### `GET /v1/domains/:domain/mailboxes/:localPart/drafts/:id`
Full draft including body.
**Response `200`:** `{ "id": "uuid", "to_address": ..., "subject": ..., "body_text": string | null, "body_html": string | null, "updated_at": "..." }`
**Errors:** `404` if not found.

#### `POST /v1/domains/:domain/mailboxes/:localPart/drafts`
**Request body:** `{ "to"?: string, "subject"?: string, "text"?: string, "html"?: string }`
**Response `201`:** the created draft (same shape as the single-draft GET above). **Errors:** `404` mailbox not found.

#### `PUT /v1/domains/:domain/mailboxes/:localPart/drafts/:id`
Partial update (autosave-friendly) — only fields present in the body are touched.
**Request body:** any subset of `{ "to"?: string, "subject"?: string, "text"?: string, "html"?: string }`
**Response:** `204`. **Errors:** `404` not found.

#### `DELETE /v1/domains/:domain/mailboxes/:localPart/drafts/:id`
**Response:** `204`. **Errors:** `404` not found.

---

### Sending

#### `POST /v1/emails`
Send a real email. The `from` address's domain must already be registered to this account.

**Request body:**
```json
{
  "from": "hello@example.com",
  "to": "someone@gmail.com",
  "subject": "Hello",
  "text": "Plain text body",
  "html": "<p>Optional HTML body</p>",
  "attachments": [{ "filename": "invoice.pdf", "contentType": "application/pdf", "content": "base64..." }],
  "draftId": "optional uuid — deletes the draft on successful send"
}
```
At least one of `text`/`html` is required. Attachments are base64-encoded in the request body, max 10MB per attachment, max 25MB total request size. The mailbox's own display name and signature (from the Identity endpoint) are appended automatically — don't add a signature yourself, it'll be doubled.

**Response `202`:**
```json
{ "messageId": "<...@example.com>", "envelope": { "from": "hello@example.com", "to": ["someone@gmail.com"] } }
```

**Errors:** `402` if the account's monthly send limit or the sending domain's storage limit is reached. `400` for a `from` address not owned by this account, missing required fields, or an attachment over the size limit.

---

### Messages & threads

Messages are grouped into threads server-side (`thread_key`, derived from `Message-ID`/`In-Reply-To`/`References` per RFC 5322). List endpoints return one row per thread; use the thread endpoint to expand.

#### `GET /v1/messages`
List messages (as one row per thread) for a mailbox.

**Query params:**
| Param | Required | Description |
|---|---|---|
| `mailbox` | yes | `local@domain`, must be owned by this account |
| `limit` | no | Max rows, default 50, max 200 |
| `direction` | no | `inbound` or `outbound` — omit for Inbox+Sent merged (rare; normally pass this) |
| `trash` | no | `1` to view Trash instead of the active mailbox |
| `folder` | no | A folder ID — view a custom folder instead (ignores `direction`) |
| `q` | no | Full-text search (subject + from/to + body), results ranked by relevance |

**Response `200`:**
```json
{
  "messages": [{
    "id": "uuid", "direction": "inbound" | "outbound",
    "from_address": string | null, "to_address": string | null, "subject": string | null,
    "size_bytes": number, "received_at": "...", "read_at": "..." | null,
    "deleted_at": "..." | null, "flagged_at": "..." | null, "folder_id": "uuid" | null,
    "thread_key": "string", "thread_count": number, "thread_has_unread": boolean
  }]
}
```
`thread_count`/`thread_has_unread` are aggregates over the whole thread within the current filtered view (e.g. only non-deleted messages when not in Trash). When `q` is set, `thread_count` reflects matching messages only, not the thread's full size.

#### `GET /v1/threads/:threadKey`
Every individual message in one thread, oldest first — same query params as above (`mailbox` required, plus `direction`/`trash`/`folder` to match the view you expanded from) except `q`/`limit`.

**Response `200`:** `{ "messages": [/* same per-message shape as above, minus thread_count/thread_has_unread */] }`

#### `GET /v1/messages/:id`
Fetch one message's full content. **Marks it read as a side effect** (`read_at` set if it wasn't already) — same behavior as opening a message in any real mail client.

**Response `200`:**
```json
{
  "id": "uuid", "direction": "inbound", "from_address": "...", "to_address": "...", "subject": "...",
  "size_bytes": 1234, "received_at": "...", "read_at": "...", "deleted_at": null, "flagged_at": null,
  "folder_id": null, "thread_key": "...",
  "text": string | null, "html": string | null,
  "attachments": [{ "filename": string | null, "contentType": "application/pdf", "size": 1234 }]
}
```
**Errors:** `404` not found.

> **Security note for anything rendering `html`:** this is attacker-controlled content from anyone who can email the mailbox. Never render it directly — sanitize (strip scripts/event handlers) and isolate it (e.g. a sandboxed iframe) before display, exactly as Mektup's own dashboard does.

#### `PATCH /v1/messages/:id`
General-purpose state update — mark read/unread, restore from trash, flag/unflag, move to (or out of) a folder. Any combination in one call.

**Request body (all optional, at least one required):**
```json
{ "read"?: boolean, "deleted"?: false, "flagged"?: boolean, "folderId"?: "uuid" | null }
```
Note: `deleted` only accepts `false` here (restore) — use `DELETE` to actually delete. `folderId` must belong to the same mailbox as the message, or be `null`.

**Response:** `204`. **Errors:** `400` empty body. `404` not found, or `folderId` doesn't belong to this message's mailbox.

#### `DELETE /v1/messages/:id`
Two-stage delete, like any real mail client: first call soft-deletes (moves to Trash). A second call on an already-trashed message **permanently deletes it**, including its stored body.

**Response:** `204`. **Errors:** `404` not found.

#### `GET /v1/messages/:id/attachments/:index`
Streams one attachment's raw bytes with the correct `Content-Type`/`Content-Disposition`. `index` is the position in the `attachments` array from `GET /v1/messages/:id`.

**Response `200`:** binary. **Errors:** `404` message or attachment index not found.

---

### Unread counts

#### `GET /v1/unread-counts`
One grouped query for every domain/mailbox's unread Inbox count at once (what a sidebar would badge with) — Sent and Trash aren't counted, only Inbox.

**Response `200`:** `{ "counts": [{ "domain": "example.com", "local_part": "hello", "unread": 3 }] }`

---

### Usage & billing

#### `GET /v1/usage`
Current tier, its limits, and real usage against them.

**Response `200`:**
```json
{
  "tier": "free",
  "limits": { "label": "Free", "domains": 1, "emailsPerMonth": 1000, "storageBytesPerDomain": 3221225472 },
  "domainCount": 1,
  "emailsThisMonth": 12,
  "storageByDomain": [{ "domain_id": "uuid", "domain": "example.com", "bytes": "48213" }]
}
```
`limits` fields are `Infinity`-valued (serializes to `null` in JSON) for `enterprise`. Check this before a bulk operation to avoid hitting a `402` mid-batch.

---

## Worked example: set up a domain and send your first email

```bash
API=https://api.usemektup.com
KEY="mek_live_..."

# 1. Register the domain
curl -s -X POST "$API/v1/domains" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"domain":"example.com"}'
# -> add the returned MX/SPF/DMARC/DKIM records at example.com's DNS provider

# 2. Check DNS propagated
curl -s -X POST "$API/v1/domains/example.com/verify" -H "Authorization: Bearer $KEY"
# -> { "verified": true, "mx": [...] } once it has

# 3. Create a mailbox
curl -s -X POST "$API/v1/domains/example.com/mailboxes" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"localPart":"hello"}'
# -> save the returned password now, it's shown once

# 4. Send a real email from it
curl -s -X POST "$API/v1/emails" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"from":"hello@example.com","to":"you@gmail.com","subject":"It works","text":"Real mail, sent through Mektup."}'
```

---

## See also

- [`MCP.md`](./MCP.md) — using Mektup through the MCP server (recommended for AI coding agents)
- [`openapi.yaml`](./openapi.yaml) — machine-readable OpenAPI 3.1 spec of this same surface
