View as markdown ↓

Authentication & key scoping

Every /api/v1/ request authenticates with a bearer API key. Keys are opaque secrets that start with pf_ and are stored only as a SHA-256 hash server-side.

Authorization: Bearer pf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

A missing or malformed header returns 401 {"error": "missing bearer token"}; an unknown key returns 401 {"error": "invalid api key"}.

Key scopes

Each key carries one scope. Only three scopes can authenticate a bearer request:

  • bootstrap — provision-only. Accepted by POST /api/v1/mailboxes (to create a mailbox) and nothing else.
  • api — the standard key for direct HTTP use.
  • mcp — the standard key for the MCP server.

api and mcp are interchangeable across the data-plane endpoints (send, read, drafts). A key whose scope is not permitted for an endpoint returns 403 {"error": "scope '<scope>' not permitted for this endpoint"}.

Mailbox-bound keys

A key is either account-wide (mailbox_id is null) or bound to a single mailbox (mailbox_id set). A bound key can only ever act on its one mailbox.

Each key also carries two capability flags:

  • can_read — may read messages and drafts (GET routes).
  • can_send — may send and create/edit/delete/send drafts (POST/PATCH/DELETE).

Both default to true; a key is restricted only when a flag is explicitly set false.

403 key_scope

When a key lacks the capability or targets a different mailbox, the response is 403 with code: "key_scope":

{ "error": "this api key cannot send", "code": "key_scope" }

You will see code: "key_scope" in these cases:

Situation Example message
can_send=false on a send/draft-write endpoint this api key cannot send
can_read=false on a read endpoint this api key cannot read messages
Bound key, body mailbox_id is a different mailbox this api key is scoped to a different mailbox
Bound key calls an account-plane endpoint (mailboxes/domains) this api key is scoped to a single mailbox

Data plane vs. control plane

Postfleet splits its surface deliberately. API keys operate the data plane. Sensitive control-plane operations are dashboard-only and are never exposed to a bearer key.

Data plane (API keys):

  • Send email — POST /api/v1/send
  • Read messages — GET /api/v1/messages, GET /api/v1/messages/:id
  • Drafts — POST/GET /api/v1/drafts, GET/PATCH/DELETE /api/v1/drafts/:id, POST /api/v1/drafts/:id/send
  • Provision mailboxes and custom domains — POST /api/v1/mailboxes, /api/v1/domains (account-wide keys only — a mailbox-bound key gets 403 key_scope here)

Control plane (dashboard only — no API):

  • Creating and revoking API keys
  • Managing send allow/blocklists
  • Approving or rejecting pending drafts
  • Toggling a mailbox's require_approval setting
  • Configuring the webhook URL/secret and redriving dead-lettered webhook deliveries

An agent holding a data-plane key therefore cannot escalate its own permissions, edit policy, approve its own drafts, or replay webhooks — those levers stay with a human.

Anti-enumeration: cross-scope ids return 404

Resource-id endpoints never confirm that an id exists outside your scope. A message or draft id that belongs to another account, or (for a mailbox-bound key) to another mailbox in your own account, returns the same 404 as a truly missing id:

{ "error": "message not found" }
{ "error": "draft not found" }

This is byte-identical to the "never existed" response, so a probe cannot tell "not yours" from "does not exist". (Body-supplied mailbox_id mismatches on POST /api/v1/send and POST /api/v1/drafts are the exception — those are 403 key_scope, since a body field is a scope violation rather than an id probe.)