Keys, modes, scopes and versions — and why the mode lives in the credential.
Every request to /v1 carries a secret key as a bearer token:
Authorization: Bearer sk_test_...
The key's prefix decides which:
| Prefix | Mode | What it touches | | --- | --- | --- | | sk_test_ | test | Simulated chain, test data, /v1/test/* available | | sk_live_ | live | Real chains, real money | | pk_test_ / pk_live_ | publishable | Browser-safe. Reads a checkout it was handed, nothing else |
Live mode is part of the identity, not part of the request. There is no header, query parameter or body field that overrides it. That is what makes "did I just do that against real money" a question with a structural answer rather than a code-review answer.
A deployment not configured for live mode refuses a live key with 403 live_mode_disabled rather than quietly treating it as a test one — a silent downgrade would mean real payments disappearing into test data.
pk_ keys are printed into page source by design. They can create nothing and read nothing except through a checkout token they were already given. Any endpoint that moves or reveals money requires a secret key and answers 403 insufficient_scope otherwise.
Deliberately coarse. Five, not fifty — fifty look rigorous and end up with every integration holding all fifty, because nobody can reason about which subset their code path needs.
| Scope | Grants | | --- | --- | | payments:read | Reading payments, charges, events, balance, the ledger | | payments:write | Creating and cancelling payments | | mandates:write | Creating mandates and charging against them | | payouts:write | Moving money out — refunds | | config:write | Chains, webhook endpoints, merchant settings, keys |
Absence is denial. A key with no scope for what it just tried gets 403 insufficient_scope.
[!note] The wire value of the refund scope is still payouts:write. Payouts are retired; the string is not renamed, because every key already issued carries it and renaming would silently revoke the capability from every live integration.
The response shape a key sees is pinned to the version on the key. Override it for one request:
Paygate-Version: 2026-08-23
A version this deployment does not serve is refused with 400 unknown_api_version rather than rounded to the nearest one — rounding is how an integration silently starts parsing a shape it was never written for.
The merchant console at /app authenticates with a cookie, not a key. The two never mix: a request that presents an Authorization header is judged on that header alone and is 401 if it fails. It never falls back to the cookie — falling back is how an expired API key silently starts being served as whoever happens to be signed in on that browser.