Integration reference · /partner/v1 # ClashX Partner API Everything a casino's engineering team needs to run ClashX games behind their own front end: player identity, the transfer wallet, match settlement, and the signed callback stream. > **There are two documents, and this is the reference.** It describes every endpoint, field and failure. If you are starting from nothing, read the **[Build brief](/integration/build-brief)** instead and come back here for detail — it is the same API in the order you will build it, with a worked sequence end to end. Where the two differ, the brief is the shorter path and this is the complete one; neither supersedes the other. 1. [01. Which integration model this is](#01-which-integration-model-this-is) 2. [02. The boundary](#02-the-boundary) 3. [03. Authentication](#03-authentication) 4. [04. Five things that break integrations silently](#04-five-things-that-break-integrations-silently) 5. [05. Players and the launch flow](#05-players-and-the-launch-flow) 6. [06. The transfer wallet](#06-the-transfer-wallet) 7. [07. The match feed](#07-the-match-feed) 8. [08. Detection](#08-detection) 9. [09. Settlement callbacks](#09-settlement-callbacks) 10. [10. Error handling](#10-error-handling) 11. [11. Rate limits](#11-rate-limits) 12. [12. After the launch — what the session is for](#12-after-the-launch-what-the-session-is-for) 13. [13. Two facts a reconciliation depends on](#13-two-facts-a-reconciliation-depends-on) 14. [14. Undefined contract](#14-undefined-contract) ## 01. Which integration model this is ClashX supports two arrangements, and they consume almost disjoint surfaces. Establish which one you are building before reading further. | Model | Who runs the player experience | What you build | | --- | --- | --- | | **Hosted white-label** | ClashX, on your domain, with your branding | No code. You configure a Clerk application, payment and identity credentials, branding, and published policies. | | **Partner API** | You. ClashX supplies games and settlement behind your front end. | This document. | In the partner model your players never see a ClashX login. They exist in your system, you name them by your own identifier, and you hand them across into a game session for as long as they are playing. ## 02. The boundary > **BOUNDARY** > > #### ClashX owns > > - The wallet balance and the ledger > > - Settlement — the sole authority that moves money > > - Game servers, matchmaking, and the in-game experience > > - Your tenant database, isolated from every other casino > > - Collusion detection and match records > **BCARD** > > #### You own > > - Player accounts and authentication > > - Card processing, deposits, and withdrawals in your own system > > - The decision to move value in or out of a player's game wallet > > - Acting on detection flags — review and enforcement are yours in both models > > - Receiving and verifying settlement callbacks **ClashX makes no unilateral corrections to a balance.** There is no bonus, no promotional credit and no support adjustment on this side of the boundary — nothing we originate moves a player's balance. Two things other than a match settlement or your own `wallet/credit` / `wallet/debit` *can* move it, and both are **yours**: an operator grant from your casino admin console, and an affiliate movement if that player enrols. Neither appears on any `/partner/v1` endpoint, so reconcile as `transfers + matches` and read §13 before you build the check. ### The transfer wallet, and what it is not Value moves by explicit transfer. You credit a player's ClashX wallet before they play and debit it afterwards; the balance lives with ClashX for as long as it is there, and stakes and payouts are settled internally. ⚠ **Real money moves in `sweeps`, in both directions.** `gold` is play money (§18): a `gold` credit would sell your player something that can never be debited back out, so both a `gold` credit and a `gold` debit are refused `422`. > **Why it is built this way** > > A seamless wallet — where ClashX calls your system for balance on every wager — is **deliberately not built**. Keeping the balance on this side means settlement remains the only code path that moves money, so every conservation invariant holds without a network call in the middle of a wager. It is a different product, not a larger version of this one. ## 03. Authentication Every request carries an API key in the `X-Api-Key` header. Keys are prefixed `wlk_` and are shown to you exactly once at issue; ClashX stores only a SHA-256 hash, so a lost key is replaced, never recovered. ``` X-Api-Key: wlk_a1b2c3d4e5f6... ``` The key *is* the tenant claim. The partner routes do not resolve your casino from a hostname — everything a request touches is determined by the key it was made with, and nothing in the request body can redirect it to another casino's data. > **Two families of endpoint, two ways of naming your casino** > > **`/partner/v1/*` is identified by the API key.** No hostname resolution runs on these routes; call them on whatever base URL you were given. > > **`/api/v1/*` is identified by the `Host` header.** The launch exchange and every player-facing route resolve your casino from the hostname, so they must be called on your casino's own host. A request with the wrong Host reaches a different casino, or none. ### Scopes Each key carries a set of scopes, checked before the request reaches the handler. | Scope | Grants | | --- | --- | | `sessions:write` | Create players and mint launch tokens | | `wallet:write` | Credit and debit the transfer wallet | | `matches:read` | Read settled match results and economics | | `detection:read` | Read collusion flags, evidence, and the account review queue | | `crm:read` | Read player records and player events for your CRM (`GET /crm/players`, `GET /crm/events`), exactly as your casino's CRM setting releases them; refused while that setting is off | | `rewards:read` | Read rewards-link changes (`GET /rewards/links`) and the play of confirmed links (`GET /rewards/activity`), by membership number and your own player reference | | `rewards:write` | Confirm or reject a player's rewards link (`POST /rewards/links/{id}/confirm`, `/reject`) and send a reward in (`POST /rewards/grants`): a promo credit or a free entry, through your bonus rules; at or above your dual-control threshold a staff member approves it | > **A key with no scopes can still ping** > > `GET /ping` requires no scope, so a freshly issued key can always prove it authenticates before any capability is granted to it. Every other route requires exactly one scope. **An empty scope list therefore means ping-only** — not full access. If you were issued a key before scopes existed, that is its behaviour, and it is correct: a key that was never granted a capability does not have one. > > Scopes are separated on purpose. A back-office reporting integration that reads matches has no business minting player credentials, so `matches:read` cannot do what `sessions:write` does. ### Refusals | Condition | Status | Body | | --- | --- | --- | | Header absent | 401 | `{"error":"missing X-Api-Key header"}` | | Key unknown | 401 | `{"error":"invalid api key"}` | | Key revoked | 401 | `{"error":"api key is revoked"}` | | Casino `suspended` or `churned` (**not** `onboarding`) | 403 | `{"error":"tenant \"golden-palace\" is suspended"}` | | Valid key, missing scope | 403 | Names the required scope | **403 rather than 401 for a missing scope is deliberate**: you are authenticated, and retrying with the same key will never succeed. Treat 401 as "fix the key" and 403 as "the key is real but may not do this". #### `GET /partner/v1/ping` *Scope: `no scope`* ```json { "status": "ok", "tenant": "golden-palace", "tenant_status": "active", "serves_hosted_players": true, "key_label": "production-backoffice", "scopes": ["sessions:write", "wallet:write", "matches:read"] } ``` Call this first. It confirms the key works, reports the scopes you actually hold — the fastest way to diagnose a 403 later — and tells you the casino's lifecycle state. #### The casino's lifecycle state `status` is **this call's** outcome. `tenant_status` is **the casino's**, and they are independent — a casino that is not yet live answers `"status": "ok"` on every endpoint in this document. | `tenant_status` | `/partner/v1` | ClashX-hosted player site | | --- | --- | --- | | `onboarding` | **Fully transactional** — sessions, wallet, matches, callbacks | **Closed.** Players cannot sign in | | `active` | Fully transactional | Open | | `closing` | Fully transactional, so you can reconcile | Open, for players to withdraw | | `suspended` | **Refused** — `403` on every route | Closed | | `churned` | **Refused** — `403` on every route | Closed | **`onboarding` is a working state, not a broken one.** You can build and certify your entire integration in it: mint launch tokens, move real balances, play real matches, receive signed callbacks. Partner-launched players reach games in it, because in this model the players are yours and the game is what ClashX supplies. What `onboarding` does *not* open is the **ClashX-hosted** player site — a separate surface, for casinos that let ClashX serve their players directly. If you are integrating this API you are not using it, and `serves_hosted_players: false` is the expected reading. > ⚠ **`suspended` and `churned` refuse both surfaces.** Suspension is the platform's stop lever and it stops the integration too; treat a `403` naming either state as terminal for the key and escalate commercially rather than retrying. ## 04. Five things that break integrations silently These are behaviours a reasonable engineer would guess wrong. Each is deliberate; each will pass your tests and fail in production if you assume the conventional answer. > **Trap 1 — a rejected transfer returns HTTP 200** > > Debiting more than a player holds is a **business outcome, not an error**. It returns `200 OK` with a discriminator field: > > ```json > { > "kind": "insufficient_balance", > "available_cents": 1500, > "requested_cents": 5000, > "shortfall_cents": 3500 > } > ``` > > Branch on `kind`, never on the status code alone. Code that treats `200` as success will record a withdrawal that never happened. The same pattern applies to `POST /api/v1/queue` — but it answers **three** kinds at 200, not two: `"matched"`, `"queued"` and `"insufficient_balance"`. Its refusal also uses different field names from the wallet shape above (`currency`, `balance_cents`, `required_cents`, `shortfall_cents` and a player-facing `message`), so do not reuse this example’s fields for it. See §12. > **Trap 2 — `applied: false` means replay, not failure** > > Every wallet transfer is idempotent on your own `transfer_ref`. If you retry after a timeout, the call succeeds and reports the *original* movement: > > ```json > { "kind": "settled", "applied": false, ... } // nothing moved this time > ``` > > `applied: true` — money moved on this call. `applied: false` — this `transfer_ref` was already processed and the response describes the earlier movement. Both are successes. Treating `false` as a failure and retrying under a new reference is how an integration double-counts. > **Trap 3 — errors have no machine-readable code** > > Every failure is `{"error": ""}`. There is no stable code field today. **String-matching the message is currently the only way to branch on a specific cause**, and the wording is not a stable contract — it can change without the HTTP status changing. > > Build against status codes plus the response `kind` where one exists. Log the message rather than parsing it, and treat any message-matching you must do as something to revisit. > **Trap 4 — the launch token lives 60 seconds and every refusal reads the same** > > A launch token expires **60 seconds** after minting and is single-use. That is tight for a real redirect: an email link, a slow mobile page, or a user who pauses will all fail. Mint the token at the moment of the redirect, never earlier. > > Unknown, expired, and already-used all return one identical message. This is deliberate — telling a holder which one it was tells them whether a token they should not have was ever real — but it means you cannot diagnose from the response. Instrument your own side: log the mint time and the exchange time, and the gap tells you what the response will not. > **Trap 5 — your player can buy into another paid match without your API being called** > > When a match ends, the ClashX-rendered result screen offers **Play Again**. Your player taps it, ClashX re-enters them into matchmaking **at the same stake**, and the entry fee is debited from their wallet when the next match forms. You did not call `POST /api/v1/queue`, and no wallet endpoint of yours was involved. > > Two consequences, and the second is the one that costs a reconciliation: > > - **Balances move between your transfers.** The wallet you funded is drawn down by entries you did not originate. A balance cached at your last transfer will drift. > - **The match feed carries matches you never queued.** Any check of the form "every settled match corresponds to a `/queue` call I made" will fail, and it will fail on a *correct* platform. > > Reconcile against the **match feed**, which is authoritative for what happened, rather than against your own record of what you asked for. §07 is the feed; §12 has the per-game detail of what Play Again costs. ## 05. Players and the launch flow > **The launch is a top-level navigation. App pages cannot be iframed; game bundles can, by arrangement.** > > This is an architecture decision, so it belongs before you design your lobby rather than after — and the two halves differ, so read both. > > **App pages cannot be framed at all.** Every ClashX app page is served `Content-Security-Policy: frame-ancestors 'none'` and `X-Frame-Options: DENY`. The launch exchange and the lobby are app pages, so the launch itself is always a **top-level navigation** — a normal link, a redirect, or a new tab to `launch_url`. Never an iframe or an embedded popup. > > **Game bundles under `/games/*` are different.** They are served `frame-ancestors 'self'` plus any origins ClashX has configured, and — deliberately — no `X-Frame-Options`, because that header has no per-origin form and would otherwise refuse cross-origin framing whatever the CSP said. By default the configured list is empty in production, so **your domain is not on it and the browser will refuse your frame**: no API error, no response body, only a console message inside a frame that never rendered. > > So embedding a game in your own page is **possible but not self-service**. Ask ClashX to add your origin before you build against it. Two things to know when you do: the allowlist is applied at **build time**, so adding an origin is a deployment rather than a setting; and it is currently **global rather than per-casino**, so an origin added for you is an origin allowed to frame game bundles generally. That is why it is a conversation and not a toggle — `frame-ancestors` is what stops a hostile page framing a casino's game surface and phishing its players, and the list must stay an allowlist. > > If you have your origin registered, §15 is the client half: how to mount the bundle and talk to it. #### `POST /partner/v1/sessions` *Scope: `sessions:write`* #### Request ```json { "external_ref": "casino-user-88413", "display_name": "Alex R.", "email": "alex@example.com" } ``` `external_ref` is required, at most 200 characters, and is **your** identifier for the player. `display_name` and `email` are optional. #### Response ```json { "player_id": "9c1f7d2a-4e6b-4f1a-9a3d-2b8c5e7f1042", "launch_token": "", "expires_at": "2026-09-01T14:32:05Z", "ttl_seconds": 60, "single_use": true } ``` The token's hash alone is stored. There is no endpoint that can show it again — by construction, not by policy. **Format:** `launch_token` is 64 lowercase hex characters with **no prefix**. The `pst_` session token issued at the exchange *does* carry its prefix — the two are deliberately different shapes, so do not validate one against the other's pattern. > **external_ref is a permanent key** > > This endpoint is **get-or-create**. An `external_ref` already seen returns the same player; a new one creates an account. So `external_ref` **must be stable for the lifetime of the player** — a value that changes creates a second account with a second wallet, and the balance does not follow. > > A consequence worth knowing: because an existing player is returned immediately, **`display_name` and `email` are only read when the player is created.** Sending changed values later has no effect and no error. There is no endpoint to update them. ### The exchange Hand the launch token to the player's browser and have it exchange the token for a session. #### `POST /api/v1/partner/launch` *Scope: `unauthenticated`* Unauthenticated by design — the caller is the player's browser, which holds nothing but the token. Your casino is identified by the `Host` header, and the player by the token. #### Request ```http POST /api/v1/partner/launch Host: play.your-casino.example Content-Type: application/json { "launch_token": "" } ``` #### Response ```json { "session_token": "pst_9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "expires_at": "2026-09-01T18:31:05Z", "player_id": "9c1f7d2a-4e6b-4f1a-9a3d-2b8c5e7f1042" } ``` The claim is atomic — a token cannot be exchanged twice even under concurrent requests. ### Using the session The `pst_` token is a **4-hour** bearer credential presented on the ordinary player endpoints: ``` Authorization: Bearer pst_9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 Host: play.your-casino.example ``` It can only ever act as the one player it was issued for, on the one casino that minted it, and it cannot reach an account created through a hosted-model identity provider. > **Session expiry is not renewable** > > There is no refresh endpoint. After four hours the player must be launched again — mint a fresh launch token and repeat the exchange. Build re-launch into your session handling from the start rather than discovering it as an outage four hours in. > **`POST /api/v1/queue` — the request body, and who you match with** > > The body is `{module_key, game_key, currency, entry_fee_cents}`. Ask ClashX which module and game keys your account is entitled to; they are not derivable and are not listed here because they differ per account. > > It answers **three** kinds, all at 200: `matched`, `queued`, and `insufficient_balance` (which carries `currency`, `balance_cents`, `required_cents`, `shortfall_cents` and a player-facing `message`). Write the switch for three. > > ⚠ **A player matches only with others whose `module_key`, `game_key`, `currency` AND `entry_fee_cents` are all identical.** The fee is matched on exact equality — there is no banding by stake, unlike rating, which does widen. > > So if you let players name their own buy-in, or compute a per-player fee, each one queues alone. The symptom is not an error: `waiting: 1` reads as thin liquidity, so it looks like an empty platform rather than a mismatch you created. **Publish fixed stake tiers.** > > If fixed tiers do not suit your product, raise it — whether stake should band the way rating does is a platform decision, not something you can work around at your end. > **Four refusals the tables do not give you** > > **A stake ceiling applies to new accounts.** A buy-in above **$10.00** (`1000` cents) is refused `422` until that player has played **10 paid lives of that game**. Neither ceiling nor rule appears anywhere else, and the refusal copy is player-facing, so it reaches your end user. Make sure your stake tiers give a new player a legal entry point — a high-value player launched straight onto a $50 table is refused on day one. > > **A `403` whose message begins `compliance_block:` is about the PLAYER, not your key.** KYC, sanctions, self-exclusion or the collusion gate has refused. The 403 row in the error table is about your KEY or the casino's STATE, which routes a regulated refusal to your integrations team as a credential problem. Route `compliance_block:` to compliance and **do not retry**. This is the one message worth matching on, notwithstanding the general rule against matching messages. > > **`rank` and `score` are nullable.** A match that produced no ranking — abandoned, fully refunded, failed to start — has `null` for both on every participant. `rank === 1` is not a safe test for "winner": on a refunded match nobody is rank 1. Sort with nulls handled explicitly. > > **A refused launch exchange returns `401`**, with one identical message for unknown, expired and already-spent tokens — deliberately, so a probe cannot tell them apart. > **`display_name` is write-once, and omitting it brands the account permanently** > > `display_name` and `email` are read ONLY when the account is created, on the first `POST /partner/v1/sessions` for a given `external_ref`. There is no update endpoint. > > If you omit them, ClashX **synthesises** values — an invented in-game name and a deliberately unresolvable address — and they are permanent. Wiring up the integration without `display_name` therefore brands every player created in that window with a name nobody chose, and if those are real players the casino cannot fix it afterwards. Send the real name on the very first call for each `external_ref`. ## 06. The transfer wallet #### `POST /partner/v1/wallet/credit` *Scope: `wallet:write`* Moves value **into** the player's ClashX wallet — the game-side equivalent of a deposit you have already taken. **`sweeps` only**: a `gold` credit is refused `422`. **A credit is checked against where the player was last located.** Your server makes the request, so its address says nothing about the player; ClashX instead reads the most recent location it has on record for that player. If that places them outside your casino's permitted jurisdictions, the credit is refused `403` with a message beginning `compliance_block: geo` — route it to compliance and do not retry, as for any `compliance_block:`. If no location is on record, the credit is applied and the response's `recipient_location.outcome` reads `location_unverified`. A retry of a credit that already applied always returns the original movement (`applied: false`), whatever the check would say now. Debits are not checked. #### `POST /partner/v1/wallet/debit` *Scope: `wallet:write`* Moves value **out**. Refused if the player does not hold it — a player cannot be overdrawn into debt. **Only `sweeps` can be debited.** `gold` is play money and does not leave the casino: a `gold` debit is refused `422`, and retrying will not change that. #### Request — identical for both ```json { "external_ref": "casino-user-88413", "transfer_ref": "your-ledger-entry-000912", "currency": "sweeps", "amount_cents": 5000 } ``` | Field | Rule | | --- | --- | | `external_ref` | The player must already exist. An unknown value is **refused, not created**. | | `transfer_ref` | Required, ≤ 200 chars. Your identifier for this movement, and the idempotency key. **Must be unique across your ENTIRE casino** — not per player, per currency, per day, or per **direction**. One reference names one movement: a credit and the debit that corrects it need different references. See the note below. | | `currency` | **`"sweeps"` only**, both directions. `"gold"` is play money and is refused `422`; anything else is 422. | | `amount_cents` | Positive integer. Zero or negative is 422. | #### Response — success ```json { "kind": "settled", "transaction_id": "4d2e8a01-...", "player_id": "9c1f7d2a-...", "balance_after_cents": 12500, "applied": true, "recipient_location": { "outcome": "location_unverified", "detail": "no_location_recorded", "decided_at": null } } ``` `recipient_location` is `null` on a debit and on a replay. Otherwise its `outcome` is `permitted`, `location_unverified`, or `not_checked` (the compliance gate is not in force for your casino; `why` says which reason). #### Response — insufficient balance (HTTP 200) ```json { "kind": "insufficient_balance", "available_cents": 1500, "requested_cents": 5000, "shortfall_cents": 3500 } ``` > **Why a money call never creates an account** > > An unknown `external_ref` returns `404` with a message pointing at `POST /partner/v1/sessions`. If a transfer provisioned accounts, a typo in your integration would silently open and fund one — and that player would have no launch, no session, and no compliance history. Accounts are created deliberately and visibly, in one place. ## 07. The match feed #### `GET /partner/v1/matches?since=&limit=` *Scope: `matches:read`* `since` — RFC 3339 timestamp; returns matches settled strictly after it. Omit for the beginning of the feed. `limit` — default 50, maximum 200. ```json { "matches": [ { "session_id": "b31c...", "module_key": "skill", "game_key": "knockout", "currency": "gold", "entry_fee_cents": 500, "fiat": true, "status": "completed", "started_at": "2026-09-01T13:02:11Z", "settled_at": "2026-09-01T13:09:48Z", "participants": [ { "session_id": "b31c...", "player_id": "9c1f7d2a-...", "external_ref": "casino-user-88413", "rank": 1, "score": 4820, "stake_cents": 500, "payout_cents": 3700, "refund_cents": 0 } ] } ], "truncated": false, "next_since": "2026-09-01T13:09:48Z", "coverage": "settled matches only; matches in progress are absent until they settle" } ``` > **Two timestamps, two different questions** > > `settled_at` is **when the platform recorded the settlement**. It is this feed's cursor and its ordering key, which is why it cannot mean anything else — an ordering key has to be the key rows become *visible* on, or paging skips rows that appear behind your cursor. > > `engine_ended_at` is **when the game actually finished**, reported by the game server. > > On a healthy match they are milliseconds apart. They diverge on exactly the matches a casino investigates — one ended by something other than being played out. A session reaped by the platform's stuck-sweep once recorded a settlement **2m38s before the match really ended**, so a timeline built from `settled_at` alone put the match in the wrong place with nothing saying so. Read them together with `settlement_reason`; that is the same population. > > `engine_ended_at` is `null` when the game server never reported a terminal status for that session — an honest gap rather than a fabricated instant. Do not substitute `settled_at` for it; that is the substitution this field exists to stop. > **A 500 is not the only permanent failure, and 422/404 here are terminal** > > A wallet call refuses with **422** when the player's wallet is not `active` (frozen, closed), and **404** when there is no wallet for that player at this casino — a money call never creates an account. Both are permanent: **retrying cannot clear either.** > > These used to be 500s, which our own error table tells you to retry with backoff. A partner following that instruction retried a frozen wallet forever, the queue never drained, and a genuine outage was masked by the same signal. If you built that retry loop, it is now safe: these arrive as 4xx and should be escalated, not retried. > **Free practice matches settle into this feed** > > The feed selects every match with a settlement time. There is **no money filter**, and match-based practice creates real sessions that settle through the ordinary path — so free games against bots arrive here alongside paid ones, carrying `fiat: false` and `entry_fee_cents: 0`. > > `fiat` is the only tell. Filter on it unless you intend to count practice, because the money identity still closes either way: the stakes are zero, so nothing is out of balance and nothing flags the mistake. Only your **counts** are wrong — matches played, activity reports, and anything paid per match such as revenue share, affiliate volume or loyalty points. > > A second trap rides along: `participants` omits bots entirely, so a practice match usually appears with a **single participant** and reads as a one-player match rather than as practice. > **This feed is not a ledger** > > It reports the economics of **matches** — and it is complete for matches. A player's balance can also change outside it: an operator crediting them from the casino's own admin console, or affiliate movements if they enrol. Neither appears here or anywhere else on this API; there is no ledger or transaction endpoint on `/partner/v1`. > > Same species as the coverage note: an empty page means nothing settled, not nothing happened. > **`refund_cents` is a third economic term, not part of `payout_cents`** > > A participant carries `stake_cents`, `payout_cents` and `refund_cents`, and settlement accumulates all three **independently**. A refund is its own settlement kind, not a payout and not a reversal. > > **`refund_cents` is non-zero whenever a buy-in is returned in full**, and there are four such cases — not one: > > | case | what happened | > | --- | --- | > | failed start | the match never got under way | > | forfeit | a player forfeited their entry | > | **draw** | no winner, so every stake returns | > | orphan | the match was abandoned | > > A **draw** is the case integrations miss, because it is not an abandonment and the match completed normally. The platform rule is absolute: **a buy-in is either settled to a winner or refunded — never forfeited to the house.** > > Your reconciliation identity therefore has five terms, not four: > > ``` > credited − staked + won + refunded − debited == balance > ``` > > **Run it once per currency.** A wallet holds two independent balances, > `gold` and `sweeps`, in separate columns. `balance_after_cents` is the > balance of the currency named in *your* request, not a combined total, and > every movement touches exactly one of them. Summed across both currencies > and compared to one balance, the identity cannot close — and a casino that > "corrects" the gap with an adjusting transfer turns a reporting error into > a money error. > > Drop `refunded` and every abandoned match reports a false discrepancy. This is compatible with settlement being final (§13): the refund *is* the settlement, not a correction to one. ### Paging correctly - Pass `next_since` back as your next `since`. It comes from the last row's settlement time, **never from a clock**. - **`next_since` is `null` when the page is empty — keep your cursor, do not store the null.** The obvious loop (`cursor = res.next_since`) stores it on the first quiet poll; the next request then omits `since` and the feed replays **from the beginning of time**, re-delivering every settlement you have ever ingested, on every quiet tick thereafter. Deduplicate on `session_id` regardless (§13) — but the cursor is what stops the replay. - `truncated: true` means more rows were available than your limit — fetch again immediately rather than waiting. - `participants` is grouped under its match, so you never join two arrays yourself. - `external_ref` is `null` for players who did not come through your partner integration. > **Why the cursor is settlement time, not start time** > > A feed ordered by `started_at` loses matches silently. A long match that started before your cursor but settled after it would never appear — by the time it has a result, your cursor is already past its start. You would poll forever and nothing would report the gap. Rows enter this feed when they settle, so the feed is ordered by settlement. > > An empty page means "nothing has settled since your cursor" — *not* "nothing has happened". Matches in progress are absent entirely, because reporting one with null economics invites treating "not finished" as "finished with nothing". ## 08. Detection Collusion review and enforcement are yours. These endpoints deliver what you act on. #### `GET /partner/v1/detection/flags?since=&limit=` *Scope: `detection:read`* ```json { "coverage": { "scanned": true, "summary": "", "last_scan": { ... } }, "flags": [ ... ], "truncated": false, "next_since": "2026-09-01T12:00:00Z" } ``` #### `GET /partner/v1/detection/accounts` *Scope: `detection:read`* ```json { "coverage": { "scanned": true, "summary": "...", "last_scan": { ... } }, "accounts": [ ... ] } ``` > **Always read coverage before you read the list** > > Every detection list ships `coverage` beside it, and an empty `flags` array has **three** possible meanings, not two: > > 1. every detector ran and found nothing; > 2. **some detectors declined at this casino's scale** — they need more data than you have yet; > 3. no scan has ever run. > > `scanned: false` is the third. **`scanned: true` means a scan ran — not that every detector ran**, so it covers the first two and cannot tell them apart. `coverage.summary` is the field that names any detector which declined; read it before rendering anything. > > Rendering an empty list as "all clear" on the strength of the boolean reports a casino as screened for collusion when the collusion detector declined. ## 09. Settlement callbacks ClashX pushes settled matches to an HTTPS endpoint you register. There is exactly **one** event type today: `match.settled`. ### Registration Your endpoint is registered by a casino *owner* through the operator console (`PUT /api/v1/admin/partner/callback`). The response returns a signing secret **once**. The URL must be `https://` — a signed payload over plaintext still discloses every settlement event to anyone on the path. ### The delivery ``` POST https://your-endpoint.example/clashx/settlements content-type: application/json x-clashx-event: match.settled x-clashx-signature: t=1756738245,v1=9f86d081884c7d659a2f... { "event_type": "match.settled", "tenant": "golden-palace", "matches": [ /* same objects as GET /matches, up to 50 per delivery */ ] } ``` ### Verifying the signature The scheme is **the same construction Stripe uses** — deliberately, so an engineer who has written a Stripe verifier has written this one. ``` signed_payload = "{t}.{raw_request_body}" expected = HMAC_SHA256(your_signing_secret, signed_payload) compare = constant_time_equals(expected_hex, v1) ``` 1. Parse `t` and `v1` from `x-clashx-signature`. 2. Concatenate `t`, a literal `.`, and the **raw body bytes** — before any JSON parse or re-serialisation. 3. HMAC-SHA256 with your secret; compare hex, constant-time. 4. Reject if `t` is outside your freshness window. The timestamp is inside the signed material, so a captured delivery cannot be replayed later against a consumer that checks freshness. > **Choosing a freshness window** > > ClashX does not mandate one — it is your policy. **300 seconds is the right default**, and it is safe despite retries: the timestamp and signature are regenerated on *every attempt*, not fixed when the event first occurred. A retry two hours later still arrives with a current `t`. Do not widen the window to accommodate retries; you would only be accepting older replays. ### Retries and dead-lettering | Behaviour | Value | | --- | --- | | Success | Any 2xx. Anything else is a failure. | | Request timeout | 10 seconds | | Maximum attempts | 8 | | Backoff | 30s, 60s, 120s, 240s, 480s, 960s, 1920s, then capped at 3600s | | Batch size | up to 50 matches per delivery | | After 8 failures | **Dead-lettered — delivery stops permanently** until a human clears it. | > **Dead-lettering is a full stop, not a longer wait** > > A stream that exhausts its retries **stops**. It does not keep retrying hourly in the background. If your endpoint is down for a few hours, the stream must be cleared by an operator before deliveries resume. > > Two consequences. Return 2xx as soon as you have durably accepted the payload — do your processing after. And **reconcile with `GET /matches` on a schedule regardless**: the pull feed and the push stream run off the same query and cannot disagree, so polling is the backstop that makes a stopped stream survivable. > **If no signing secret is stored, deliveries are withheld** > > A casino with a registered endpoint but no stored secret gets **no deliveries at all** — they are skipped, not sent unsigned and not signed with a fallback key. Signing with a key you were never given produces deliveries you cannot verify and must reject, which is indistinguishable from an attacker. If your endpoint is registered and silent, re-save it to provision a secret. > **The watermark advances only on success** > > A failed delivery does not move the cursor. Advancing on failure would mean the retry skips exactly the events that failed, silently. The watermark tracks the last *delivered* event, never the current time. ## 10. Error handling Every failure has the same body shape: ```json { "error": "amount_cents must be positive" } ``` | Status | Meaning | What to do | | --- | --- | --- | | 401 | Key missing, unknown, or revoked | Fix the key. Retrying will not help. | | 403 | Authenticated but not permitted — missing scope, or the casino is `suspended`/`churned`. `onboarding` is permitted | Request the scope, or check account status. Retrying will not help. | | 404 | Player not found, or no wallet for them at this casino | Create via `POST /sessions` first. A money call never creates an account. | | **409** | **`transfer_ref` already used for a DIFFERENT movement** | **Terminal. Send this movement under a NEW `transfer_ref`; retrying cannot clear it.** | | 422 | Body failed validation — or the player's wallet is not `active` (frozen, closed) | Fix the request. A non-active wallet is permanent: escalate, do not retry. | | 500 | Server-side failure | Retry with backoff, reusing the same `transfer_ref`. | > **There is no error code field — plan around it** > > Branch on **HTTP status** and, for endpoints that have one, the response `kind`. Those are the stable contract. The `error` string is written for a human reading a log; it may be reworded at any time without the status changing. If you must match on it, isolate that matching in one place so it is cheap to revisit. > **⚠ `transfer_ref` must be unique across your ENTIRE casino** > > The idempotency key is the `transfer_ref` **alone** — there is no player, currency or amount in it. Its scope is the whole casino, not the player. > > That matters because the obvious ways to mint one collide: a per-player sequence, a per-day sequence, a deposit id from a table that resets, or **the same reference for the gold and sweeps legs of one deposit**. Every example in this document (`dep-2026-09-01-000912`) reads like a per-ledger sequence, which is exactly the shape that collides. > > A reference reused for a **different** movement — different player, currency, amount or direction — is refused **`409`**. It is terminal: retrying cannot clear it, and only a new reference will work. A genuine retry of the *same* movement is unaffected and still returns `applied: false` with the original transaction. > > ⚠ **A `if (!response.ok) retry()` loop spins forever on this.** 409 is the one refusal on this endpoint that a generic retry rule cannot survive, because it is permanent and identical on every attempt. Branch on it explicitly. > > (Before this was refused, a collision returned `200` / `applied: false` carrying the FIRST movement's transaction while echoing the second player's id — a real deposit was silently never credited and both sides' books still balanced. If you integrated against that behaviour, the 409 is the fix, not a regression.) A `500` on a wallet call is the case that matters most: you do not know whether the transfer applied. Retry with **the same `transfer_ref`** — idempotency will either apply it once or tell you it was already applied via `applied: false`. Never retry a wallet call under a new reference. ## 11. Rate limits > **These are enforced now.** `require_rate_limit` is layered on every `/partner/v1` route group and refuses with `429`. Write the retry path; you will meet it. Every `/partner/v1` call is counted **per API key**, in a sixty-second window. | Class | Endpoints | Default | | --- | --- | --- | | Writes | `POST /sessions`, `POST /wallet/credit`, `POST /wallet/debit` | **6,000 / min** | | Reads | `GET /matches`, `GET /detection/*` | **600 / min** | | Ping | `GET /ping` | **60 / min** | > **These are the numbers for a new integration, not a ceiling on your growth** > > A casino with 500 concurrent players generates roughly **51 requests a minute**; 5,000 concurrent is about **500/min**; 50,000 concurrent is about **5,000/min**. The defaults sit an order of magnitude above ordinary traffic on purpose — they exist to catch a retry loop with no backoff, which runs thousands of requests a minute and is the only realistic way to reach them. > > **Above roughly 5,000 concurrent players, your limits are provisioned when you onboard** — set per key and per class for your account, not left at the default. This is the primary mechanism, not an exception process. A large operator meeting a refusal nobody chose for them is the outcome this design exists to prevent, so the conversation happens at onboarding rather than at an incident. > **Per key — so use a separate key per integration** > > The window is counted against the **key**, not the tenant. A back-office reporting job and your player-facing traffic on two different keys get two separate allowances, and a runaway report cannot exhaust the budget your players are served through. > > That is a real isolation benefit and worth taking deliberately: issue one key per integration rather than sharing one across everything. ### Sizing your polling loop The read class at 600/min is exactly **10 requests per second**, chosen so a 100 ms polling loop sits precisely on the limit and anything tighter is refused. A 1-second poll runs at 60/min — ten times under. **Poll no faster than once per second** and the read class will not trouble you; if you need faster, that is an onboarding conversation, not a retry loop. ### The refusal `429 Too Many Requests`, with the same body shape as every other error on this API: ```json { "error": "rate limit exceeded for this API key: more than 600 requests per minute. This is a per-key limit; if your integration needs a higher allowance, contact ClashX and it will be raised for your account." } ``` ⚠ **Branch on the status code, never on this message.** There is no machine-readable code here — that is the documented shape (see the error-handling trap), not an omission. The number in the sentence is *your* limit, so it reflects a provisioned override rather than the default, which also means it is not a constant you can hard-code. ## 12. After the launch — what the session is for Both the launch flow and the wallet stop at "you hold a `pst_` session". This is what it spends on. These are **player** routes: `Authorization: Bearer pst_…` on your casino's own host, not `X-Api-Key` on `/partner/v1`. #### `GET /api/v1/me` *Scope: `pst_` session* The player the session belongs to, with their wallet. Use it as the first call after an exchange to confirm the session is live and the balance is what you credited — it is the cheapest possible check that the launch flow worked end to end. #### `POST /api/v1/queue` *Scope: `pst_` session* Enter matchmaking. **The response is a discriminated union and you must branch on `kind`** — this is the same shape as the wallet's `kind` field, and the same trap: ```json { "kind": "matched", "session_id": "…", "reconnected": true, "ws": { … } } ``` ```json { "kind": "queued", "position": 3, "waiting": 7, "roster_size": 8 } ``` `matched` carries the session id and the WebSocket details to connect to. `queued` carries the player's place in line — `position` of `waiting`, against the `roster_size` the game needs before it starts. Poll or re-enter until you are matched. > **⚠ `entry_fee_cents` must be one of the casino's published stake tiers** > > **Do not let a player name an arbitrary buy-in, and do not compute a per-player fee.** Matchmaking pairs players on an **exact** `entry_fee_cents` — two players match only if `module_key`, `game_key`, `currency` **and** the fee are all identical. An unlisted amount therefore does not fail, it **queues alone**, and `{"kind":"queued","waiting":1}` is a true number that reads as thin liquidity rather than as a mistake. > > So an unlisted stake is refused at the call instead: > > ```json > // POST /api/v1/queue { … "entry_fee_cents": 137 } > // 422 > { > "error": "$1.37 is not a stake this casino offers for this game. Matchmaking pairs players on an EXACT entry fee, so an unlisted amount would queue alone forever rather than fail — which is why this is refused here instead. Valid stakes: $1.00, $5.00 (entry_fee_cents: 100, 500). Build your lobby around these tiers rather than letting a player name an arbitrary buy-in." > } > ``` > > **The refusal names the legal tiers**, in dollars for your UI and in raw cents for your next request. Tiers are **per casino and per game** — two games at the same casino need not offer the same stakes, and another casino's tiers tell you nothing about this one's. > > **Build your lobby around the tiers**: present them as the choices, rather than a free-text amount you validate afterwards. A stake picker with four buttons is the shape this API expects. > > **Read the tiers from `GET /api/v1/games/offered`** — the same listing you already call to know which games a casino offers. Every game in it carries `stake_tiers_cents`: > > ```json > { > "tenant": "golden-palace", > "games": [ > { "module_key": "skill", "game_key": "gridlock", "display_name": "Gridlock", > "stake_tiers_cents": [300, 900] }, > { "module_key": "skill", "game_key": "slither", "display_name": "Slither", > "stake_tiers_cents": [100, 250, 500, 1000, 2500, 5000, 10000] } > ] > } > ``` > > The listing and the refusal read the same source, so they cannot disagree: a fee in `stake_tiers_cents` is a fee `/queue` accepts. Note the two games above offer **different** stakes — read the tiers per game, not once per casino. `DELETE /api/v1/queue` leaves the queue. It never involves money. > **Play Again — what it costs, per settlement model** > > **Play Again is a ClashX control, not an endpoint.** It lives on the result screen of the game ClashX renders, so it is your player's tap and not your API call. There is no way to disable it, and no field on `POST /partner/v1/sessions` that suppresses it. What it *does* depends on which settlement model the game uses, and the three cases are genuinely different: > > | game | what Play Again does | what it costs | > | --- | --- | --- | > | **practice** (`fiat: false`) | joins a fresh practice match against bots | nothing — no debit, no payout | > | **pot / match** (the paid matchmaking games) | re-enters matchmaking **at the same stake** | a **full fresh entry fee**, debited when the next match forms | > | **open-server / arena** | buys another life in the arena the player is already in | a **full fresh entry fee**, added to the same seat's total stake | > > ⚠ **In an arena, the stake accumulates on one seat and the rake threshold does not.** Rake there is charged on cash-out above **one life's** entry fee, not above the total the player has staked. A player who buys three lives at 100¢ (300¢ staked) and cashes out 250¢ is **down 50¢ overall and still pays rake**, because 250¢ is above the 100¢ single-life threshold. This is deliberate and it is the published behaviour — but it means "did this player profit?" and "was this player raked?" are different questions in an arena, and you cannot infer either from the other. > > Read `rake_cents` from the match object rather than deriving it (§19). For a player's true position across repeated entries, sum the ledger rather than reasoning from a single match: the second and third lives are separate entries against the same seat. > **Where the player actually goes, and what you may build yourself** > > Two options, and the boundary between them matters more than it sounds. > > **The hosted path.** ClashX hosts the exchange and forwards the player into the lobby once the session is established, and you build nothing after the redirect. Deployed — see §14. > > **Your own lobby.** You may build your own lobby, catalog and wallet UI on top of `/api/v1/me`, `/api/v1/queue` and the wallet endpoints. The `pst_` session authenticates all of them. > > ⚠ **You cannot build your own GAME client, and "your own front end" is the phrase that misleads.** The line falls at the match itself: > > | you can build | ClashX renders | > | --- | --- | > | lobby, catalog, wallet UI, queue UI | the game itself, once a match starts | > > `POST /api/v1/queue` returns `{"kind":"matched", session_id, ws}`, and that WebSocket carries **`bincode`-encoded frames** — a Rust-native binary format with no cross-language specification. Inside the envelope, `Snapshot` is an opaque byte string holding a **per-game** render state, and the input channel is likewise a game-specific serialized struct. There is no published wire format, and the field layout of ClashX's Rust types *is* the contract. > > So implementing a client for one game means reimplementing `bincode`, that game's exact render-state struct, and its input encoding — then keeping all three in lockstep with a `protocol_version` that moves when ClashX changes a field. That is reimplementing the game, once per game, against a format that is not specified for you. > > **Scope on this sentence: you build your own lobby around ClashX-rendered games, not your own front end.** If self-hosted game rendering is something you need, raise it with ClashX as a roadmap question rather than an integration task. ### The return path — what does not exist Asked early by every casino, and the honest answer is that most of it is unbuilt. Stated here so it is not discovered during a demo. > **There is no `return_url`, and no way to say where a player goes when they are done** > > `POST /partner/v1/sessions` accepts `external_ref`, `display_name` and `email`. It accepts **no return, redirect or callback URL**, and no such field exists anywhere in the launch flow. A casino cannot nominate a page to send the player back to. **On success**, the exchange page replaces itself with the lobby at `/` on your casino host. It uses `location.replace`, so the spent launch URL is removed from history — a player pressing back reaches whatever page sent them, not a dead link. **On failure** — expired, unknown, or already used, which are deliberately indistinguishable — the player sees one sentence naming your casino and telling them to start play again. There is **no retry button** (the token is spent, so retrying always fails), **no sign-in form** (a partner player has no ClashX credentials, so it is a dead end that becomes your support ticket), and ⚠ **no link back to your site**. The only route back is the browser's back button or typing your address. > **If you need a return path, raise it — do not design around one** > > Today the practical answer is the back button. An integrator will assume better, and a casino will ask on day one, so it is written here rather than left to a support thread. The lever you *do* control is making failures rare: **mint at click time, not at page-render time.** Sixty seconds is generous for a click and tight for a render. ## 13. Two facts a reconciliation depends on Both are verified against the implementation, and both are the kind of thing an API reference usually leaves you to discover. > **A balance can move without a `/partner/v1` call — know the two paths** > > ⚠ An earlier version of this document said ClashX never moves a partner balance on its own. **That was wrong.** Two paths move a balance with no partner endpoint, and an integration that does not know about them cannot explain what it is seeing. > > Two movements are real and appear on **no partner endpoint**: > > | path | what it is | > | --- | --- | > | Operator grant | your own staff crediting a player from the casino admin console (`POST /api/v1/admin/players/{id}/grant`, owner-only) — it accepts any player, with no partner exclusion | > | Affiliate movement | accrual, redemption or clawback, if that player enrols — their `pst_` session can reach it | > > Both are **yours**, not ours: an operator action on your console, and a programme your players opt into. Neither is a ClashX correction to a settlement. > > So reconcile as `transfers + matches` — and **keep asserting exact equality.** > > ⚠ **Do not widen this into a tolerance.** These two paths do not produce small drift; they produce exact amounts somebody deliberately moved. A band wide enough to swallow a 500-cent operator credit is wide enough to hide one, and there is nothing for it to absorb in the first place — the system is integer cents end to end, so there is no rounding to tolerate. Loosening the check is how a reconciliation quietly stops being one. > > Treat a mismatch as a **two-candidate investigation, not an error**: an operator grant, or an affiliate movement. Poll `GET /matches` first — it may simply be a settlement you have not applied yet. An alarm with no candidates gets muted; an alarm carrying two named candidates and a direction is a two-minute check. > **A settled match is final — there are no voids** > > `match.settled` is the only event type, and it is sufficient because a settlement is never undone. A session reaches `completed` or `abandoned`, both are terminal, and both stamp their settlement time when they happen — so both arrive in the feed in order, exactly once. > > A settled session is terminal, a duplicate settlement is a no-op, and **no correction is ever re-sent** — so deduplicating on `session_id` is safe. Scope that as "no correction is re-sent" rather than "the wallet is immutable": the two paths above are exactly why the stronger claim would be false. > > There is no void, no cancellation, and no compensating inverted settlement. If you have integrated a provider that voids, **do not build that handling here**; there is no state it would ever observe. > > ⚠ **"Voids do not exist" is not "refunds do not exist."** Refunds are ordinary and common — see `refund_cents` in §07. The difference is where they live: a void would be a *later event reversing an earlier settlement*, and there is none; a refund is a *field on the original settlement*, and every integration must read it. Build refund handling. Do not build void handling. ## 14. Undefined contract One item here is now settled and one remains open. Both are recorded rather than guessed at, because an integration that assumes an answer will break when one is chosen. > **ClashX HOSTS THE LAUNCH EXCHANGE** > > ✅ **ClashX HOSTS THE EXCHANGE. YOU BUILD NO PAGE.** > > Mint a token, redirect the player to it, and stop. ClashX serves the exchange > page under your branding: no exchange page, no session handling, no error > page on your side. > > This document previously told you to build one, because the page was written > but not deployed. **It is deployed now.** If you built a thin exchange page > against the earlier instruction, you can delete it — the redirect URL and the > token contract are unchanged, which is why that instruction said you would be > able to. > > Easier still: the session response carries **`launch_url`**, the complete > redirect assembled for you. Send the player there and concatenate nothing. > > The contract is settled and verified against a running stack: ClashX hosts the exchange page, and your side of the launch flow becomes *mint a token and redirect*, nothing else. The deployment is true as well — `/launch?token=…` on the casino host answers 200 and completes the exchange, driven end to end on 2026-09-04. > > ``` > https://{casino-host}/launch?token={launch_token} > ``` > > You build no exchange page, no session handling, and no error page. Two problems live on the ClashX side of that line, and a casino that keeps its own page reintroduces both: the page reads an existing session **before** spending the token, so a refresh or a back-navigation re-enters the lobby instead of reporting a dead link on a session that is still valid for four hours; and it guards the double-exchange that React StrictMode produces in development, which otherwise looks like a server fault. > > **A launch token is not a compliance bypass.** The `pst_` session it produces is a full four-hour player session, not a reduced-capability one, and it passes every gate a hosted-model player passes: compliance, KYC, self-exclusion, and the collusion gate. Your compliance reviewer should read that sentence rather than infer it. > **Open — signature freshness tolerance** > > ClashX enforces clock-skew windows on webhooks it *receives*, but publishes no required tolerance for deliveries it *sends*. The window is yours to choose. Use 300 seconds (see §09) and record it as your decision, not as a documented platform guarantee. ## 15. Running a game inside your own page Everything below is the **client** half. You do not need it if you redirect players to the launch exchange — ClashX serves the whole game surface there. You need it if you want the game rendered inside your own page. > **⚠ Your origin must be registered with ClashX before any of this works.** > > Game bundles are served with `frame-ancestors` naming the origins allowed to embed them. If yours is not on that list the browser refuses the frame **before the bundle loads at all** — no request, no script, no error you can catch. The only trace is a console warning inside a frame that never rendered: > > ``` > Framing 'https://…' violates the following Content Security Policy > directive: "frame-ancestors 'self'". The request has been blocked. > ``` > > Send us every origin you will embed from, including staging and localhost ports. This is the least discoverable failure on this page and it looks exactly like "the game is broken". ### The URL ``` https://{casino-host}/games/{slug}/index.html? ``` `/games/{slug}` without `index.html` now serves the bundle too. It used to `404`, which reads as *not deployed* rather than *wrong path* — if you are on an older deployment and see a `404` there, add `/index.html`. ### The parameters | param | type | value | | --- | --- | --- | | `ws` | **string** | the WebSocket URL — `ws.url` from the session response | | `token` | string | `ws.token` | | `match_id` | string | `ws.match_id` | | `session_id` | string | `ws.session_id` | | `player_id` | string | your player's ClashX id | | `mode` | string | `practice` or `compete` | > **⚠ `ws` MEANS TWO DIFFERENT THINGS ON THE TWO SIDES OF THIS BOUNDARY.** > > The API returns `ws` as an **object**: `{url, token, match_id, session_id, protocol_version}`. > The bundle reads `ws` as a **string**: the URL alone. > > Passing the object through — the obvious thing, since the names match — yields `?ws=[object Object]`, and the bundle mounts, renders, and silently never connects. Pass **`ws.url`**. ### ⚠ The token rides the WebSocket subprotocol, not the query If you build your own connection rather than embedding the bundle, the JWT goes in `Sec-WebSocket-Protocol`, **not** in the query string: ```js new WebSocket(wsUrl, ["clashx.v1", `clashx.token.${jwt}`]) ``` Every other parameter on this page travels in a query string, so a query parameter is the reasonable guess — and it is refused while your URL looks correct. This one costs an afternoon. ### Knowing whether it worked — the `game-net` channel The bundle posts its connection state to the parent window. **Without listening for this you cannot tell "still loading" from "connected" from "refused" from "never responded"** — they all look like a frame that has not done anything yet. ```js window.addEventListener("message", (e) => { if (e.data?.type !== "game-net") return; // state: "connecting" | "open" | "closed" | "error" // code: close/error code when the state carries one // attempt: reconnection attempt number }); ``` Treat "no `game-net` within ~15s" as a failure and surface it. A frame that renders and never connects is the most common integration symptom and this channel is the only thing that distinguishes its causes. ### ⚠ The contract is not the same for every game Three shapes ship today, and only the first is what this section describes: | shape | games | notes | | --- | --- | --- | | Standard | `tumble`, `open_up`, `golazo`, `memory_lane`, `turbo_track` | the table above | | Different names | `pickle_punisher` | `wsUrl` not `ws`, `jwt` not `token`, and camelCase: `matchId`, `playerId`, `sessionId`, plus `playerName`, `currencyCode` (`AC` practice / `USD` compete) and `entryFee` | | WebAssembly | `gridlock`, `knockout`, `slither`, `tank` | Now embeddable. They read the **standard** parameters. | ### ✅ Use `launch_url` and assemble nothing The session response carries **`launch_url`** — a complete, ready-to-use URL with the right parameter names *for that game*. It is returned **alongside** `ws`, so an integration already reading `ws` keeps working. ```json { "ws": { "url": "…", "token": "…", "match_id": "…", "session_id": "…" }, "launch_url": "https://casino.example/games/tumble/index.html?ws=…&token=…" } ``` Put it in the iframe `src`. That is the whole integration, and it is the only form uniform across the spellings above — the server knows which game it is, so you never have to. `launch_url` is `null` when a game has no embeddable bundle. That is deliberate: a URL that 404s reads as *not deployed*. ## 16. Practice mode Practice is a **free, bot-filled** game: no debit, no payout, and **no ledger row, ever**. It is the mode a casino demonstrates on and lets players try, and it needs no wallet balance and no other human players. It is a **separate route** and cannot be found by probing the queue — `/partner/v1/sessions` has no practice flag, because the practice lane is not the same lane. ``` POST /api/v1/practice/join { "module_key": "skill", "game_key": "tumble" } ``` ⚠ It is authenticated as the **player** (the `pst_` session from the launch exchange), not by your API key — a practice game is something a player starts, not something your servers start on their behalf. The response carries the same `ws` object as a competitive session, so the mount contract above is identical. Seats are filled with bots. ## 17. The `fiat` field, and why you must filter on it Every match object carries **`fiat`**. | `fiat` | what it is | | --- | --- | | `true` | a real-money match — stakes, payouts and rake | | `false` | **practice** — free, bot-filled, and **no ledger row, ever** | ⚠ **Practice matches arrive in the same feed with zero economics.** They are real matches with real results, and their `stake_cents`, `payout_cents` and `rake_cents` are all `0`. So an integration that does not filter on `fiat` produces **inflated match counts and settlement rows that all reconcile** — every arithmetic check still passes, because adding zeros changes no total. A wrong number that reconciles is far harder to find than one that does not. Filter on `fiat` wherever you count matches, bill, or report volume. ## 18. What a wallet unit is worth Wallet amounts are **integer minor units** (cents) in the wallet's currency. `gold` is play money; `sweeps` is the redeemable one. That is why only `sweeps` can be debited out: a play balance that could leave as money would not be play. ⚠ **ClashX applies no conversion, anywhere.** There is no exchange rate in the platform. A unit you credit is the unit that is staked and the unit that is paid out — the number that leaves your ledger is the number that arrives. **ClashX gives a wallet cent no real-world currency.** `sweeps` carries no ISO code and no exchange rate, and nothing converts it, so what one `sweeps` cent is worth is set by what you take for it and what you pay out for it. Fix it as one minor unit of the currency you settle in (one US cent, if you settle in dollars) and hold it everywhere. If your side ever uses two, every figure you reconcile is wrong by a constant factor and nothing here will disagree with you. ## 19. Rake Every match object carries **`rake_cents`** — the house cut, summed from the settled figures — and each participant carries their own `rake_cents`. ⚠ **Do not derive it.** The obvious derivation is `Σstake − Σpayout − Σrefund`, and it is unsafe: a participant with no recorded economics reads as `0`, which is **indistinguishable from a genuinely free seat**. The derived figure is then wrong while every arithmetic check still balances. Settlement knows the exact number; read it. `rake_cents` is the **casino's** gross gaming revenue. What ClashX charges is a separate contract term and does not appear on this API. ## 20. ⚠ The ordering rule, in both directions §06 states the debit rule: **debit ClashX first, then release funds on your side.** The rule for the other direction is the same shape and is easier to get wrong, because the mistake destroys money instead of duplicating it. | you are | do | never | | --- | --- | --- | | **crediting** (funding play) | debit your own ledger first, then `wallet/credit` | credit ClashX first and debit yourself after | | **debiting** (cashing out) | `wallet/debit` first, then release on your side | release first and reconcile later | ⚠ **AND THE THIRD CASE: a retryable failure is not a reversal.** If `wallet/credit` or `wallet/debit` fails with a timeout, a `500`, or any network error, **you do not know whether it applied.** Do not reverse your own side. Retry with **the same `transfer_ref`** — idempotency will either apply it once or tell you it already applied. Reversing on a retryable failure is how money is destroyed: the transfer landed, you reversed your side, and the two ledgers now disagree by exactly the amount, with nothing logged as an error anywhere. ## 21. Pointer lock Two games use pointer lock for mouse-look: **`tumble`** and **`pickle_punisher`**. The rest aim with absolute cursor position or are click-driven, and locking the pointer would break their aiming outright. If you embed a game in an iframe, grant the permission: ```html ``` ⚠ **Without it the lock is refused silently.** Permissions Policy rejects the request, nothing throws, and the game runs with a visible cursor and no mouse-look — the same invisible class as the embedding-origin problem in §15. ⚠ **The pointer cannot lock on arrival.** Browsers require a user gesture, so the player's first click is what engages it. That is a browser rule, not a setting. `Esc` releases the pointer and a further click re-engages it. ## 22. Your CRM Player records and player events for the CRM your casino already uses. Exactly what your casino's **CRM setting** releases, and nothing while it is off: it is off until your owner turns it on (Settings → Compliance → CRM export and player groups) and chooses what it carries. Every pull is recorded in your casino's audit log, with your key as the actor. The same rows are also a file download for your owner. | Category | Releases | | --- | --- | | *(always)* | `player_id` (ours, pseudonymous), `external_ref` (yours), `status`, `created_at` | | `activity` | `last_activity_at`, `level`, and `GET /crm/events` | | `groups` | `groups`: the built-in groups the player is in (`active`, `dormant`, `high_value`) | | `contact` | `email`, `username`, **only** for a player who has opted in to marketing and may be contacted at that moment; empty for everyone else | #### `GET /partner/v1/crm/players?at=` *Scope: `crm:read`* One row per account, as of `at` (default: now). ```json { "at": "2026-10-05T12:00:00Z", "players": [ { "player_id": "…", "external_ref": "your-id", "status": "active", "created_at": "…", "last_activity_at": "…", "level": "3", "groups": "active,high_value", "email": "", "username": "" } ] } ``` #### `GET /partner/v1/crm/events?since=&until=` *Scope: `crm:read`. Needs the `activity` category.* The activity a CRM or rewards program earns on, over `[since, until)` (default: the last day; at most 92 days per request). `event` is one of `wager`, `entry`, `tournament_entry`, `win`, `tournament_prize`, `refund`, `rake`. `amount_cents` is unsigned; `event` says which way it went. ```json { "since": "…", "until": "…", "events": [ { "event_at": "…", "player_id": "…", "external_ref": "your-id", "event": "entry", "currency": "sweeps", "amount_cents": "300", "session_id": "…" } ] } ``` ⚠ **A refusal here is your casino's setting, not your key.** With the setting off, or without `activity` for events, both endpoints answer 403 and say why. Ask your casino's owner; rotating the key will not change it. ## 23. Your rewards program If your casino runs its own rewards (loyalty) program, its owner can link it on the Compliance page (off by default). A player then links their membership number, or asks to join, from their account page. **Every link waits for you to confirm it.** That is how nobody earns on someone else's membership. A player can unlink at any time, at once. You only ever see the membership number and, for players who came through your integration, your own player reference: never a name or an email address. #### `GET /partner/v1/rewards/links?since=` *Scope: `rewards:read`* Link changes after `since` (exclusive), oldest first, up to 500 per call (a page never ends between two changes with the same time). Keep `through` and pass it as the next `since`. `status` is `pending`, `active`, `rejected` or `unlinked`. The same changes are also sent to your callback endpoint as `rewards.link` events, signed like `match.settled`. ```json { "links": [ { "link_id": "…", "kind": "membership", "status": "pending", "membership_id": "M-1234", "external_ref": null, "terms_version": "1", "changed_at": "…" } ], "through": "…", "more": false } ``` #### `POST /partner/v1/rewards/links/{link_id}/confirm` · `…/reject` *Scope: `rewards:write`* Decide a pending link. Confirming a `signup` link names the membership number you gave the player: `{ "membership_id": "M-5678" }`. A `membership` link keeps the number the player gave, so you can't change it. A link that is no longer pending answers 409. #### `GET /partner/v1/rewards/activity?since=&until=` *Scope: `rewards:read`* The play of ACTIVE links over `[since, until)` (default: the last day; at most 92 days per request). It includes only play after each link was confirmed, and only the kinds your casino's program earns on: `entry`, `rake` or `win`. #### `POST /partner/v1/rewards/grants` *Scope: `rewards:write`* Send a reward in to the player behind an ACTIVE link. It goes through your casino's bonus rules, never around them. It counts towards the rewards daily total, and at or above your dual-control threshold it waits for one of your staff to approve it. ```json { "link_id": "…", "grant_ref": "your-unique-ref", "kind": "promo", "currency": "sweeps", "amount_cents": 500 } { "link_id": "…", "grant_ref": "your-unique-ref-2", "kind": "free_entry", "game_key": "tanks", "entry_cents": 100 } ``` `grant_ref` is yours and makes the call safe to retry: the same reference returns the first answer (`"replayed": true`). The answer's `outcome` is the bonus engine's: `credited`, `voucher`, `proposed` (waiting for approval) or `refused` with its reason.