Authentication & API Keys
How to authenticate programmatic access to your inbox infrastructure.
Every key has two axes
A key answers two separate questions, and you choose both when you create it. Where it reaches is fixed by the kind of key. What it may do there is a set of permissions you tick.
| Account key | Inbox key | |
| Looks like | mf_live_… | mf_sk_… |
| Reaches | All inboxes in your account | One single inbox |
| Header | X-API-Key: mf_live_… | X-API-Key: mf_sk_… |
| How you get one | Agents → API keys → New key | Open the inbox → 🔑 in the header |
| Can create inboxes? | Only with inbox:manage | ❌ never, it has no target outside its own inbox |
| Best for | Your own agents and CI that spin up many inboxes | Sharing just one inbox with a third party |
Inboxes are born without a key
Creating an inbox no longer mints a key for it. The inbox exists, and the 🔑 button issues a key when you actually want one. That is why POST /api/v1/inboxes answers with api_key: null.
Permissions: what the key may do
| Permission | In the dialog | What it unlocks |
|---|---|---|
| inbox:read | Read messages | List and open mail, download attachments, mark as read |
| email:send | Send mail | Send from your inboxes |
| message:write | Delete messages | Delete a single message, not the inbox itself |
| inbox:manage | Create and delete inboxes | Includes burn. Deleting an inbox cannot be undone |
| full | Full access | Everything above, plus any permission added later |
A new key can only read
The default is inbox:read and nothing else, because the safest key is the narrowest one. A key that only reads cannot open an inbox, so the classic agent flow fails on its first call until you also tick inbox:manage.
full is a wildcard, not a fourth checkbox
Ticking all four boxes and choosing full grant the same power today. They part ways tomorrow: full also covers permissions we have not written yet, while four ticks stay exactly four. Pick full only when you mean the wildcard.
Which boxes does an agent need?
The signup flow in our quickstarts opens an inbox, polls for the code, then throws the inbox away. That is two permissions, not one.
| The call | Permission it needs |
|---|---|
| POST /api/v1/inboxes | inbox:manage |
| GET /api/v1/inboxes | inbox:read |
| GET /api/v1/inboxes/{addr}/latest | inbox:read |
| GET /api/v1/inboxes/{addr}/messages | inbox:read |
| POST /api/v1/inboxes/{addr}/send | email:send |
| DELETE /api/v1/inboxes/{addr}/messages/{id} | message:write |
| DELETE /api/v1/inboxes/{addr} | inbox:manage |
| POST /api/v1/inboxes/{addr}/burn | inbox:manage |
Missing a permission returns 403, and says which one
The error names the exact scope it wanted, for example This API key is missing the required scope: inbox:manage. You do not have to guess. Add the box in Agents → API keys and issue a new key.
How long the key lives
Every key gets a lifetime when you create it: never expires, 30 days, 90 days or 1 year.
An expired key is refused like an unknown one, with 401. It is not deleted, so it still shows in your inventory and you can see when it lapsed. Nothing rolls it over for you: a CI job holding an expiring key starts failing on the day it lapses, which is the point of choosing a lifetime.
Auth, three ways
• UI / session: Authorization: Bearer <token>, from login/signup. Full account in the browser.
• Account key: X-API-Key: mf_live_…, every inbox the account owns (Agents → API keys).
• Inbox key: X-API-Key: mf_sk_…, one inbox only (🔑 in the inbox header).
Issuing a key always needs the session token. An API key cannot mint another API key, however wide its permissions are, so a leaked narrow key cannot widen itself.
Creating a key from code
Both endpoints take a session token, and both answer with the key in full exactly once.
# an account key that can run the whole signup flow
curl -X POST https://mailflat.net/api/keys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "label": "ci", "scopes": ["inbox:read", "inbox:manage"], "expires_in_days": 90 }'
# → { "key": "mf_live_…", "scopes": "inbox:read,inbox:manage", "expires_at": … }
# a key for one inbox, read only
curl -X POST https://mailflat.net/api/inboxes/shop@xxxx.mailflat.net/key \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "scopes": ["inbox:read"] }'
# → { "api_key": "mf_sk_…", "scopes": "inbox:read" }
One key per inbox
If the inbox already has a key, this call is refused rather than silently replacing it. Overwriting would stop whatever is using the old key without a word.
Example: Account key (manage many inboxes)
Classic agent flow: create an inbox, sign up somewhere, poll for the OTP. Needs inbox:manage for step 1 and inbox:read for step 3.
# 1) create a fresh inbox
curl -X POST https://mailflat.net/api/v1/inboxes \
-H "X-API-Key: mf_live_abc123…"
# → { "address": "agent-7f3a@xxxx.mailflat.net", "api_key": null }
# 2) list every inbox the account owns
curl https://mailflat.net/api/v1/inboxes -H "X-API-Key: mf_live_abc123…"
# 3) poll the latest mail (OTP auto-extracted)
curl https://mailflat.net/api/v1/inboxes/agent-7f3a@xxxx.mailflat.net/latest \
-H "X-API-Key: mf_live_abc123…"
# → { "otp": "482913", "subject": "Your code", … }
Example: Inbox key (share just one inbox)
Open the inbox, click 🔑 in the header to issue its key. The key works on both API surfaces, and on both it sees only that inbox.
# read messages of THIS inbox only
curl https://mailflat.net/api/inboxes/you@xxxx.mailflat.net/emails \
-H "X-API-Key: mf_sk_9d2f8a…"
# the v1 surface works too, and lists exactly one inbox
curl https://mailflat.net/api/v1/inboxes -H "X-API-Key: mf_sk_9d2f8a…"
# asking for another inbox → 403
curl https://mailflat.net/api/inboxes/someone-else@…/emails \
-H "X-API-Key: mf_sk_9d2f8a…" # ✗ denied
The reverse mix is the one that bites
An account key on the per-inbox paths (/api/inboxes/{addr}/…) is refused with 403. Those paths take a session token or that inbox's own key. An inbox key on /api/v1 is fine.