Errors and limits
Every coded refusal carries a stable machine-readable reason — branch on it, never on the message text.
The shape
{ "error": "<human message>", "reason": "<stable code>" }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 | 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 | Your rate bucket (or the shared coarse one) is empty — retryable; honour Retry-After. | Exponential backoff, not a tight loop. |
| 403 | partner_inactive | POST | 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 | 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 | 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 | 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 | You are not licensed for this game in any environment. | Check the gameId string first — the ids are listed in the Quickstart. |
| 403 | game_not_licensed_in_this_environment | POST | 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 | 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 | 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 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 | 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 | 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 | 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 | 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 | 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 | No active rake agreement is on file for your account. | Nothing to fix in your code. Talk to us. |
| 409 | idempotency_key_conflict | POST | 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 | 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 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 | 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 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 | 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 | 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 | 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 | 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 | 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 | A verified Google account that administers no partner. | — |
| 403 | role_forbidden | Every /partner | 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 | totp_not_enrolled | Any step-up-gated /partner | The action needs step-up, but this admin has no enrolled TOTP factor — enrol first. | — |
| 403 | step_up_required | Any step-up-gated /partner | The step-up window is closed: verify a code, then retry. | — |
| 403 | fresh_step_up_required | The /partner | 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 | Too many incorrect codes; verification is locked for a while. | — |
| 403 | invalid_code | POST | The TOTP code didn't verify — about an authenticator code, not an API key or an invite. | — |
| 409 | partner_changed | Any /partner | 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 | Not a valid web origin. | — |
| 400 | too_many_origins | POST | 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 | 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 | A new API key needs a label. | — |
| 409 | last_admin | DELETE | The change would leave the partner account with no admin — removal and demotion both trigger it. | — |
| 409 | already_accepted | POST | 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 | This invite was addressed to a different e-mail address than the one signed in. | — |
| 403 | expired | POST | The invite has expired — never about an API key or a session token. | — |
| 409 | already_administers_another_partner | POST | The invited Google account already administers a different partner. | — |
| 409 | already_a_member | POST | The invited account already administers this partner — nothing to claim; role changes go through the members page. | — |
| 400 | invalid_email | POST | The invitee's address is not a valid e-mail address. | — |
| 400 | address_outside_domain | POST | The invited address is outside the account's admin domain. | — |
| 400 | unknown_game | POST/DELETE | Not a game this platform runs. | — |
| 409 | not_licensed_in_test | POST | Live access can't be requested for a game not licensed in test — enable it in test first. | — |
| 409 | already_live | POST | The game is already licensed live, so there is nothing to request. | — |
| 400 | invalid_domain | POST | Not a valid admin domain. | — |
| 409 | domain_already_set | POST | 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. |
| 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; 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.