/v1/* except POST /v1/signup requires a bearer token:
app.farthing.ai. Keys are for agents; sessions are for the person in the
dashboard, and your integration never handles one. There is no third scheme — no
query-string keys, no HMAC request signing, no OAuth. The one exception is the live-view
embed, which is gated by a separate short-lived JWT rather than your API key — see
Live view.
Key format
Keys are shown once
The API stores onlysha256(key), plus enough non-secret metadata for a dashboard to render
a key row: its 16-character prefix, its mode, its scopes, when it was created, and when it was
last used.
Consequences, stated plainly:
- The plaintext is returned exactly once, in the
keyfield of thePOST /v1/api-keysresponse (orapiKeyfromPOST /v1/signup). Nothing can recover it afterwards. - Losing a key is not a support ticket. Create a new one and revoke the old one.
- A key is scoped to one tenant. What it may do within that tenant is narrowed by
scopes; whose data it may do it to is narrowed by
subject. Both are optional and both are fixed at creation.
Scopes
A scope is<resource>.read or <resource>.write, plus the wildcard *.
/v1, so the boundary you reason
about — “this key may read checkouts and nothing else” — is the same boundary the code
enforces. There is no mapping table in between to fall out of date.
The billing.* scopes exist because the route group does, but they buy a key nothing:
/v1/billing refuses API keys outright, whatever their scopes — see
dashboard sessions.
The three rules
1
write implies read on the same resource
A key holding
buyer-profiles.write can GET a profile without also being granted
buyer-profiles.read. Requiring both would be noise, not safety.It does not work in the other direction, and it does not cross resources.2
A key issued without scopes has full access
Omitting
scopes at creation stores ["*"], which is what a key has always meant here —
and what every key issued before scopes existed still carries. Narrowing is opt-in, per
key, at creation. There is no way to widen or re-scope a key afterwards; create a new one.3
No escalation
A key cannot mint a key with scopes it does not itself hold. Without that rule
api-keys.write would be equivalent to * in one extra call — mint a wildcard key, then
use it.403
Out-of-scope requests
Enforcement happens once, on the whole/v1 surface, before any route sees the request. The
resource comes from the first path segment and read/write from the HTTP method — so DELETE
counts as a write, and a route added later is covered by construction rather than by someone
remembering.
403
requiredScope is there so you never have to parse the message.
An unrecognised resource is denied, not allowed. A path under
/v1 that is not one of the
seven resources returns 404 Unknown resource — a new route group is unreachable until it is
named in the scope list, which is the failure direction you want.Why this matters more for agents
The holder of a key is often an autonomous process, and the useful question is not “do I trust this tenant” but what is the worst this particular agent can do if its prompt is turned against it.buyer-profiles.read and handing the agent a buyerProfileId directly — attaching a profile
to a checkout does not require the scope to read it, so an agent holding only
["checkouts.write"] can still ship to an address it can never enumerate.
Binding a key to one end user
Scopes narrow what a key may do.subject narrows whose data it may do it to — the second axis,
and the one that matters if you run one agent on behalf of many people.
subject is your own opaque id for one of your end users; we never generate one. A key
carrying one sees and creates only that person’s checkouts and buyer profiles, cannot list or
revoke account keys, cannot read /v1/usage, cannot manage webhooks, and cannot mint a key for
anybody else. A key without one is tenant-wide — which is what every key issued before this
existed already is, so the mechanism is inert until you use it.
Send a real id or omit the field: "" is a 400, and a misspelled subjectt is a 400 too, so
there is no body that returns 201 with a key you believe is bound and is not.
Full contract, including what it deliberately does not cover:
End-user isolation.
Managing keys
Create → 201
List → 200
data, the field the checkout and buyer-profile lists also use. The same array
comes back as keys too — the original field name, kept for existing callers — and nextCursor is
always null because this collection is not paged.
Listing never returns a secret — the key field exists only on the creation response. It does
return scopes and subject, which is how you audit what each outstanding key can reach and
whose data it can reach it on. subject: null is a tenant-wide key.
Rotation
Any key holdingapi-keys.write can mint another, so rotation needs no downtime and no
dashboard:
POST /v1/api-keys— deploy the new key. Copy the old key’sscopesunless you are deliberately narrowing it.- Confirm the old key’s
lastUsedAthas stopped advancing. DELETE /v1/api-keys/{id}on the old one.
revokedAt set and stops authenticating immediately. Keeping the record matters more than a
tidy table if a key is ever abused — you want to be able to say which key was live when.
What key mode actually controls
Mode is enforced, not decorative. Ack_test_ key can only check out at sandbox
merchants — merchants we control — no matter how your account is configured.
POST /v1/checkouts confines a request to sandbox merchants if either of these is true:
- The account is sandbox-only, or
- the request arrived on a
ck_test_key.
Minting a live key
Two independent things have to be true, and each refusal is a403.
The account must be activated. A sandbox-only account cannot create a live key at all:
ck_test_ key cannot issue a ck_live_ one, however
activated the account is and however broad the test key’s scopes are:
subject: authority passes down, never
sideways. Without it, the sandbox confinement a test key exists to provide would be one POST deep
— a tenant that deliberately handed its agent a test key could be talked into minting a live one,
and every guarantee on this page would evaporate. A live key minting a test key is fine; that is
narrowing.
With an activated account and a live key in hand, {"mode":"live"} works, and only then can any of
its keys reach a real merchant.
Where the first live key comes from: the dashboard. A dashboard session
on an activated workspace counts as a live-mode credential, so the workspace owner mints the
first ck_live_ key there — see Going live.
Mode, scopes and
subject are all fixed at creation. There is no way to promote a test key to
live, no way to re-scope a key, and no way to rebind or unbind one — create a new one and
revoke the old one.Dashboard sessions
The second credential, stated briefly because an integration never touches it. When you are signed in atapp.farthing.ai, your browser holds a short-lived session token
(a Clerk JWT, expiring in about a minute), and the dashboard calls this same API with it —
same Authorization: Bearer header, dispatched by shape: a session token is a JWT, a key
starts with ck_, and neither can be mistaken for the other.
What a session gets:
- Your workspace, from membership — never from anything the request claims. It acts as
the owner: full scopes, no
subjectbinding, and key mode follows the workspace’s state — a sandbox workspace’s session behaves like a test key, an activated one like a live key. - The session-only surface.
/v1/workspaces(provisioning) and/v1/billing(plan, identity verification, AUP) refuse API keys with401. Money and membership are decisions the accountable human makes in a browser — and refusing keys there means a leaked agent key cannot re-plan, de-verify, or re-terms a workspace.
403 on /v1/* with a machine-readable marker —
403
code with 404 on GET /v1/workspaces/me. POST /v1/workspaces is the
fix. The dashboard keys its onboarding off that code; nothing agent-facing ever produces it.
Auth failures
They all come back as
{ "error": true, "message": "..." }. There is no distinction in the
401 between “never existed” and “revoked” — deliberately, since that difference is only
useful to someone probing.
Note the ordering: authentication runs first, then scope, then any route-specific check. A
revoked key gets 401 whatever it was asking for; a valid but narrow key gets 403 with the
scope named. One consequence on /v1/billing: a key without billing.* scopes gets the
scope 403 first, while a full-access key reaches the session-only check and gets its 401
— either way, no API key gets in.
lastUsedAt is written best-effort on every authenticated request and never blocks it. Treat
it as a good signal for rotation, not as an audit log.