REST API

The API reference.

Everything a script, a backend or an agent can do with a Ledger Mail account over HTTPS. One bearer header, JSON in and JSON out, and a machine-readable contract you can generate a client from.

Base URL and versioning

Every route below is relative to one origin, and every user-facing route lives under a version prefix.

Base URL
https://api.theledgermail.com

User-facing routes are under /api/v1. The version is part of the path, so a future /api/v2 can exist beside it without changing a single call you have already written. Two routes sit outside the prefix because they describe the service rather than an account: /api/health and /api/openapi.json. The MCP endpoint at /mcp is documented on the MCP page.

Requests and responses are JSON, UTF-8, with one exception: an attachment download answers the file's bytes. Timestamps come in two shapes, both UTC: the camelCase fields on credentials (createdAt, lastUsedAt, expiresAt) are ISO 8601 with a trailing Z, while the snake_case fields on a profile, an alias and a domain (created_at, verified_at, created, modified) are YYYY-MM-DD HH:MM:SS.

Authentication

Three credentials reach the API, all of them in the same header. Which one you hold decides what you can do.

Create a personal API token

Sign in to the web app, open Settings and create an API token under Developer. Give it a label, tick only the scopes your script needs, and optionally an expiry between 1 and 365 days. The secret is shown once, at creation, and is stored nowhere — copy it then or make a new one. An account may hold 20 tokens at a time.

A token looks like lmk_ followed by 43 letters and digits, and goes in the Authorization header of every request:

Header
Authorization: Bearer lmk_p7Qz2Xk9RtA4sVb1NmJhGcD6yE0wLoF3iU8rTpZqXsY

A token is a password.

Keep it in an environment variable or a secret store, never in a repository or a front-end bundle. If one leaks, revoke it in Settings — the token stops working before the mail server is even told.

The three credentials

What each credential is
CredentialWhere it comes fromWhat it reaches
Web session JWTPOST /api/v1/auth/login, valid 15 minutesEverything its owner may reach. It carries no scopes: it is the account.
Personal API tokenPOST /api/v1/tokens, created in SettingsOnly the routes its scopes name. Never anything that manages the account.
Stalwart OAuth tokenThe OAuth 2.1 flow the MCP server usesMail and alias reads, mail writes and the profile — so an agent that has already signed in need not sign in twice.

The API tells them apart by the shape of the header: an lmk_ prefix is an API token, a decodable JOSE header is a web session, and anything else that could be an opaque token is checked with the mail server. A header that is none of those is refused outright.

What a token can never do

Managing the account's own credentials is never something a credential may delegate. These routes take the signed-in session's JWT and nothing else, so a leaked token can neither mint another token nor widen its own scopes:

  • POST /api/v1/tokens and the rest of /tokens
  • All of /api/v1/app-passwords
  • GET and DELETE /api/v1/profile/sessions
  • PATCH /api/v1/profile
  • POST /api/v1/profile/password
  • Every /api/v1/domains route
  • POST /api/v1/auth/logout
  • All of /api/v1/admin
Response · 403 Forbidden
{"error":"This endpoint is only available to a signed-in session, not to API or OAuth tokens"}

Scopes

A scope names a group of routes; a route names the one scope it needs. Give a token the fewest it can do its job with.

The six scopes
ScopeAllowsRoutes
mail:readList folders, list and read messages, download attachments, searchGET /mail/folders · GET /mail/folders/{folder}/messages · GET /mail/messages/{uid} · GET /mail/messages/{uid}/attachments/{index} · GET /mail/search
mail:sendSend mail from the account's own address or one of its active aliasesPOST /mail/send
mail:writeFlag, move and delete messages, one at a time or in bulkPOST /mail/messages/{uid}/flag · POST /mail/messages/{uid}/move · DELETE /mail/messages/{uid} · POST /mail/messages/bulk/flag · POST /mail/messages/bulk/move · POST /mail/messages/bulk/delete
aliases:readList the account's aliases, the domains it may use and whether a name is freeGET /aliases · GET /aliases/domains · GET /aliases/check
aliases:writeCreate, enable, disable and delete aliasesPOST /aliases · PATCH /aliases/{id} · DELETE /aliases/{id}
profile:readRead the account's profileGET /profile

A request without the scope its route needs is refused before the mail server is touched at all:

Response · 403 Forbidden
{"error":"This credential is missing the \"mail:send\" scope"}

Errors

Every failure is the same shape: one JSON object with one key, holding a sentence meant to be read by a person.

Every error
{"error":"Message not found"}
Status codes the API returns
StatusWhen
400 Bad RequestMalformed JSON, a missing field, or a value outside its range. The message names the field.
401 UnauthorizedNo credential, an unusable one, or a mail session that has expired and needs a fresh sign-in.
403 ForbiddenA credential that is valid but not allowed: a missing scope, a session-only route, or a domain you may not send from.
404 Not FoundThe message, alias, token, session or domain does not exist — or belongs to someone else.
409 ConflictThe thing already exists, or something else depends on it.
429 Too Many RequestsA rate limit. Retry-After says how long to wait.
500 Internal Server ErrorThe API could not complete the operation.
503 Service UnavailableThe mail server is starting up or could not be reached. Worth retrying.

Credential failures

An API token that is unknown, revoked or expired answers 401 with the reason spelled out, so a script can log something useful instead of guessing:

Response · 401 Unauthorized
{"error":"Invalid API token"}
{"error":"This API token has been revoked"}
{"error":"This API token has expired"}
{"error":"This API token's mail credential is no longer usable, please create a new token"}

If the mail server itself refuses a call made with a token, the answer is {"error":"This credential is no longer valid for mail access"} — and the owner's browser session is left alone.

Rate limits

