# Errors and limits Every coded refusal carries a stable machine-readable reason — branch on it, never on the message text. ## The shape ``` { "error": "", "reason": "" } ``` The `error` string is written for a person reading a log and may be reworded; `reason` is the contract. The codes share one flat namespace, so a few are short generic words — `expired`, `invalid_code`, `invalid_email`, `wrong_address`, `last_admin` — only the endpoint named in each row identifies the condition. Treat an unrecognised code as its HTTP status describes; new codes can appear at any time. ## Minting a session The checks run in a fixed order — header and key format, rate limits, partner and key validity, key environment, account active, game licensing, then player id — so a request wrong in two ways reports only the first. | Status | `reason` | Where it can occur | What it means | What to do | | --- | --- | --- | --- | --- | | 401 | `invalid_key` | `POST /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers`, `GET /partner/results`, `GET /partner/me`, `GET /partner/usage`, `POST /partner/test/matches/{match_id}/decide` (test keys only) | The API key is unknown, malformed, or revoked — one code covers all three, and the response doesn't say which. | Check the key is present, current, and exactly `pgs_live_` or `pgs_test_` plus 46 characters. | | 429 | `rate_limited` | `POST /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers`, `GET /partner/results`, `GET /partner/me`, `GET /partner/usage`, `POST /partner/test/matches/{match_id}/decide` (test keys only) | Your rate bucket (or the shared coarse one) is empty — retryable; honour `Retry-After`. | Exponential backoff, not a tight loop. | | 403 | `partner_inactive` | `POST /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers`, `GET /partner/results`, `GET /partner/me`, `GET /partner/usage`, `POST /partner/test/matches/{match_id}/decide` (test keys only), and any Game-API call carrying a partner-scoped session token | The key is genuine but the partner account is switched off; mid-session the same fact, from the liveness gate, stops play immediately. | An Admin can reactivate the account in the portal — check the event log first if nobody recognises the stop. | | 400 | `unsupported_api_version` | `POST /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers`, `GET /partner/results`, `GET /partner/me`, `GET /partner/usage`, `POST /partner/test/matches/{match_id}/decide` (test keys only), `POST /partner/admin/api-version` | The version named — the `X-Peritus-Api-Version` request header on every other row, the `version` body field here — does not exist for this build. Send one listed in `/docs/versioning`, or omit the header to use your account's own pin. Never substituted — a version we do not recognise is refused rather than quietly answered with one we do. | As a header value, the bare integer only — no `v` prefix, no whitespace, no decimal point. In a JSON body, a number, never a quoted string. | | 403 | `test_key_against_live` | `POST /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers`, `GET /partner/results`, `GET /partner/me`, `GET /partner/usage`, `POST /partner/test/matches/{match_id}/decide` (test keys only) | A `pgs_test_…` key was presented to a dealer restricted to the live environment — send the live key. | One host accepts both kinds of key, so this appears only on a deployment restricted to a single environment. | | 403 | `live_key_against_test` | `POST /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers`, `GET /partner/results`, `GET /partner/me`, `GET /partner/usage`, `POST /partner/test/matches/{match_id}/decide` (test keys only) | A `pgs_live_…` key was presented to a dealer restricted to the test environment — send the test key. | One host accepts both kinds of key, so this appears only on a deployment restricted to a single environment. | | 403 | `game_not_licensed` | `POST /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers` | You are not licensed for this game in any environment. | Check the `gameId` string first — the ids are listed in the [Quickstart](/docs/quickstart.md). | | 403 | `game_not_licensed_in_this_environment` | `POST /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers` | You are licensed for this game, but not in the environment this key authorises. | Missing in test, enable it yourself instantly from the portal; missing in live, request it from the same place. | | 400 | `malformed_player_id` | `POST /partner/sessions`, `POST /partner/matches` | `playerId` is not 1–64 characters of `A-Z a-z 0-9 _ -`. A malformed `idempotencyKey` is a plain `400` with no code — fix it once, it's not a condition to branch on. | Map your own id into that alphabet before sending it. | | 503 on a Game-API call; **500** on the API-key endpoints | `registry_unavailable` | Any Game-API call carrying a partner-scoped session token; `POST /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers`, `GET /partner/results`, `GET /partner/me`, `GET /partner/usage`, `POST /partner/test/matches/{match_id}/decide` | A read of ours failed — the partner registry, your key's index, your account record, or the results page — so we fail closed. Alone among these codes it's our fault, not the caller's, and retryable. **Two statuses, one condition:** Game-API answers 503, the API-key surface 500 — branch on `reason`, identical on both, not the status. | Transient; retry with backoff. | | 500 | *none* | — | Something failed on our side. | Transient; retry with backoff. | ## Seating a paid match [Seating a paid match](/docs/paid-play.md) authenticates the same way, so it answers the same `invalid_key`, `partner_inactive`, `rate_limited`, environment and licensing refusals; past those, the money checks are its own: | Status | `reason` | Where it can occur | What it means | What to do | | --- | --- | --- | --- | --- | | 400 | `unknown_tier` | `POST /partner/matches` | No such tier — the ladder is ours and fixed; read it from `GET /partner/tiers`. | Almost always a stale hard-coded list, or a tier id from the other environment. | | 400 | `stake_below_floor` | `POST /partner/matches` | `stakeUsdCents` is below this tier's floor; the body carries `floorUsdCents`. Price above the floor freely — below it, you fund our prize yourself. The floor bounds the price you may SET; the seat must send that price — see `stake_does_not_match_price`. | Read the floor from `GET /partner/tiers` and price above it once, not per call. | | 400 | `stake_does_not_match_price` | `POST /partner/matches` | `stakeUsdCents` is not the price your account has set for that tier; the body carries `expectedUsdCents`. It binds only a tier you have priced — an unpriced tier charges our floor and keeps `stake_below_floor` / `stake_above_prize` alone. | — | | 400 | `stake_above_prize` | `POST /partner/matches` | `stakeUsdCents` is above this tier's PRIZE; the body carries `ceilingUsdCents`. Price up to the prize — but the seat must send the price you set; see `stake_does_not_match_price`. | Almost always a units mistake — a price in cents sent as dollars, or the wrong tier’s number. | | 409 | `tier_locked` | `POST /partner/matches` | This tier isn't offerable for this game under current prize and fee assumptions — **permanent, not a wait**; retrying won't change it. It's absent from `GET /partner/tiers`: read the ladder and offer what it contains. | Cache the ladder at start-up, rather than hard-coding a fixed list. | | 403 | `rake_terms_not_agreed` | `POST /partner/matches` | No active rake agreement is on file for your account. | Nothing to fix in your code. Talk to us. | | 409 | `idempotency_key_conflict` | `POST /partner/matches` | An `idempotencyKey` reused for a DIFFERENT request — a different player, game, **tier**, or stake — is refused: answering with the earlier seat would hand one of your players' matches to another under a key you believe names this one. Use a fresh key. | Mint the key from the request itself — player, game and tier — not a timestamp or counter, so a retry can’t drift into a different request. | | 503 | `settlement_unavailable` | `POST /partner/matches`, `GET /partner/tiers` | We couldn't complete the seating, or got an answer this API doesn't publish — retryable with the SAME `idempotencyKey`; this does NOT prove the seat wasn't taken, only that we couldn't tell you it was. | Never void the charge — the seat may exist and will settle and bill. | ## Reading results [The results feed](/docs/results.md) authenticates the same way, so it answers the same `invalid_key`, `partner_inactive`, `rate_limited` and environment refusals; only after all of them pass is your cursor looked at. One reason is its own: | Status | `reason` | Where it can occur | What it means | What to do | | --- | --- | --- | --- | --- | | 400 | `invalid_cursor` | `GET /partner/results` | The `cursor` wasn't one we issued to this partner in this environment — a cursor never crosses a partner or an environment boundary. | Send back a cursor exactly as you received it, or omit it to start from the beginning. | Game licensing and the player-id check do not apply here: this endpoint takes neither a `gameId` nor a `playerId`. ## Reading your usage [The metered history](/docs/portal.md) authenticates like any key-authed endpoint, then checks one thing: the range you asked for. All five codes below name the parameter or number you need. | Status | `reason` | Where it can occur | What it means | What to do | | --- | --- | --- | --- | --- | | 400 | `missing_parameter` | `GET /partner/usage` | A required query parameter wasn't sent; the body carries `parameter`, naming the first missing, in the order the endpoint needs them. Both `from` and `to` are required, with no default window. | — | | 400 | `malformed_day` | `GET /partner/usage` | A day parameter isn't a real `yyyymmdd` UTC date; the body carries `parameter`, naming which. It's a **calendar** check, not a digit count — `20260231` is refused. | Format the day from a UTC date, not a local one — near midnight the two disagree by a day. | | 400 | `range_backwards` | `GET /partner/usage` | `from` is after `to` — swap the two arguments; both dates are individually valid. | Almost always the two arguments swapped at the call site. | | 400 | `range_too_wide` | `GET /partner/usage` | A range past what one call can answer is refused, never shortened — the body carries `maxRangeDays`: page against that number, not a guess. | The limit moving does not become your outage. | | 400 | `day_not_final` | `GET /partner/usage` | `to` is later than the newest day whose figure is final; the body carries `latestDay`. A match reaches its day's bucket when it is DECIDED, so today's number changes while you read it — move `to` back rather than retrying, using `latestDay` rather than computing yesterday (a local clock disagrees with UTC for a couple of hours a day). | Reconcile a period once it is over; the portal’s rolling window is the surface for “how are we doing right now”. | ## In-game calls, made with a session token The frame's own traffic uses the token, not your key, and every call re-checks your account's status: | Status | `reason` | Where it can occur | What it means | What to do | | --- | --- | --- | --- | --- | | 401 | *none* | Any Game-API call | The token is expired or malformed. | The `needToken` flow exists to prevent this; handled correctly, players never see it. | A deactivation reaches these calls within **60 seconds**, or within **10 minutes** if our registry cannot be read at all — measured from the same moment, not added together. Past that, we fail closed. ## The portal and the admin API These are the refusals the portal's own API answers with — the routes behind the screens your admins use, part of the same versioned surface though a mint-and-results-only backend never sees them. Several actions are step-up gated behind an authenticator code and a short window; `step_up_required` and `fresh_step_up_required` distinguish which failed. | Status | `reason` | Where it can occur | What it means | What to do | | --- | --- | --- | --- | --- | | 403 | `not_a_partner_admin` | Every `/partner/admin/…` route except `POST /partner/admin/invites/{invite_id}/accept` | A verified Google account that administers no partner. | — | | 403 | `role_forbidden` | Every `/partner/admin/…` route (role gate); also `POST /partner/admin/invites` and `PATCH /partner/admin/members/{uid}` | The signed-in admin's role doesn't permit this action; on the two invite/role routes it means granting only a role you hold or a lesser one. | — | | 403 on the action gate, 409 on `POST /partner/admin/totp/verify` | `totp_not_enrolled` | Any step-up-gated `/partner/admin/…` route; `POST /partner/admin/totp/verify` | The action needs step-up, but this admin has no enrolled TOTP factor — enrol first. | — | | 403 | `step_up_required` | Any step-up-gated `/partner/admin/…` route; `POST /partner/admin/totp/enroll` when replacing an existing factor | The step-up window is closed: verify a code, then retry. | — | | 403 | `fresh_step_up_required` | The `/partner/admin/…` actions that mint a credential: creating or rotating a key, reactivating the account, setting or rotating a webhook, and replacing an authenticator | The step-up window is open but stale — distinct from `step_up_required` because the window still looks valid to the user, so the prompt must say "again, now". | — | | 429 | `step_up_locked` | `POST /partner/admin/totp/verify`, `POST /partner/admin/totp/enroll` | Too many incorrect codes; verification is locked for a while. | — | | 403 | `invalid_code` | `POST /partner/admin/totp/verify` | The TOTP code didn't verify — about an authenticator code, not an API key or an invite. | — | | 409 | `partner_changed` | Any `/partner/admin/…` route that writes the partner record, a member record, or an invite | The record changed underneath a read-modify-write — not a refusal; re-read and retry. On `DELETE /partner/admin/invites/{invite_id}`, the invite was CLAIMED mid-flight: re-read the member list and remove the member if that's still what you want. | — | | 400 | `invalid_origin` | `POST /partner/admin/origins` | Not a valid web origin. | — | | 400 | `too_many_origins` | `POST /partner/admin/origins` | This environment already holds the maximum 128 origins — the one you sent is fine, the list is just full. Remove one before adding another. | — | | 400 | `invalid_webhook_url` | `POST /partner/admin/webhooks/{env}` | Not a URL we POST a signed result-webhook event to. Stricter than `invalid_origin`: `https` required, a path and query are allowed (unlike an origin), and the host must be a dotted name, not `localhost` or an IP literal — unlike an embed origin, this is an address *we* dial: `0x7f.1` is refused as the `127.0.0.1` it resolves to. Registration checks shape only; delivery also resolves the host and **refuses to connect to an address that is not on the public internet** (loopback, private ranges, link-local incl. the cloud metadata address, carrier-grade NAT, IPv6 equivalents), pinned to the checked address so a DNS record answering differently for the check than for the connection doesn't get through either. Such a webhook is recorded as a failed attempt reading `this webhook host does not resolve to a public address`, and counts toward the retry ladder like any other failure. | — | | 400 | `label_required` | `POST /partner/admin/keys` | A new API key needs a label. | — | | 409 | `last_admin` | `DELETE /partner/admin/members/{uid}`, `PATCH /partner/admin/members/{uid}` | The change would leave the partner account with no admin — removal and demotion both trigger it. | — | | 409 | `already_accepted` | `POST /partner/admin/invites/{invite_id}/accept`, `DELETE /partner/admin/invites/{invite_id}` | This invite has already been accepted; on the revoke route that's a refusal, not a retry — the invite record is the only account of how they joined, so removing the MEMBER undoes it. | — | | 403 | `wrong_address` | `POST /partner/admin/invites/{invite_id}/accept` | This invite was addressed to a different e-mail address than the one signed in. | — | | 403 | `expired` | `POST /partner/admin/invites/{invite_id}/accept` | The **invite** has expired — never about an API key or a session token. | — | | 409 | `already_administers_another_partner` | `POST /partner/admin/invites/{invite_id}/accept` | The invited Google account already administers a different partner. | — | | 409 | `already_a_member` | `POST /partner/admin/invites/{invite_id}/accept` | The invited account already administers this partner — nothing to claim; role changes go through the members page. | — | | 400 | `invalid_email` | `POST /partner/admin/invites` | The **invitee's** address is not a valid e-mail address. | — | | 400 | `address_outside_domain` | `POST /partner/admin/invites` | The invited address is outside the account's admin domain. | — | | 400 | `unknown_game` | `POST`/`DELETE /partner/admin/games/{game_id}/test`, `POST`/`DELETE /partner/admin/games/{game_id}/live-request` | Not a game this platform runs. | — | | 409 | `not_licensed_in_test` | `POST /partner/admin/games/{game_id}/live-request` | Live access can't be requested for a game not licensed in test — enable it in test first. | — | | 409 | `already_live` | `POST /partner/admin/games/{game_id}/live-request` | The game is already licensed live, so there is nothing to request. | — | | 400 | `invalid_domain` | `POST /partner/admin/account/domain` | Not a valid admin domain. | — | | 409 | `domain_already_set` | `POST /partner/admin/account/domain` | The admin domain is set-once, and already set — changes go through your platform contact. | — | ## The embed endpoint A plain unauthenticated `GET` — every answer is a bare HTTP status with no `reason` at all, because what requests this URL is an iframe, not a program that can read a JSON body. Handle these by status. | Status | When | | --- | --- | | 404 | Unknown partner id, deactivated account, or a malformed id — all answered identically. | | 409 | No **registered origins** for that environment, so nothing may frame it. Not transient; register an origin instead of retrying. See [Embedding a game](/docs/embedding.md). | | 429 | Your embed budget, or the shared safety limit below, is exhausted. Transient; retry with backoff. | | 500 / 502 / 503 | Our side: a registered safety check failed, the frame document couldn’t be fetched, or the service is misconfigured. Retry; contact us if it persists. | > A `200` here doesn’t mean “embeddable by you” — see [Embedding a game](/docs/embedding.md); check the frame renders, not the status code. ## Rate limits Your account has its own budget: a burst of **300 requests**, refilling at **80 per second**, keyed to your partner id. Minting and reading results draw from it, not counted separately. **Count what a launch actually costs you.** A launch is two requests — the session mint and the seating call; the mint alone is one request but seats nobody. The embed document draws a separate, same-size, per-partner budget, uncounted here. In launches, that is ~150 of burst and ~40 a second sustained. In steady-state traffic, the fix is the same: back off, retry, and ask us to raise your limit rather than working around it in the client.