Launch & sessions
Mint a one-time launch token, read the game_url built on your enabled domain, and how the session is exchanged.
A player never authenticates against PredMask directly. Instead your backend mints a one-time launch token scoped to one player, and the game-client redeems it for a short session. This keeps player credentials on your side and makes the embed URL safe to put in an iframe.
Flow
- 1. Player opens the game in your app.
- 2. Your backend signs a B2B call to the launch endpoint with
{"player_id":"…"}in the body → getslaunch_token+game_url, built on your enabled domain. The player is created on first use. - 3. You render
game_urlin an iframe. - 4. The game-client redeems the token → session (player, currency, WS feed). The token is now spent.
Two ways in, and this page is one of them
The flow above is the embedded model: the player's browser holds the session and talks to the platform directly, and a launch token is how that session begins. The other model is a backend one — your server holds the session and calls the player endpoints itself, with no launch token anywhere. For that, take a session at POST /b2b/v1/auth/session instead of minting a token; see Authentication.
Both produce the same kind of session and a player has exactly one at a time, so the two models are a choice rather than a combination: running both for one player means each new session ends the other. Pick per player, not per feature.
Create a game session
Launch tokens are minted with a single HMAC-signed B2B call — one credential, no console login. The merchant is derived from your API key and the player is created on first use, so there is no separate provisioning step. (With a hosted wallet the reference backend can make this call on your behalf; the contract below is the same either way — see the Quick Start.)
/b2b/v1/auth/launch-tokenHMACMint a single-use launch token for the player named in the body. The merchant is derived from your API key; the player is created on first use (idempotent), so no separate provisioning call is needed. Call this server-side each time a player opens the game — tokens are not reusable. Note the path and the body: this endpoint moved under /b2b/v1/auth/ and now takes {"player_id":"…"} where it used to take an X-Player-Id header and an empty body.
Body
| player_id | string | required | Your stable identifier for the player (the operator's external_uid). Auto-created on first use. Must match ^[A-Za-z0-9._-]{1,64}$ — validated BEFORE anything is minted, so an out-of-range id costs you nothing and a corrected retry works. That ordering is the fix for a real failure: the check used to run after the token was already minted and consumed, which produced a token that was invalid the moment it existed and a loop that could not be escaped by minting another. |
Headers
| X-Nonce | string | required | Fresh value on every request, never reused. It is one of the six signed fields and is also burned server-side for ±300s, so a replayed request fails even with a valid signature. Sending none is a 401, the same as sending a bad signature — which makes it easy to misdiagnose. |
| Content-Type | string | required | application/json. There is a body now; without this header it is not bound and the call answers 400 bad_request. |
POST /b2b/v1/auth/launch-token
X-Api-Key: ak_9dc9a497...
X-Timestamp: 1718000000
X-Nonce: 0f2b7c1e9a4d5f36 # unique per request — omitting it is a 401
X-Signature: <hex HMAC-SHA256 — see Authentication>
Content-Type: application/json
{"player_id":"PLAYER-123"}
# No X-Player-Id header. Sending one is refused with 400 player_id_header_retired,
# before the signature is even checked.Response
| Field | Type | Description | |
|---|---|---|---|
| launch_token | string | required | Single-use token, redeemed by the game-client and expiring after expires_in seconds. Shape: 43 characters of unpadded base64url — 32 random bytes, no prefix and no structure to parse. Do not key redaction rules or validators off a prefix; there is none, and the samples on this site now show the real shape. |
| expires_in | int | required | Token lifetime in seconds — 60 in production. Other environments may run a longer window, so read this field rather than hard-coding 60. |
| game_url | string | required | Fully-formed embed URL: https://<your enabled domain>/?lt=<launch_token>. The host is resolved from your merchant's enabled domain at mint time, so it follows a domain change with no redeploy on your side — and it is never a platform address. If no enabled domain resolves, the call fails rather than returning this field. |
{
"launch_token": "nTpntEN2ixa7Zodgmyc4iDk4y75dAE3PEDQ7Q7yx7PE",
"expires_in": 60,
"game_url": "https://play.your-domain.com/?lt=nTpntEN2ixa7Zodgmyc4iDk4y75dAE3PEDQ7Q7yx7PE"
}Errors
| unauthorized | 401 | Bad signature, or a timestamp more than 300s from ours. A body problem is not this — a missing or malformed player_id answers 400, because the signature was fine. |
| player_id_header_retired | 400 | The request still carries X-Player-Id. Refused before the signature is checked, so it says nothing about your key — and it is the answer you get instead of the bare 401 that a still-seven-field canonical would otherwise produce. |
| bad_request | 400 | No JSON body, no Content-Type, or player_id missing or empty. |
| invalid_player_id | 400 | player_id is outside ^[A-Za-z0-9._-]{1,64}$. Nothing is minted and nothing is consumed, so a corrected retry succeeds. |
| merchant_currency_unset | 409 | Your merchant has no registered settlement currency, so the platform refuses to create the player rather than guessing one. Checked before the token is minted. Configuration state, not transient. |
| player_forbidden | 403 | The player is frozen / blocked from play. |
| merchant_domain_not_ready | 409 | Your merchant has no enabled player domain, so there is no host to build game_url on — the domain was never activated, or it was withdrawn after being enabled. No URL is returned and none is substituted: there is no platform-hosted origin to fall back to. This is a configuration state, not a transient one, so retrying is pointless until a domain is enabled. See Custom domain (CNAME). |
| too_many_requests | 429 | Launch rate limit exceeded. |
Worked example — signing this call
Minting a launch token has no signing rules of its own: it is an ordinary B2B request, so the six-field canonical applies unchanged. What makes it worth its own example is that it exercises the two things that move in this change at once — the shorter canonical, and the player id arriving as body bytes that are signed exactly as sent.
secret = demo_secret_do_not_use
X-Timestamp = 1767225600
METHOD = POST
PATH = /b2b/v1/auth/launch-token
RAWQUERY = (empty)
X-Nonce = b6f3c2e1-4a5d-4c9e-9f10-2b8a7d6e5c40
BODY = {"player_id":"player-1001"}
canonical = # 107 bytes
1767225600\n
POST\n
/b2b/v1/auth/launch-token\n
\n <- RAWQUERY is empty, but the line stays
b6f3c2e1-4a5d-4c9e-9f10-2b8a7d6e5c40\n
{"player_id":"player-1001"}
<- BODY ends the string: no trailing newline
X-Signature = 2a06b4be1741e9e98fabab9caf742806e3122dddcd3d4db95435a0ade56d4ba4Two ways this one goes wrong
The empty line survives. An empty query string still occupies its line in the canonical. Skipping the line instead of joining an empty value produces a valid-looking signature that we reject as unauthorized.
Sign the body bytes you actually send. The body is signed raw, not re-serialized: a space after the colon, a different key order or a pretty-printed newline all produce a different string and a signature we cannot verify. Build the JSON once, sign that exact string, and put that same string on the wire.
This call used to be the site's empty-body example, and it no longer is — it has a body now. The trailing-newline trap that lesson existed for has not gone anywhere, it just lives on a different vector: the empty-body GET on Signature self-check, where an empty BODY leaves the canonical ending in a newline that printf and command substitution strip for free. Both vectors are pinned by tests in our own build, so neither can drift away from the implementation.
The embed URL
Redeeming the token
If you host the client yourself, this is the call that turns the one-shot token into a player identity. Hosted launches never touch it — the client we serve redeems for you.
/b2b/v1/auth/verify-launch-tokenHMACRedeem a launch token for the player it was minted for. Signed like any other B2B call and carrying no session — there is no player yet; that is what this call returns. The merchant comes from your key, which is also what stops one merchant redeeming another's token. Note the path: this endpoint moved from /b2b/v1/session/ to /b2b/v1/auth/, and since the path is one of the six signed fields, the old path fails as a signature mismatch rather than a 404.
Body
| launch_token | string | required | The lt value from game_url. Redeemed atomically, so a token is spent the instant it is read. |
Response
| player_id | string | required | Your own external_uid, echoed back. This is who the token was minted for — bind your own session to it. It is not something you send back to us on later calls: player identity travels in the platform session from here on, and the X-Player-Id header that used to carry it is retired. |
| currency | string | required | The merchant's base currency, so the client can render money on its first frame instead of guessing at build time. If it cannot be resolved — unset on the account, or the lookup fails — the call REFUSES with 409 merchant_currency_unset rather than falling back to USDT. That fallback existed, and it is gone: a guessed currency reaching a first frame is a cosmetic error, but the same guess reaching player creation is written into the ledger permanently, so both paths now stop instead. The check runs before the token is consumed, so a refusal here leaves your token unspent and a retry works once the account is completed. |
| merchant_id | string | required | Decorative — for logs and support. It is not identity: identity comes from your API key, and the operator ignores this value if a client sends it back. |
Five different failures answer the same bare 401 with no reason attached: no token, unknown token, expired token, already-redeemed token, and a token belonging to another merchant. That is deliberate — a distinguishable error would let someone probe which tokens exist. Verified on the sandbox: a second redemption of a live token and a redemption after the token has expired both answer unauthorized, identically. Do not build retry logic that reads the reason, because there is none; mint a fresh token instead.
merchant_currency_unset is deliberately not a sixth member of that set. The bare 401 exists to stop a stranger probing which tokens are real; an unregistered settlement currency is a fact about your own account, told to a caller that has already proved it holds your key. There is nothing to protect by being vague, and a great deal to lose — silence here would read as “the token was bad” and send you re-minting tokens against a merchant that cannot create players at all.
One of those five costs more than a refusal. The token is consumed before the merchant is checked — redemption is a single atomic read-and-delete, and only then is the owning merchant compared. So an attempt made under the wrong merchant context does not bounce off a token that survives; it destroys it, and the correct retry that follows answers 401 as well. The rejection reads as "that token was never valid" when what happened is that the first attempt spent it. It is deliberate — a token that survived a failed merchant check could be walked across tenants to find its owner — but it means the redemption path must know which merchant it is acting for before it calls, not after.
https://play.your-domain.com/resolved per merchantThe client origin. Comes back inside game_url — do not construct it yourself. It is always your own enabled domain: there is nothing behind it to fall back to, so a merchant without one gets a refusal instead of a URL.
?lt=<launch_token>ours · one-shotAlready present in game_url. Burns on first load and expires after expires_in seconds (60 in production); mint a fresh one per launch and keep it out of logs.
game_url is complete in both wallet modes — embed it as returned. There is no URL parameter that redirects the client at anything of yours, and adding one changes nothing: the game-client calls the platform on same-origin relative paths, so its API and realtime endpoints follow the page it was served from — they are never read from the address bar.
Where the host comes from, and what happens when there is none
Minting resolves your merchant's one enabled domain and builds game_url on it. If that resolution finds nothing — the domain was never activated, or it was withdrawn — the call fails closed: it answers merchant_domain_not_ready and returns no URL. Older descriptions of this endpoint said an unregistered merchant falls back to a platform-hosted client origin. That is no longer true and the fallback no longer exists, so an integration that treats a missing domain as “degraded but working” will now see a hard refusal instead. Surface it as configuration, not as an outage to retry through.
One consequence worth planning for: because there is exactly one enabled domain, changing it is a cutover, not a migration. The old host stops being accepted the moment the new one takes over, and iframes already open on it need a fresh launch. Lower the DNS TTL, finish every edge check on the new host first, and pick a window — see Custom domain (CNAME).
That surprises people who expect a self-hosted wallet to mean a self-hosted client, so it is worth being explicit: running your own wallet changes who the operator calls, not who the player's browser calls. You register a wallet_base_url and implement the hooks; the operator calls them server-to-server for every debit and credit, and the player's session still goes straight to the platform. See Wallet modes for the two models, and Wallet hooks for what you implement.