Pharos
Platform.md

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:preview vs campaigns:send
  • events:write vs events: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_… or pha_… 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 --agent before hand-listing.
  • Prefer project keys over organization keys for anything long-lived; an org key's blast radius is every project you own.