Authentication & keys
Every project-scoped endpoint goes through one front door. It accepts three credentials, and the differences between them are worth understanding before you issue anything.
The three credential paths
| Credential | Prefix | Reaches | Resolves projects by |
|---|---|---|---|
| Dashboard session | — | Every project in every organization you belong to | id or slug |
| Project API key | phk_… |
Exactly one project — its own | slug |
| Organization API key | pha_… |
Any project in its organization | id or slug |
A session implies every scope: a human in the dashboard is already bounded by what the UI offers.
A project key is pinned. The key's project is joined against the requested slug in the same query that authenticates it, so a project key reaching for another project is not a permission check that could be forgotten — it simply finds nothing.
An organization key is the bootstrap credential. It can address any
project the organization owns and is the only credential that can create a
project. A project outside its organization returns 404, not 403 — an org
key must not be usable to probe for project names in other organizations.
Send the key as a bearer token:
Authorization: Bearer phk_live_…
A presented-but-invalid key fails the request. It does not fall through to the session cookie. A credential you offered and that did not work is an error, never a silent downgrade to something weaker.
Scopes
Scopes are named resource:action. A key carries a fixed set, chosen when it
is created.
| Scope | Grants |
|---|---|
projects:read |
Read project configuration |
projects:write |
Create projects — organization keys only |
contacts:read |
Read contacts and subscription state |
contacts:write |
Create, update and sync contacts |
lists:read |
Read lists |
lists:write |
Create and update lists |
templates:read |
Read email templates |
templates:write |
Create and update email templates |
campaigns:read |
Read campaigns and their status |
campaigns:preview |
Render a campaign and resolve its recipients |
campaigns:send |
Deliver a campaign |
automations:read |
Read automations, steps and runs |
insights:read |
Beacons, revenue metrics and store reviews |
events:write |
Submit and preview transactional events |
events:send |
Deliver a transactional event |
Some endpoints require more than one scope. GET /api/sends returns contact
identities crossed with campaign activity, so it demands campaigns:read
and contacts:read — a key with only one of them gets 403.
The split that matters
Two scope pairs are load-bearing:
campaigns:previewvscampaigns:sendevents:writevsevents:send
An agent key — created with --agent — carries every scope except those
two send scopes. A model holding one can draft a campaign, resolve exactly who
would receive it, render the final HTML, read the send history, and preview a
transactional email. It cannot deliver a single message.
That is deliberate, and it is why it is comfortable to let a model drive a sending system. If the guarantee lived in a prompt or a convention, it would not be a guarantee. Living in the credential, it holds no matter what the model decides to do.
The two pairs move together on purpose: a key that can loop a per-recipient transactional send over your contact list is a key that can send a campaign. Splitting one without the other would leave the door open.
In the dashboard, Settings → API keys offers the same preset. From the CLI:
# Everything except sending — the default for anything a model touches.
pnpm api-key:create acme-app "Claude Code" --agent
# A server that sends real mail needs the send scope explicitly.
pnpm api-key:create acme-app "Production" events:write,events:send
Issuing keys
Creating and revoking keys is an owner/admin action, the same authority as
managing a sending credential — because a key carrying campaigns:send can
mail your entire audience, which is the same power by a different route. See
Workspaces & roles.
A key belongs to the workspace, not to the person who created it, so it keeps working after they leave. Rotate keys when someone with admin access does.
Expiry, rotation and revocation
The raw key is shown once, at creation. Pharos stores only its SHA-256 hash — there is no way to recover a key, only to issue a new one.
Give a key an expiry (--expires-days <n> from the CLI, or a value between 1
and 3650 days in the dashboard) and it self-revokes. An expired or revoked key
fails authentication like any invalid credential.
To rotate: issue the new key, deploy it, then revoke the old one. Nothing about a key is mutable, so rotation is always create-then-revoke.
Rate limits
Key-authenticated requests are limited to 240 requests per minute, per key, in fixed one-minute windows. Sessions are not limited.
Over budget, you get:
HTTP/1.1 429 Too Many Requests
Retry-After: 23
{ "error": "Rate limit exceeded" }
Honour Retry-After and back off rather than retrying immediately. The limit
exists so that a runaway agent loop or a leaked key degrades into 429s
instead of an unbounded contact export or a sender-reputation incident.
Auditing
Every key-authenticated request writes one audit row: which key, which
project, method, path, the scope demanded, and the outcome — ok,
denied_scope, rate_limited or project_not_found.
Invalid keys are not recorded. There is no key to attribute them to, and the presented secret must never be written to a log.
Operators can read the log with pnpm key-audit.
Keeping keys safe
- Server-to-server only. A
phk_…orpha_…in browser code is a public key. - One key per integration, so revoking one does not take down the rest.
- Narrowest scopes that work. Reach for
--agentbefore hand-listing. - Prefer project keys over organization keys for anything long-lived; an org key's blast radius is every project you own.