Build brief · ClashX /partner/v1 # Build a ClashX Integration A complete, self-contained brief for implementing a ClashX partner integration. Follow it in order. Every request and response below is real; reproduce them rather than inferring shapes. > **There are two documents, and this is the buildable one.** It is ordered the way you will write the code. The **[Partner API reference](/integration/partner-api)** is the complete surface — every endpoint, every field, every failure — and is where to look when you need a detail this brief does not carry. Neither supersedes the other. ## Read before writing any code Four behaviours where the obvious assumption is wrong. Each will pass a naive test and fail in production. They are listed first because you will write the code they affect within the next twenty minutes. 1. **A rejected wallet transfer returns HTTP 200 — not 4xx.** Insufficient balance is a business outcome, not an error. Branch on the `kind` field, never on the status code alone. ```json { "kind": "insufficient_balance", "available_cents": 1500, "requested_cents": 5000, "shortfall_cents": 3500 } ``` 2. `applied: false` means replay, not failure. Wallet transfers are idempotent on your `transfer_ref`. A retry returns success describing the *original* movement. Both `true` and `false` are successes. Treating `false` as failure and retrying under a new reference is how you double-count. 3. Errors carry no machine-readable code. Every failure is `{"error": ""}`. Branch on HTTP status and on `kind`. The message wording is **not a stable contract** — log it, do not parse it. 4. Launch tokens live 60 seconds and every refusal is identical. Single-use, 60-second TTL. Mint at the moment of redirect, never in advance. Unknown, expired, and already-used return the same message by design, so you cannot diagnose from the response — log your own mint and exchange timestamps. ## What you cannot build without ClashX These must be provisioned by ClashX before any of the steps below will work. **If you do not have them, stop and request them.** Do not stub, mock, or work around a missing prerequisite — an integration built against a fabricated substitute will not work against the real platform. | Prerequisite | Why you cannot proceed without it | | --- | --- | | A provisioned casino (tenant) | Nothing exists to hold players, wallets, or matches. | | An API key with the scopes you need | A key with no scopes can only call `/ping`. Request `sessions:write`, `wallet:write`, `matches:read`, and `detection:read` if you need flags. | | Module entitlements | Games are gated on entitlement. Without them your lobby is empty and no match can form. | | A resolvable hostname **pointed at ClashX** | The launch exchange and all player endpoints identify your casino by `Host`. ⚠ **This is the clause that tells you ClashX hosts the player experience** — your casino host resolves to ClashX infrastructure, and the lobby, the games and the exchange page are all served from it under your branding. See "what you may build yourself" in the reference. | | A registered callback endpoint and its signing secret | The secret is shown once at registration. Without it you receive no deliveries at all. | | Your base URL and casino hostname | Ask for both. Do not guess either — see the note below. | > **Which hostname to call — two different rules** > > The two families of endpoint identify your casino in **different ways**, and getting this wrong is a confusing early failure. > > - **`/partner/v1/*` — identified by your API key.** These routes do no hostname resolution at all; the key *is* the claim. Call them on whatever base URL ClashX gives you. > > - **`/api/v1/*` — identified by the `Host` header.** The launch exchange and every player-facing route resolve your casino from the hostname. They must be called on **your casino's hostname**, and a request with the wrong Host reaches a different casino or none at all. > **Ownership boundary — do not invent one** > > **ClashX owns:** the wallet balance and ledger, settlement, game servers and matchmaking, match records, collusion detection. > > **You own:** player accounts and authentication, card processing and real deposits/withdrawals, the decision to move value in or out, acting on detection flags, receiving and verifying callbacks. > > Value moves by **explicit transfer**. ClashX will never call your system to ask for a balance mid-wager — a seamless wallet is deliberately not part of this product. Do not build a balance-callback endpoint; nothing will call it. ## Authenticate Send your key in `X-Api-Key` on every partner request. Keys are prefixed `wlk_`. ```bash curl -s https://api.clashx.example/partner/v1/ping \ -H "X-Api-Key: wlk_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" ``` #### Expected response ```json { "status": "ok", "tenant": "golden-palace", "tenant_status": "active", "serves_hosted_players": true, "key_label": "production-backoffice", "scopes": ["sessions:write", "wallet:write", "matches:read"] } ``` #### 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. Implement a startup check that calls `/ping` and asserts both the scopes you need and the `tenant_status` you expect. `/ping` requires no scope, so it succeeds even for a key that can do nothing else — which makes it the only reliable way to distinguish "bad key" from "key lacks permission" before you are deep in a money path. #### Failure modes | Status | Meaning | Retry? | | --- | --- | --- | | `401` | Key missing, unknown, or revoked | No — fix the key | | `403` | Authenticated but not permitted — missing scope, or the casino is `suspended`/`churned`. **Not** `onboarding`, which is permitted | No — request access | ## Resolve a player Before a player can hold value or play, they must exist on the ClashX side. One call does get-or-create and mints a launch token at the same time. ```bash curl -s -X POST https://api.clashx.example/partner/v1/sessions \ -H "X-Api-Key: wlk_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" \ -H "Content-Type: application/json" \ -d '{ "external_ref": "casino-user-88413", "display_name": "Alex R.", "email": "alex@example.com" }' ``` #### Response ```json { "player_id": "9c1f7d2a-4e6b-4f1a-9a3d-2b8c5e7f1042", "launch_token": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "expires_at": "2026-09-01T14:32:05Z", "ttl_seconds": 60, "single_use": true } ``` The `launch_token` is **64 lowercase hex characters with no prefix** — do not expect or validate one. The `pst_` session token returned later *does* carry its prefix; the two are different shapes. `external_ref` is the identity key: the same value always returns the same player. Use your immutable primary key, **never** an email address, username, or anything a user can change. A changed `external_ref` silently creates a second player with a second wallet, and the balance does not follow. Store the returned `player_id` against your user record. You will not need it for wallet calls — those take `external_ref` — but it appears in match results and makes reconciliation unambiguous. > **display_name and email are write-once** > > They are read **only when the player is created**. Sending changed values on a later call has no effect and returns no error, and there is no endpoint to update them. Do not build a profile-sync feature; it will appear to work and do nothing. > **`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`. ## Credit the wallet You take the real payment in your own system. Then move the corresponding value into the player's ClashX wallet. ```bash curl -s -X POST https://api.clashx.example/partner/v1/wallet/credit \ -H "X-Api-Key: wlk_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" \ -H "Content-Type: application/json" \ -d '{ "external_ref": "casino-user-88413", "transfer_ref": "dep-2026-09-01-000912", "currency": "sweeps", "amount_cents": 5000 }' ``` #### Response — value moved ```json { "kind": "settled", "transaction_id": "4d2e8a01-77bc-4d1e-9f30-1a2b3c4d5e6f", "player_id": "9c1f7d2a-4e6b-4f1a-9a3d-2b8c5e7f1042", "balance_after_cents": 5000, "applied": true } ``` #### Field rules | Field | Rule | | --- | --- | | `external_ref` | Player must already exist. Unknown → `404`, **not** auto-created. | | `transfer_ref` | Required, ≤ 200 chars. Your ledger entry ID and the idempotency key. **Unique across your ENTIRE casino** — not per player, per currency, per day, or per **direction**. One reference names one movement, so a charge and its correction need different references — a ledger row id reused for both is refused `409`. | | `currency` | **`"sweeps"` only**, both directions. Real money moves in `sweeps`; `gold` is play money — a gold credit could never be debited back out — and is refused `422`. Anything else → `422`. | | `amount_cents` | Positive integer. Zero or negative → `422`. | Idempotency only protects you if a retry sends the **same** `transfer_ref`. Generate it once, persist it with your ledger entry, and reuse it on every retry of that entry. A freshly generated ID per attempt defeats the mechanism entirely and will move money twice. > **Handle both non-obvious responses** > > **HTTP 200 with `"kind": "insufficient_balance"`** — the transfer did not happen. Only possible on debit, but handle the shape on both paths. > > **`"applied": false`** — this `transfer_ref` was already processed; the response describes the earlier movement and nothing moved now. This is a success. Reconcile against it; do not retry. > > ``` > // correct handling > if (res.status !== 200) throw new TransferError(res.status, body.error); > if (body.kind === "insufficient_balance") return { moved: false, shortfall: body.shortfall_cents }; > if (body.applied === false) return { moved: false, alreadyApplied: true, txId: body.transaction_id }; > return { moved: true, txId: body.transaction_id, balance: body.balance_after_cents }; > ``` > **⚠ `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.) > **On HTTP 500, retry with the same transfer_ref** > > A 500 leaves you unsure whether the transfer applied. Retrying with the same reference resolves it safely: you will either apply it once or receive `applied: false` telling you it was already done. **Never retry a wallet call under a new reference.** ## Launch the player into a game > **The launch is a top-level navigation. Nothing here can be iframed.** > > This is an architecture decision, so it belongs before you design your lobby rather than after. > > **The launch itself is always a top-level navigation.** Every ClashX app page — the exchange, the lobby — is served `Content-Security-Policy: frame-ancestors 'none'` and `X-Frame-Options: DENY`, so it can never be framed. Send the player to `launch_url` as a normal link, a redirect, or a new tab. > > **Game bundles under `/games/*` are a separate case and can be framed by arrangement.** They carry `frame-ancestors 'self'` plus any origins ClashX has configured, and no `X-Frame-Options`. The configured list is empty in production by default, so **your domain is not on it** and the browser will refuse your frame with nothing but a console message inside a frame that never rendered. > > If you want the game inside your own page, ask ClashX to add your origin before you build against it — it is applied at build time and is currently global rather than per-casino, so it is a deployment conversation rather than a setting. The reference's §15 is the client half once your origin is registered. Two hops: mint a token, redirect the browser to it. > ✅ **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. > > **ClashX hosts the exchange page.** You build no exchange page, no session handling and no error page — send the player to `launch_url` and stop. > > (This paragraph previously read "when it ships". It has shipped: `GET /launch?token=…` on the casino host answers today. If you already built an exchange against the earlier instruction, deleting it is safe — the redirect URL and the token contract are unchanged.) ``` https://{casino-host}/launch?token={launch_token} ``` > **`external_ref` is idempotent — do not build deduplication** > > Launching the same `external_ref` twice reaches the **same account**. Identity is stable, the credential is disposable. An integrator who does not know this builds a dedupe layer they do not need, and one that can only introduce bugs of its own. #### 4a — Mint at the moment of the click Call `POST /partner/v1/sessions` again (step 02) to get a fresh `launch_token`. It expires in **60 seconds**. Do not mint it when rendering a page the user may read for a minute first — mint it in the handler for the "Play" click. #### 4b — Exchange the token ```bash curl -s -X POST https://play.your-casino.example/api/v1/partner/launch \ -H "Content-Type: application/json" \ -d '{ "launch_token": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }' ``` This endpoint is **unauthenticated** — the caller is the player's browser, which holds nothing but the token. Your casino is identified by the `Host` header; the player by the token. The claim is atomic, so a token cannot be exchanged twice even under concurrent requests. #### Response ```json { "session_token": "pst_9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "expires_at": "2026-09-01T18:31:05Z", "player_id": "9c1f7d2a-4e6b-4f1a-9a3d-2b8c5e7f1042" } ``` #### 4c — Use the session The `pst_` token is a **4-hour** bearer credential for the ordinary player endpoints: ``` Authorization: Bearer pst_9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 Host: play.your-casino.example ``` > **There is no session refresh** > > After four hours the `pst_` session simply stops working. There is no refresh endpoint. Detect expiry and re-launch — mint a new token and repeat 4a–4c. Build this in now; discovering it as a four-hour production outage is the common failure. ## Receive settlement callbacks ClashX pushes settled matches to an HTTPS endpoint you register. One event type exists: `match.settled`. ``` POST https://your-endpoint.example/clashx/settlements content-type: application/json x-clashx-event: match.settled x-clashx-signature: t=1756738245,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 { "event_type": "match.settled", "tenant": "golden-palace", "matches": [ ... ] } ``` #### Verify the signature — this is the same scheme Stripe uses ``` signed_payload = t + "." + raw_request_body expected = hmac_sha256(signing_secret, signed_payload) // hex valid = constant_time_equals(expected, v1) ``` 1. Parse `t` and `v1` from `x-clashx-signature`. 2. Concatenate `t`, a literal `.`, and the **raw body bytes** — captured before any JSON parsing. Re-serialising the parsed object will produce a different byte string and the signature will not match. 3. HMAC-SHA256 with your secret, compare hex in constant time. 4. Reject if `t` is outside your freshness window. ClashX does not mandate a tolerance, so choose one: **300 seconds**. This is safe despite retries because the timestamp and signature are regenerated on *every delivery attempt*, not fixed when the match settled. 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. Any 2xx counts as delivered; anything else is a failure. The request times out at **10 seconds**. Persist the raw payload, return 200, and process from your own queue. Doing the work inline risks a timeout that marks a delivered event as failed. #### Retry policy | Behaviour | Value | | --- | --- | | Timeout | 10 seconds | | Max attempts | 8 | | Backoff | 30s, 60s, 120s, 240s, 480s, 960s, 1920s, then 3600s | | Batch size | up to 50 matches per delivery | | After 8 failures | **Dead-lettered — delivery stops permanently** | > **Dead-lettering is a full stop** > > A stream that exhausts its retries **stops** — it does not keep retrying quietly in the background. An operator must clear it before deliveries resume. This is why step 06 is not optional. > **Silent endpoint? You are missing a signing secret** > > A registered endpoint with no stored signing secret receives **nothing** — deliveries are withheld rather than sent unsigned or signed with a fallback key. If you registered an endpoint and see no traffic, ask ClashX to re-save it to provision a secret. ## Reconcile by polling Poll the match feed on a schedule **in addition to** receiving callbacks. Push and pull run off the same underlying query and cannot disagree, so polling is the backstop that makes a dead-lettered stream survivable. ```bash curl -s "https://api.clashx.example/partner/v1/matches?since=2026-09-01T13:00:00Z&limit=200" \ -H "X-Api-Key: wlk_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" ``` #### Response ```json { "matches": [ { "session_id": "b31c4a77-1f2e-4d8b-93a0-5c6d7e8f9a01", "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": "b31c4a77-1f2e-4d8b-93a0-5c6d7e8f9a01", "player_id": "9c1f7d2a-4e6b-4f1a-9a3d-2b8c5e7f1042", "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" } ``` Store `next_since` and pass it as your next `since` — **except when it is `null`, which is what an empty page returns.** Keep your existing cursor then; storing the null drops `since` from the next request and replays the feed from the beginning, re-ingesting every settlement you have already seen. **Never derive the cursor from your own clock** — a match that started before your cursor and settled after it would be skipped forever, and nothing would report the gap. The cursor is settlement time for exactly this reason. When `truncated: true`, more rows were waiting than your limit — fetch again immediately rather than waiting for the next tick. `limit` defaults to 50, maximum 200. > **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 are in this feed** > > There is no money filter on it. Match-based practice settles through the same path as paid play, so free games against bots appear here with `fiat: false` and `entry_fee_cents: 0` — and because their stakes are zero, the money identity still closes and nothing flags them. Only your counts are wrong. > > Filter on `fiat` unless you mean to count practice. Note also that `participants` excludes bots, so a practice match typically shows one participant and reads like a one-player match. > **Reading the results** > > `participants` is nested inside its match, so you never join two arrays. `external_ref` is `null` for players who did not come through your integration. An empty page means "nothing settled since your cursor", not "nothing happened" — matches in progress are absent until they settle. ## Debit the wallet Identical shape to credit. Moves value out, back to your system for a real withdrawal — in `sweeps` only. A `gold` debit is refused `422`: gold is play money and does not leave the casino. ```bash curl -s -X POST https://api.clashx.example/partner/v1/wallet/debit \ -H "X-Api-Key: wlk_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" \ -H "Content-Type: application/json" \ -d '{ "external_ref": "casino-user-88413", "transfer_ref": "wd-2026-09-01-000418", "currency": "sweeps", "amount_cents": 3700 }' ``` > **This is where trap 1 actually bites** > > A player who does not hold the amount produces **HTTP 200** with `"kind": "insufficient_balance"`. A player cannot be overdrawn into debt. Code that reads only the status code will mark a withdrawal complete and pay out money that was never debited. Order matters. Debit ClashX first; only on `"kind": "settled"` with `applied: true` (or a confirmed `applied: false` for a known retry) should you release funds on your side. Never pay out first and reconcile later. ## Handle 429 Counted **per API key**, per sixty seconds. Defaults: | Class | Endpoints | Default | | --- | --- | --- | | Writes | `/sessions`, `/wallet/credit`, `/wallet/debit` | 6,000 / min | | Reads | `/matches`, `/detection/*` | 600 / min | | Ping | `/ping` | 60 / min | > **⚠ Built and verified, but not released yet** > > The numbers are final; the date is not set here. Respect them now and nothing changes when they take effect — but **do not expect a `429` yet, and do not write a test that requires one.** **Poll no faster than once per second.** That is 60/min, ten times under the read class. 600/min is exactly 10 requests per second, so a 100 ms loop sits on the limit and anything tighter is refused. **On a 429, retry a wallet call with the SAME `transfer_ref`** — exactly as for a 500. A refused request moved no money, and idempotency makes the retry safe either way. Never retry under a new reference. You will not reach these with real traffic — 500 concurrent players is about 51 req/min, and 50,000 concurrent is about 5,000 req/min. They catch a retry loop with no backoff. **Above roughly 5,000 concurrent your numbers are set when you onboard**, per key and per class; the defaults are for a new integration, not a cap on growth. **Use a separate key per integration.** The window is per key, so a runaway reporting job cannot exhaust the allowance your players are served through. ```json { "error": "rate limit exceeded for this API key: more than 600 requests per minute. …" } ``` ⚠ Branch on the **429 status code**, never on the message — there is no machine-readable code (same rule as every other error here), and the number in the sentence is your provisioned limit rather than a constant. ## Verify your integration actually works Each item proves the step *happened*, rather than that a call returned 200. Work through them in order. ### Authentication - `/ping` returns your expected `tenant` slug and every scope you need. - A deliberately wrong key returns `401`, not a 500 or a hang. ### Player resolution - Calling `/sessions` twice with the same `external_ref` returns the **same `player_id`**. If it differs, your `external_ref` is not stable — fix it before going further. - Two different `external_ref` values return two different `player_id` values. ### Wallet - After a credit, `balance_after_cents` equals your expected running total — not just a 200. - Replaying the exact same `transfer_ref` returns `applied: false` and **the same `transaction_id`**, and the balance does not change. - Debiting more than the balance returns HTTP **200** with `kind: "insufficient_balance"`, and your code takes the rejection branch. Assert the branch was taken, not just that no exception was thrown. - A debit for an unknown `external_ref` returns `404` and does **not** create a player — verify by calling `/sessions` afterwards and confirming a new `player_id` is minted at that point. ### Launch - A launch token exchanges successfully and returns a `pst_` session. - Exchanging the **same token twice** fails the second time — proves single-use. - A token held for 61+ seconds fails — proves expiry, and confirms you are reading the TTL correctly. - The `pst_` session authenticates `GET /api/v1/me` — called on **your casino hostname** with `Authorization: Bearer pst_…` — and the player it returns matches the `player_id` from step 02. ### Callbacks - A delivery arrives with both `x-clashx-event` and `x-clashx-signature` headers. - Your verifier **accepts** a genuine delivery. - Your verifier **rejects** a payload whose body you have altered by one byte. If it accepts, you are verifying re-serialised JSON rather than the raw body. - Your verifier rejects a delivery whose `t` you have moved outside your window. - Your endpoint returns 2xx in well under 10 seconds under realistic load. ### Reconciliation - A match played end to end appears in `GET /matches` with your `external_ref` on the right participant. - `stake_cents`, `payout_cents`, and `refund_cents` reconcile against the wallet movements you observed. All three are **independent** — a refund is not a payout, and settlement accumulates them separately. - Polling with the returned `next_since` does not return the same match twice. - The same match is seen by **both** the callback and the poll, and your system stores it once. > **The one end-to-end test worth automating** > > Credit a player, launch them, play a match to settlement, receive the callback, poll the feed, debit the winnings. Assert the wallet arithmetic closes: `credited − staked + won + refunded − debited` equals the final balance — **once per currency**, since a wallet holds two independent balances (`gold` and `sweeps`) and `balance_after_cents` is only the one your request named. **`refunded` is the term integrations miss** — `refund_cents` is a separate field from `payout_cents`, not a component of it, and it is non-zero whenever a match is abandoned. Omit it and every abandoned match reports a false discrepancy. Every trap in this document shows up as a discrepancy in that sum, which is why it is worth more than any individual endpoint test. ## Getting a seated player back into their match ⚠ **Re-POST `/queue` with the identical body.** This is the reconnect path and it is the single most useful thing in this document. When a roster fills, only the request that COMPLETED it receives `ws` coordinates in its response. Everyone else is seated, staked, and holding a `queued` reply. They get their coordinates by polling the same route again: ``` POST /api/v1/queue { "module_key": "skill", "game_key": "pickle_punisher", "currency": "gold", "entry_fee_cents": 500 } // IDENTICAL to the join -> { "kind": "matched", "reconnected": true, "session_id": "...", "ws": { "url": ..., "token": ..., "match_id": ..., "session_id": ... }, "launch_url": "..." } ``` The same call recovers a player whose browser refreshed, crashed, or lost the connection mid-match. **No money moves** — the seat was bought at match formation, and re-polling does not stake again. ### Why this is easy to get wrong A `queued` response reads exactly like *"you are not in a match"*. It is not: it means "no seat yet". There is no separate reconnect endpoint to discover, and nothing about `queued` suggests polling is how you progress — so an integration that treats it as terminal concludes its players are stranded with money staked. That has happened, to an experienced integrator, on this API. ⚠ **The body must match.** The lookup is keyed on module_key + game_key + player. A re-poll with a different or missing `module_key` misses the seat and falls through to matchmaking, which reads as a brand-new `queued` — the same symptom, with the seat sitting there the whole time. ⚠ **Each seat's `ws.token` is its own.** The JWT carries `user_id`. Reusing one seat's token for another fails; if you are driving several players, assert `user_id` matches the seat before connecting. ### Two timers worth designing around - The **launch token** in `/partner/v1/sessions` lives ~60 seconds and is single-use. Mint it at redirect time, not at page-render time — no email link, no confirmation step in between. - A match that forms and that nobody joins is eventually swept and refunded. The window is generous by default, but it is why the reconnect poll should start immediately rather than after a delay. ## Reading rake: it is on the MATCH, not the seats `rake_cents` on a match object is the real figure. **Every participant's `rake_cents` is 0 for a pot game**, and that is correct rather than missing: the rake is booked against the house, not against any seat, so no player bears a share of it. Read it from the match. Summing it across participants gives zero. ⚠ **Rake never touches a player's wallet, so it does not belong in a balance reconciliation.** It comes out of the pot before payout. In the worked example below, the winner staked 500 and received 2775; the 225 was taken from the pot, not from anyone's balance. ``` 6 seats x 500 = 3000 pot payout 2775 -> the winner rake 225 -> the house (750 bps) 3000 - 2775 - 0 - 225 = 0 ``` Revenue reconciliation and balance reconciliation are different books that happen to arrive in the same feed row. A player-balance identity that includes rake will not close. ## When real-money play is refused: `compliance_block` Everything above can be built, tested and working, and real-money play can still refuse. This section exists because that refusal was previously documented nowhere — an integrating casino reached "fiat does not work" with no route to a diagnosis. ### What you will see `POST /api/v1/queue` — the first money-moving call — returns **403**: ```json { "error": "compliance_block: kyc" } ``` The word after the colon names the check that refused. Practice play, session creation, the launch exchange and wallet credit all keep working, so the failure looks specific to real-money matchmaking. It is. ### What it means Your casino has a compliance gate, and one of its checks could not be satisfied. The checks you may see named: | value | what it means | who can fix it | | --- | --- | --- | | `kyc` | the player is not identity-verified, or your casino has no KYC rail configured | ClashX + your KYC vendor | | `age` | no verified date of birth is on file for this player | your KYC vendor, via ClashX | | `sanctions` | sanctions screening could not be performed — an *unscreened* player is never cleared | ClashX + your screening vendor | | `geo` | the player's jurisdiction could not be established or is not permitted | ClashX + your geo vendor | | `self_exclusion`, `account_ban`, `cash_ineligible` | this specific player is barred | you, in your portal | ⚠ **The first four are not bugs in your integration and you cannot fix them from the partner API.** They mean a compliance rail your casino depends on is not configured or not operational. There is no request field, header or parameter that satisfies them. ### ⚠ Date of birth cannot be supplied by you `POST /partner/v1/sessions` currently **accepts and silently ignores** `date_of_birth`, `dob` and `age` in the request body. It returns 200 and the player record is unchanged. Do not rely on those fields. A date of birth is only ever accepted from your KYC vendor, attested and encrypted at rest — supplying one yourself would be an unverified claim about a person's age, which is the thing the check exists to prevent. That the fields are accepted at all is a defect on our side, tracked; treat them as absent. ### What to ask ClashX for Quote the exact `compliance_block:` value you received, and ask: 1. **Which rail is behind this check, and is it configured and operational for my casino?** (`kyc` and `sanctions` in particular are per-casino rails that must be granted before any real-money play is possible.) 2. **If it is configured, is the vendor actually reachable?** A rail that exists but cannot answer refuses exactly like one that is missing — for sanctions, deliberately so. 3. **Is this check blocked platform-side?** Geo screening in particular may be unavailable for reasons that have nothing to do with your configuration. You can see which rails your casino has under **Payments & Identity → Credentials** in your portal. A rail listed as "not set up" will refuse every gated action for every player until it is configured. ## Deliberately out of scope here This brief covers the money-and-play path end to end. Two capabilities exist on the API and are **not** covered above — build them only once the path above works. - **Detection** — `GET /partner/v1/detection/flags` and `/detection/accounts`, scope `detection:read`. Collusion review and enforcement are yours in this model. Both responses carry a `coverage` object beside the list. **`scanned: false` means no scan has run** — an empty list then means "nothing was examined", not "nothing was found". **`scanned: true` means a scan ran, not that every detector ran**: some decline at small scale, and the boolean cannot tell that apart from a clean result. `coverage.summary` names any that declined. Never render an empty list as "all clear" without reading it. - **The hosted white-label model** — where ClashX runs the entire player experience on your domain and you write no code at all. If that is what you were sold, this brief is the wrong document.