# The Peritus Games partner API contract **Version: `1`** This is the contract for the HTTP surface a partner integrates against: `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`, `GET /embed/{partner_id}`, the partner-scoped Game API an embedded session calls, and the `/partner/admin/…` routes the partner portal drives. What is written here is what the service emits. ## The version Every response from `/partner/…` and `/embed/…` — the mint, the results feed, the frame document, and the `/partner/admin/…` routes the portal drives — carries the header: ``` X-Peritus-Api-Version: 1 ``` It is on success and error responses alike, including a `429` and a `404`. Log it, assert on it, and quote it to support. Two surfaces are not stamped: the Game API an embedded session calls, and anything under `/internal/…`. One call to `/partner/sessions` answers the question for the whole integration. Live and test are pinned separately, overridable per call with `X-Peritus-Api-Version` as a *request* header — see `/docs/versioning` for the pin, the override, and the versions served today. Only a call authenticated by your API key resolves either one; `/embed/…` and `/partner/admin/…` carry the header too, but never a resolved one. Versions are whole numbers, issued in order. `1` is the first. This build currently serves: `1`. ## What may change without notice Any of these may ship at any time, on the current version: - **A new field on a response.** - **A new `reason` code.** - **A new value in an existing enum-like field.** - **A new endpoint.** These are additive: a correctly written client is never broken by one. "Correctly written" means two things: 1. **Ignore fields you do not recognise.** Do not fail to parse a response because it grew a key. Do not assert on the full shape of a JSON object. 2. **Treat an unrecognised `reason` as the generic failure its HTTP status already describes.** A `403` with a `reason` your code has never seen is still a `403`: refused. Branch on the codes you handle specially, and fall through to the status for everything else — never to an error path that assumes the code list is closed. A client that does those two things is safe against every change in this section. ## What may not change without a version bump and 90 days' notice These are breaking, and each requires a **version bump and 90 days' notice**: - **Removing or renaming a field.** - **Removing a `reason` code, or changing what an existing one means.** A code that keeps its spelling but starts describing a different condition is a break: the client's branch on it then runs at the wrong time. - **Changing a field's type.** - **Changing the HTTP status for an existing condition.** - **Tightening validation**, so that a request which worked now fails. **Notice period: 90 days.** Announced in `/partner-api-changelog.md` as an entry naming the new version, what changed, and when the ninety days start and end. The previous behaviour keeps working for the whole period. ## Errors Every coded refusal has the same body shape: ```json { "error": "a human-readable sentence, for a log or a support ticket", "reason": "a_stable_machine_readable_code" } ``` **Branch on `reason`, never on `error`.** The `error` sentence is prose written for a person and may be reworded at any time; `reason` is the contract. A `429` also carries `Retry-After`. ### One flat namespace, so each code names its endpoint The `reason` codes share a single namespace across the whole partner surface, and a few of them are short generic words — `expired`, `invalid_code`, `invalid_email`, `wrong_address`, `last_admin`. Beside the route that emits them they read plainly; in a flat published list they do not. **Renaming them would break the wire**, which is the one thing this contract exists to prevent, so instead every row below names the endpoint(s) that can produce it. Read the code together with its endpoint — that pair, not the code alone, identifies the condition. | Code | HTTP | Where it can occur | What it means | | --- | --- | --- | --- | | `invalid_key` | 401 | `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. | | `rate_limited` | 429 | `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`. | | `partner_inactive` | 403 | `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. | | `unsupported_api_version` | 400 | `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. | | `test_key_against_live` | 403 | `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. | | `live_key_against_test` | 403 | `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. | | `game_not_licensed` | 403 | `POST /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers` | You are not licensed for this game in any environment. | | `game_not_licensed_in_this_environment` | 403 | `POST /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers` | You are licensed for this game, but not in the environment this key authorises. | | `malformed_player_id` | 400 | `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. | | `unknown_tier` | 400 | `POST /partner/matches` | No such tier — the ladder is ours and fixed; read it from `GET /partner/tiers`. | | `stake_below_floor` | 400 | `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`. | | `stake_does_not_match_price` | 400 | `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. | | `stake_above_prize` | 400 | `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`. | | `tier_locked` | 409 | `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. | | `rake_terms_not_agreed` | 403 | `POST /partner/matches` | No active rake agreement is on file for your account. | | `idempotency_key_conflict` | 409 | `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. | | `settlement_unavailable` | 503 | `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. | | `invalid_cursor` | 400 | `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. | | `missing_parameter` | 400 | `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. | | `malformed_day` | 400 | `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. | | `range_backwards` | 400 | `GET /partner/usage` | `from` is after `to` — swap the two arguments; both dates are individually valid. | | `range_too_wide` | 400 | `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. | | `day_not_final` | 400 | `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). | | `registry_unavailable` | 503 on a Game-API call; **500** on the API-key endpoints | 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. | | `not_a_partner_admin` | 403 | Every `/partner/admin/…` route except `POST /partner/admin/invites/{invite_id}/accept` | A verified Google account that administers no partner. | | `role_forbidden` | 403 | 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. | | `totp_not_enrolled` | 403 on the action gate, 409 on `POST /partner/admin/totp/verify` | 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. | | `step_up_required` | 403 | 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. | | `fresh_step_up_required` | 403 | 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". | | `step_up_locked` | 429 | `POST /partner/admin/totp/verify`, `POST /partner/admin/totp/enroll` | Too many incorrect codes; verification is locked for a while. | | `invalid_code` | 403 | `POST /partner/admin/totp/verify` | The TOTP code didn't verify — about an authenticator code, not an API key or an invite. | | `partner_changed` | 409 | 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. | | `invalid_origin` | 400 | `POST /partner/admin/origins` | Not a valid web origin. | | `too_many_origins` | 400 | `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. | | `invalid_webhook_url` | 400 | `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. | | `label_required` | 400 | `POST /partner/admin/keys` | A new API key needs a label. | | `last_admin` | 409 | `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. | | `already_accepted` | 409 | `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. | | `wrong_address` | 403 | `POST /partner/admin/invites/{invite_id}/accept` | This invite was addressed to a different e-mail address than the one signed in. | | `expired` | 403 | `POST /partner/admin/invites/{invite_id}/accept` | The **invite** has expired — never about an API key or a session token. | | `already_administers_another_partner` | 409 | `POST /partner/admin/invites/{invite_id}/accept` | The invited Google account already administers a different partner. | | `already_a_member` | 409 | `POST /partner/admin/invites/{invite_id}/accept` | The invited account already administers this partner — nothing to claim; role changes go through the members page. | | `invalid_email` | 400 | `POST /partner/admin/invites` | The **invitee's** address is not a valid e-mail address. | | `address_outside_domain` | 400 | `POST /partner/admin/invites` | The invited address is outside the account's admin domain. | | `unknown_game` | 400 | `POST`/`DELETE /partner/admin/games/{game_id}/test`, `POST`/`DELETE /partner/admin/games/{game_id}/live-request` | Not a game this platform runs. | | `not_licensed_in_test` | 409 | `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. | | `already_live` | 409 | `POST /partner/admin/games/{game_id}/live-request` | The game is already licensed live, so there is nothing to request. | | `invalid_domain` | 400 | `POST /partner/admin/account/domain` | Not a valid admin domain. | | `domain_already_set` | 409 | `POST /partner/admin/account/domain` | The admin domain is set-once, and already set — changes go through your platform contact. | ## The rate bucket the API-key endpoints share `POST /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers`, `GET /partner/results`, `GET /partner/me`, `GET /partner/usage` and `POST /partner/test/matches/{match_id}/decide` share ONE per-partner rate bucket, keyed on your partner id. Spending it on one endpoint spends it for all seven; `GET /embed/{partner_id}` has its own, because a player launch and a backend call are not the same traffic. Stated because it changes how you pace a batch: seating a hundred matches in a loop draws from the same budget your result polling needs. A `rate_limited` response is retryable — honour `Retry-After`. ## `GET /partner/usage` — the range you may ask for The metered history is read with your API key, one row per game and day: ``` GET /partner/usage?from=20260701&to=20260731 ``` Four rules decide whether a range is answered, and each has its own `reason` in the table above so a client can tell them apart: - **`from` and `to` are both required.** There is no default window. Omitting one is `missing_parameter`, and the body names which. - **Both are `yyyymmdd` in UTC**, and are checked as calendar dates — `20260231` is `malformed_day`, naming the parameter. - **`to` may be no later than yesterday (UTC).** A day's figure is final only once the day is over, because a match reaches its bucket when it is decided. Asking for today is `day_not_final`, and the body carries `latestDay` — use that rather than computing yesterday from a local clock. - **A single call spans at most 366 days.** A wider range is `range_too_wide`, and the body carries `maxRangeDays`. Rows are scoped to the environment of the key you used, which is why `environment` is in the answer. A day with no matches was never written, so absence is a zero. ## `GET /embed/{partner_id}` emits no `reason` at all This is deliberate: there are no embed rows in the table above because there are no embed codes. Every refusal from `/embed/{partner_id}` (and `/embed/test/{partner_id}`) is a bare HTTP status — typically `404`, `409`, `429` or `500` — with no `reason` in the body. What requests that URL is an `