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.

StatusreasonWhere it can occurWhat it meansWhat to do
401invalid_keyPOST /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.
429rate_limitedPOST /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.
403partner_inactivePOST /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 tokenThe 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.
400unsupported_api_versionPOST /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-versionThe 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.
403test_key_against_livePOST /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.
403live_key_against_testPOST /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.
403game_not_licensedPOST /partner/sessions, POST /partner/matches, GET /partner/tiersYou are not licensed for this game in any environment.Check the gameId string first — the ids are listed in the Quickstart.
403game_not_licensed_in_this_environmentPOST /partner/sessions, POST /partner/matches, GET /partner/tiersYou 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.
400malformed_player_idPOST /partner/sessions, POST /partner/matchesplayerId 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 endpointsregistry_unavailableAny 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}/decideA 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.
500noneSomething 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:

StatusreasonWhere it can occurWhat it meansWhat to do
400unknown_tierPOST /partner/matchesNo 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.
400stake_below_floorPOST /partner/matchesstakeUsdCents 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.
400stake_does_not_match_pricePOST /partner/matchesstakeUsdCents 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.
400stake_above_prizePOST /partner/matchesstakeUsdCents 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.
409tier_lockedPOST /partner/matchesThis 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.
403rake_terms_not_agreedPOST /partner/matchesNo active rake agreement is on file for your account.Nothing to fix in your code. Talk to us.
409idempotency_key_conflictPOST /partner/matchesAn 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.
503settlement_unavailablePOST /partner/matches, GET /partner/tiersWe 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:

StatusreasonWhere it can occurWhat it meansWhat to do
400invalid_cursorGET /partner/resultsThe 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.

StatusreasonWhere it can occurWhat it meansWhat to do
400missing_parameterGET /partner/usageA 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.
400malformed_dayGET /partner/usageA 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.
400range_backwardsGET /partner/usagefrom is after to — swap the two arguments; both dates are individually valid.Almost always the two arguments swapped at the call site.
400range_too_wideGET /partner/usageA 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.
400day_not_finalGET /partner/usageto 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:

StatusreasonWhere it can occurWhat it meansWhat to do
401noneAny Game-API callThe 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.

StatusreasonWhere it can occurWhat it meansWhat to do
403not_a_partner_adminEvery /partner/admin/… route except POST /partner/admin/invites/{invite_id}/acceptA verified Google account that administers no partner.
403role_forbiddenEvery /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/verifytotp_not_enrolledAny step-up-gated /partner/admin/… route; POST /partner/admin/totp/verifyThe action needs step-up, but this admin has no enrolled TOTP factor — enrol first.
403step_up_requiredAny step-up-gated /partner/admin/… route; POST /partner/admin/totp/enroll when replacing an existing factorThe step-up window is closed: verify a code, then retry.
403fresh_step_up_requiredThe /partner/admin/… actions that mint a credential: creating or rotating a key, reactivating the account, setting or rotating a webhook, and replacing an authenticatorThe 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".
429step_up_lockedPOST /partner/admin/totp/verify, POST /partner/admin/totp/enrollToo many incorrect codes; verification is locked for a while.
403invalid_codePOST /partner/admin/totp/verifyThe TOTP code didn't verify — about an authenticator code, not an API key or an invite.
409partner_changedAny /partner/admin/… route that writes the partner record, a member record, or an inviteThe 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.
400invalid_originPOST /partner/admin/originsNot a valid web origin.
400too_many_originsPOST /partner/admin/originsThis environment already holds the maximum 128 origins — the one you sent is fine, the list is just full. Remove one before adding another.
400invalid_webhook_urlPOST /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.
400label_requiredPOST /partner/admin/keysA new API key needs a label.
409last_adminDELETE /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.
409already_acceptedPOST /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.
403wrong_addressPOST /partner/admin/invites/{invite_id}/acceptThis invite was addressed to a different e-mail address than the one signed in.
403expiredPOST /partner/admin/invites/{invite_id}/acceptThe invite has expired — never about an API key or a session token.
409already_administers_another_partnerPOST /partner/admin/invites/{invite_id}/acceptThe invited Google account already administers a different partner.
409already_a_memberPOST /partner/admin/invites/{invite_id}/acceptThe invited account already administers this partner — nothing to claim; role changes go through the members page.
400invalid_emailPOST /partner/admin/invitesThe invitee's address is not a valid e-mail address.
400address_outside_domainPOST /partner/admin/invitesThe invited address is outside the account's admin domain.
400unknown_gamePOST/DELETE /partner/admin/games/{game_id}/test, POST/DELETE /partner/admin/games/{game_id}/live-requestNot a game this platform runs.
409not_licensed_in_testPOST /partner/admin/games/{game_id}/live-requestLive access can't be requested for a game not licensed in test — enable it in test first.
409already_livePOST /partner/admin/games/{game_id}/live-requestThe game is already licensed live, so there is nothing to request.
400invalid_domainPOST /partner/admin/account/domainNot a valid admin domain.
409domain_already_setPOST /partner/admin/account/domainThe 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.

StatusWhen
404Unknown partner id, deactivated account, or a malformed id — all answered identically.
409No registered origins for that environment, so nothing may frame it. Not transient; register an origin instead of retrying. See Embedding a game.
429Your embed budget, or the shared safety limit below, is exhausted. Transient; retry with backoff.
500 / 502 / 503Our 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.