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 | yourStakeUsdCents 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 | yourStakeUsdCents 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 | yourStakeUsdCents 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. On POST /partner/matches, retry with the SAME idempotencyKey — this does NOT prove the seat wasn't taken, only that we couldn't tell you it was. The read endpoints here carry no idempotency key at all, so that guidance doesn't apply to them; just retry once the underlying call is working again. | 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. On /partner/usage and /partner/seat-summary both from and to are required, with no default window; on /partner/invoices and /partner/admin/invoices the window is optional, but half of one is refused rather than completed for you. | — |
| 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. One path is the exception: if the refusal comes from the invoice store rather than from the day check, parameter is absent, because that answer covers both days at once. Treat it as optional. | 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”. |
Reading your invoices
The environment comes from your key, so an invoice raised against live play is not visible to a test key and the other way round. The list takes the same from and to as the meter and refuses a bad range with the same codes — those are in Reading your usage. Paging refuses a cursor we did not issue with the same invalid_cursor as the result feed, in Reading results. Neither is repeated here. A 503 settlement_unavailable can reach these calls too — that row is in Seating a paid match.
| Status | reason | Where it can occur | What it means | What to do |
|---|---|---|---|---|
| 404 | invoice_not_found | GET | No invoice with that id in this environment. An invoice belonging to another partner answers the same way — the id alone is never confirmation that one exists. | Invoice ids come from GET /partner/invoices — that list is the only place they are published, and it is already scoped to you. |
| 400 | invoice_status_invalid | GET | status isn't a status this endpoint knows. Refused rather than ignored — a dropped filter would answer a different question without saying so. Omit it, or send all, for no filter. | — |
| 409 | invoice_not_readable | GET | The invoice is yours but isn't final: its total is still being assembled and would change under you. Retry — nothing needs fixing. | — |
Changing how the games look
Your embed’s appearance is readable with either key and writable with a test one. Reading needs no code of its own: the key names the environment, so there is no environment you could ask for and be refused. The auth and rate refusals are the same as everywhere else, in Minting a session.
| Status | reason | Where it can occur | What it means | What to do |
|---|---|---|---|---|
| 422 | logo_unsupported_type | PUT | A logo must be a PNG or a WebP. An SVG is never accepted, whatever it is named: an SVG is a document and can carry scripts, and your mark is rendered inside the game frame. | — |
| 422 | logo_malformed | PUT | The base64 field was not valid base64. Send the file's bytes encoded on their own — no data: prefix, no URL-encoding. | — |
| 422 | logo_too_large | PUT | The decoded file is over the limit in the body's maxBytes. Your mark is inlined into the embed document so it paints before anything is fetched, which is what makes the size a hard bound rather than a preference. | — |
| 422 | logo_not_that_image | PUT | The bytes are not the type the mime field claims — a renamed file, usually. It is checked against the file's own signature, not its name. | — |
| 403 | live_theme_not_writable | PUT | A live key may read a theme but not write one. Send this with a test key; change the live theme in the portal. The body carries changeItAt. | — |
Reading your seat summary
The seat summary authenticates the same way, so it answers the same invalid_key, partner_inactive, rate_limited and environment refusals — those are in Minting a session. Both from and to are required, and a bad range answers the same codes as the meter, in Reading your usage. A 503 settlement_unavailable can reach this call too — that row is in Seating a paid match. None of these is repeated here.
One refusal is this call’s own, because only this call reads the seat ledger itself:
| Status | reason | Where it can occur | What it means | What to do |
|---|---|---|---|---|
| 400 | range_too_expensive | GET | The window is inside the day limit but still covers more play than one call can answer. Permanent for that range: ask for a shorter one rather than retrying the same one. | Halve the window and ask twice. The same range will not start working. |
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.
The three invoice routes are the exception, and they answer more than this table lists. They read the same documents GET /partner/invoices does, so a bad cursor, an unknown status, a missing or unreadable invoice and a settlement outage all reach them with the codes in Reading your invoices. GET /partner/admin/invoices also checks from and to the way the key-authenticated list does, answering the range codes in Reading your usage. None of those is repeated here.
| 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.