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.
ClashX supports two arrangements, and they consume almost disjoint surfaces. Establish which one you are building before reading further.
Model
Who runs the player experience
What you build
Hosted white-label
ClashX, on your domain, with your branding
No code. You configure a Clerk application, payment and identity credentials, branding, and published policies.
Partner API
You. ClashX supplies games and settlement behind your front end.
This document.
In the partner model your players never see a ClashX login. They exist in your system, you name them by your own identifier, and you hand them across into a game session for as long as they are playing.
02. The boundary
BOUNDARY
ClashX owns
The wallet balance and the ledger
Settlement — the sole authority that moves money
Game servers, matchmaking, and the in-game experience
Your tenant database, isolated from every other casino
Collusion detection and match records
BCARD
You own
Player accounts and authentication
Card processing, deposits, and withdrawals in your own system
The decision to move value in or out of a player's game wallet
Acting on detection flags — review and enforcement are yours in both models
Receiving and verifying settlement callbacks
ClashX makes no unilateral corrections to a balance. There is no bonus, no promotional credit and no support adjustment on this side of the boundary — nothing we originate moves a player's balance. Two things other than a match settlement or your own wallet/credit / wallet/debitcan move it, and both are yours: an operator grant from your casino admin console, and an affiliate movement if that player enrols. Neither appears on any /partner/v1 endpoint, so reconcile as transfers + matches and read §13 before you build the check.
The transfer wallet, and what it is not
Value moves by explicit transfer. You credit a player's ClashX wallet before they play and debit it afterwards; the balance lives with ClashX for as long as it is there, and stakes and payouts are settled internally.
⚠ Real money moves in sweeps, in both directions.gold is play money (§18): a gold credit would sell your player something that can never be debited back out, so both a gold credit and a gold debit are refused 422.
Why it is built this way
A seamless wallet — where ClashX calls your system for balance on every wager — is deliberately not built. Keeping the balance on this side means settlement remains the only code path that moves money, so every conservation invariant holds without a network call in the middle of a wager. It is a different product, not a larger version of this one.
03. Authentication
Every request carries an API key in the X-Api-Key header. Keys are prefixed wlk_ and are shown to you exactly once at issue; ClashX stores only a SHA-256 hash, so a lost key is replaced, never recovered.
X-Api-Key: wlk_a1b2c3d4e5f6...
The key is the tenant claim. The partner routes do not resolve your casino from a hostname — everything a request touches is determined by the key it was made with, and nothing in the request body can redirect it to another casino's data.
Two families of endpoint, two ways of naming your casino
/partner/v1/* is identified by the API key. No hostname resolution runs on these routes; call them on whatever base URL you were given.
/api/v1/* is identified by the Host header. The launch exchange and every player-facing route resolve your casino from the hostname, so they must be called on your casino's own host. A request with the wrong Host reaches a different casino, or none.
Scopes
Each key carries a set of scopes, checked before the request reaches the handler.
Scope
Grants
sessions:write
Create players and mint launch tokens
wallet:write
Credit and debit the transfer wallet
matches:read
Read settled match results and economics
detection:read
Read collusion flags, evidence, and the account review queue
crm:read
Read player records and player events for your CRM (GET /crm/players, GET /crm/events), exactly as your casino's CRM setting releases them; refused while that setting is off
rewards:read
Read rewards-link changes (GET /rewards/links) and the play of confirmed links (GET /rewards/activity), by membership number and your own player reference
rewards:write
Confirm or reject a player's rewards link (POST /rewards/links/{id}/confirm, /reject) and send a reward in (POST /rewards/grants): a promo credit or a free entry, through your bonus rules; at or above your dual-control threshold a staff member approves it
A key with no scopes can still ping
GET /ping requires no scope, so a freshly issued key can always prove it authenticates before any capability is granted to it. Every other route requires exactly one scope. An empty scope list therefore means ping-only — not full access. If you were issued a key before scopes existed, that is its behaviour, and it is correct: a key that was never granted a capability does not have one.
Scopes are separated on purpose. A back-office reporting integration that reads matches has no business minting player credentials, so matches:read cannot do what sessions:write does.
Refusals
Condition
Status
Body
Header absent
401
{"error":"missing X-Api-Key header"}
Key unknown
401
{"error":"invalid api key"}
Key revoked
401
{"error":"api key is revoked"}
Casino suspended or churned (notonboarding)
403
{"error":"tenant \"golden-palace\" is suspended"}
Valid key, missing scope
403
Names the required scope
403 rather than 401 for a missing scope is deliberate: you are authenticated, and retrying with the same key will never succeed. Treat 401 as "fix the key" and 403 as "the key is real but may not do this".
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.
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:
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.
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_refmust 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.
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.
The player must already exist. An unknown value is refused, not created.
transfer_ref
Required, ≤ 200 chars. Your identifier for this movement, and the idempotency key. Must be unique across your ENTIRE casino — not per player, per currency, per day, or per direction. One reference names one movement: a credit and the debit that corrects it need different references. See the note below.
currency
"sweeps" only, both directions. "gold" is play money and is refused 422; anything else is 422.
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).
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.
settled_at is when the platform recorded the settlement. It is this feed's cursor and its ordering key, which is why it cannot mean anything else — an ordering key has to be the key rows become visible on, or paging skips rows that appear behind your cursor.
engine_ended_at is when the game actually finished, reported by the game server.
On a healthy match they are milliseconds apart. They diverge on exactly the matches a casino investigates — one ended by something other than being played out. A session reaped by the platform's stuck-sweep once recorded a settlement 2m38s before the match really ended, so a timeline built from settled_at alone put the match in the wrong place with nothing saying so. Read them together with settlement_reason; that is the same population.
engine_ended_at is null when the game server never reported a terminal status for that session — an honest gap rather than a fabricated instant. Do not substitute settled_at for it; that is the substitution this field exists to stop.
A 500 is not the only permanent failure, and 422/404 here are terminal
A wallet call refuses with 422 when the player's wallet is not active (frozen, closed), and 404 when there is no wallet for that player at this casino — a money call never creates an account. Both are permanent: retrying cannot clear either.
These used to be 500s, which our own error table tells you to retry with backoff. A partner following that instruction retried a frozen wallet forever, the queue never drained, and a genuine outage was masked by the same signal. If you built that retry loop, it is now safe: these arrive as 4xx and should be escalated, not retried.
Free practice matches settle into this feed
The feed selects every match with a settlement time. There is no money filter, and match-based practice creates real sessions that settle through the ordinary path — so free games against bots arrive here alongside paid ones, carrying fiat: false and entry_fee_cents: 0.
fiat is the only tell. Filter on it unless you intend to count practice, because the money identity still closes either way: the stakes are zero, so nothing is out of balance and nothing flags the mistake. Only your counts are wrong — matches played, activity reports, and anything paid per match such as revenue share, affiliate volume or loyalty points.
A second trap rides along: participants omits bots entirely, so a practice match usually appears with a single participant and reads as a one-player match rather than as practice.
This feed is not a ledger
It reports the economics of matches — and it is complete for matches. A player's balance can also change outside it: an operator crediting them from the casino's own admin console, or affiliate movements if they enrol. Neither appears here or anywhere else on this API; there is no ledger or transaction endpoint on /partner/v1.
Same species as the coverage note: an empty page means nothing settled, not nothing happened.
refund_cents is a third economic term, not part of payout_cents
A participant carries stake_cents, payout_cents and refund_cents, and settlement accumulates all three independently. A refund is its own settlement kind, not a payout and not a reversal.
refund_cents is non-zero whenever a buy-in is returned in full, and there are four such cases — not one:
case
what happened
failed start
the match never got under way
forfeit
a player forfeited their entry
draw
no winner, so every stake returns
orphan
the match was abandoned
A draw is the case integrations miss, because it is not an abandonment and the match completed normally. The platform rule is absolute: a buy-in is either settled to a winner or refunded — never forfeited to the house.
Your reconciliation identity therefore has five terms, not four:
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"
}
Every detection list ships coverage beside it, and an empty flags array has three possible meanings, not two:
every detector ran and found nothing;
some detectors declined at this casino's scale — they need more data than you have yet;
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.
Concatenate t, a literal ., and the raw body bytes — before any JSON parse or re-serialisation.
HMAC-SHA256 with your secret; compare hex, constant-time.
Reject if t is outside your freshness window.
The timestamp is inside the signed material, so a captured delivery cannot be replayed later against a consumer that checks freshness.
Choosing a freshness window
ClashX does not mandate one — it is your policy. 300 seconds is the right default, and it is safe despite retries: the timestamp and signature are regenerated on every attempt, not fixed when the event first occurred. A retry two hours later still arrives with a current t. Do not widen the window to accommodate retries; you would only be accepting older replays.
Retries and dead-lettering
Behaviour
Value
Success
Any 2xx. Anything else is a failure.
Request timeout
10 seconds
Maximum attempts
8
Backoff
30s, 60s, 120s, 240s, 480s, 960s, 1920s, then capped at 3600s
Batch size
up to 50 matches per delivery
After 8 failures
Dead-lettered — delivery stops permanently until a human clears it.
Dead-lettering is a full stop, not a longer wait
A stream that exhausts its retries stops. It does not keep retrying hourly in the background. If your endpoint is down for a few hours, the stream must be cleared by an operator before deliveries resume.
Two consequences. Return 2xx as soon as you have durably accepted the payload — do your processing after. And reconcile with GET /matches on a schedule regardless: the pull feed and the push stream run off the same query and cannot disagree, so polling is the backstop that makes a stopped stream survivable.
If no signing secret is stored, deliveries are withheld
A casino with a registered endpoint but no stored secret gets no deliveries at all — they are skipped, not sent unsigned and not signed with a fallback key. Signing with a key you were never given produces deliveries you cannot verify and must reject, which is indistinguishable from an attacker. If your endpoint is registered and silent, re-save it to provision a secret.
The watermark advances only on success
A failed delivery does not move the cursor. Advancing on failure would mean the retry skips exactly the events that failed, silently. The watermark tracks the last delivered event, never the current time.
10. Error handling
Every failure has the same body shape:
{ "error": "amount_cents must be positive" }
Status
Meaning
What to do
401
Key missing, unknown, or revoked
Fix the key. Retrying will not help.
403
Authenticated but not permitted — missing scope, or the casino is suspended/churned. onboarding is permitted
Request the scope, or check account status. Retrying will not help.
404
Player not found, or no wallet for them at this casino
Create via POST /sessions first. A money call never creates an account.
409
transfer_ref already used for a DIFFERENT movement
Terminal. Send this movement under a NEW transfer_ref; retrying cannot clear it.
422
Body failed validation — or the player's wallet is not active (frozen, closed)
Fix the request. A non-active wallet is permanent: escalate, do not retry.
500
Server-side failure
Retry with backoff, reusing the same transfer_ref.
There is no error code field — plan around it
Branch on HTTP status and, for endpoints that have one, the response kind. Those are the stable contract. The error string is written for a human reading a log; it may be reworded at any time without the status changing. If you must match on it, isolate that matching in one place so it is cheap to revisit.
⚠ transfer_ref must be unique across your ENTIRE casino
The idempotency key is the transfer_refalone — there is no player, currency or amount in it. Its scope is the whole casino, not the player.
That matters because the obvious ways to mint one collide: a per-player sequence, a per-day sequence, a deposit id from a table that resets, or the same reference for the gold and sweeps legs of one deposit. Every example in this document (dep-2026-09-01-000912) reads like a per-ledger sequence, which is exactly the shape that collides.
A reference reused for a different movement — different player, currency, amount or direction — is refused 409. It is terminal: retrying cannot clear it, and only a new reference will work. A genuine retry of the same movement is unaffected and still returns applied: false with the original transaction.
⚠ A if (!response.ok) retry() loop spins forever on this. 409 is the one refusal on this endpoint that a generic retry rule cannot survive, because it is permanent and identical on every attempt. Branch on it explicitly.
(Before this was refused, a collision returned 200 / applied: false carrying the FIRST movement's transaction while echoing the second player's id — a real deposit was silently never credited and both sides' books still balanced. If you integrated against that behaviour, the 409 is the fix, not a regression.)
A 500 on a wallet call is the case that matters most: you do not know whether the transfer applied. Retry with the same transfer_ref — idempotency will either apply it once or tell you it was already applied via applied: false. Never retry a wallet call under a new reference.
11. Rate limits
These are enforced now.require_rate_limit is layered on every /partner/v1 route group and refuses with 429. Write the retry path; you will meet it.
Every /partner/v1 call is counted per API key, in a sixty-second window.
Class
Endpoints
Default
Writes
POST /sessions, POST /wallet/credit, POST /wallet/debit
6,000 / min
Reads
GET /matches, GET /detection/*
600 / min
Ping
GET /ping
60 / min
These are the numbers for a new integration, not a ceiling on your growth
A casino with 500 concurrent players generates roughly 51 requests a minute; 5,000 concurrent is about 500/min; 50,000 concurrent is about 5,000/min. The defaults sit an order of magnitude above ordinary traffic on purpose — they exist to catch a retry loop with no backoff, which runs thousands of requests a minute and is the only realistic way to reach them.
Above roughly 5,000 concurrent players, your limits are provisioned when you onboard — set per key and per class for your account, not left at the default. This is the primary mechanism, not an exception process. A large operator meeting a refusal nobody chose for them is the outcome this design exists to prevent, so the conversation happens at onboarding rather than at an incident.
Per key — so use a separate key per integration
The window is counted against the key, not the tenant. A back-office reporting job and your player-facing traffic on two different keys get two separate allowances, and a runaway report cannot exhaust the budget your players are served through.
That is a real isolation benefit and worth taking deliberately: issue one key per integration rather than sharing one across everything.
Sizing your polling loop
The read class at 600/min is exactly 10 requests per second, chosen so a 100 ms polling loop sits precisely on the limit and anything tighter is refused. A 1-second poll runs at 60/min — ten times under. Poll no faster than once per second and the read class will not trouble you; if you need faster, that is an onboarding conversation, not a retry loop.
The refusal
429 Too Many Requests, with the same body shape as every other error on this API:
{
"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:
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 exactentry_fee_cents — two players match only if module_key, game_key, currencyand 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:
The listing and the refusal read the same source, so they cannot disagree: a fee in stake_tiers_cents is a fee /queue accepts. Note the two games above offer different stakes — read the tiers per game, not once per casino.
DELETE /api/v1/queue leaves the queue. It never involves money.
Play Again — what it costs, per settlement model
Play Again is a ClashX control, not an endpoint. It lives on the result screen of the game ClashX renders, so it is your player's tap and not your API call. There is no way to disable it, and no field on POST /partner/v1/sessions that suppresses it. What it does depends on which settlement model the game uses, and the three cases are genuinely different:
game
what Play Again does
what it costs
practice (fiat: false)
joins a fresh practice match against bots
nothing — no debit, no payout
pot / match (the paid matchmaking games)
re-enters matchmaking at the same stake
a full fresh entry fee, debited when the next match forms
open-server / arena
buys another life in the arena the player is already in
a full fresh entry fee, added to the same seat's total stake
⚠ In an arena, the stake accumulates on one seat and the rake threshold does not. Rake there is charged on cash-out above one life's entry fee, not above the total the player has staked. A player who buys three lives at 100¢ (300¢ staked) and cashes out 250¢ is down 50¢ overall and still pays rake, because 250¢ is above the 100¢ single-life threshold. This is deliberate and it is the published behaviour — but it means "did this player profit?" and "was this player raked?" are different questions in an arena, and you cannot infer either from the other.
Read rake_cents from the match object rather than deriving it (§19). For a player's true position across repeated entries, sum the ledger rather than reasoning from a single match: the second and third lives are separate entries against the same seat.
Where the player actually goes, and what you may build yourself
Two options, and the boundary between them matters more than it sounds.
The hosted path. ClashX hosts the exchange and forwards the player into the lobby once the session is established, and you build nothing after the redirect. Deployed — see §14.
Your own lobby. You may build your own lobby, catalog and wallet UI on top of /api/v1/me, /api/v1/queue and the wallet endpoints. The pst_ session authenticates all of them.
⚠ You cannot build your own GAME client, and "your own front end" is the phrase that misleads. The line falls at the match itself:
you can build
ClashX renders
lobby, catalog, wallet UI, queue UI
the game itself, once a match starts
POST /api/v1/queue returns {"kind":"matched", session_id, ws}, and that WebSocket carries bincode-encoded frames — a Rust-native binary format with no cross-language specification. Inside the envelope, Snapshot is an opaque byte string holding a per-game render state, and the input channel is likewise a game-specific serialized struct. There is no published wire format, and the field layout of ClashX's Rust types is the contract.
So implementing a client for one game means reimplementing bincode, that game's exact render-state struct, and its input encoding — then keeping all three in lockstep with a protocol_version that moves when ClashX changes a field. That is reimplementing the game, once per game, against a format that is not specified for you.
Scope on this sentence: you build your own lobby around ClashX-rendered games, not your own front end. If self-hosted game rendering is something you need, raise it with ClashX as a roadmap question rather than an integration task.
The return path — what does not exist
Asked early by every casino, and the honest answer is that most of it is unbuilt. Stated here so it is not discovered during a demo.
There is no return_url, and no way to say where a player goes when they are done
POST /partner/v1/sessions accepts external_ref, display_name and email. It accepts no return, redirect or callback URL, and no such field exists anywhere in the launch flow. A casino cannot nominate a page to send the player back to.
On success, the exchange page replaces itself with the lobby at / on your casino host. It uses location.replace, so the spent launch URL is removed from history — a player pressing back reaches whatever page sent them, not a dead link.
On failure — expired, unknown, or already used, which are deliberately indistinguishable — the player sees one sentence naming your casino and telling them to start play again. There is no retry button (the token is spent, so retrying always fails), no sign-in form (a partner player has no ClashX credentials, so it is a dead end that becomes your support ticket), and ⚠ no link back to your site. The only route back is the browser's back button or typing your address.
If you need a return path, raise it — do not design around one
Today the practical answer is the back button. An integrator will assume better, and a casino will ask on day one, so it is written here rather than left to a support thread. The lever you do control is making failures rare: mint at click time, not at page-render time. Sixty seconds is generous for a click and tight for a render.
13. Two facts a reconciliation depends on
Both are verified against the implementation, and both are the kind of thing an API reference usually leaves you to discover.
A balance can move without a /partner/v1 call — know the two paths
⚠ An earlier version of this document said ClashX never moves a partner balance on its own. That was wrong. Two paths move a balance with no partner endpoint, and an integration that does not know about them cannot explain what it is seeing.
Two movements are real and appear on no partner endpoint:
path
what it is
Operator grant
your own staff crediting a player from the casino admin console (POST /api/v1/admin/players/{id}/grant, owner-only) — it accepts any player, with no partner exclusion
Affiliate movement
accrual, redemption or clawback, if that player enrols — their pst_ session can reach it
Both are yours, not ours: an operator action on your console, and a programme your players opt into. Neither is a ClashX correction to a settlement.
So reconcile as transfers + matches — and keep asserting exact equality.
⚠ Do not widen this into a tolerance. These two paths do not produce small drift; they produce exact amounts somebody deliberately moved. A band wide enough to swallow a 500-cent operator credit is wide enough to hide one, and there is nothing for it to absorb in the first place — the system is integer cents end to end, so there is no rounding to tolerate. Loosening the check is how a reconciliation quietly stops being one.
Treat a mismatch as a two-candidate investigation, not an error: an operator grant, or an affiliate movement. Poll GET /matches first — it may simply be a settlement you have not applied yet. An alarm with no candidates gets muted; an alarm carrying two named candidates and a direction is a two-minute check.
A settled match is final — there are no voids
match.settled is the only event type, and it is sufficient because a settlement is never undone. A session reaches completed or abandoned, both are terminal, and both stamp their settlement time when they happen — so both arrive in the feed in order, exactly once.
A settled session is terminal, a duplicate settlement is a no-op, and no correction is ever re-sent — so deduplicating on session_id is safe. Scope that as "no correction is re-sent" rather than "the wallet is immutable": the two paths above are exactly why the stronger claim would be false.
There is no void, no cancellation, and no compensating inverted settlement. If you have integrated a provider that voids, do not build that handling here; there is no state it would ever observe.
⚠ "Voids do not exist" is not "refunds do not exist." Refunds are ordinary and common — see refund_cents in §07. The difference is where they live: a void would be a later event reversing an earlier settlement, and there is none; a refund is a field on the original settlement, and every integration must read it. Build refund handling. Do not build void handling.
14. Undefined contract
One item here is now settled and one remains open. Both are recorded rather than guessed at, because an integration that assumes an answer will break when one is chosen.
ClashX HOSTS THE LAUNCH EXCHANGE
✅ ClashX HOSTS THE EXCHANGE. YOU BUILD NO PAGE.
Mint a token, redirect the player to it, and stop. ClashX serves the exchange page under your branding: no exchange page, no session handling, no error page on your side.
This document previously told you to build one, because the page was written but not deployed. It is deployed now. If you built a thin exchange page against the earlier instruction, you can delete it — the redirect URL and the token contract are unchanged, which is why that instruction said you would be able to.
Easier still: the session response carries launch_url, the complete redirect assembled for you. Send the player there and concatenate nothing.
The contract is settled and verified against a running stack: ClashX hosts the exchange page, and your side of the launch flow becomes mint a token and redirect, nothing else. The deployment is true as well — /launch?token=… on the casino host answers 200 and completes the exchange, driven end to end on 2026-09-04.
https://{casino-host}/launch?token={launch_token}
You build no exchange page, no session handling, and no error page. Two problems live on the ClashX side of that line, and a casino that keeps its own page reintroduces both: the page reads an existing session before spending the token, so a refresh or a back-navigation re-enters the lobby instead of reporting a dead link on a session that is still valid for four hours; and it guards the double-exchange that React StrictMode produces in development, which otherwise looks like a server fault.
A launch token is not a compliance bypass. The pst_ session it produces is a full four-hour player session, not a reduced-capability one, and it passes every gate a hosted-model player passes: compliance, KYC, self-exclusion, and the collusion gate. Your compliance reviewer should read that sentence rather than infer it.
Open — signature freshness tolerance
ClashX enforces clock-skew windows on webhooks it receives, but publishes no required tolerance for deliveries it sends. The window is yours to choose. Use 300 seconds (see §09) and record it as your decision, not as a documented platform guarantee.
15. Running a game inside your own page
Everything below is the client half. You do not need it if you redirect players to the launch exchange — ClashX serves the whole game surface there. You need it if you want the game rendered inside your own page.
⚠ Your origin must be registered with ClashX before any of this works.
Game bundles are served with frame-ancestors naming the origins allowed to embed them. If yours is not on that list the browser refuses the frame before the bundle loads at all — no request, no script, no error you can catch. The only trace is a console warning inside a frame that never rendered:
Framing 'https://…' violates the following Content Security Policy
directive: "frame-ancestors 'self'". The request has been blocked.
Send us every origin you will embed from, including staging and localhost ports. This is the least discoverable failure on this page and it looks exactly like "the game is broken".
/games/{slug} without index.html now serves the bundle too. It used to 404, which reads as not deployed rather than wrong path — if you are on an older deployment and see a 404 there, add /index.html.
The parameters
param
type
value
ws
string
the WebSocket URL — ws.url from the session response
token
string
ws.token
match_id
string
ws.match_id
session_id
string
ws.session_id
player_id
string
your player's ClashX id
mode
string
practice or compete
⚠ ws MEANS TWO DIFFERENT THINGS ON THE TWO SIDES OF THIS BOUNDARY.
The API returns ws as an object: {url, token, match_id, session_id, protocol_version}. The bundle reads ws as a string: the URL alone.
Passing the object through — the obvious thing, since the names match — yields ?ws=[object Object], and the bundle mounts, renders, and silently never connects. Pass ws.url.
⚠ The token rides the WebSocket subprotocol, not the query
If you build your own connection rather than embedding the bundle, the JWT goes in Sec-WebSocket-Protocol, not in the query string:
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:
shape
games
notes
Standard
tumble, open_up, golazo, memory_lane, turbo_track
the table above
Different names
pickle_punisher
wsUrl not ws, jwt not token, and camelCase: matchId, playerId, sessionId, plus playerName, currencyCode (AC practice / USD compete) and entryFee
WebAssembly
gridlock, knockout, slither, tank
Now embeddable. They read the standard parameters.
✅ Use launch_url and assemble nothing
The session response carries launch_url — a complete, ready-to-use URL with the right parameter names for that game. It is returned alongsidews, so an integration already reading ws keeps working.
Put it in the iframe src. That is the whole integration, and it is the only form uniform across the spellings above — the server knows which game it is, so you never have to.
launch_url is null when a game has no embeddable bundle. That is deliberate: a URL that 404s reads as not deployed.
16. Practice mode
Practice is a free, bot-filled game: no debit, no payout, and no ledger row, ever. It is the mode a casino demonstrates on and lets players try, and it needs no wallet balance and no other human players.
It is a separate route and cannot be found by probing the queue — /partner/v1/sessions has no practice flag, because the practice lane is not the same lane.
POST /api/v1/practice/join
{ "module_key": "skill", "game_key": "tumble" }
⚠ It is authenticated as the player (the pst_ session from the launch exchange), not by your API key — a practice game is something a player starts, not something your servers start on their behalf.
The response carries the same ws object as a competitive session, so the mount contract above is identical. Seats are filled with bots.
17. The fiat field, and why you must filter on it
Every match object carries fiat.
fiat
what it is
true
a real-money match — stakes, payouts and rake
false
practice — free, bot-filled, and no ledger row, ever
⚠ Practice matches arrive in the same feed with zero economics. They are real matches with real results, and their stake_cents, payout_cents and rake_cents are all 0.
So an integration that does not filter on fiat produces inflated match counts and settlement rows that all reconcile — every arithmetic check still passes, because adding zeros changes no total. A wrong number that reconciles is far harder to find than one that does not.
Filter on fiat wherever you count matches, bill, or report volume.
18. What a wallet unit is worth
Wallet amounts are integer minor units (cents) in the wallet's currency. gold is play money; sweeps is the redeemable one. That is why only sweeps can be debited out: a play balance that could leave as money would not be play.
⚠ ClashX applies no conversion, anywhere. There is no exchange rate in the platform. A unit you credit is the unit that is staked and the unit that is paid out — the number that leaves your ledger is the number that arrives.
ClashX gives a wallet cent no real-world currency.sweeps carries no ISO code and no exchange rate, and nothing converts it, so what one sweeps cent is worth is set by what you take for it and what you pay out for it. Fix it as one minor unit of the currency you settle in (one US cent, if you settle in dollars) and hold it everywhere. If your side ever uses two, every figure you reconcile is wrong by a constant factor and nothing here will disagree with you.
19. Rake
Every match object carries rake_cents — the house cut, summed from the settled figures — and each participant carries their own rake_cents.
⚠ Do not derive it. The obvious derivation is Σstake − Σpayout − Σrefund, and it is unsafe: a participant with no recorded economics reads as 0, which is indistinguishable from a genuinely free seat. The derived figure is then wrong while every arithmetic check still balances. Settlement knows the exact number; read it.
rake_cents is the casino's gross gaming revenue. What ClashX charges is a separate contract term and does not appear on this API.
20. ⚠ The ordering rule, in both directions
§06 states the debit rule: debit ClashX first, then release funds on your side. The rule for the other direction is the same shape and is easier to get wrong, because the mistake destroys money instead of duplicating it.
you are
do
never
crediting (funding play)
debit your own ledger first, then wallet/credit
credit ClashX first and debit yourself after
debiting (cashing out)
wallet/debit first, then release on your side
release first and reconcile later
⚠ AND THE THIRD CASE: a retryable failure is not a reversal.
If wallet/credit or wallet/debit fails with a timeout, a 500, or any network error, you do not know whether it applied. Do not reverse your own side. Retry with the same transfer_ref — idempotency will either apply it once or tell you it already applied.
Reversing on a retryable failure is how money is destroyed: the transfer landed, you reversed your side, and the two ledgers now disagree by exactly the amount, with nothing logged as an error anywhere.
21. Pointer lock
Two games use pointer lock for mouse-look: tumble and pickle_punisher. The rest aim with absolute cursor position or are click-driven, and locking the pointer would break their aiming outright.
If you embed a game in an iframe, grant the permission:
⚠ 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.
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.
⚠ 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.
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.
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.