Skip to content

Authentication

Every gateway request carries a credential in the Authorization header. There are three kinds, and which one you use is a decision about where the code runs.

Credential Looks like Where it runs
Secret key psg_live_7hQ2mKpR9fL3xW8v_4bNq… Your servers. Never anywhere else.
Publishable key psg_pk_live_7hQ2mKpR9fL3xW8v A browser or mobile app. Restricted to client-safe scopes.
Client token psg_ct_… A browser, for one transaction, for up to an hour.
Terminal window
Authorization: Bearer psg_live_7hQ2mKpR9fL3xW8v_4bNq…

The psg_ prefix is deliberate: GitHub and GitLab run secret scanners over public pushes and can notify a vendor whose prefix they recognise. A key that looks like random base62 is invisible to them.

Every key says live or test in the string itself, not just in our database.

psg_test_7hQ2mKpR9fL3xW8v_… sandbox
psg_live_7hQ2mKpR9fL3xW8v_… real money

This exists because a test key in a live deployment — or, worse, the reverse — is the most common integration failure there is, and it is cheap to make visible. You can tell which environment a key belongs to by looking at it, before you paste it into a config file. Presenting a key against the wrong host returns 403 with code environment_mismatch, which has its own code for exactly this reason.

A secret key authenticates as your account and can do anything its scopes allow. Treat it like a database password: server-side environment or secret manager, never in a repository, never in a mobile binary, never in frontend JavaScript.

A publishable key is designed to be visible. It is restricted at the database level to client-safe scopes, so it cannot create a payout or read your settlements even if someone lifts it out of your page source. Use it for the narrow set of things a customer’s browser legitimately needs to do — creating a payment session, confirming a checkout.

A client token is narrower still: minted for one transaction, expiring within the hour. It is what a hosted checkout page holds.

A key carries an explicit list of scopes, and each operation requires one. You set them when you create the key in the portal. Grant the narrowest set that works: a key that can only create collections cannot be used to drain your balance if it leaks.

Scope Allows
collection.create Create charges
collection.read Read charges
transaction.read Read transactions
payout.create Create payouts
payout.read Read payouts
refund.create Create refunds
refund.read Read refunds
balance.read Read balances
settlement.read Read settlements and withdrawals
customer.read / customer.write Read and manage customers
webhook.read / webhook.write Read events and deliveries; manage endpoints
reference.read Read the reference lists (banks, networks, currencies)
verification.create Start a verification
payment_session.create / .read / .confirm Payment sessions (the client-safe set)

Matching is exact. There are no wildcards and no prefix matching: holding transaction.read does not imply transaction.reversed, and a scope ending in .read grants nothing else. Authorisation bugs come from prefix checks, so there are none.

A request with a valid key that lacks the required scope returns 403 with code insufficient_scope — distinct from unauthenticated, so you can tell “wrong key” from “right key, wrong permissions” without guessing.

Every operation’s required scope is shown in the API reference.

A key can be restricted to a set of IP addresses or CIDR blocks. If your integration calls from fixed egress addresses, turn this on — it means a leaked key is unusable from anywhere else. A request from an address not on the list returns 403 with code ip_not_allowed.

Rotation, and the grace period you should watch for

Section titled “Rotation, and the grace period you should watch for”

When you rotate a key, the old one keeps working for a grace period so you can redeploy without an outage. While that is true, every response authenticated by the superseded key carries:

PaySigna-Key-Grace-Expires: 2026-10-12T09:00:00Z

Watch for that header and alert on it. Without it, a rotation’s grace period is invisible to the only party who can act on it: the requests succeed, you have no way to tell which of your servers is still presenting the old credential, and you find out when every one of them fails at the same instant.

So: rotate, deploy, then check your logs for that header. If it is still appearing, something is still holding the old key.

After the grace period the old key returns 403 with code api_key_revoked — deliberately distinct from unauthenticated, because a merchant whose key was revoked needs to know to rotate, not to re-check their spelling.

The authentication errors, and what each one means

Section titled “The authentication errors, and what each one means”
Code Status What actually happened
unauthenticated 401 No credential, or one we could not parse. Also returned for a wrong secret — indistinguishable on purpose, so the endpoint cannot be used to confirm that a key id exists.
api_key_revoked 403 The key was valid once. Rotate.
api_key_expired 403 The key had an expiry and it has passed.
ip_not_allowed 403 This key requires an allowlist and your source address is not on it.
insufficient_scope 403 Right key, wrong scopes.
environment_mismatch 403 A test key against live, or the reverse.
forbidden 403 Authenticated and in scope, but not permitted on this particular resource — usually another merchant’s.

The merchant portal and provider console APIs authenticate with a PASETO session rather than a key. Refresh tokens rotate on every use, and presenting a rotated generation revokes the whole chain — so a stolen refresh token is usable once and then kills the session it came from.

You will not implement this: it is what the portal does on behalf of a signed-in user. It is documented because those two APIs appear in the reference and their authentication model differs from this one.