Base URL https://api.foundermail.mx. Requests and responses are JSON. Authenticate
every request with an API key from Settings → API keys, sent in the
X-Api-Key header. API keys are on every plan, the Free one included.
curl -H "X-Api-Key: $FOUNDERMAIL_KEY" https://api.foundermail.mx/mailboxes An API key is for your own code: a script, a backend job or an automation tool that reads the mail arriving at your mailboxes, waits for a particular message, drafts or sends a reply, or attaches your customer records to contacts. If you only want to read and answer mail, you do not need one: the dashboard and any IMAP client do that with the mailbox's own address and password. To use FounderMail from Claude, Cursor or another assistant, the same key goes into the MCP server instead.
401 means the key was not sent
or is wrong; a 403 names the scope the key lacks.
export FOUNDERMAIL_KEY="paste-your-key-here"
# Which mailboxes can this key see?
curl -H "X-Api-Key: $FOUNDERMAIL_KEY" https://api.foundermail.mx/mailboxes
# The ten newest messages in hello@yourdomain.com
curl -H "X-Api-Key: $FOUNDERMAIL_KEY" \
"https://api.foundermail.mx/messages/hello@yourdomain.com?take=10"
# Wait up to 60 seconds for the next message to arrive there
curl -H "X-Api-Key: $FOUNDERMAIL_KEY" \
"https://api.foundermail.mx/messages/hello@yourdomain.com/wait?timeout=60" On Windows PowerShell the first call is:
$key = "paste-your-key-here"
Invoke-RestMethod https://api.foundermail.mx/mailboxes -Headers @{ "X-Api-Key" = $key } In code it is the same header on an ordinary HTTPS request. In JavaScript, for example:
const res = await fetch("https://api.foundermail.mx/messages/hello@yourdomain.com?take=10", {
headers: { "X-Api-Key": process.env.FOUNDERMAIL_KEY },
});
const messages = await res.json();
To send from code, create a key with mail:send ticked and see
Sending; to put a person between your code and the recipient, use
Drafts instead.
The API can send, and it can draft. A key with mail:send sends
mail from a mailbox; a key with only mail:draft leaves the message in Drafts for
a person to send, which is what you want when a human should read it first. Sending through
the API goes through the same gate as the dashboard's compose window, so the same rules apply:
most domains may only reply to people who have written to them, and every send counts against
the plan's allowance.
A key carries a set of scopes, chosen when you create it in the dashboard — a new key reads
mail and writes drafts unless you say otherwise, and sending is never on by default. Keys made
before scopes could be chosen keep every scope, and the dashboard labels them
Full access (legacy); replace one to narrow it. The names matter when you read a
403, which names the scope that was missing.
| Scope | Grants |
|---|---|
inbox:read | List mailboxes; read and wait for messages |
inbox:write | Create and delete mailboxes |
mail:draft | Create, list and delete drafts. Never sends |
mail:send | Send mail from a mailbox |
config:write | Templates and contact links |
lists:read | Read mailing lists and their subscribers (Campaigns) |
lists:write | Ask people to join a list, and take them off. Cannot send a campaign |
admin | All of the above |
A mailbox is addressed by its key, the local part of its address. A bare key such as
hello works while exactly one mailbox in the workspace has it; when the same key
exists on two domains, use the full address, hello@yourdomain.com.
| Endpoint | Scope | |
|---|---|---|
GET /mailboxes | inbox:read | Every mailbox in the workspace |
GET /mailboxes/{key} | inbox:read | One mailbox |
POST /mailboxes | inbox:write | Create a mailbox |
DELETE /mailboxes/{key} | inbox:write | Delete a mailbox and its messages |
POST /mailboxes
{ "name": "orders", "domain": "yourdomain.com" }
200 OK
{
"key": "orders",
"domain": "yourdomain.com",
"address": "orders@yourdomain.com",
"password": "…",
"smtpHost": "smtp.foundermail.mx", "smtpPort": 587,
"imapHost": "imap.foundermail.mx", "imapPort": 993
}
domain is required and must be one of your domains. If name is
omitted a random key is generated, which is useful for an integration that needs an address
without inventing one. The password is the mailbox's IMAP and SMTP password.
Messages
Endpoint GET /messages/{key}?skip=0&take=10Messages in a mailbox, newest first. take is capped at 25. Add since=2026-10-10T08:00:00Z to list only what arrived at or after that time. GET /messages/{key}/countHow many messages the mailbox holds GET /messages/{key}/lastThe most recent message GET /messages/{key}/singleThe only message in the mailbox; an error if there are none or several GET /messages/{key}/get?id={uniqueId}One message by its unique id GET /messages/{key}/wait?timeout=60&since=…Long-poll: returns the first message received after since (default: now), or 204 when timeout seconds pass. Default 60, maximum 120.
{
"id": 48211,
"uniqueId": "9d0b6b1e-…",
"from": ["dana@arboretum.co"],
"to": ["billing@yourdomain.com"],
"cc": [], "bcc": [],
"subject": "Invoice 2291 shows the old VAT rate",
"body": "…",
"isHtml": true,
"received": "2026-09-05T07:14:02Z",
"spamAction": "Deliver",
"spamScore": 0.8,
"spamSymbols": "HFILTER_HOSTNAME_UNKNOWN(2.5)"
}
The list endpoint and the message endpoints return the message's parsed body: HTML when the
message had one, otherwise its text. Attachments are not exposed through the API.
spamAction is what the filter decided — Deliver, Junk,
Defer or Reject — and spamSymbols lists the rules that
fired, heaviest first, which is the answer to "why did this land in Junk". All three are
null when nothing scanned the message: no filter reachable, or the message too
large to scan. That is not the same as a clean verdict, and the difference is worth checking
if you are acting on the field.
Parsed with a template
Add a template key to last or single, or pass
template= to wait, and the message comes back with the values the
template extracted instead of the raw subject and body. See
Templates.
GET /messages/orders/order-confirmation/last
{
"uniqueId": "…",
"from": ["shop@example.com"], "to": ["orders@yourdomain.com"],
"subject": { "order": "1048" },
"body": { "total": "€240.00" },
"isHtml": false,
"received": "2026-09-05T07:14:02Z"
}
Drafts
A draft written through the API is the same row as one written in the dashboard: it appears in
the mailbox's Drafts folder, in the dashboard's Drafts view and in any IMAP client, from where a
person sends it.
Endpoint POST /messages/{key}/draftCreate a draft, or overwrite one when the body carries its id GET /messages/{key}/draftsDrafts in the mailbox DELETE /messages/{key}/drafts/{id}Delete one
POST /messages/billing/draft
{
"to": ["dana@arboretum.co"],
"subject": "Re: Invoice 2291 shows the old VAT rate",
"text": "Hi Dana, a corrected invoice is attached…",
"html": null,
"id": null
}
html takes precedence over text when both are given.
Sending
POST /messages/{key}/send sends now, from that mailbox's own address, and needs
the mail:send scope. The message is recorded in Sent exactly as one sent from the
dashboard is, so it appears in the thread, in the dashboard and in any IMAP client.
POST /messages/billing/send
{
"to": ["dana@arboretum.co"],
"subject": "Re: Invoice 2291 shows the old VAT rate",
"text": "Hi Dana, a corrected invoice is attached…",
"html": null,
"cc": [],
"bcc": [],
"inReplyToMessageId": 48211
}
inReplyToMessageId is the id of the message being answered, as the read
endpoints return it. Supply it unless the sending domain has been approved for writing to people who have
not written first: without it such a send is refused with 403, which is the
anti-abuse rule working rather than a fault. A 202 means the delivery provider has
accepted the message.
Status Means 202Accepted for delivery; the body carries the Sent message's id 403The domain may only reply, is not verified for sending, or the mailbox is disabled 429The monthly allowance, or the Free plan's daily one, is used up 503Delivery is unavailable; nothing was sent
There is no bulk endpoint and there will not be one: FounderMail hosts a team's mail, and
transactional or marketing blasts belong with a service built for them.
Contacts
Attach your own record of a customer to the contact FounderMail keeps for their address, so it
appears beside their mail in the dashboard.
PUT /contacts/{email}/link
{
"externalUserId": "usr_8Kd21",
"plan": "Pro",
"monthlyRevenue": 290,
"signedUpAt": "2025-03-14T09:00:00Z",
"displayName": "Dana Kessler",
"attributes": "{\"Seats\":\"12\"}"
}
DELETE /contacts/{email}/link
Every field is optional. attributes is a JSON object serialised as a string; its
members are shown as extra rows under In your app. The response includes
created, true when the address had never written to you and the contact was created
by this call. Such a contact is not verified by inbound mail and does not make the address a
permitted recipient in reply-only mode; see Contacts.
Templates
Endpoint GET /templatesAll templates GET /templates/{key}One template POST /templatesCreate: { "key", "subject", "body" } PUT /templates/{key}Update DELETE /templates/{key}Delete POST /templates/testTry patterns against sample text without saving: { "subject", "body", "sampleSubject", "sampleBody" }
Status
GET /status returns 200 when the API is up. It requires a key like everything else.
Errors and limits
401: missing or unknown key. 403 with a missingScope field: the key lacks that scope. Check the key before checking the data. 404: the mailbox, message or template is not in your workspace. Ids from another workspace are not found rather than forbidden. 429: the plan's request rate was exceeded (Free: 20 a minute, 100 an hour; Plus: 40 a minute, 300 an hour; Pro: 60 a minute, 1,500 an hour; Scale: 200 a minute, 10,000 an hour). Back off and retry. - Other errors are returned as problem details with a
title and detail.