Limits are per credential and per minute. A busy token cannot spend the browser's budget, or another account's.

Per-minute caps
AreaLimit
Mail60 a minute
Search20 a minute
Aliases30 a minute
Profile30 a minute
API tokens30 a minute
App passwords30 a minute
Sign in5 a minute
MCP120 a minute

Every limited response carries the state of your bucket:

Headers
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1758290580

X-RateLimit-Reset is a Unix time in seconds. Go over the limit and the answer is 429 with a Retry-After header in seconds — wait that long rather than reading the clock, and you need no clock in step with ours.

Response · 429 Too Many Requests
{"error":"Too many requests, please try again later"}

Pagination

Listings and searches are paged the same way, newest first.

Paging parameters
NameTypeDescription
pageinteger, query — 1 or more, default 1Which page to return. Page 1 holds the newest messages.
limitinteger, query — 1 to 100, default 50How many per page. Above 100 the request is refused rather than quietly clamped.

The answer's total is the size of the whole folder or result set, not of the page, so the number of pages is Math.ceil(total / limit). A search also echoes the page and limit it used.

Message ids (uid) name a message across the whole mailbox and survive a move, so an id you stored yesterday still resolves today, in whichever folder the message now sits.

Authentication

Signing a person in. Scripts do not use these routes — they present a personal API token instead — but the web app and anything that logs in as a user does.

POST/api/v1/auth/login

No credential

Exchange an address and password for a 15-minute access token and a refresh token. Limited to 5 attempts a minute per client.

