# Peritus Games — the complete partner documentation Every page published at https://peritusgames.com/docs, in reading order, followed by the API contract and its changelog. This file is assembled from the same render as those pages, so it cannot disagree with them. Each page below opens with a `Source:` line naming its own canonical URL — the canonical page for the errors section, for example, is /docs/errors, not /docs. Every page is also published on its own as markdown (/docs/results.md beside /docs/results). --- Source: https://peritusgames.com/docs # Overview A Peritus game is a self-contained frame you embed. You identify the player; the game does the rest and hands back a scored, replayable result. ## What you embed One iframe holds an entire game: the lobby, play, match history, results and replays. The lobby shows your players the tier ladder they can enter, with practice available alongside it — the tier cards appear when your handshake declares you are seating paid matches. There is no board component to wrap and no game state for you to keep; it loads from our origin, so an update reaches your players without you shipping anything. ## The four moving parts - **Your backend** holds the API key. Four endpoints run an integration: mint a session, read the [tier ladder](/docs/paid-play.md), seat a player into a match, and read match results. The first alone reaches practice; a match needs the other three. - **Your page** embeds the frame and exchanges a small, fixed set of messages with it. It never sees the API key. - **The game frame** plays the match, talks to our servers directly, and reports progress to your page. - **The partner portal** is where your team creates keys, registers the origins allowed to embed you, licenses games, and reads your usage. ## What crosses the boundary - Into the game: a short-lived session token identifying one player, scoped to one game — and, if sent, the name their opponent sees. Nothing else about them reaches us. - Out of the game: the authoritative result, read by your backend from [the results feed](/docs/results.md). Nothing the browser reports is part of it. - Never: your users' credentials, your player balances, or any money at all. ## Reading these docs from a tool Every page under `/docs` is also published as markdown — `/docs/results.md` beside `/docs/results`, and so on — with [/llms.txt](/llms.txt) as the index. Every page is also published as ONE file — the link at the foot of the nav, or [/llms-full.txt](/llms-full.txt). These pages are also an MCP server at `https://peritusgames.com/mcp` — two tools, `search_docs` and `get_doc`, and no credential needed. > Also published: the [contract](/partner-api-contract.md), its [changelog](/partner-api-changelog.md), and the surface itself as [OpenAPI 3.1](/partner-api.openapi.json). They are the same files, verbatim. ## Where to go next - [Quickstart](/docs/quickstart.md) — a working embed, end to end, with real hosts and copyable calls. - [Keys and environments](/docs/authentication.md) — what a key looks like, and how test and live stay apart. - [Match results](/docs/results.md) — the authoritative outcome of every match, and the only signal you may move money on. - [Errors and limits](/docs/errors.md) — every coded refusal, what causes it, and what to do about it. --- Source: https://peritusgames.com/docs/quickstart # Quickstart Six steps from an API key to an embedded game, a seventh to a played match — the same hosts, paths and payloads production uses. ## The hosts | What | Where | Called by | | --- | --- | --- | | API | `https://api.peritusgames.com` | your backend | | Game frame | `https://games.peritusgames.com` | your page (iframe) | | Partner portal | `https://peritusgames.com/portal` | your team (browser) | One API host serves both environments; which one a call lands in follows from the key, never the URL — see [Keys and environments](/docs/authentication.md). ## Before you start - Your account's **partner id** — 3–32 characters of lowercase letters, digits and hyphens (`acme` below). - A **test API key**, minted by your first admin in the portal. - The **origin** that will embed the frame, registered in the portal — until then the frame refuses to render. See [Embedding a game](/docs/embedding.md). ## 1. Mint a session, from your server ``` curl -X POST https://api.peritusgames.com/partner/sessions \ -H "Authorization: Bearer pgs_test_" \ -H "Content-Type: application/json" \ -d '{ "playerId": "u_8813", "gameId": "streakpeaks", "displayName": "Ada Lovelace" }' ``` ## 2. Read the response ``` 200 { "uid": "ptr:test:acme:u_8813", // a TEST key — a live one gives ptr:acme:u_8813 "token": "eyJhbGciOiJIUzI1NiJ9…", "expiresAt": 1754211600, "displayName": "Ada Lovelace" } ``` Valid one hour, scoped to that player and game; mint per session, do not store it. The `uid` is derived from `playerId` — store it. Full rules in [Identifying your player](/docs/players.md). ## 3. Embed the frame ``` ``` The path segment is your partner id, not your key — no credentials here. Live drops `/test`. ## 4. Answer the handshake ``` const GAME_ORIGIN = "https://games.peritusgames.com"; window.addEventListener("message", async (e) => { if (e.origin !== GAME_ORIGIN) return; if (e.data?.type === "ready") { frame.contentWindow.postMessage({ type: "init", token: session.token, uid: session.uid, dealerUrl: "https://api.peritusgames.com", gameId: "streakpeaks", }, GAME_ORIGIN); } if (e.data?.type === "needToken") { const fresh = await mintSession(); // same player, same game frame.contentWindow.postMessage( { type: "token", token: fresh.token }, GAME_ORIGIN, ); } }); ``` Every message your page sends and trusts is listed in [Embedding a game](/docs/embedding.md). ## 5. Read the result from your server ``` curl "https://api.peritusgames.com/partner/results?limit=50" \ -H "Authorization: Bearer pgs_test_" ``` The authoritative outcome — the frame never reports a score to your page. See [Match results](/docs/results.md). ## 6. Go live Move your backend to a live key and your page to the live embed path; the base URL stays the same. Live access is per game, requested from the portal. ## 7. Play for a prize This step seats a match. Live play needs a live key plus two more calls — `GET /partner/tiers` for the ladder, `POST /partner/matches` to seat a player — free of charge with a test key. The handshake's `init` message must also carry `seating: "host"`, which puts the tier cards in the frame — omit it and your player has no way in. To seat from your own page instead, send `route`. [Paid matches](/docs/paid-play.md) has the full treatment. > **A paid match seats two players of the same operator** — never paired with ours or another platform's. Plan your liquidity on that. ## The games, and their ids The `gameId` in every call is one of these, licensed per environment: test instant and self-service, live requested the same way. | Game | `gameId` | | --- | --- | | Streak Peaks | `streakpeaks` | | Poker Squares | `pokersquares` | | Spades | `spades` | | Streets | `streets` | | Dice 13 | `dice13` | | Press | `press` | | Squeeze | `squeeze` | | 21 | `twentyone` | | Rally | `rally` | > Each game runs, playable and without an account, from [the games page](/games). --- Source: https://peritusgames.com/docs/authentication # Keys and environments One API key authenticates your backend. Which environment a call belongs to follows from the key, not the URL — there is no second host to point at. ## What a key looks like ``` pgs_live_8f3a1c9d2e7b4a6f5c0d1e2f3a4b5c6d7e8f9a0b14NXoq pgs_test_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1L0EZk ``` The prefix names the environment; the 46 characters after it are fixed-length and self-checking, so a truncated paste or mistyped character is refused as a `401`. ## The key is a server credential It never belongs in a browser: a key readable from your page lets any of your players mint a session *as any other player of yours*. Keep it in your backend's secret store and call the mint endpoint from there. Your account starts with no key; your first admin claims their invite, enrols a second factor, and mints the first key themselves, shown exactly once. We store only a hash, so a lost key is replaced, never re-displayed. Keys are created, rotated and revoked entirely by your team, from [the portal](/docs/portal.md). ## Test and live Both environments are served by `https://api.peritusgames.com`. Keys are scoped to one environment each; an account may hold more than one live key and more than one test key at once. What they keep apart: | Separated | How | | --- | --- | | Player identity | A test session produces `ptr:test::` — the same `playerId` is two different players. | | The embed path | `/embed/test/` instead of `/embed/`, same games host. | | Origins | Registered per environment; a test-registered origin can't embed the live frame, and the reverse. | | Opponents | A test player is only ever matched with another test player; joining across environments is refused with `403 cross_environment_join`. | | Results | [The results feed](/docs/results.md) returns only rows for the key's environment, cursors included, but every match in it. | | Licensing | Decided per environment; a game enabled for test says nothing about live. | ## Reading your own account One call answers “what have I got, and which environment am I in” — a build script or start-up check can ask it without anyone signing in: ``` GET /partner/me Authorization: Bearer pgs_test_ 200 { "partnerId": "acme", "name": "Acme Arcade", "apiVersion": 1, "environment": "test", "games": ["streakpeaks", "pokersquares"], "origins": ["https://staging.acme.example"], "prices": { "usd_10": 700 }, "webhook": { "url": "https://staging.acme.example/hooks", "enabled": true, "consecutiveFailures": 0, "createdAt": 1785000000, "secretRotatedAt": 1785000000, "disabledAt": null } } ``` `partnerId` is what every uid and later record is built from. `prices` is sparse: an unpriced tier is absent rather than 0, since absent means our floor — read `entryUsdCents` on [the tier ladder](/docs/paid-play.md) for what a player actually pays. `webhook` is absent when unconfigured, and never carries the signing secret, shown once, in the portal. > **It is a read, and only a read.** Changing any of it — minting a key, registering an origin, setting a price or a webhook URL — stays in the portal. This response, like every one on this surface, is answered on your account's pinned version unless the call named a different one — see [Versioning and change](/docs/versioning.md). `apiVersion` on the body is the version this call was actually answered on, always equal to the `X-Peritus-Api-Version` response header on the same reply — read whichever one your code already has in hand. ## How two test players are paired A stake joins the **oldest waiting seat** matching on game and tier whose stake is confirmed — deterministic, unlike live's skill-based pairing, so a two-account rig behaves the same on its fiftieth run as its first. A test key can also decide a match outright — see [Testing your integration](/docs/testing.md). ## Licensing a game Turning a game on **for test** is instant and self-service from the portal. **Live** access is requested from the same place and granted by someone on our side; withdrawable anytime, and a denial isn't final — but a game must be on in test first. The mint endpoint distinguishes a game licensed in the *other* environment (`game_not_licensed_in_this_environment`) from one licensed in neither (`game_not_licensed`) — both in [Errors and limits](/docs/errors.md). ## Rotating a key Rotation is an overlap, not a cutover: it mints the new key immediately — shown once — and schedules the old one for revocation **seven days** out; both mint valid sessions until then, and the portal shows a live countdown plus each key's last use. The new key is accepted within **60 seconds** of minting, and up to **10 minutes** if our registry cannot be read — the same window as [any other change to your account](/docs/embedding.md) — so a call made sooner can still answer `401 invalid_key`. After revocation, calls with the old key answer `401 invalid_key` — but that affects future mints only, so an already-issued token stays valid until it expires, up to an hour later. To stop sessions already in play, use the emergency stop in [the portal](/docs/portal.md) instead. --- Source: https://peritusgames.com/docs/testing # Testing your integration A test key plays the whole flow with no money in it. One endpoint exists only here: it decides a match, so you can see a result without two people playing one. ## Deciding a match in test The test environment can end a seated match on demand. You need a `matchId` first: seat two of your own test players with `POST /partner/matches` (see [Paid play](/docs/paid-play.md)) — that call returns the `matchId` this endpoint decides. ``` POST /partner/test/matches/{matchId}/decide Authorization: Bearer pgs_test_ Content-Type: application/json { "scores": { "u_8813": 4180, "u_9001": 3960 } } ``` Name every seat by the same `playerId` you minted its session with — a missing one is refused rather than filled in. The match finishes exactly as a played one does: same finish, same seal, same rows in [the results feed](/docs/results.md), same webhook nudge. Your CI can then check that it paid the player you decided would win. > **Test keys only.** The handler cannot be reached without a token only a test key can produce. A live key gets the same `404 no such test match` as a nonexistent id, so it learns nothing about which matches do — unless you're on a deployment pinned to one environment, where it's refused earlier with `live_key_against_test`. The match must also be between test players of yours. **What it refuses with.** A match id that isn't yours, isn't a test match, or doesn't exist is `404`. A match not in a decidable state — already finished, or never seated — is `409`. A body missing a seat is `400`. These three carry a message but no `reason` code, so branch on the status. > **What a green suite covers.** The half that is yours: the seat, the feed, the webhook, and your ledger paying the player you decided would win. It does *not* exercise our scoring engine or the anti-cheat replay behind it — the score comes from you, not moves — nor a payout of ours, since a test match moves no money on either side. --- Source: https://peritusgames.com/docs/players # Identifying your player One server-to-server call, before you show a game, returns a short-lived token scoped to the player and game you name. ## The call ``` POST /partner/sessions Authorization: Bearer Content-Type: application/json { "playerId": "u_8813", // yours, stable per player "gameId": "streakpeaks", "displayName": "Ada Lovelace" // optional — what their opponent sees } ``` ## The response ``` 200 { "uid": "ptr:acme:u_8813", "token": "eyJhbGciOiJIUzI1NiJ9…", "expiresAt": 1754211600, "displayName": "Ada Lovelace" } ``` | Field | Meaning | | --- | --- | | `uid` | Our identifier, derived from yours; every later record refers to it. Store what you were given rather than rebuilding it — see below. | | `token` | An opaque bearer credential, valid **one hour**, scoped to this player and game. | | `expiresAt` | That expiry, as Unix epoch seconds. | | `displayName` | The name we actually stored, not always the one you sent — see below. | Mint one per session rather than storing tokens, and pass it to the frame per [Embedding a game](/docs/embedding.md). ## The player id is yours; the uid is ours `playerId` is **1–64 characters** of `A-Za-z0-9_-` — no colons, dots, slashes, spaces or non-ASCII; whitespace trimmed, nothing else transformed. Map ids like emails or braced UUIDs to a compliant one first, or get `400 malformed_player_id`. **The environment is part of the uid**: a live key gives `ptr:acme:u_8813`, a test key `ptr:test:acme:u_8813` — the same `playerId` is two different players whose histories never mix. The uid is derived, not chosen, and never collides with another partner's, ours or any first-party account. Changing how you generate `playerId` for the same real person produces a different player with none of that history carried over. `displayName` plays no part in identity: changing it never creates a new player or breaks the link to their history. ## The name is yours too Send `displayName` and that is what the opponent sees — the only way a name reaches us, since there is no in-frame route to set one. Names are 1–20 characters of letters, digits, spaces and `- _ .`; we clean rather than reject one, so a disallowed character never stops play, substituting our own handle if nothing usable is left — compare the response against what you sent to see if anything changed. Omitting `displayName` leaves whatever is already stored rather than clearing it. ## What we store about a player of yours The complete list, none asked directly — it comes from the mint call and the games played. - **The uid** — `ptr::` — and **the display name** we actually stored (yours, or a generated handle). - **Their match records** — both uids, each display name at the time, each score, and when created, started and finished. Kept after an account closes — it is the other player's history too. - **Per-game stats** folded from those matches — play/win/loss/tie counts and scores — plus a skill rating, never shown to anyone. Not stored: email, password or credential, payout address, or balance — settlement runs through you, so it skips the payout check a first-party account is held to. ## Closing a player's account No partner-facing route for this — ask us; we close it by uid. --- Source: https://peritusgames.com/docs/embedding # Embedding a game The frame and your page exchange a small, fixed set of messages. Both sides check the origin of every message before trusting it. ## Place the frame ``` ``` > The frame’s own `` is your registered account name, not ours; the `<iframe>`’s own `title` attribute is yours to set, and is what assistive technology announces. The path segment is your partner id; the endpoint takes no credentials. The test environment has its own path, `/embed/test/acme`, with its own origin list. The frame is served from a different origin than your page: your page cannot read the game’s token or DOM, and the game cannot read yours. ## How the embedded game looks The frame takes a background colour, a font and a corner radius from your account, so the table matches your site — set once per environment, in the portal, or over the API for test only. That is its own page: [Matching your brand](/docs/appearance.md). ## If you sandbox the frame If you sandbox the frame, two tokens are required: | Token | Why the frame needs it | | --- | --- | | `allow-scripts` | The game is a script; without this nothing runs. | | `allow-same-origin` | Without it the frame gets an *opaque* origin — nobody’s. Its own scripts and stylesheet fail to load, and every storage call throws. | Anything beyond those two is yours to decide — we do not need popups, forms, modals, downloads, or top-level navigation. The separate `allow` attribute is a different mechanism (Permissions Policy). Include `clipboard-write` for the frame’s copy-link buttons to work; without it they fail quietly. ## Register your origins first Every origin hosting a page that embeds the frame must be registered in [the portal](/docs/portal.md) — exact scheme, host, and port if not default, including staging and preview — per environment: a test origin cannot embed the live frame. > **An unregistered origin is not something your code can catch.** The embed request still returns `200` with a normal HTML document; the browser — not our server — refuses to render it, via the `frame-ancestors` policy. There is no error for your JavaScript to see — check the devtools console, where the violation is logged. An account with *no* origins registered for an environment is embeddable *nowhere* — the state a brand-new account starts in. The request answers `409 Conflict` naming the environment, visible to a `curl` or deploy check, unlike the case above. An unknown or deactivated partner id answers a plain `404` with no document. **A change is not instant.** It is saved the moment you press the button, but takes up to **60 seconds** to reach the frame, and up to **10 minutes** if our registry cannot be read. ## Messages from the game | Message | What it means | | --- | --- | | `{ type: "ready" }` | Loaded, waiting for an identity — send `init`. | | `{ type: "needToken" }` | Token is getting old, or was rejected. Mint a fresh one for the same player and game and reply with a `token` message — expect this around the halfway point of the hour, or after backgrounding the tab. | | `{ type: "exit", to? }` | The player asked to leave — navigate on your site. | | `{ type: "chrome", sidebar }` | Entering or leaving an immersive match, asking your navigation out of the way. Honour it or ignore it. | `sidebar`: **`false` hides** your navigation (entering a match); `true` restores it (match over). A request about your layout, never a requirement — ignoring it still works. `to`, when present, is a **path on your own site** beginning with `/` — never an absolute URL, never a route inside the game. **Validate it anyway**: refuse anything that does not start with a single `/` and fall back to your own games page. Navigating to a string from a frame without checking it is an open redirect, and that is true however trustworthy the sender. > **Message types are additive under the version policy.** One more, `seatRequest`, is under [The seat request](#the-seat-request) below. Match only the types you handle: a switch with no default, or a throw on unknown, breaks on a change that breaks nobody else. > There is no per-match score message; the outcome is in [the results feed](/docs/results.md), read server-to-server and computed from the moves as replayed. ## Messages to the game ``` frame.postMessage({ type: "init", token, // session token for this player and this game uid, // the uid the token was issued for dealerUrl, // required: https://api.peritusgames.com gameId, // optional route, // optional: deep link — "/play/" + matchId seating, // optional: "host" — see below backAtRoot,// optional: "exit" — see below }, GAME_ORIGIN) ``` `seating: "host"` tells the game **you** take the money here: it may show tier cards, and a tap asks you to confirm and charge. Leave it out for no match entry — solo practice, plus whatever you seat server-side and deep-link with `route`. Any other value is treated as absent. `backAtRoot: "exit"` draws a back arrow on the game’s first screen, which posts `{ type: "exit" }` with no `to` — you choose where the player lands. Leave it out and that screen has no arrow. Inside the game the arrow is ours and always there: it moves the player between the lobby and a match and never reaches your page. Any other value is treated as absent. `route` on `init` runs the other way: a path inside the GAME, not your site like `exit`’s `to`. The only shape you need is on [Paid matches](/docs/paid-play.md) — `"/play/" + matchId`. Send `{ type: "token", token }` on its own whenever you refresh the session, including in reply to `needToken` — the frame already has the uid, so this is not a second `init`. ## The seat request With `seating: "host"` set, a tap on a tier card sends one more message from the game — the only one that carries money: ``` { type: "seatRequest", nonce: "…", // opaque id for this request; nothing to reply to gameId: "streakpeaks", tierId: "usd_10", attempt: "1724…-uuid", // derive your idempotencyKey from this } ``` Confirm and charge on your own side, then seat the match from your server — the whole sequence is on [Paid matches](/docs/paid-play.md). The frame does not wait for a `postMessage` reply: it detects the seat through our own API, which is why nothing your page posts back can seat a player. ## Check the origin, both ways Always pass an explicit target origin to `postMessage`, and always compare `event.origin` against the game origin before acting — anything on your page can post to your window. The frame does the same: it only accepts `init` from a registered origin, and says so plainly if yours is missing, rather than sitting in its loading state. --- Source: https://peritusgames.com/docs/appearance # Matching your brand The embedded game takes three settings from your account — a background colour, a font, and a corner radius — so the table reads as part of your site rather than a window into someone else’s. ## The three settings Chosen in [the portal](/docs/portal.md), per environment. Saving one takes up to **60 seconds** to reach the frame, the same window a [rotated key](/docs/authentication.md#rotating-a-key) waits out — so a reload straight after saving can still show you the old look, and that is not a save that failed. > **The colour you save is not the exact colour you typed.** Your background sets the table’s hue; we set its lightness, so our near-white text and translucent panels stay legible against it. The portal’s preview shows the result you will actually get before you save it. The font is one of a fixed set of named faces already on the player’s own device — `ours`, `system-sans`, `system-serif`, `helvetica`, `georgia`, or `mono` — never a file you host or link; there is no way to point the frame at a font of your own. Corner radius is one of `sharp`, `soft`, or `round`. ## What stays ours The gold highlight that marks a playable card and the accent that marks your opponent stay ours: those colours carry meaning on the board itself, and recolouring them would take information away from a player rather than restyle the table around them. The corner radius follows the same line: it reshapes panels, buttons, chips and modals, and leaves the playing cards alone — a card’s rounded corner is part of what makes it read as a card. The portal’s preview is the game’s own end-of-match modal, so you see the setting on the chrome it actually reshapes. ## Reading and setting it from the API `GET /partner/theme` answers with the theme for the environment your key names — there is no environment parameter, because every call on this surface takes it from the key. Two keys, two answers. ``` GET /partner/theme Authorization: Bearer pgs_test_… { "environment": "test", "background": "#3b1d6e", "font": "system-sans", "corners": "sharp", "derived": { "felt0": "#342d48", "felt1": "#221e2f", "felt2": "#0d0b13" } } ``` `derived` is the three-step felt we compute from your background — your hue, our lightness — and it is what the frame actually paints. It is `null` when `background` is empty, which is what “use ours” looks like on the wire. Read it rather than deriving your own: a second calculation is a second answer that can disagree with the document we serve. `logo` is your own mark, or `null`. It is two fields rather than a `data:` URI, because we build the URI ourselves when we write it into your embed: ``` "logo": { "mime": "image/png", "base64": "iVBORw0KGgoAAAAA…" } ``` `mime` is `image/png` or `image/webp` — never SVG, which is a document and can carry scripts. `base64` is the file's bytes on their own, with no `data:` prefix, and at most 32 KB decoded — export it around `256×256`, which is what the limit is sized for: the mark is drawn at 84 px, so 256 covers a three-times display and a 1024×1024 export is four times more pixels than anything will ever show. All four refusals, and why the size is a bound rather than a preference, are in [Errors and limits](/docs/errors.md). > **Send it only when you mean to change it.** Omit `logo` and your stored mark is left alone — which is what a call written before marks existed does, so it cannot delete one. Send `null` to remove it. Send nothing at all and your players see your background alone; they never see ours. `PUT /partner/theme` takes the same settings and answers with the stored result. It accepts a **test key only**: a live key reading is fine, a live key writing is refused with [live_theme_not_writable](/docs/errors.md). > **A write is a deploy.** The theme lives on your account, so setting it repaints every embed in that environment — your staging site, your QA, your CI — not a copy belonging to the call. That is why test is the automatable one: it is where you iterate against your own page. Changing live is done in the portal, beside a preview, by a person. The refusals both calls answer are in [Errors and limits](/docs/errors.md). Where the settings land on the page — the frame itself, the origins that decide who may embed it, and the messages it exchanges with yours — is [Embedding a game](/docs/embedding.md). --- Source: https://peritusgames.com/docs/paid-play # Paid matches Your player picks a tier, **your page confirms and charges them**, and your server seats the match. You collect the entry and pay your own winner; we supply the game, decide the result, and bill a fee for the seat. **A paid match pairs two of your own players.** ## How it starts 1. Your player taps a tier card, priced with **your** number — see [Managing your account](/docs/portal.md). 2. The frame posts to **your page**: `{ type: "seatRequest", nonce, gameId, tierId, attempt }`. It carries no amount — price from your own copy of the ladder, never a browser. 3. **You run your own confirm dialog**: your price, your currency, your balance. 4. Your server debits the player and calls the seating endpoint below. 5. The frame enters the match on its own, through our API; it does not wait for an answer from your page, and no message from it can seat anybody. > **The confirm step is required, not advisory** — we place no limit on seat requests; your dialog is what stands between repeated taps and repeated charges. Derive your `idempotencyKey` from `attempt`: same for that player, game and tier across a double tap or reload, so those collapse onto one charge and one seat. ## Who plays whom **Today a paid match seats two players of the same operator** — when nobody is waiting, they wait for another of *your* players at that game and tier, not one of ours or another platform’s. A tier nobody else on your platform is playing does not fill. ## What the numbers are - **The prize is ours, fixed per tier** — what your winner takes. You do not set it. - **The entry is yours to set,** from the tier’s floor to its prize — below is refused, and so is above the prize. Anything above the floor you keep; it never reaches us. You set it per tier **in the portal**, the number your player sees on the card. The seating call must send that same number — a seat that disagrees is refused. - **The fee is per seat**, our share of a fixed base under the split agreed with your account — quoted before the match and pinned to the seat, so renegotiating a split does not restate an earlier one. > **The tier cards do not appear unless your handshake asks for them** — your `init` message must carry `seating: "host"`. Leave it out and the embed has practice and no match entry — see [Embedding a game](/docs/embedding.md). ## Read the ladder first ``` GET /partner/tiers?gameId=streakpeaks Authorization: Bearer <your API key> 200 { "tiers": [ { "tierId": "usd_1", "prizeUsdCents": 100, "minStakeUsdCents": 55, "entryUsdCents": 55 }, { "tierId": "usd_20", "prizeUsdCents": 2000, "minStakeUsdCents": 1100, "entryUsdCents": 1400 } ], "pricesUpdatedAt": 1760000000 } ``` `minStakeUsdCents` is the floor your price must clear; the prize is the ceiling. Read this — don’t hard-code it. **`entryUsdCents` is what YOUR player is charged** — your Admin’s portal price, or our floor if none. Price your confirm dialog from this. Read it per launch, not hard-coded: a stale figure is refused at seat time. `pricesUpdatedAt` is when your Admin last saved the price map, in epoch seconds, or `null` if nobody has — a real answer, not missing. Cache the ladder and compare this to know your copy is current. > **It is a live read, not a start-up setting to cache** — an Admin can change the price at any moment and this always answers current. `minStakeUsdCents` is different: ours, the least the seating call accepts. **The card your player sees follows within 60 seconds, not instantly** — the same propagation described for origins on [Embedding a game](/docs/embedding.md). Read it per launch, and for a promotion, change the price before the traffic rather than during it. **The ladder is the tiers you can seat.** A tier we cannot offer is simply absent, not listed and flagged. Read it at start-up, cache it, and refresh — don’t hard-code the list. ## Seat the match > **`yourStakeUsdCents` must match the price we published.** Read it from `entryUsdCents`, not a constant, so a portal price change cannot leave your backend behind. [Errors and limits](/docs/errors.md) gives the rule, the `stake_does_not_match_price` code, and how an unpriced tier is handled. ``` POST /partner/matches Authorization: Bearer <your API key> { "playerId": "u_8813", "gameId": "streakpeaks", "tierId": "usd_20", "yourStakeUsdCents": 1400, "idempotencyKey": "your-own-id-for-this-request" } 200 { "matchId": "m_7f3a…", "uid": "ptr:acme:u_8813", "joined": false } ``` `joined: false` means seated and waiting for an opponent — another of your own players, as above. `true` means one was already waiting and play is under way. Either way the seat exists and the match id is yours. ## Seating a player who is not already in the frame Seating from somewhere else — a lobby, a promotion, an email — does not put anyone in front of the match. Mint a session, embed the frame as on [Embedding a game](/docs/embedding.md), and deep-link it on `init`: ``` frame.postMessage({ type: "init", token, // from POST /partner/sessions, same gameId uid, // the uid POST /partner/matches returned dealerUrl: "https://api.peritusgames.com", gameId: "streakpeaks", route: "/play/" + matchId, }, GAME_ORIGIN) ``` Send `route` to land your player straight in the match; without it they land in the lobby, where the seat is waiting — sorted above finished matches in their list, and counted on the Matches tab. ## The idempotency key, and the one answer that lies `idempotencyKey` is yours to choose — 1 to 128 characters of `A-Z a-z 0-9 _ - .` The same key for the same request returns the same seat; for a *different* request it returns `409 idempotency_key_conflict` instead. [Errors and limits](/docs/errors.md) gives the rule and the code. > **A 503 does not mean the seat was not taken** — the answer can be lost after we have already seated, so `settlement_unavailable` means only that we could not tell you. Retry with the **same** key: you get the existing seat back, or a new one. A fresh key opens a second paid match beside the one you already have. ## When nobody joins A match that expires without an opponent is `no_contest`, billed a stated zero. **Return the entry to your player.** A player who paid but did not play is different: `ended`, with `played: false` — no refund; the result stands by score. ## What happens next [Match results](/docs/results.md) carries the tier, prize, your stake echoed back, and the fee — from the `started` row, not just the end. A draw and a `no_contest` are both billed a stated zero. Every refusal this page can produce is catalogued under [Errors and limits](/docs/errors.md). --- Source: https://peritusgames.com/docs/results # Match results The confirmed, server-computed outcome of every match your players play — the only signal you may move value on. ## The call ``` GET /partner/results?cursor=<opaque>&limit=50 Authorization: Bearer <your API key> 200 { "rows": [ { "matchId": "m_7f3a…", "playerId": "u_8813", "gameId": "streakpeaks", "state": "ended", "outcome": "win", "endedAt": 1785000420, "tierId": "usd_10", "prizeUsdCents": 1000, "yourStakeUsdCents": 583, "feeUsdMicros": 250000, "played": true } ], "cursor": "…", "hasMore": false } ``` It authenticates with your API key, not a session token, and returns only that key’s environment’s rows. ## `limit` counts matches, not rows `limit` defaults to **50**, capped at **200**, bounding **matches**, not `rows`. A match produces one row per player of yours in it — [a paid match seats two players of the same operator](/docs/paid-play.md), so a decided one produces two rows. `limit=1` can return 2 rows; `limit=200`, up to **400**. Size buffers on the row count, not `limit`. > The cursor is a position in the match history; a page always ends on a match boundary, never splitting one player's row from its opponent's. Read `rows.length` for what arrived and `hasMore` for whether to continue — never compare `rows.length` with `limit` to decide you’re done. ## The three states | `state` | What happened | Your move | | --- | --- | --- | | `started` | Your player is seated — on a paid match, at payment, not their first move | Committed — take it once, if not already | | `ended` | The match is over; `outcome` is final | Credit the result, once | | `no_contest` | Nobody joined | Return the entry, once | > **If you already took the entry when you seated the match, a `started` row is a confirmation, not a second charge — the one mistake on this feed that costs real money.** The seating call moves the money ([Paid matches](/docs/paid-play.md) step 4); this row only confirms it. Key your ledger on `(matchId, playerId)` and apply each movement once. > **Neither a `draw` nor a `no_contest` carries a fee:** a draw returns both players their entry in full, an unjoined match returns the one that was paid, and neither accrues. The model is on [the integration page](/integration). `outcome` is `win`, `loss` or `draw`, appearing only once the state is `ended`; it and `endedAt` are omitted entirely until then — never sent as `null`. Timestamps are Unix epoch seconds. ## The money on a row The last four fields appear on every partner-seated match — on `started` as well as `ended`, since *debit the stake, once* needs a number. | Field | What it is | | --- | --- | | `tierId` | Which tier the seat was sold at. | | `prizeUsdCents` | What the winner takes. Ours, fixed per tier. | | `yourStakeUsdCents` | What *you* charged this player, sent on the seating call — for a priced tier, your account’s set price. We compute nothing from it; it matches your ledger to ours. | | `feeUsdMicros` | What this seat costs you, under the agreed split, in **micro-USD** — millionths of a dollar: `10000` is one cent, `250000` is 25 c. Quoted before play, not after. | > `feeUsdMicros` is always present; `0` and `null` differ. `0` means **nothing is owed** (a `draw` or a `no_contest` — you may close the accrual); `null` means **we have not said**. A falsy check collapses the two. | Field | What it is | | --- | --- | | `played` | Whether this player made a server-accepted move, present only on `ended`: `true` — they played; `false` — they did not (neither player opened reports `false` on both rows); **absent** — not recorded, not the same as `false`. **The only way to tell a no-show from a real tie on a paid match:** two who paid and left report the same `draw` as two who tied. Branch on `played`, not outcome. | Each row carries **only that seat’s** money — two of your players meeting yields two rows, each with what was charged, which may differ. ## Four rules that decide whether your ledger is right - **Key every move on `(matchId, playerId)`, never on `matchId` alone**: two of your own players yield two rows sharing a matchId, and keying on the match alone drops one side's credit. - **Expect rows to repeat.** Once caught up, the cursor is held a few seconds behind the clock, so your next poll re-returns that window, catching any same-second result. Applying each `(matchId, playerId)` exactly once is therefore not optional. - **You will often see `ended` without ever seeing `started`** — a short match can begin and finish inside one poll interval. Debit and credit in one step; this is ordinary, not an edge case. - **No row means nothing moved:** a player who opens a game without starting a match never produces one, so there is no reservation to time out. ## How long a match can wait A match that never finds an opponent ends as `no_contest` after **72 hours** — the longest your reservation ever stands. One with an opponent runs on its own clock, unaffected. ## Paging, and catching up Pass the `cursor` to get the next page; omit it to start from the beginning of your history. Follow `hasMore` — not whether `rows` came back non-empty. > **Your first call has history behind it:** omitting the cursor starts at the ACCOUNT’s history, not your integration’s, so a fresh build may re-pay months-old matches. On first start: page to the end, keep only the final cursor, settle what arrives after. > **If a cursor is refused** (`400 invalid_cursor`), **stop and raise it** — a valid cursor never expires, so refusal means corruption, truncation, or the wrong environment. Restarting re-reads the whole history, safe only if your ledger is keyed on `(matchId, playerId)`. **An empty `"rows": []` does not mean you are done, and it can happen mid-catch-up, two ways:** a page can land entirely on matches with no row while `hasMore` stays true; or, when what’s left is an exact multiple of `limit`, the last full page still says `hasMore: true` before an empty one — neither is an error. Store your last cursor to catch up after downtime. It’s opaque, valid only for its partner and environment; a foreign or edited one answers `400 invalid_cursor`, never an empty page. ## What a row does not contain Almost nothing: a row carries only what’s tied to **price, payout or identification**. It omits the opponent’s uid, name, the deal, the seed and the move history — except on a paid match with both seats yours, where the two rows share a `matchId` and reveal who your player faced. It also omits your own uid, either side’s score, and the engine version. Your uid is derivable from `playerId`: exactly `ptr:<partnerId>:<playerId>` (or `ptr:test:<partnerId>:<playerId>` in test) — the value [session minting](/docs/players.md) returned. Scores drive no payment: your player sees their own inside your frame; `outcome` plus `played` are what your ledger needs. For a count of matches played rather than their outcomes, use the usage meter in [the portal](/docs/portal.md), not this endpoint. --- Source: https://peritusgames.com/docs/webhooks # Result webhooks If you do nothing with webhooks, you lose nothing — `GET /partner/results` is the contract, and a webhook only says *there is something new, go poll*. ## What it is, and what it is not [The results feed](/docs/results.md) is the only source of truth for what a match did. A webhook is a small signed message that carries **no result data at all** — no score, no outcome, no player, not even a match id — and **nothing to poll from**: no cursor either. A delivery that is missed, duplicated, late or out of order costs you nothing — your next poll returns the same rows regardless. The only thing a webhook buys you is *latency* — skip this page if your polling interval is already fine, or wire it up to react in a second rather than a minute. Keep the poll loop either way. ## Setting one up In the portal, per environment — a test key's events never reach a live endpoint, and vice versa. You give us a URL and we mint the secret: - **The URL is `https` only**, on a real dotted public hostname — no `localhost`, no bare service name, no IP literal in any encoding (see [invalid_webhook_url](/docs/errors.md)); a path and a query string are fine. Delivery re-resolves and re-checks the host every time, not just at registration. - **The secret is shown exactly once** — copy it in now. Rotating mints a new one, also shown once; the old secret stops signing immediately, so a rotation is a redeploy. Changing the URL does not change the secret. - **Turning delivery off keeps the endpoint on file.** Saving the URL again turns it back on and clears the failure count. Setting or rotating is an Admin-or-Developer action and needs a second-factor code. Switching delivery *off* needs no code at all. ## What arrives ``` POST /your/endpoint HTTP/1.1 Content-Type: application/json X-Peritus-Signature: t=1785000421,v1=4b1f…64 hex chars…9a02 {"type":"results.available","partnerId":"acme","at":1785000421} ``` `type` is `results.available`, the only event type today. `at` is when we sent it, Unix epoch seconds, and is the same value the signature's `t=` carries. The environment is *which endpoint received it*, never a field. **One nudge per partner, per row transition**: a match of yours starting, ending, ending as `no_contest`, or an existing row's contents changing (e.g. money fields recorded after the row was first served). Once a row is `ended` its `outcome` never moves again. Two seats of yours in one match send **one** nudge, not two. Nothing is sent for an environment with no endpoint configured, a disabled endpoint, or a deactivated account — none of those is an error on your side. > If you have no cursor yet, poll with none and start from the beginning of your history. ## Verifying the signature — against the raw bytes `X-Peritus-Signature` is `t=<unix seconds>,v1=<hex>`, where the hex is **HMAC-SHA256 of the string `"<t>.<raw body>"`**, keyed with your webhook secret. To verify: 1. Split the header on `,` and read `t` and `v1`. 2. Reject a `t` more than five minutes old — it's inside the signed content, so an attacker can't edit around it. 3. Recompute the HMAC over `t`, a literal `.`, and the exact bytes of the request body, keyed with your secret, hex-encoded. 4. Compare with `v1` in constant time. ``` import hmac, hashlib, time def verify(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t, v1 = parts["t"], parts["v1"] if abs(time.time() - int(t)) > 300: # stale, or future return False signed = t.encode() + b"." + raw_body # raw bytes expected = hmac.new( secret.encode(), signed, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, v1) ``` A vector to test your verifier against before any traffic exists — secret `test-secret`, `t` `1700000000`, and the body bytes `{"type":"results.available"}` (28 bytes, no trailing newline) sign to: ``` 9e19f0763e0b177f599a4659d6ecc686 797cb2fe2dfb70eebb5836d98a15f608 ``` Once your endpoint is registered, **send yourself a real one**: the portal's webhooks page has a *Send test event* button on any endpoint that is switched on, delivering one properly signed `results.available` event through the same code path a real row transition uses. It appears in the attempt log within a few seconds. > A test event never counts toward the ten failures that switch you off, and a successful one does not reset an existing failure run — only a real delivery that lands does that. Every test attempt is recorded either way. One test event per endpoint every ten seconds. > **An endpoint that is already switched off has no test button** — the case you meet after ten failures switch you off automatically. Save its URL again (above) to re-enable it, then test. > **Verify against the raw bytes your HTTP layer received, never a re-serialised object.** Re-encoding the body — `json.dumps(json.loads(body))`, or any framework that hands your handler a decoded object — can change whitespace, key order or number formatting and invalidate the signature. It fails intermittently, so it survives testing. ## Answer quickly, and expect duplicates **Respond `2xx` and do the work afterwards.** A `2xx` is the only thing we count as delivered. Our budget for your endpoint is short — 2 seconds to connect, 5 seconds end to end — so acknowledge the nudge immediately and run your poll outside the request. You may get two nudges for one transition, or a nudge about rows you already processed — both harmless, under the `(matchId, playerId)` keying the feed requires. Building anything to deduplicate or reorder nudges means treating the nudge as data, which it isn't. **Do not redirect us.** We never follow redirects — a `3xx` is recorded as a failed attempt carrying that `3xx`, not as a delivery. Change the URL in the portal instead. ## When we retry, and when we stop **Three attempts per nudge: immediately, then after 1 second, then after 5 seconds.** Then we stop, for good — nothing is queued or replayed later, **the feed is the replay**. All three attempts carry the identical body and signature, `t` included. A `3xx`, or a `4xx` other than `408` or `429`, is a considered answer, not a retryable outage: we record it and stop. A missing response (DNS, TCP, TLS, timeout), a `408`, a `429`, or any `5xx` gets the ladder. Either way it counts as a failed delivery below. ## Ten failed deliveries switch the endpoint off After **10 consecutive failed deliveries** — deliveries, not attempts, so up to 30 consecutive failed calls — we disable the endpoint and stop sending to it. One delivery that lands resets the count to zero. > **We do not e-mail you when an endpoint is disabled.** If you depend on these deliveries, treat “we have stopped receiving webhooks” as a condition your own monitoring detects. You find out by looking: the endpoint's own state (disabled, with the time); the [event log](/docs/portal.md), attributed to *the platform* rather than your team, with the URL and failure count; and the attempt log below. ## Seeing what we saw Each environment's endpoint has a **delivery attempt log** in the portal, newest first. Every attempt records: - When it was made, and which try of the ladder it was — 1, 2 or 3. - Whether it was delivered. - The status code we received, or *no status at all* if the request never produced a response. - How long it took. - The first 200 characters of your response body. - Which match the nudge was about — the one thing the wire body never carries. Attempts are recorded from the first delivery onwards and outlive the endpoint's enable switch. **Attempts are kept for 30 days.** > The refusals this surface can return when you configure an endpoint — including `invalid_webhook_url` — are in [the error catalogue](/docs/errors.md), with every other code. --- Source: https://peritusgames.com/docs/billing # Invoices One invoice per calendar month per environment, listing the seats you were charged for. Read it with your API key, reconcile it against [match results](/docs/results.md), and pay it by bank transfer. ## What you are charged for A *seat* is one player entering one paid match, charged [per seat](/docs/paid-play.md). Practice and free play are never charged, and neither is anything played with a test key. ## Listing them ``` GET /partner/invoices Authorization: Bearer pgs_live_… ``` The environment comes from your key, so a live key never returns a sandbox invoice. `from` and `to` take the same `yyyymmdd` range as the usage meter and are optional for this list. Add `status` to filter — `issued`, `paid` or `void`. ``` { "environment": "live", "invoices": [ { "invoiceId": "inv_01J8…", "status": "issued", "periodFromDay": "20260801", "periodToDay": "20260831", "currency": "USD", "totalMicros": 1250000, "seatCount": 50, "issuedAt": 1788220800, "dueAt": 1790812800 } ], "nextCursor": null } ``` Pass `nextCursor` back as `cursor` for the next page. > **Invoices come back oldest first.** Your most recent one is on the LAST page, not the first — so “what do I owe right now” means paging to the end, or asking for the month you mean with `from` and `to`. Reading only the first page and taking the top row gives you your oldest bill. > **Stop on a null cursor, never on an empty page.** A page can come back with no invoices and a cursor still set — loop until `nextCursor` is `null`, or you will stop short of invoices that are there. > **A `…Micros` field is micro-USD — millionths of a dollar.** Divide by 1,000,000 for dollars: `totalMicros: 1250000` above is $1.25. A `…UsdCents` field is plain cents. The fee on the $1 tier is 2.5¢, which no integer-cent field can hold — booking these as cents loses half of it on every seat. ## One invoice ``` GET /partner/invoices/{invoiceId} ``` The same fields plus the `number` and `paymentReference` — quote the reference on the transfer — and `subtotalMicros`, `taxMicros`, `taxTreatment`, `taxRateBps`, and `seller` and `buyer` — both parties’ registered name, address, country and VAT number as they stood when the invoice was issued. An address that changes afterwards does not rewrite an invoice you have already been sent. ## The lines ``` GET /partner/invoices/{invoiceId}/lines ``` One row per seat, paginated by the same `cursor`, under the same stop rule. Each carries `matchId`, `tierId`, `prizeUsdCents`, `feeUsdMicros` and `at`. Join on `matchId` to reconcile a line against the match it came from. ## As a spreadsheet ``` GET /partner/invoices/{invoiceId}/lines?format=csv ``` The same rows as `text/csv`, paginated the same way — the cursor for the next page comes back in the `x-next-cursor` response header, since a CSV body has nowhere to carry one. A response with no such header is the last page. Only the first page carries the header row, so the pages concatenate into one file. Cells that would otherwise be read as a spreadsheet formula are prefixed with an apostrophe, so open the file rather than trusting a raw byte comparison against the JSON. ## Which tiers earn what A per-day, per-tier fold of the seats we recorded a fee for — the number to watch your own pricing against without waiting for a period to close. ``` GET /partner/seat-summary?from=20260801&to=20260831 Authorization: Bearer pgs_live_… 200 { "environment": "live", "fromDay": "20260801", "toDay": "20260831", "rows": [ { "day": "20260801", "tierId": "usd_1", "seats": 42, "yourStakeUsdCents": 2520, "yourStakeSeats": 42, "feeUsdMicros": 1050000 } ] } ``` `from` and `to` take the same `yyyymmdd` range as the usage meter. Both are required here, and refuse with the same codes — see [errors and limits](/docs/errors.md#reading-your-seat-summary). One row per `day` and `tierId` present in that fold; a day and tier with none was never written. > **`yourStakeSeats` can be lower than `seats`.** `yourStakeUsdCents` sums only the seats that recorded a price, and `yourStakeSeats` counts how many of `seats` are in that sum — read the two together, or a partial total reads as a complete one. > **`yourStakeUsdCents` is cents; `feeUsdMicros` is micro-USD, as above.** 2520 is $25.20; 1050000 is $1.05. > **`day` is when the match was decided, not when the seat was taken.** A match seated late on one day but decided the next lands in the next day’s bucket, not the one your own seat log would put it in — that is the seam to expect if you reconcile against seat time rather than match time. Reconcile a bill against the invoice lines above instead: an invoice is defined by the rows it claimed when it was issued, while this view is defined by whatever date range you hand it. > **A window with too much history to fold in one call is refused, not answered short.** Ask for a narrower `from`/`to` range rather than retrying the same one. ## Paying Bank transfer, quoting `paymentReference` — that is how a transfer is matched to an invoice. Payment is recorded against the invoice and `status` becomes `paid`; a partial payment leaves it `issued` until the balance arrives. A `void` invoice has been cancelled and is not payable. It stays readable so a reference you hold can still be looked up. --- Source: https://peritusgames.com/docs/errors # 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 /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/theme` | 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 /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/theme` | Your rate bucket (or the shared coarse one) is empty — retryable; honour `Retry-After`. | Exponential backoff, not a tight loop. | | 403 | `partner_inactive` | `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` (test keys only), and any Game-API call carrying a partner-scoped session token, `GET /partner/theme`, `PUT /partner/theme` | 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 /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/theme` | 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 /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/theme` | 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 /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/theme` | 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 /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers` | You are not licensed for this game in any environment. | Check the `gameId` string first — the ids are listed in the [Quickstart](/docs/quickstart.md). | | 403 | `game_not_licensed_in_this_environment` | `POST /partner/sessions`, `POST /partner/matches`, `GET /partner/tiers` | 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 /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. | 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 /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/theme` | 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](/docs/paid-play.md) 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 /partner/matches` | 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 /partner/matches` | `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 /partner/matches` | `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 /partner/matches` | `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 /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. | Cache the ladder at start-up, rather than hard-coding a fixed list. | | 403 | `rake_terms_not_agreed` | `POST /partner/matches` | No active rake agreement is on file for your account. | Nothing to fix in your code. Talk to us. | | 409 | `idempotency_key_conflict` | `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. | 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 /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}/lines` | 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](/docs/results.md) 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 /partner/results`, `GET /partner/invoices`, `GET /partner/admin/invoices`, `GET /partner/invoices/{invoice_id}/lines`, `GET /partner/admin/invoices/{invoice_id}/lines` | 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](/docs/portal.md) 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 /partner/usage`, `GET /partner/invoices`, `GET /partner/admin/invoices`, `GET /partner/seat-summary` | 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 /partner/usage`, `GET /partner/invoices`, `GET /partner/seat-summary`, `GET /partner/admin/invoices` | 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 /partner/usage`, `GET /partner/invoices`, `GET /partner/admin/invoices`, `GET /partner/seat-summary` | `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 /partner/usage`, `GET /partner/invoices`, `GET /partner/admin/invoices`, `GET /partner/seat-summary` | 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 /partner/usage`, `GET /partner/seat-summary` | `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](#reading-your-usage). Paging refuses a cursor we did not issue with the same `invalid_cursor` as the result feed, in [Reading results](#reading-results). Neither is repeated here. A 503 `settlement_unavailable` can reach these calls too — that row is in [Seating a paid match](#seating-a-paid-match). | Status | `reason` | Where it can occur | What it means | What to do | | --- | --- | --- | --- | --- | | 404 | `invoice_not_found` | `GET /partner/invoices/{invoice_id}`, `GET /partner/admin/invoices/{invoice_id}`, `GET /partner/invoices/{invoice_id}/lines` | 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 /partner/invoices`, `GET /partner/admin/invoices` | `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 /partner/invoices/{invoice_id}`, `GET /partner/admin/invoices/{invoice_id}`, `GET /partner/invoices/{invoice_id}/lines` | 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](/docs/appearance.md) 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](#minting-a-session). | Status | `reason` | Where it can occur | What it means | What to do | | --- | --- | --- | --- | --- | | 422 | `logo_unsupported_type` | `PUT /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. | — | | 422 | `logo_malformed` | `PUT /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. | — | | 422 | `logo_too_large` | `PUT /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. | — | | 422 | `logo_not_that_image` | `PUT /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. | — | | 403 | `live_theme_not_writable` | `PUT /partner/theme` | 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](/docs/billing.md) authenticates the same way, so it answers the same `invalid_key`, `partner_inactive`, `rate_limited` and environment refusals — those are in [Minting a session](#minting-a-session). Both `from` and `to` are required, and a bad range answers the same codes as the meter, in [Reading your usage](#reading-your-usage). A 503 `settlement_unavailable` can reach this call too — that row is in [Seating a paid match](#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 /partner/seat-summary` | 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](#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](#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/admin/…` route except `POST /partner/admin/invites/{invite_id}/accept` | A verified Google account that administers no partner. | — | | 403 | `role_forbidden` | 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. | — | | 403 on the action gate, 409 on `POST /partner/admin/totp/verify` | `totp_not_enrolled` | 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. | — | | 403 | `step_up_required` | 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. | — | | 403 | `fresh_step_up_required` | 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". | — | | 429 | `step_up_locked` | `POST /partner/admin/totp/verify`, `POST /partner/admin/totp/enroll` | Too many incorrect codes; verification is locked for a while. | — | | 403 | `invalid_code` | `POST /partner/admin/totp/verify` | The TOTP code didn't verify — about an authenticator code, not an API key or an invite. | — | | 409 | `partner_changed` | 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. | — | | 400 | `invalid_origin` | `POST /partner/admin/origins` | Not a valid web origin. | — | | 400 | `too_many_origins` | `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. | — | | 400 | `invalid_webhook_url` | `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. | — | | 400 | `label_required` | `POST /partner/admin/keys` | A new API key needs a label. | — | | 409 | `last_admin` | `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. | — | | 409 | `already_accepted` | `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. | — | | 403 | `wrong_address` | `POST /partner/admin/invites/{invite_id}/accept` | This invite was addressed to a different e-mail address than the one signed in. | — | | 403 | `expired` | `POST /partner/admin/invites/{invite_id}/accept` | The **invite** has expired — never about an API key or a session token. | — | | 409 | `already_administers_another_partner` | `POST /partner/admin/invites/{invite_id}/accept` | The invited Google account already administers a different partner. | — | | 409 | `already_a_member` | `POST /partner/admin/invites/{invite_id}/accept` | The invited account already administers this partner — nothing to claim; role changes go through the members page. | — | | 400 | `invalid_email` | `POST /partner/admin/invites` | The **invitee's** address is not a valid e-mail address. | — | | 400 | `address_outside_domain` | `POST /partner/admin/invites` | The invited address is outside the account's admin domain. | — | | 400 | `unknown_game` | `POST`/`DELETE /partner/admin/games/{game_id}/test`, `POST`/`DELETE /partner/admin/games/{game_id}/live-request` | Not a game this platform runs. | — | | 409 | `not_licensed_in_test` | `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. | — | | 409 | `already_live` | `POST /partner/admin/games/{game_id}/live-request` | The game is already licensed live, so there is nothing to request. | — | | 400 | `invalid_domain` | `POST /partner/admin/account/domain` | Not a valid admin domain. | — | | 409 | `domain_already_set` | `POST /partner/admin/account/domain` | 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](/docs/embedding.md). | | 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](/docs/embedding.md); 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. --- Source: https://peritusgames.com/docs/versioning # Versioning and change Every partner-facing response tells you which version of this surface answered it. Additive changes ship without notice; anything that could break a correct client needs a new version and 90 days. ## The header ``` X-Peritus-Api-Version: 1 ``` On success and error responses alike, including a `429` and a `404`. Log it, assert on it, and quote it to support. Versions are whole numbers, issued in order. `1` is the first. ## Your two pins, and the header that overrides them **Live and test are pinned separately.** A call authenticated by your API key — everything on this surface except `/embed/…`, `/partner/admin/…`, and the Game API — is answered on that key's own environment's pin when it sends no `X-Peritus-Api-Version` request header. Try a new version in test without moving live. `POST /partner/admin/api-version` moves one environment's pin, from the portal or from your own tooling against the same Firebase-authed admin surface: ``` POST /partner/admin/api-version Authorization: Bearer <admin token> { "environment": "test", "version": 1 } 200 { "live": 1, "test": 1 } ``` A version outside what this build serves is refused, `400 unsupported_api_version`, and nothing is written. Moving backwards is a plain, unrefused write — the rollback for an upgrade that turned out to break something. Naming a served version in that same header as a *request* header uses that version for the one call it's sent on, whatever your account is pinned to. A name this build cannot serve gets `400 unsupported_api_version` instead of any version at all. `1` is what this build serves today. `/embed/…` and `/partner/admin/…` carry the response header too, but always at this build's current version — neither authenticates by API key, so neither has an account pin for it to reflect. ## Which responses carry it | Surface | Stamped | | --- | --- | | `/partner/…` — the session mint, the results feed, and the `/partner/admin/…` routes the portal drives | Yes, including its own 404s and 429s | | `/embed/…` — the frame document | Yes | | `/internal/…` — our own ops console | No | | The player-facing Game API your embedded session calls | No | `/internal/…` is not a contract with anyone. **The Game API is excluded because a partner's embedded player travels the same paths a first-party player does** — the same `/me/…` and `/matches/…` our own frontend calls. One call to `POST /partner/sessions` answers the version question for your whole integration. ## 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.** ## What that requires of your client Those changes are additive: a correctly written client is never broken by one. “Correctly written” means exactly two things, and they are requirements rather than suggestions. 1. **Ignore fields you do not recognise.** Do not fail to parse a response because it grew a key, and 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` carrying a code your build has never seen is still a `403`: refused. Branch on the codes you handle specially and fall through to the status for everything else. ## What may not change without a version bump and 90 days' notice These are breaking, and each one needs a new version and **90 days' notice**, with the old behaviour kept working for the whole period: - **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 still a break. - **Changing a field's type.** - **Changing the HTTP status for an existing condition.** - **Tightening validation**, so that a request which worked now fails. > **Notice is a changelog entry** naming the new version, what changed, and when the 90 days start and end. The changelog is below, and the whole document is at [/partner-api-changelog.md](/partner-api-changelog.md). ## The changelog One version has been announced, and it is the current one: `1`. No notice period is running yet. Each entry says what moved, when the ninety days started and when they end. Read the full text at [/partner-api-changelog.md](/partner-api-changelog.md) — published verbatim. ## Where the contract itself lives The version, this policy and every `reason` code are one document. [The error catalogue](/docs/errors.md) on this site is generated from that same document, published at [/partner-api-contract.md](/partner-api-contract.md), and every page under /docs is also available as markdown beside it (this one is [/docs/versioning.md](/docs/versioning.md)), with [/llms.txt](/llms.txt) as the index. --- Source: https://peritusgames.com/docs/portal # Managing your account Keys, origins, colleagues, game licensing, result webhooks, prices, [how your games look](/docs/appearance.md), usage and an audit trail — all of it self-service, in the partner portal. ## Where, and how you get in `https://peritusgames.com/portal` — the same address for every partner, authenticated by Google sign-in, not your API key. > **Google sign-in is the only way in today.** No password, no SAML or OIDC federation — every colleague needs a Google account on the invited address. Tell us first if yours can’t. We create your account, its first licensed games, and your first admin by hand, and send an invite link redeemable only by the address it was sent to — holding it alone is no use to anyone else. Once claimed, that account administers yours directly; there is no second, more privileged door. ## Roles | Role | Can | | --- | --- | | Viewer | See everything: key metadata (never a key itself), origins, members, licensing, usage and the event log. | | Developer | All of the above, plus create, rotate and revoke keys; add and remove origins; enable or disable a game in test and request or withdraw live access; and trigger the emergency stop. | | Admin | All of the above, plus set what your players pay per tier; invite colleagues, change roles, remove members, and reactivate a stopped account. | An Admin can only grant a role at or below their own. The last remaining Admin can never be removed or demoted. ## What your players pay **Pricing** sets your entry price per tier, per environment — the number on the tier card, Admin’s alone to set. Each row shows the prize, our minimum, and today’s charge; anything above the minimum you keep. **An empty field is not zero and not “off”** — it charges our minimum, a complete, working configuration. Saving replaces the whole set for that environment; test and live stay separate. > Your **rake split** is not here — it is agreed with us and shown on Overview. ## Inviting a colleague Name an address and a role on the Members page; the portal hands you a copyable claim link. Each colleague enrols their own second factor the first time they take an action that needs one — a member with nothing enrolled yet is the normal state for someone new. A domain rule, if your account has one recorded, refuses an outside address at invite time rather than at claim time. An account without one can record its first rule from the same page; **changing or clearing a rule already recorded goes through us**. ## The second factor Before an Admin or Developer can create a key — test included — they enrol an authenticator app (standard TOTP) the first time it’s needed. A correct code opens a **two-hour** window for most gated actions, fixed from that code — a long session asks again partway through. Gated actions come in two levels, by whether the action can be undone: - **Anything that mints a credential needs a code from the last five minutes** — creating a key, rotating one, reactivating a stopped account, setting or rotating a webhook, and replacing an authenticator. A key cannot be un-minted, and a webhook secret is shown once. - **Everything else rides the two-hour window** — revoking a key, changing origins, setting tier prices, inviting or removing a member, changing a role, recording the domain rule, and licensing a game in test or requesting live access. Switching webhook delivery off needs no code at all, like the emergency stop. Five wrong codes in a row lock further attempts for 15 minutes, logged to the event log. Moving to a new authenticator while the old one still works is self-service; losing the device outright goes through us, identity-proofed. ## The emergency stop Any Admin or Developer can deactivate the account in one action, with **no code prompt**. The worst an attacker holding a session can do with this button is take the account offline. It stops every match, key and embed for the account — **including calls made with a session token a player's browser already holds**, which is what makes it stronger than revoking a key. It takes effect within **60 seconds**, and up to **10 minutes** if our registry is having trouble being read. There is no instant case. Reactivating needs both an Admin role and the second factor. Origin changes propagate on this same window — see [Embedding a game](/docs/embedding.md). ## The usage meter Per game, how many matches counted toward your account: a rolling **30 days, through yesterday** (UTC) — the window the portal draws, not the window we keep. Daily figures are retained in full, and an invoice is built from them. - **The meter follows your key** — a live key answers with your billable numbers, a test key with the sandbox’s. To watch a number move while testing, use [the results feed](/docs/results.md) instead. - **“Through yesterday” is not a display lag.** A match is folded in once it is decided, visible here within a couple of minutes of finishing — so today's figure would change while you looked at it. - **One match adds one**, however many of your players were in it: two of your own players against each other is one match, not two. **Any older period is a call, not a request to us.** `GET /partner/usage?from=&to=` answers the same daily rows for any range you name, with your API key: ``` GET /partner/usage?from=20260701&to=20260731 Authorization: Bearer pgs_live_<your key> 200 { "environment": "live", "fromDay": "20260701", "toDay": "20260731", "days": [ { "gameId": "streakpeaks", "day": "20260701", "matches": 12 }, { "gameId": "spades", "day": "20260701", "matches": 3 } ] } ``` `from` and `to` are both required, inclusive, and `yyyymmdd` in UTC. A single call may span **up to 366 days**. One row per `gameId` and `day` that had at least one match; a day with none was never written. `matches` counts one per match, however many of your players were in it. > **The rows are scoped to the environment of the key you used** — a live key gives billable numbers, a `pgs_test_` key the sandbox’s, with the same couple-of-minutes lag. To check whether a match happened at all, read [the results feed](/docs/results.md) instead. > **A day is final only once it is over** — `to` may be no later than yesterday (UTC). A wider span is refused, not shortened, and names the limit. [Errors and limits](/docs/errors.md) gives the rule and the code. ## The event log Six months of every sensitive thing that happened to the account: keys created, rotated or revoked; origins added or removed; members invited, accepted or removed; roles changed; step-up completed or locked out; games licensed in test; live access requested or withdrawn; the account stopped or reactivated. It includes **actions we took on your account**, not only your own team's — deciding a live-access request, granting licensing directly, revoking a key, removing a member or resetting their second factor, renaming the account, or changing your domain rule — attributed to the platform, never to a name on your team. If something changed and nobody on your side recognises it, look here before asking us. --- Source: https://peritusgames.com/docs/security # Security model The design assumes a hostile client — reading the network, editing code, replaying requests — and stays correct anyway. ## Hidden information stays on the server A concealed value is released only for a position the server's replay has proved that player reached, never because the client asked. A rewritten client plays under the same uncertainty an honest one does. ## The server replays the match The deal is fixed before either player sees a card, and every move is replayed server-side against it — that replay's score is the score; the client's own scoring has no authority. A hash chain seals the move sequence for integrity and sync only. After both players finish, the deal is released so a player can re-derive and re-score it themselves. We do *not* publish a commitment to the deal *before* play — “fixed before either player saw a card” is stated, not verifiable. ## We are not certified No independent testing-lab certification and no authority approval, for any game — that requirement is yours to satisfy. Raise it early and we will say plainly what we can document. ## Both players get the same deal A match is one deal played by two people — except in Spades, where each also sees their bot partner's hand while the opponents' stay concealed. Neither can be dealt a better game, and a loser can watch the winner's replay of the same cards. That is narrower than “luck plays no part”: once players diverge, one can take on variance the other avoided. ## What a finished match is reused for One thing crosses the boundary between accounts: a finished match's deal and score can be served, read-only, as a practice opponent — ours or another partner's. That pool is shared across operators, with nothing at stake in it. - **Nothing is written back** — no rating, counter, or record against your player. Practice is not a match. - **The name shown is the one on the match** — your `displayName` from mint. Omit it at first mint to keep a player's name off another platform's board; a nameless account gets our own handle. See [Identifying your player](/docs/players.md). - **The deal was already released** to those who played it — never reused for a new match. ## What this means for you - You do not have to trust our client, because we do not either. - No fraud tooling needed for the game layer: a dispute is a question we can answer from the replay record — ask your platform contact. No partner-facing route returns a replay. - Session tokens are short-lived and scoped to one game, so a leak cannot reach another game or player. --- Source: https://peritusgames.com/docs/responsibilities # Who owns what Peritus supplies game software. Everything about your players and their money stays with you. ## Yours - Your users: accounts, authentication, support, and their data. - Any money: stakes, entry fees, prizes, payouts, refunds, and the accounts they move through. - How you operate where you operate — including any permissions your own activity requires, and any territories you choose to restrict. - Your branding, your placement, and what you charge. ## Ours - The games, the deals, and the engine that scores them. - Matchmaking and rating — a [match](/docs/paid-play.md) seats two players of the same operator, so yours play each other. - Anti-cheat: move validation, server-side replay, and the sealed record. - The result, and the replay that explains it. > The full argument — and what it means for a contract — is on [the integration page](/integration). --- Source: https://peritusgames.com/partner-api-contract.md # The contract — the version policy and every reason code # 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`, `GET /partner/invoices`, `GET /partner/seat-summary`, `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. <!-- CODES:BEGIN --> | 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`, `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/theme` | 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`, `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/theme` | 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`, `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/theme` | 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`, `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/theme` | 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`, `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/theme` | 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`, `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/theme` | 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` | `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`. | | `stake_does_not_match_price` | 400 | `POST /partner/matches` | `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. | | `stake_above_prize` | 400 | `POST /partner/matches` | `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`. | | `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`, `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}/lines` | 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. | | `invalid_cursor` | 400 | `GET /partner/results`, `GET /partner/invoices`, `GET /partner/admin/invoices`, `GET /partner/invoices/{invoice_id}/lines`, `GET /partner/admin/invoices/{invoice_id}/lines` | 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`, `GET /partner/invoices`, `GET /partner/admin/invoices`, `GET /partner/seat-summary` | 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. | | `malformed_day` | 400 | `GET /partner/usage`, `GET /partner/invoices`, `GET /partner/seat-summary`, `GET /partner/admin/invoices` | 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. | | `range_backwards` | 400 | `GET /partner/usage`, `GET /partner/invoices`, `GET /partner/admin/invoices`, `GET /partner/seat-summary` | `from` is after `to` — swap the two arguments; both dates are individually valid. | | `range_too_wide` | 400 | `GET /partner/usage`, `GET /partner/invoices`, `GET /partner/admin/invoices`, `GET /partner/seat-summary` | A range past what one call can answer is refused, never shortened — the body carries `maxRangeDays`: page against that number, not a guess. | | `range_too_expensive` | 400 | `GET /partner/seat-summary` | 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. | | `logo_unsupported_type` | 422 | `PUT /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. | | `logo_malformed` | 422 | `PUT /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. | | `logo_too_large` | 422 | `PUT /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. | | `logo_not_that_image` | 422 | `PUT /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. | | `day_not_final` | 400 | `GET /partner/usage`, `GET /partner/seat-summary` | `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). | | `live_theme_not_writable` | 403 | `PUT /partner/theme` | 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`. | | `invoice_not_found` | 404 | `GET /partner/invoices/{invoice_id}`, `GET /partner/admin/invoices/{invoice_id}`, `GET /partner/invoices/{invoice_id}/lines` | 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_status_invalid` | 400 | `GET /partner/invoices`, `GET /partner/admin/invoices` | `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. | | `invoice_not_readable` | 409 | `GET /partner/invoices/{invoice_id}`, `GET /partner/admin/invoices/{invoice_id}`, `GET /partner/invoices/{invoice_id}/lines` | The invoice is yours but isn't final: its total is still being assembled and would change under you. Retry — nothing needs fixing. | | `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`, `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/theme` | 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. | <!-- CODES:END --> ## 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`, `GET /partner/seat-summary`, `GET /partner/invoices`, `GET /partner/invoices/{invoice_id}`, `GET /partner/invoices/{invoice_id}/lines` 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 of them; `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`. ## The range you may ask for — `GET /partner/usage`, `GET /partner/invoices`, `GET /partner/seat-summary` All three take a `from`/`to` window over `yyyymmdd` days and are scoped to the environment of the key you used, which is why `environment` is in every one of their answers: ``` GET /partner/usage?from=20260701&to=20260731 ``` The same shape reads `GET /partner/invoices` and `GET /partner/seat-summary`. Five rules decide whether a range is answered, and each has its own `reason` in the table above so a client can tell them apart: - **On `GET /partner/usage` and `GET /partner/seat-summary`, `from` and `to` are both required** — there is no default window, and omitting one is `missing_parameter`, naming which. **On `GET /partner/invoices` the window is optional**, but sending half of one is `missing_parameter` too, rather than completing it for you. - **Both are `yyyymmdd` in UTC**, and are checked as calendar dates — `20260231` is `malformed_day`, naming the parameter, on all three endpoints. - **Sending `from` later than `to` answers `range_backwards`, on all three.** Check which value went into which parameter — the dates themselves need not be wrong. - **A single call spans at most 366 days.** A wider range is `range_too_wide` on all three, and the body carries `maxRangeDays`. - **On `GET /partner/usage` and `GET /partner/seat-summary` only, `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. `GET /partner/invoices` carries no such limit: an invoice's own `status` tells you whether it is final, not the day range you asked for. On `GET /partner/usage` and `GET /partner/seat-summary`, 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 `<iframe>`, not a program that can read a JSON body, so a machine-readable code would have no reader. Handle those responses by status. `409` is the one worth naming here: it means the environment you asked for has **no registered origins**, so nothing may frame it and we do not serve the game document at all. Its body is a short human-readable page rather than a frame — an `<iframe>` cannot read a status code, and under `frame-ancestors 'none'` it cannot render the page either, so the signal is for the person or the deploy check that opens the URL directly. It is a configuration state, not a transient failure: retrying without registering an origin returns the same thing. A refusal your player *can* see with a code attached comes from the Game API the embedded session calls, not from the embed document itself; those are the `partner_inactive` and `registry_unavailable` rows above. ## Where this contract stops **This document covers what a partner's software receives** — their backend calling `/partner/sessions` and `/partner/results`, their portal on `/partner/admin/…`, and the partner-scoped gate on the embedded session's own calls. **It does not cover the player-facing Game API inside the frame.** Our own frontend runs in that iframe and talks to `/me/…` and `/matches/…`; those codes are between our client and our server, they are not addressed to a partner's software, and they are not promised here. Three of them look like they might belong, because they are *about* partner identities — `partner_uid_no_display_name`, `partner_uid_no_payout` and `cross_environment_join`. They are emitted to the embedded player, not to the partner's server, and they are **out of scope of this contract**. If a published document ever describes what a partner's embedded player can hit, they belong there rather than here. **It does not cover `/internal/…`.** Those routes serve our own ops console; ops and the dealer ship together, so they are not a contract with anyone and carry no version header. ## Changes See `/partner-api-changelog.md`. Every version, what changed, and — for anything in the breaking list — when the notice period started and when it ends. --- Source: https://peritusgames.com/partner-api-changelog.md # The changelog — every breaking change and its notice period # Partner API changelog Newest first. The contract is `/partner-api-contract.md`. A breaking change gets a version bump and **90 days' notice**, published here, with the previous behaviour kept working for the whole notice period. The contract lists exactly which changes count as breaking. Additive changes ship on the current version and are not listed. ## 1 The first published version of the partner API. It covers the session mint, paid match seating, the tier ladder, the results feed, the usage meter, the embed document, the partner-scoped Game API an embedded session calls, and the `/partner/admin/…` routes behind the portal. The contract describes all of it. ### A seat-summary window too big to fold is a 400, not a 503 `GET /partner/seat-summary` refuses a window that passes every range check and still covers more of the seat ledger than one call can read. That refusal is a 400 `range_too_expensive`. It is permanent for the range asked for: ask for a shorter window rather than retrying the same one. Previously the same condition answered a 503 `settlement_unavailable`, which this document publishes as retryable. Changing the status for a condition is on the contract's breaking list, so it is recorded here, and it ships on version 1 rather than a bump: the endpoint has no partner integrated against it, so no client can hold a branch on the old answer. A partner integrating before the next deploy gets the full ninety days. ### The seat summary calls your price `yourStake…`, not `theirStake…` `GET /partner/seat-summary` returns `yourStakeUsdCents` and `yourStakeSeats`. They were `theirStakeUsdCents` and `theirStakeSeats`. Nothing about the numbers changed: they are still what you charged, summed over the seats that recorded a price, and how many of `seats` are in that sum. The old names were the only two fields in this API written from our side of the table — the rest of it, and every line of the documentation, addresses you as "you". From your chair "their stake" reads as somebody else's, most naturally your own player's, and that is a bad ambiguity to leave on a billing surface. Renaming a field is on the contract's breaking list, so it is recorded here, and it ships on version 1 rather than a bump for the same reason as the refusal above: no partner is integrated against this endpoint, so no client can hold a reader on the old names. A partner integrating before the next deploy gets the full ninety days. ### `GET /partner/admin/invoices` checks its own date window The portal's invoice list validates `from` and `to` the way the key-authenticated list does, and answers `malformed_day`, `range_backwards`, `range_too_wide` and `missing_parameter` for a bad one. Both ends are required together: one alone is refused rather than ignored. Previously both days were passed through unchecked, and a bad one came back as a 503. ### A back arrow on the frame's first screen is opt-in The embedded game draws a back arrow on its first screen only when `init` carries `backAtRoot: "exit"`. It posts `{ type: "exit" }` with no `to`, so the destination is yours. Inside the game — between the lobby and a match — the arrow is unchanged and needs nothing from you. `/docs/embedding` has the field. ### Fields removed from the results feed `opponentScore`, `rulesVersion`, `score` and `uid` no longer appear on the row `GET /partner/results` and the result webhook publish. What remains carries price, payout or identification and nothing else: your own player's score is visible inside your embedded frame already; an opponent's score is not yours to relay; the engine version is ours to track; and `uid` is recoverable from `playerId`, which the row still carries — it is exactly `ptr:<partnerId>:<playerId>`, or `ptr:test:<partnerId>:<playerId>` in the test environment. `/docs/results` and `/docs/webhooks` describe the row as it stands today. ### Field order in JSON responses is not guaranteed Fields in a JSON response — for example `POST /partner/sessions`'s `displayName`, `expiresAt`, `token`, `uid` — may appear in any order. Parse by field name, never by position. ---