Launch & sessions
Mint a one-time launch token, read the platform-provided game_url the call returns, 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, a platform-provided URL you embed as returned. 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.)
Mint 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. |
| market_id | string | optional | Send it to launch straight into one market instead of the lobby — see “Launching into a single market” below for what changes. Must match ^[A-Za-z0-9_-]{1,36}$. Omit it for a normal lobby launch. It affects the landing page only — the session it produces is a normal, full session for your merchant. Cannot be combined with event_id. |
| event_id | string | optional | Send it to launch straight into an event page — every outcome of one question on a single screen — instead of the lobby; see “Launching into an event” below. Use the event_id from a lobby row or from GET /b2b/v1/events/{eventId}. Must match ^[A-Za-z0-9_-]{1,36}$. Send market_id or event_id, not both: both together is refused with 400 before anything is minted. Like market_id, it changes only where the player lands. |
| side | string | optional | Pre-select an outcome on a single-market launch: "1" for YES, "2" for NO, or omit for neither. A JSON string, not a number — the field binds as a string server-side, so "side": 1 is refused as a bad body while "side": "1" is accepted. Ignored without market_id (including on an event_id launch, where there is no single outcome to pre-select), and like it, it changes only where the player lands. |
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. Redeem it from your own backend with verify-launch-token below, which takes it exactly as issued (bare); the same-origin /c-api/v1/session/exchange is the hosted game-client's own path — browser-only, and not one you call. |
| 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://<game-host>/?lt=<launch_token>. The host is provided by the platform at mint time — a platform address, never one of yours — so you never build it and there is no CNAME to configure. Passing market_id instead builds a single-market deep link, and passing event_id an event deep link (see below). If the merchant is not launch-ready, or the platform has no host available, the call fails rather than returning this field. |
{
"launch_token": "nTpntEN2ixa7Zodgmyc4iDk4y75dAE3PEDQ7Q7yx7PE",
"expires_in": 60,
"game_url": "https://<game-host>/?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. Also: market_id or event_id outside ^[A-Za-z0-9_-]{1,36}$, side other than "1" / "2", or market_id and event_id sent together. The response's reason field says which; nothing is minted, so a corrected retry succeeds. |
| 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. |
| launch_pool_empty | 503 | Your merchant is set up, but at mint time the platform had no host available to serve the launch. This is a transient platform-side issue rather than anything about your request, so a retry after a short backoff is reasonable — unlike merchant_currency_unset, a configuration state that will not clear on its own. Note: minting does not run the site_key / go-live gate — that check (and its merchant_domain_not_ready, 409) lives on POST /b2b/v1/auth/session, not on this endpoint. |
| 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.
Launching into a single market
A launch that names a market opens the game directly on it instead of the lobby. Add market_id to the launch-token body — and, if you want to pre-select an outcome, side (1 or 2) — and the game_url comes back as a deep link rather than the bare lobby URL:
https://<game-host>/market/<market_id>?side=1#lt=<launch_token>
# The host is still the platform-provided one. Two things differ from a lobby launch:
# · the path carries the market (and ?side= the pre-selected outcome, if you sent one)
# · the launch token rides in the URL FRAGMENT (after #), not the query string —
# the client reads it from there, and a fragment is never sent to the server.
# It still burns on first load and expires after expires_in seconds, exactly like ?lt=.The session it produces is an ordinary one. market_id decides where the player lands, and nothing else: they can navigate back to the lobby and trade anything else you have listed, exactly as they could from a lobby launch. Quote, buy, cash out, limit orders and the live feed all work across your whole catalogue.
This is a change from the first release of single-market launches, where the token also narrowed the session to that one market and a call reaching for another was refused with market_out_of_scope. That error no longer exists. The narrowing never constrained you — omitting market_id has always minted a full-catalogue token, so it stopped nothing a merchant could otherwise do — while it did force anyone who wanted a deep link to accept a single-market session as the price. The two concerns are now separate: the parameter positions the player, your API key scopes what may be traded.
Launching into an event
Some questions have several outcomes — a rate decision with five possible moves, a season win total with a ladder of lines. Each outcome is its own market, and they share one event_id. Send that event_id in the launch-token body instead of a market_id and the player lands on the event page, with every outcome and its YES / NO prices on one screen:
https://<game-host>/event/<event_id>#lt=<launch_token>
# Same rules as the single-market link: platform host, token in the URL FRAGMENT,
# burns on first load, expires after expires_in seconds.
# There is no ?side= here — an event has several outcomes, so there is nothing to pre-select.Take event_id from a lobby row or from GET /b2b/v1/events/{eventId} — it is an id the platform assigns, the same one those endpoints return. Send either market_id or event_id, never both; both together is a 400 bad_request and nothing is minted. The session is the same ordinary, full-catalogue session as any other launch: the player can open any outcome, go back to the lobby, and trade everything you have listed.
The embed URL
Redeeming the token
Hosted launches — the client we serve — redeem for you: the player's browser posts the token same-origin to /c-api/v1/session/exchange and gets the player session back directly, so you never make this call. That exchange response also carries embed_origins — the parent-site origins you declared with the operator — which the client uses for its in-app parent-origin check. If you host the client yourself, use the B2B endpoint below instead: it is the call that turns the one-shot token into a player identity.
Redeem 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://<game-host>/platform-providedThe client origin. Comes back inside game_url — do not construct it yourself. It is a platform-provided host, not a domain of yours, so a merchant that is not launch-ready 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 builds game_url on a platform-provided host. Two things can stop that. If your merchant has no registered settlement currency, the call fails closed with merchant_currency_unset (409), a configuration state that a retry will not clear. If the currency is set but the platform has no host available at mint time, you get launch_pool_empty (503) instead — a transient issue worth a short backoff. Either way no URL is returned, and none is substituted onto a host of yours: there is no merchant-owned origin in this model to fall back to. Minting does not run the site_key / go-live check — that gate, and its merchant_domain_not_ready (409), applies to POST /b2b/v1/auth/session, not to this endpoint.
If you built against an older description of this endpoint, note the reversal: there is no longer a single merchant-owned enabled domain the host is resolved from — the platform provides it. The merchant_domain_not_ready (409) code still exists and has kept its name but changed its meaning — it no longer reports a missing CNAME, it reports a merchant that has not reached go-live — but it is raised by POST /b2b/v1/auth/session, not by minting. See Custom domain (CNAME) for how you embed the returned URL and the now-deprecated custom-domain path.
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.