Paste this into Claude Code, Cursor, or your assistant of choice, it has everything needed to build the integration.

⭳ Download .md

Or point it at the raw Markdown: /docs/partner-api.md

ClashX Partner API · version 2026-09-01 · Download PDF, the web version is canonical.

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 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

  1. 02. The boundary

  1. 03. Authentication

  1. 04. Five things that break integrations silently

  1. 05. Players and the launch flow

  1. 06. The transfer wallet

  1. 07. The match feed

  1. 08. Detection

  1. 09. Settlement callbacks

  1. 10. Error handling

  1. 11. Rate limits

  1. 12. After the launch — what the session is for

  1. 13. Two facts a reconciliation depends on

  1. 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.

ModelWho runs the player experienceWhat you build
Hosted white-labelClashX, on your domain, with your brandingNo code. You configure a Clerk application, payment and identity credentials, branding, and published policies.
Partner APIYou. 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.

ScopeGrants
sessions:writeCreate players and mint launch tokens
wallet:writeCredit and debit the transfer wallet
matches:readRead settled match results and economics
detection:readRead collusion flags, evidence, and the account review queue
crm:readRead 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:readRead rewards-link changes (GET /rewards/links) and the play of confirmed links (GET /rewards/activity), by membership number and your own player reference
rewards:writeConfirm 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

ConditionStatusBody
Header absent401{"error":"missing X-Api-Key header"}
Key unknown401{"error":"invalid api key"}
Key revoked401{"error":"api key is revoked"}
Casino suspended or churned (not onboarding)403{"error":"tenant \"golden-palace\" is suspended"}
Valid key, missing scope403Names 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

{
  "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/v1ClashX-hosted player site
onboardingFully transactional — sessions, wallet, matches, callbacksClosed. Players cannot sign in
activeFully transactionalOpen
closingFully transactional, so you can reconcileOpen, for players to withdraw
suspendedRefused — 403 on every routeClosed
churnedRefused — 403 on every routeClosed

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:

{
  "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:

{ "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

{
  "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

{
  "player_id": "9c1f7d2a-4e6b-4f1a-9a3d-2b8c5e7f1042",
  "launch_token": "<opaque, returned once>",
  "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

POST /api/v1/partner/launch
Host: play.your-casino.example
Content-Type: application/json

{ "launch_token": "<the token>" }

Response

{
  "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

{
  "external_ref": "casino-user-88413",
  "transfer_ref": "your-ledger-entry-000912",
  "currency": "sweeps",
  "amount_cents": 5000
}
FieldRule
external_refThe player must already exist. An unknown value is refused, not created.
transfer_refRequired, ≤ 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_centsPositive integer. Zero or negative is 422.

Response — success

{
  "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)

{
  "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.

{
  "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:

casewhat happened
failed startthe match never got under way
forfeita player forfeited their entry
drawno winner, so every stake returns
orphanthe 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

{
  "coverage": {
    "scanned": true,
    "summary": "<human description of what the last scan covered>",
    "last_scan": { ... }
  },
  "flags": [ ... ],
  "truncated": false,
  "next_since": "2026-09-01T12:00:00Z"
}

GET /partner/v1/detection/accounts

Scope: detection:read

{
  "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.

  1. Concatenate t, a literal ., and the raw body bytes — before any JSON parse or re-serialisation.

  1. HMAC-SHA256 with your secret; compare hex, constant-time.

  1. 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

BehaviourValue
SuccessAny 2xx. Anything else is a failure.
Request timeout10 seconds
Maximum attempts8
Backoff30s, 60s, 120s, 240s, 480s, 960s, 1920s, then capped at 3600s
Batch sizeup to 50 matches per delivery
After 8 failuresDead-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:

{ "error": "amount_cents must be positive" }
StatusMeaningWhat to do
401Key missing, unknown, or revokedFix the key. Retrying will not help.
403Authenticated but not permitted — missing scope, or the casino is suspended/churned. onboarding is permittedRequest the scope, or check account status. Retrying will not help.
404Player not found, or no wallet for them at this casinoCreate via POST /sessions first. A money call never creates an account.
409transfer_ref already used for a DIFFERENT movementTerminal. Send this movement under a NEW transfer_ref; retrying cannot clear it.
422Body 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.
500Server-side failureRetry 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.

ClassEndpointsDefault
WritesPOST /sessions, POST /wallet/credit, POST /wallet/debit6,000 / min
ReadsGET /matches, GET /detection/*600 / min
PingGET /ping60 / 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:

{
  "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:

{ "kind": "matched", "session_id": "…", "reconnected": true, "ws": { … } }
{ "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:

// 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:

{
  "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:

gamewhat Play Again doeswhat it costs
practice (fiat: false)joins a fresh practice match against botsnothing — no debit, no payout
pot / match (the paid matchmaking games)re-enters matchmaking at the same stakea full fresh entry fee, debited when the next match forms
open-server / arenabuys another life in the arena the player is already ina 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 buildClashX renders
lobby, catalog, wallet UI, queue UIthe 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:

pathwhat it is
Operator grantyour 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 movementaccrual, 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?<params>

/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

paramtypevalue
wsstringthe WebSocket URL — ws.url from the session response
tokenstringws.token
match_idstringws.match_id
session_idstringws.session_id
player_idstringyour player's ClashX id
modestringpractice 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:

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.

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:

shapegamesnotes
Standardtumble, open_up, golazo, memory_lane, turbo_trackthe table above
Different namespickle_punisherwsUrl not ws, jwt not token, and camelCase: matchId, playerId, sessionId, plus playerName, currencyCode (AC practice / USD compete) and entryFee
WebAssemblygridlock, knockout, slither, tankNow 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.

{ "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.

fiatwhat it is
truea real-money match — stakes, payouts and rake
falsepractice — 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 aredonever
crediting (funding play)debit your own ledger first, then wallet/creditcredit ClashX first and debit yourself after
debiting (cashing out)wallet/debit first, then release on your siderelease 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:

<iframe src="…" allow="autoplay; fullscreen; pointer-lock"></iframe>

⚠ 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.

CategoryReleases
(always)player_id (ours, pseudonymous), external_ref (yours), status, created_at
activitylast_activity_at, level, and GET /crm/events
groupsgroups: the built-in groups the player is in (active, dormant, high_value)
contactemail, 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).

{
  "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.

{
  "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.

{
  "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.

{ "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.