View as Markdown

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, GET /partner/seat-summary, GET /partner/invoices, GET /partner/invoices/{invoice_id}, GET /partner/invoices/{invoice_id}/lines, POST /partner/test/matches/{match_id}/decide (test keys only), GET /partner/theme, PUT /partner/themeThe 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, GET /partner/seat-summary, GET /partner/invoices, GET /partner/invoices/{invoice_id}, GET /partner/invoices/{invoice_id}/lines, POST /partner/test/matches/{match_id}/decide (test keys only), GET /partner/theme, PUT /partner/themeYour 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, GET /partner/seat-summary, GET /partner/invoices, GET /partner/invoices/{invoice_id}, GET /partner/invoices/{invoice_id}/lines, POST /partner/test/matches/{match_id}/decide (test keys only), and any Game-API call carrying a partner-scoped session token, GET /partner/theme, PUT /partner/themeThe 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, GET /partner/seat-summary, GET /partner/invoices, GET /partner/invoices/{invoice_id}, GET /partner/invoices/{invoice_id}/lines, POST /partner/test/matches/{match_id}/decide (test keys only), POST /partner/admin/api-version, GET /partner/theme, PUT /partner/themeThe 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, GET /partner/seat-summary, GET /partner/invoices, GET /partner/invoices/{invoice_id}, GET /partner/invoices/{invoice_id}/lines, POST /partner/test/matches/{match_id}/decide (test keys only), GET /partner/theme, PUT /partner/themeA 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, GET /partner/seat-summary, GET /partner/invoices, GET /partner/invoices/{invoice_id}, GET /partner/invoices/{invoice_id}/lines, POST /partner/test/matches/{match_id}/decide (test keys only), GET /partner/theme, PUT /partner/themeA 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, GET /partner/seat-summary, GET /partner/invoices, GET /partner/invoices/{invoice_id}, GET /partner/invoices/{invoice_id}/lines, POST /partner/test/matches/{match_id}/decide, GET /partner/theme, PUT /partner/themeA 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.
500none—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:

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/matchesyourStakeUsdCents 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/matchesyourStakeUsdCents 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/matchesyourStakeUsdCents 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/tiers, GET /partner/seat-summary, GET /partner/invoices, GET /partner/admin/invoices, GET /partner/invoices/{invoice_id}, GET /partner/admin/invoices/{invoice_id}, GET /partner/invoices/{invoice_id}/lines, GET /partner/admin/invoices/{invoice_id}/linesWe 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:

StatusreasonWhere it can occurWhat it meansWhat to do
400invalid_cursorGET /partner/results, GET /partner/invoices, GET /partner/admin/invoices, GET /partner/invoices/{invoice_id}/lines, GET /partner/admin/invoices/{invoice_id}/linesThe 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/usage, GET /partner/invoices, GET /partner/admin/invoices, GET /partner/seat-summaryA 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.—
400malformed_dayGET /partner/usage, GET /partner/invoices, GET /partner/seat-summary, GET /partner/admin/invoicesA 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.
400range_backwardsGET /partner/usage, GET /partner/invoices, GET /partner/admin/invoices, GET /partner/seat-summaryfrom 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/usage, GET /partner/invoices, GET /partner/admin/invoices, GET /partner/seat-summaryA 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/usage, GET /partner/seat-summaryto 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.

StatusreasonWhere it can occurWhat it meansWhat to do
404invoice_not_foundGET /partner/invoices/{invoice_id}, GET /partner/admin/invoices/{invoice_id}, GET /partner/invoices/{invoice_id}/linesNo 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.
400invoice_status_invalidGET /partner/invoices, GET /partner/admin/invoicesstatus 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.—
409invoice_not_readableGET /partner/invoices/{invoice_id}, GET /partner/admin/invoices/{invoice_id}, GET /partner/invoices/{invoice_id}/linesThe 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.

StatusreasonWhere it can occurWhat it meansWhat to do
422logo_unsupported_typePUT /partner/theme, PUT /partner/admin/theme/{env}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.—
422logo_malformedPUT /partner/theme, PUT /partner/admin/theme/{env}The base64 field was not valid base64. Send the file's bytes encoded on their own — no data: prefix, no URL-encoding.—
422logo_too_largePUT /partner/theme, PUT /partner/admin/theme/{env}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.—
422logo_not_that_imagePUT /partner/theme, PUT /partner/admin/theme/{env}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.—
403live_theme_not_writablePUT /partner/themeA 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:

StatusreasonWhere it can occurWhat it meansWhat to do
400range_too_expensiveGET /partner/seat-summaryThe 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:

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.

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.

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.