Parameters
NameTypeDescription
emailrequiredstring, bodyThe mailbox address.
passwordrequiredstring, bodyThe account password.
Request
curl -X POST "https://api.theledgermail.com/api/v1/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"email":"you@theledgermail.com","password":"…"}'
Response · 200 OK
{
  "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9…",
  "refreshToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9…",
  "user": {
    "email": "you@theledgermail.com",
    "isAdmin": false
  }
}
Errors
StatusBody
400 Bad Request{"error":"Invalid email or password format"}
401 Unauthorized{"error":"Invalid credentials"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

POST/api/v1/auth/refresh

No credential

Trade a refresh token for a new pair. The old refresh token is blacklisted in the same call, so each one is used once.

Parameters
NameTypeDescription
refreshTokenrequiredstring, bodyThe refresh token from login.
Request
curl -X POST "https://api.theledgermail.com/api/v1/auth/refresh" \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"eyJhbGciOi…"}'
Response · 200 OK
{
  "accessToken": "eyJhbGciOi…",
  "refreshToken": "eyJhbGciOi…"
}
Errors
StatusBody
400 Bad Request{"error":"Missing refresh token"}
401 Unauthorized{"error":"Invalid or expired refresh token"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

POST/api/v1/auth/logout

Session only

Blacklist the refresh token and close the account's mail session. Available to a signed-in session only.

Parameters
NameTypeDescription
refreshTokenrequiredstring, bodyThe refresh token to retire.
Request
curl -X POST "https://api.theledgermail.com/api/v1/auth/logout" \
  -H "Authorization: Bearer $LEDGER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"eyJhbGciOi…"}'
Response · 200 OK
{
  "success": true
}
Errors
StatusBody
400 Bad Request{"error":"refreshToken is required"}
403 Forbidden{"error":"This endpoint is only available to a signed-in session, not to API or OAuth tokens"}

GET/api/v1/auth/domains

No credential

The domains open for registration. No credential at all — this is what the sign-up form reads.

Request
curl "https://api.theledgermail.com/api/v1/auth/domains"
Response · 200 OK
{
  "domains": [
    {
      "domain": "theledgermail.com"
    },
    {
      "domain": "theledger.email"
    }
  ]
}
Errors
StatusBody
500 Internal Server Error{"error":"Unable to fetch domains"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

POST/api/v1/auth/register

No credential

Create a mailbox and sign it in. Limited to 3 a minute per client; the local part must not be a reserved name and the account holder must be at least 13.

Parameters
NameTypeDescription
local_partrequiredstring, body — 2 to 64 charactersThe part before the @. Letters, digits, dots, hyphens and underscores, starting and ending alphanumeric.
domainrequiredstring, bodyOne of the domains from GET /auth/domains.
passwordrequiredstring, body — 8 or more charactersMust contain an upper-case letter, a lower-case letter and a digit.
first_namerequiredstring, body — 1 to 64 charactersAccount holder's first name.
last_namerequiredstring, body — 1 to 64 charactersAccount holder's last name.
dobrequiredstring, body — YYYY-MM-DDDate of birth.
Request
curl -X POST "https://api.theledgermail.com/api/v1/auth/register" \
  -H "Content-Type: application/json" \
  -d '{"local_part":"ada","domain":"theledgermail.com","password":"…","first_name":"Ada","last_name":"Lovelace","dob":"1990-12-10"}'
Response · 201 Created
{
  "accessToken": "eyJhbGciOi…",
  "refreshToken": "eyJhbGciOi…",
  "user": {
    "email": "ada@theledgermail.com",
    "isAdmin": false,
    "first_name": "Ada",
    "last_name": "Lovelace"
  }
}
Errors
StatusBody
400 Bad Request{"error":"Username must be at least 2 characters"}
403 Forbidden{"error":"Registration is currently disabled"}
409 Conflict{"error":"This email address is already taken"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

Mail

Folders, messages, attachments, search and sending. Every route here is limited to 60 requests a minute per credential; search has its own limit of 20.

GET/api/v1/mail/folders

API token or sessionmail:read

Every folder in the mailbox with its message and unread counts. Special-use folders lead the list; the inbox is always spelled INBOX.

Request
curl "https://api.theledgermail.com/api/v1/mail/folders" \
  -H "Authorization: Bearer $LEDGER_TOKEN"
Response · 200 OK
{
  "folders": [
    {
      "path": "INBOX",
      "name": "INBOX",
      "delimiter": "/",
      "messages": 128,
      "unseen": 3,
      "specialUse": "\\Inbox"
    },
    {
      "path": "Sent",
      "name": "Sent",
      "delimiter": "/",
      "messages": 64,
      "unseen": 0,
      "specialUse": "\\Sent"
    },
    {
      "path": "Archive",
      "name": "Archive",
      "delimiter": "/",
      "messages": 902,
      "unseen": 0,
      "specialUse": "\\Archive"
    },
    {
      "path": "Receipts/2026",
      "name": "2026",
      "delimiter": "/",
      "messages": 11,
      "unseen": 0
    }
  ]
}
  • specialUse is present only on a folder the mail server gives a role: \Inbox, \Flagged, \Sent, \Drafts, \All, \Archive, \Junk or \Trash.
  • A nested folder's path joins its parents with the delimiter, so pass the whole path (URL-encoded) wherever a folder is named.
Errors
StatusBody
401 Unauthorized{"error":"Mail session expired, please log in again"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

GET/api/v1/mail/folders/{folder}/messages

API token or sessionmail:read

One page of a folder's messages, newest first, as summaries with a short text preview.

Parameters
NameTypeDescription
folderrequiredstring, pathURL-encoded folder path, e.g. INBOX or Receipts%2F2026.
pageinteger, query — 1 or more, default 1Which page of the newest-first listing to return.
limitinteger, query — 1 to 100, default 50; outside that range the listing falls back to page 1, limit 50Messages per page. A larger value is rejected, not clamped.
Request
curl "https://api.theledgermail.com/api/v1/mail/folders/INBOX/messages?page=1&limit=25" \
  -H "Authorization: Bearer $LEDGER_TOKEN"
Response · 200 OK
{
  "messages": [
    {
      "uid": 4193,
      "seq": 1,
      "from": [
        {
          "name": "Ada Lovelace",
          "address": "ada@example.com"
        }
      ],
      "to": [
        {
          "name": "",
          "address": "you@theledgermail.com"
        }
      ],
      "subject": "Analytical engine notes",
      "date": "2026-09-18T09:12:44.000Z",
      "flags": [
        "\\Seen"
      ],
      "size": 8421,
      "preview": "The notes are attached — section G is the one to read first."
    }
  ],
  "total": 128
}
  • total is the number of messages in the folder, not in the page — divide it by your limit to know how many pages there are.
  • preview is the sender's own plain text and may contain < and &. Show it as text; never render it as HTML.
  • uid names a message across the whole mailbox and survives a move, so it stays valid after POST /mail/messages/{uid}/move.
Errors
StatusBody
401 Unauthorized{"error":"Mail session expired, please log in again"}
429 Too Many Requests{"error":"Too many requests, please try again later"}
API token or sessionmail:read

Search one folder for messages whose subject, sender, recipients or body contain the term. Limited to 20 requests a minute.

Parameters
NameTypeDescription
qrequiredstring, queryThe search term. An empty term is refused.
folderstring, query — default INBOXFolder path as GET /mail/folders spells it. The inbox is always "INBOX".
pageinteger, query — 1 or more, default 1Which page of the newest-first listing to return.
limitinteger, query — 1 to 100, default 50; outside that range the listing falls back to page 1, limit 50Messages per page. A larger value is rejected, not clamped.
Request
curl "https://api.theledgermail.com/api/v1/mail/search?q=invoice&folder=INBOX&limit=25" \
  -H "Authorization: Bearer $LEDGER_TOKEN"
Response · 200 OK
{
  "messages": [
    {
      "uid": 4193,
      "seq": 1,
      "from": [
        {
          "name": "Ada Lovelace",
          "address": "ada@example.com"
        }
      ],
      "to": [
        {
          "name": "",
          "address": "you@theledgermail.com"
        }
      ],
      "subject": "Analytical engine notes",
      "date": "2026-09-18T09:12:44.000Z",
      "flags": [
        "\\Seen"
      ],
      "size": 8421,
      "preview": "The notes are attached — section G is the one to read first."
    }
  ],
  "total": 7,
  "page": 1,
  "limit": 25
}
  • A search covers one folder at a time. To search the whole mailbox, loop over GET /mail/folders.
Errors
StatusBody
400 Bad Request{"error":"Missing search term"}
400 Bad Request{"error":"Invalid search parameters"}
500 Internal Server Error{"error":"Search failed"}
401 Unauthorized{"error":"Mail session expired, please log in again"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

GET/api/v1/mail/messages/{uid}

API token or sessionmail:read

One message in full: headers, threading headers, body and the attachment list.

Parameters
NameTypeDescription
uidrequiredinteger, pathThe uid from a listing or a search.
folderstring, query — default INBOXFolder path as GET /mail/folders spells it. The inbox is always "INBOX".
Request
curl "https://api.theledgermail.com/api/v1/mail/messages/4193?folder=INBOX" \
  -H "Authorization: Bearer $LEDGER_TOKEN"
Response · 200 OK
{
  "uid": 4193,
  "seq": 1,
  "from": [
    {
      "name": "Ada Lovelace",
      "address": "ada@example.com"
    }
  ],
  "to": [
    {
      "name": "",
      "address": "you@theledgermail.com"
    }
  ],
  "cc": [],
  "replyTo": [],
  "subject": "Analytical engine notes",
  "date": "2026-09-18T09:12:44.000Z",
  "flags": [
    "\\Seen"
  ],
  "size": 8421,
  "messageId": "<0d6f5a1e@example.com>",
  "references": [],
  "text": "The notes are attached — section G is the one to read first.",
  "attachments": [
    {
      "index": 0,
      "filename": "notes.pdf",
      "contentType": "application/pdf",
      "size": 98213
    }
  ]
}
  • html is present when the message has an HTML part, text when it has a plain-text one; a message may carry both.
  • messageId, inReplyTo and references are the threading headers. Pass messageId back as inReplyTo to reply in thread.
Errors
StatusBody
400 Bad Request{"error":"Invalid message UID"}
404 Not Found{"error":"Message not found"}
401 Unauthorized{"error":"Mail session expired, please log in again"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

GET/api/v1/mail/messages/{uid}/attachments/{index}

API token or sessionmail:read

Download one attachment by its position in the message's attachment list. The only route that does not answer JSON.

Parameters
NameTypeDescription
uidrequiredinteger, pathThe message's uid.
indexrequiredinteger, path — 0 or moreThe attachment's index field.
folderstring, query — default INBOXFolder path as GET /mail/folders spells it. The inbox is always "INBOX".
Request
curl "https://api.theledgermail.com/api/v1/mail/messages/4193/attachments/0?folder=INBOX" \
  -H "Authorization: Bearer $LEDGER_TOKEN" \
  -o notes.pdf
Response · 200 OK
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="notes.pdf"; filename*=UTF-8''notes.pdf
Content-Length: 98213
X-Content-Type-Options: nosniff
Cache-Control: private, no-store

<the file's bytes>
  • Every attachment is served as application/octet-stream whatever its own type, so an HTML or SVG attachment can never execute as a document in the API's origin. Read the real type from the message's attachment list.
Errors
StatusBody
400 Bad Request{"error":"Invalid attachment index"}
404 Not Found{"error":"Attachment not found"}
401 Unauthorized{"error":"Mail session expired, please log in again"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

POST/api/v1/mail/send

API token or sessionmail:send

Send a message and file a copy in Sent. from must be the account's own address or one of its enabled aliases.

Parameters
NameTypeDescription
fromrequiredstring, body — a bare addressThe account's address or an enabled alias. No display name here: the route authorizes on this value.
torequiredstring or string[], bodyRecipients. A string may hold several comma-separated addresses and may use the "Name <address>" form.
ccstring or string[], bodyCarbon copy recipients.
bccstring or string[], bodyBlind carbon copy recipients.
subjectrequiredstring, body — at most 998 charactersNo line breaks.
textstring, bodyPlain-text body.
htmlstring, bodyHTML body.
inReplyTostring, bodyMessage-ID being replied to, angle brackets included.
referencesstring, bodySpace-separated Message-IDs of the thread.
attachmentsobject[], body — at most 20Each { filename, contentType?, content }, where content is standard base64 and the whole message stays under 25 MB.
Request
curl -X POST "https://api.theledgermail.com/api/v1/mail/send" \
  -H "Authorization: Bearer $LEDGER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"from":"you@theledgermail.com","to":["ada@example.com"],"subject":"Re: Analytical engine notes","text":"Read section G. Thanks!","inReplyTo":"<0d6f5a1e@example.com>"}'
Response · 200 OK
{
  "messageId": "<a71c33f0@theledgermail.com>",
  "savedTo": "Sent"
}
  • At most 50 recipients across to, cc and bcc in one call.
  • savedTo is left out entirely when the mailbox has no Sent folder or the copy could not be filed. The message has still been sent.
Errors
StatusBody
400 Bad Request{"error":"Invalid JSON body"}
403 Forbidden{"error":"You can only send from your own address or active aliases"}
403 Forbidden{"error":"You do not have permission to send from this domain"}
401 Unauthorized{"error":"Mail session expired, please log in again"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

POST/api/v1/mail/messages/{uid}/flag

API token or sessionmail:write

Add or remove IMAP flags on one message — \Seen, \Flagged, \Answered and the rest.

Parameters
NameTypeDescription
uidrequiredinteger, pathThe message's uid.
flagsrequiredstring[], bodyThe flags to add or remove.
actionrequired"add" or "remove", bodyWhat to do with them.
folderstring, body — default INBOXAccepted but not needed: a uid is unique across the mailbox.
Request
curl -X POST "https://api.theledgermail.com/api/v1/mail/messages/4193/flag" \
  -H "Authorization: Bearer $LEDGER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"flags":["\\Seen"],"action":"add"}'
Response · 200 OK
{
  "success": true
}
Errors
StatusBody
400 Bad Request{"error":"Invalid flag data"}
401 Unauthorized{"error":"Mail session expired, please log in again"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

POST/api/v1/mail/messages/{uid}/move

API token or sessionmail:write

Move one message to another folder. Its uid does not change.

Parameters
NameTypeDescription
uidrequiredinteger, pathThe message's uid.
destinationrequiredstring, bodyDestination folder path.
folderstring, body — default INBOXThe folder the message is in now.
Request
curl -X POST "https://api.theledgermail.com/api/v1/mail/messages/4193/move" \
  -H "Authorization: Bearer $LEDGER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"folder":"INBOX","destination":"Archive"}'
Response · 200 OK
{
  "success": true
}
Errors
StatusBody
400 Bad Request{"error":"Invalid move data"}
401 Unauthorized{"error":"Mail session expired, please log in again"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

DELETE/api/v1/mail/messages/{uid}

API token or sessionmail:write

Delete one message.

Parameters
NameTypeDescription
uidrequiredinteger, pathThe message's uid.
folderstring, query — default INBOXFolder path as GET /mail/folders spells it. The inbox is always "INBOX".
Request
curl -X DELETE "https://api.theledgermail.com/api/v1/mail/messages/4193?folder=INBOX" \
  -H "Authorization: Bearer $LEDGER_TOKEN"
Response · 200 OK
{
  "success": true
}
Errors
StatusBody
400 Bad Request{"error":"Invalid message UID"}
401 Unauthorized{"error":"Mail session expired, please log in again"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

POST/api/v1/mail/messages/bulk/{flag|move|delete}

API token or sessionmail:write

The same three operations over a set of messages, each in a single request to the mail server. At most 200 uids per call.

Parameters
NameTypeDescription
uidsrequiredinteger[], body — 1 to 200The messages to act on. Duplicates are ignored.
flagsstring[], bodybulk/flag only, with at least one flag.
action"add" or "remove", bodybulk/flag only.
destinationstring, bodybulk/move only: the destination folder path.
folderstring, body — default INBOXWhere the messages are now, for move and delete.
Request
curl -X POST "https://api.theledgermail.com/api/v1/mail/messages/bulk/move" \
  -H "Authorization: Bearer $LEDGER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"uids":[4193,4194,4195],"folder":"INBOX","destination":"Archive"}'
Response · 200 OK
{
  "success": true,
  "count": 3
}
  • count is the number of distinct uids the request asked for, after duplicates were dropped.
Errors
StatusBody
400 Bad Request{"error":"At least one uid is required"}
400 Bad Request{"error":"Too many messages (max 200 per request)"}
401 Unauthorized{"error":"Mail session expired, please log in again"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

Aliases

Extra addresses that deliver to the mailbox, and that the account may also send from. 30 requests a minute per credential.

GET/api/v1/aliases

API token or sessionaliases:read

Every alias on the account, with whether it is currently active.

Request
curl "https://api.theledgermail.com/api/v1/aliases" \
  -H "Authorization: Bearer $LEDGER_TOKEN"
Response · 200 OK
{
  "aliases": [
    {
      "id": 813402117,
      "address": "receipts@theledgermail.com",
      "active": true,
      "created": "2026-04-02T11:04:00.000Z",
      "modified": "2026-08-21T16:40:10.000Z"
    }
  ]
}
  • id is derived from the address, so it is stable for as long as the alias exists. created and modified are absent for an alias the API never saw being made.
Errors
StatusBody
429 Too Many Requests{"error":"Too many requests, please try again later"}

GET/api/v1/aliases/domains

API token or sessionaliases:read

The domains this account may put an alias on: the platform's own, plus any active custom domain it owns.

Request
curl "https://api.theledgermail.com/api/v1/aliases/domains" \
  -H "Authorization: Bearer $LEDGER_TOKEN"
Response · 200 OK
{
  "domains": [
    {
      "domain": "theledgermail.com"
    },
    {
      "domain": "yourcompany.com"
    }
  ]
}
Errors
StatusBody
500 Internal Server Error{"error":"Unable to fetch domains"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

GET/api/v1/aliases/check

API token or sessionaliases:read

Whether a local part is free on a domain, and valid. Advisory — creation is what actually claims it.

Parameters
NameTypeDescription
namerequiredstring, queryThe local part to test.
domainrequiredstring, queryThe domain to test it on.
Request
curl "https://api.theledgermail.com/api/v1/aliases/check?name=receipts&domain=theledgermail.com" \
  -H "Authorization: Bearer $LEDGER_TOKEN"
Response · 200 OK
{
  "available": false,
  "error": "receipts@theledgermail.com is already taken"
}
  • A name that is unusable answers 200 with available: false and an error string — including "You do not have permission to use this domain".
Errors
StatusBody
400 Bad Request{"error":"name and domain query params required"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

POST/api/v1/aliases

API token or sessionaliases:write

Create an alias. Leave the name out and the API picks a random local part.

Parameters
NameTypeDescription
domainrequiredstring, bodyOne of the domains from GET /aliases/domains.
namestring, bodyLocal part: 2 to 64 lower-case letters, digits, dots, hyphens or underscores.
Request
curl -X POST "https://api.theledgermail.com/api/v1/aliases" \
  -H "Authorization: Bearer $LEDGER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"domain":"theledgermail.com","name":"receipts"}'
Response · 201 Created
{
  "alias": "receipts@theledgermail.com",
  "id": 813402117
}
Errors
StatusBody
400 Bad Request{"error":"receipts@theledgermail.com is already taken"}
400 Bad Request{"error":"domain is required"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

PATCH/api/v1/aliases/{id}

API token or sessionaliases:write

Enable or disable an alias. A disabled alias stops delivering and stops being a sending identity.

Parameters
NameTypeDescription
idrequiredinteger, pathThe alias id from GET /aliases.
activerequiredboolean, bodyWhether the alias should be on.
Request
curl -X PATCH "https://api.theledgermail.com/api/v1/aliases/813402117" \
  -H "Authorization: Bearer $LEDGER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"active":false}'
Response · 200 OK
{
  "success": true
}
Errors
StatusBody
400 Bad Request{"error":"active (boolean) is required"}
404 Not Found{"error":"Alias not found or not owned by you"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

DELETE/api/v1/aliases/{id}

API token or sessionaliases:write

Delete an alias for good. Mail already delivered to the mailbox stays where it is.

Parameters
NameTypeDescription
idrequiredinteger, pathThe alias id.
Request
curl -X DELETE "https://api.theledgermail.com/api/v1/aliases/813402117" \
  -H "Authorization: Bearer $LEDGER_TOKEN"
Response · 200 OK
{
  "success": true
}
Errors
StatusBody
400 Bad Request{"error":"Invalid alias ID"}
404 Not Found{"error":"Alias not found or not owned by you"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

Profile

The account holder's own details. Reading is a scope an API token can hold; changing anything is the signed-in session's alone. 30 requests a minute.

GET/api/v1/profile

API token or sessionprofile:read

The account holder's name, date of birth and the date the profile was created.

Request
curl "https://api.theledgermail.com/api/v1/profile" \
  -H "Authorization: Bearer $LEDGER_TOKEN"
Response · 200 OK
{
  "email": "you@theledgermail.com",
  "first_name": "Ada",
  "last_name": "Lovelace",
  "dob": "1990-12-10",
  "created_at": "2026-04-02T10:58:31.000Z"
}
  • A mailbox created outside the web sign-up has no profile row and answers 404 — that is not an error in your credential.
Errors
StatusBody
404 Not Found{"error":"Profile not found"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

PATCH/api/v1/profile

Session only

Change the name or date of birth. Session only.

Parameters
NameTypeDescription
first_namestring, body — 1 to 64 charactersNew first name.
last_namestring, body — 1 to 64 charactersNew last name.
dobstring, body — YYYY-MM-DDNew date of birth.
Request
curl -X PATCH "https://api.theledgermail.com/api/v1/profile" \
  -H "Authorization: Bearer $LEDGER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"first_name":"Ada"}'
Response · 200 OK
{
  "success": true
}
Errors
StatusBody
400 Bad Request{"error":"Nothing to update"}
400 Bad Request{"error":"Profile does not exist. Provide first_name, last_name, and dob to create."}
403 Forbidden{"error":"This endpoint is only available to a signed-in session, not to API or OAuth tokens"}

POST/api/v1/profile/password

Session only

Change the account password and rotate the web session's mail credential with it. Session only, 5 attempts a minute.

Parameters
NameTypeDescription
current_passwordrequiredstring, bodyThe password in use now.
new_passwordrequiredstring, body — 8 or more charactersMust contain an upper-case letter, a lower-case letter and a digit, and differ from the current one.
Request
curl -X POST "https://api.theledgermail.com/api/v1/profile/password" \
  -H "Authorization: Bearer $LEDGER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"current_password":"…","new_password":"…"}'
Response · 200 OK
{
  "success": true
}
Errors
StatusBody
400 Bad Request{"error":"New password must be different from current password"}
401 Unauthorized{"error":"Current password is incorrect"}
500 Internal Server Error{"error":"Failed to update password"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

Credentials

Personal API tokens, mail-client app passwords, and the list of everything that can currently reach the account. Managing credentials is never something a credential may delegate, so every route here takes the signed-in session's JWT and nothing else. 30 requests a minute.

POST/api/v1/tokens

Session only

Mint a personal API token with a fixed set of scopes. The secret is in this response and in no other — store it at once.

Parameters
NameTypeDescription
labelrequiredstring, body — 1 to 64 charactersWhat the token is for. Trimmed.
scopesrequiredstring[], body — at least oneAny of the six scopes. Order does not matter and duplicates are dropped.
expiresInDaysinteger, body — 1 to 365Leave it out for a token that does not expire.
Request
curl -X POST "https://api.theledgermail.com/api/v1/tokens" \
  -H "Authorization: Bearer $LEDGER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"label":"Receipt filer","scopes":["mail:read","mail:write"],"expiresInDays":90}'
Response · 201 Created
{
  "id": "V1StGXR8_Z5jdHi6B-myT",
  "label": "Receipt filer",
  "scopes": [
    "mail:read",
    "mail:write"
  ],
  "token": "lmk_p7Qz2Xk9RtA4sVb1NmJhGcD6yE0wLoF3iU8rTpZqXsY",
  "hint": "qXsY",
  "createdAt": "2026-09-19T14:22:05Z",
  "expiresAt": "2026-12-18T14:22:05Z"
}
  • scopes comes back in canonical order, whatever order you asked in.
  • hint is the secret's last four characters, so a listing can tell two tokens apart without holding either.
  • An account may hold 20 tokens at once.
Errors
StatusBody
400 Bad Request{"error":"At least one scope is required"}
400 Bad Request{"error":"Unknown scope \"mail:all\". Available scopes: mail:read, mail:send, mail:write, aliases:read, aliases:write, profile:read"}
409 Conflict{"error":"You already have 20 API tokens; revoke one before creating another"}
401 Unauthorized{"error":"Mail session expired, please log in again"}
503 Service Unavailable{"error":"Mail server is initializing, please try again in a moment"}

GET/api/v1/tokens

Session only

The account's API tokens, newest first. Never a secret — only the hint.

Request
curl "https://api.theledgermail.com/api/v1/tokens" \
  -H "Authorization: Bearer $LEDGER_JWT"
Response · 200 OK
{
  "tokens": [
    {
      "id": "V1StGXR8_Z5jdHi6B-myT",
      "label": "Receipt filer",
      "scopes": [
        "mail:read",
        "mail:write"
      ],
      "hint": "qXsY",
      "createdAt": "2026-09-19T14:22:05Z",
      "lastUsedAt": "2026-09-19T15:01:00Z",
      "expiresAt": "2026-12-18T14:22:05Z"
    }
  ]
}
  • lastUsedAt is kept to the minute. A token that has never been used has null.
  • An expired token is still listed, so you can see why it stopped working; a revoked one is gone.
Errors
StatusBody
429 Too Many Requests{"error":"Too many requests, please try again later"}

DELETE/api/v1/tokens/{id}

Session only

Revoke a token. It stops authenticating immediately, before the mail server is told to drop the credential behind it.

Parameters
NameTypeDescription
idrequiredstring, pathThe token id from the listing.
Request
curl -X DELETE "https://api.theledgermail.com/api/v1/tokens/V1StGXR8_Z5jdHi6B-myT" \
  -H "Authorization: Bearer $LEDGER_JWT"
Response · 200 OK
{
  "success": true
}
  • Revoking twice is still 200: the call is idempotent.
Errors
StatusBody
404 Not Found{"error":"Token not found"}
500 Internal Server Error{"error":"Failed to revoke token"}
401 Unauthorized{"error":"Mail session expired, please log in again"}

POST/api/v1/app-passwords

Session only

Mint a mail-client app password: what Thunderbird, Apple Mail or a phone signs in with over IMAP, SMTP and JMAP.

Parameters
NameTypeDescription
labelrequiredstring, body — 1 to 64 charactersWhich device or client this is for.
Request
curl -X POST "https://api.theledgermail.com/api/v1/app-passwords" \
  -H "Authorization: Bearer $LEDGER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"label":"Thunderbird on the laptop"}'
Response · 201 Created
{
  "id": "bd21f0c4",
  "type": "app_password",
  "label": "Thunderbird on the laptop",
  "username": "you@theledgermail.com",
  "password": "curlew-ashfall-tremolo-42",
  "scopes": null,
  "access": "Full mailbox access over IMAP, SMTP and JMAP. An app password has no scopes and cannot be used as an API token."
}
  • The password is shown once and stored nowhere. An app password carries no scopes at all — it is the whole mailbox.
Errors
StatusBody
400 Bad Request{"error":"label is required"}
500 Internal Server Error{"error":"Failed to create app password"}
401 Unauthorized{"error":"Mail session expired, please log in again"}

DELETE/api/v1/app-passwords/{id}

Session only

Revoke a mail-client app password.

Parameters
NameTypeDescription
idrequiredstring, pathThe credential id from the list.
Request
curl -X DELETE "https://api.theledgermail.com/api/v1/app-passwords/bd21f0c4" \
  -H "Authorization: Bearer $LEDGER_JWT"
Response · 200 OK
{
  "success": true
}
  • Revoking the credential your own browser session runs on ends that session here too, exactly as revoking the session does.
Errors
StatusBody
404 Not Found{"error":"App password not found"}
409 Conflict{"error":"This credential backs the API token \"Receipt filer\"; revoke it with DELETE /api/v1/tokens/V1StGXR8_Z5jdHi6B-myT"}
500 Internal Server Error{"error":"Failed to revoke app password"}

GET/api/v1/profile/sessions

Session only

Everything that can currently reach the account: browser sessions, mail-client app passwords and API tokens, newest first.

Request
curl "https://api.theledgermail.com/api/v1/profile/sessions" \
  -H "Authorization: Bearer $LEDGER_JWT"
Response · 200 OK
{
  "sessions": [
    {
      "id": "7a1c02de",
      "description": "Ledger web session",
      "createdAt": "2026-09-19T13:02:10Z",
      "expiresAt": null,
      "current": true
    }
  ],
  "credentials": [
    {
      "id": "V1StGXR8_Z5jdHi6B-myT",
      "type": "api_token",
      "label": "Receipt filer",
      "createdAt": "2026-09-19T14:22:05Z",
      "lastUsedAt": "2026-09-19T15:01:00Z",
      "expiresAt": "2026-12-18T14:22:05Z",
      "current": false,
      "scopes": [
        "mail:read",
        "mail:write"
      ]
    },
    {
      "id": "7a1c02de",
      "type": "web_session",
      "label": "Ledger web session",
      "createdAt": "2026-09-19T13:02:10Z",
      "lastUsedAt": null,
      "expiresAt": null,
      "current": true
    },
    {
      "id": "bd21f0c4",
      "type": "app_password",
      "label": "Thunderbird on the laptop",
      "createdAt": "2026-09-12T08:40:00Z",
      "lastUsedAt": null,
      "expiresAt": null,
      "current": false
    }
  ]
}
  • sessions is the older, narrower view and is unchanged field for field. credentials is the one to read: it is the same set, classified.
  • scopes appears on api_token entries only. The other two kinds have none, because they are full mailbox access.
  • current is true on the web session this very request was made with, and never on anything else.
  • An api_token entry's id is the token's id — the one DELETE /api/v1/tokens/{id} takes, not the app password's.
Errors
StatusBody
401 Unauthorized{"error":"Mail session expired, please log in again"}
500 Internal Server Error{"error":"Unable to fetch sessions"}

DELETE/api/v1/profile/sessions/{id}

Session only

Revoke a browser session or a mail-client app password by its credential id.

Parameters
NameTypeDescription
idrequiredstring, pathThe credential id.
Request
curl -X DELETE "https://api.theledgermail.com/api/v1/profile/sessions/bd21f0c4" \
  -H "Authorization: Bearer $LEDGER_JWT"
Response · 200 OK
{
  "success": true
}
  • This revokes app passwords, which is what a browser session and a mail client use. It does not revoke an OAuth access token — see the MCP page.
Errors
StatusBody
404 Not Found{"error":"Session not found"}
500 Internal Server Error{"error":"Failed to revoke session"}
401 Unauthorized{"error":"Mail session expired, please log in again"}

Custom domains

Bringing your own domain to the mailbox. No scope reaches these: they belong to the signed-in session, like every other route that changes what the account is.

GET/api/v1/domains

Session only

The domains claimed on this account, with how many the plan allows. 30 requests a minute.

Request
curl "https://api.theledgermail.com/api/v1/domains" \
  -H "Authorization: Bearer $LEDGER_JWT"
Response · 200 OK
{
  "domains": [
    {
      "domain": "yourcompany.com",
      "status": "active",
      "created_at": "2026-05-04T09:20:00.000Z",
      "verified_at": "2026-05-04T09:41:12.000Z"
    }
  ],
  "limit": 2,
  "used": 1
}
Errors
StatusBody
429 Too Many Requests{"error":"Too many requests, please try again later"}

POST/api/v1/domains

Session only

Claim a domain. The answer carries the TXT record to publish; the domain stays pending until it is verified. 10 requests a minute.

Parameters
NameTypeDescription
domainrequiredstring, bodyThe domain you own, e.g. example.com.
Request
curl -X POST "https://api.theledgermail.com/api/v1/domains" \
  -H "Authorization: Bearer $LEDGER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"domain":"yourcompany.com"}'
Response · 201 Created
{
  "domain": "yourcompany.com",
  "status": "pending_verification",
  "created_at": "2026-05-04T09:20:00.000Z",
  "verification": {
    "type": "TXT",
    "host": "_ledger-challenge.yourcompany.com",
    "value": "ledger-site-verification=8f2c…"
  }
}
  • Claiming a domain you already hold answers 200 with the domain as it stands, not 201.
Errors
StatusBody
400 Bad Request{"error":"Enter a valid domain you own (e.g. example.com)"}
403 Forbidden{"error":"Custom domains aren't included in your plan"}
409 Conflict{"error":"This domain has already been claimed by another user"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

POST/api/v1/domains/{domain}/verify

Session only

Check for the TXT record and, when it is there, provision the domain on the mail server. 10 requests a minute.

Parameters
NameTypeDescription
domainrequiredstring, pathThe claimed domain.
Request
curl -X POST "https://api.theledgermail.com/api/v1/domains/yourcompany.com/verify" \
  -H "Authorization: Bearer $LEDGER_JWT"
Response · 200 OK
{
  "domain": "yourcompany.com",
  "status": "active",
  "created_at": "2026-05-04T09:20:00.000Z",
  "verified_at": "2026-05-04T09:41:12.000Z",
  "records": [
    {
      "kind": "MX",
      "host": "yourcompany.com",
      "value": "10 mail.theledgermail.com.",
      "ok": true
    },
    {
      "kind": "SPF",
      "host": "yourcompany.com",
      "value": "v=spf1 include:_spf.theledgermail.com -all",
      "ok": true
    },
    {
      "kind": "DKIM",
      "host": "dkim._domainkey.yourcompany.com",
      "value": "v=DKIM1; k=rsa; p=…",
      "ok": null
    },
    {
      "kind": "DMARC",
      "host": "_dmarc.yourcompany.com",
      "value": "v=DMARC1; p=quarantine; rua=mailto:postmaster@yourcompany.com",
      "ok": false
    }
  ]
}
  • ok is true when the record was found as published, false when it was not, and null when it could not be checked.
  • A failed check is remembered for 30 seconds, so asking again straight away answers the same thing without a DNS lookup.
Errors
StatusBody
400 Bad Request{"error":"Verification TXT not found yet — DNS can take a while to propagate"}
404 Not Found{"error":"Domain not found"}
503 Service Unavailable{"error":"Couldn't read DNS right now, try again shortly"}

GET/api/v1/domains/{domain}

Session only

One domain with its full record set and the live state of each record. 30 requests a minute.

Parameters
NameTypeDescription
domainrequiredstring, pathThe claimed domain.
Request
curl "https://api.theledgermail.com/api/v1/domains/yourcompany.com" \
  -H "Authorization: Bearer $LEDGER_JWT"
Response · 200 OK
{
  "domain": "yourcompany.com",
  "status": "active",
  "created_at": "2026-05-04T09:20:00.000Z",
  "verified_at": "2026-05-04T09:41:12.000Z",
  "records": [
    {
      "kind": "MX",
      "host": "yourcompany.com",
      "value": "10 mail.theledgermail.com.",
      "ok": true
    }
  ]
}
Errors
StatusBody
404 Not Found{"error":"Domain not found"}
429 Too Many Requests{"error":"Too many requests, please try again later"}

DELETE/api/v1/domains/{domain}

Session only

Release a domain. Remove its aliases first. 10 requests a minute.

Parameters
NameTypeDescription
domainrequiredstring, pathThe claimed domain.
Request
curl -X DELETE "https://api.theledgermail.com/api/v1/domains/yourcompany.com" \
  -H "Authorization: Bearer $LEDGER_JWT"
Response · 200 OK
{
  "success": true
}
Errors
StatusBody
404 Not Found{"error":"Domain not found"}
409 Conflict{"error":"Remove all aliases on this domain first"}
500 Internal Server Error{"error":"Failed to delete domain"}

Service

Two routes that need no credential at all.

GET/api/openapi.json

No credential

The machine-readable contract: OpenAPI 3.1, every user-facing route, all three bearer schemes, the error shapes and the rate-limit headers.

Request
curl "https://api.theledgermail.com/api/openapi.json"
Response · 200 OK
{
  "openapi": "3.1.1",
  "info": {
    "title": "The Ledger Mail API",
    "version": "2.0.0"
  },
  "servers": [
    {
      "url": "https://api.theledgermail.com",
      "description": "Production"
    }
  ],
  "x-api-token-scopes": {
    "mail:read": "List folders, list and read messages, download attachments, search"
  }
}
  • Each operation names the one scope it needs in x-required-scope; x-api-token-scopes at the top maps every scope to its description.
  • An operation a token can never reach lists sessionJwt alone and carries no x-required-scope.

GET/api/health

No credential

Liveness. Answers without touching the mail server.

Request
curl "https://api.theledgermail.com/api/health"
Response · 200 OK
{
  "status": "ok",
  "timestamp": "2026-09-19T14:22:05.113Z"
}

OpenAPI document

The whole contract, machine-readable, unauthenticated and always in step with the running API.

Point a code generator, a Postman import or an agent at it. It is OpenAPI 3.1 and covers every user-facing route, all three bearer schemes, the error shapes and the rate-limit headers. Each operation names the one scope it needs in x-required-scope, and x-api-token-scopes at the top maps every scope to its description.

Fetch it
curl https://api.theledgermail.com/api/openapi.json

Open the OpenAPI document