Authentication

B2B API key + HMAC-SHA256 request signing, IP allowlist and replay window.

Every B2B call is signed with HMAC-SHA256 over a canonical string. The operator verifies the signature, the timestamp window and your source IP before executing. Whoever calls /b2b/v1 signs — that is you if you call it yourself, or the reference merchant backend if it runs your wallet and calls on your behalf.

Breaking change

The canonical is six fields, and X-Player-Id is gone.

Two things changed together, because they are the same change. The PLAYER-ID line has been removed from the canonical, which now joins six fields rather than seven. And the X-Player-Id header is retired: a player is named once, in the body of the call that takes a session, and every other endpoint reads the player from that session.

Sending the header is refused, not ignored — a request carrying it answers 400 player_id_header_retired, before the signature is even examined, with a body that says what to do instead. That refusal is deliberate: an integration still on the old shape is also still signing seven fields, and a bare 401 would send you looking for a bug in your HMAC that is not there. There is no compatibility window and none is planned: accepting both shapes would let a call site you forgot to change keep working silently, which is the failure this refusal exists to prevent.

Two credentials, two questions

Player-scoped endpoints carry both an HMAC signature and a session token, and the two are not redundant. The signature and the IP allowlist answer where this call came from; the session answers which player it is for. Dropping either one collapses a property you want: with only a session, a leaked token could be spent from any address on the internet; with only a signature, the player id would be back to travelling as a parameter that anything in your stack could set.

It also decides how much an error is allowed to tell you, and that turns out to be the practical benefit. Signature failures stay deliberately indistinguishable — a wrong key, a skewed clock and a replayed nonce all answer the same bare 401, because anything finer would let a stranger probe. A session failure is answered after the signature verified, so by then the caller has already proved it holds your secret and calls from your address: it is you, and there is nothing left to leak by being specific. That is why session_required and session_invalid exist as separate, explicit codes while the HMAC layer has exactly one.

FieldTypeDescription
HMAC signaturewhere fromrequiredX-Api-Key, X-Timestamp, X-Nonce and X-Signature, on EVERY /b2b/v1 call including the one that takes a session. Identifies your merchant and proves the request came from your systems and your allowlisted addresses.
Session tokenwhorequiredAuthorization: Bearer <session_token>, on the player-scoped endpoints only. Identifies the player. Take one from POST /b2b/v1/auth/session; it is not something you mint.

Headers

FieldTypeDescription
X-Api-KeystringrequiredYour merchant API key.
X-TimestampstringrequiredUnix time in seconds. Rejected if more than ±300s from server time.
X-NoncestringrequiredUnique random value per request (e.g. 16 random bytes as hex). Signed and single-use — the operator rejects any repeat within the timestamp window.
X-SignaturestringrequiredLowercase hex HMAC-SHA256 of the canonical string, keyed by your API secret.
AuthorizationstringoptionalBearer <session_token>, from POST /b2b/v1/auth/session. Required on every player-scoped endpoint and refused nowhere — the merchant-dimension reads simply ignore it. It is NOT one of the six signed fields: the canonical does not cover this header, so rotating a session never changes a signature and you can hold one session while re-signing every request. Absent where it is needed, the answer is 401 session_required; present but no longer valid, 401 session_invalid. Both arrive only after the signature verified, so neither is ever a signing bug.

There is no X-Player-Id row, and its absence is enforced rather than documented: the header is refused on the whole of /b2b/v1, including the merchant-dimension endpoints that never used it. That breadth is on purpose — it was a written part of this contract, so “we removed every send” has to be checkable in one place rather than argued endpoint by endpoint.

Taking a session

This is the one call where you name a player, and it is the only one. It is signed like any other B2B request and carries no session of its own — it is the call that gets you one.

POST/b2b/v1/auth/sessionHMAC

Exchange your merchant credential for a player session. Requires an enabled player domain — the same prerequisite as launching, and it applies here even though this call builds no game_url. The merchant comes from your API key and the player from the body; the player is created on first use, so there is no separate provisioning call. Idempotent in the sense that matters: the same player_id always resolves to the same player. It is NOT idempotent in the sense of returning the same token — every call mints a new session and revokes the previous one for that player.

Body

player_idstringrequiredYour own stable identifier for the player — the operator's external_uid, unique inside your merchant. Must match ^[A-Za-z0-9._-]{1,64}$; anything else is refused with 400 invalid_player_id before a player, a session or a token is created, so a corrected retry succeeds cleanly.
Requestbash
POST /b2b/v1/auth/session
X-Api-Key:    ak_9dc9a497...
X-Timestamp:  1767225600
X-Nonce:      b6f3c2e1-4a5d-4c9e-9f10-2b8a7d6e5c40
X-Signature:  <hex HMAC-SHA256 over the six-field canonical>
Content-Type: application/json

{"player_id":"player-1001"}

# Content-Type: application/json is not optional here. Without it the body is
# not bound at all and the call answers 400 bad_request with an empty player_id.

Response

FieldTypeDescription
session_tokenstringrequiredSend as Authorization: Bearer <session_token> on every player-scoped endpoint. An opaque random string with no structure to parse — do not key redaction rules off a prefix, because there is none. Treat it as a credential: never log it, never put it in a URL, never hand it to a browser.
expires_inintrequiredSeconds of IDLE life (1800). Every player-scoped call slides it forward, so a session in continuous use never expires on this clock. A session that goes quiet for longer than this is gone.
absolute_expires_atstringrequiredRFC3339. The hard ceiling, four hours after the session was taken, and sliding does not move it. Past this the session ends however busy it was — so take a new one on a schedule rather than assuming an active session lives forever.
player_idstringrequiredYour external_uid, echoed from the stored player row rather than from your request. Reading it back from storage is deliberate: an echo of the input would keep looking right the day the input stopped arriving.
currencystringrequiredThe player's ledger currency, fixed when the player was created and never revisited. An existing player keeps the currency it was created with.
Response · 200json
{
  "session_token": "0Xy1mQ8pR2vK7hN4tS6wZ3bJ5cF9dG1aL8eU0iO2yT4",
  "expires_in": 1800,
  "absolute_expires_at": "2026-08-18T12:00:00Z",
  "player_id": "player-1001",
  "currency": "EUR"
}

Errors

player_id_header_retired400The request still carries X-Player-Id. Refused before the signature is checked, so this answer says nothing about your key — and it deliberately arrives instead of the 401 you would otherwise get from still signing the seven-field canonical.
bad_request400The body is not JSON, or player_id is missing or empty. Check Content-Type before you check anything else.
invalid_player_id400player_id is outside ^[A-Za-z0-9._-]{1,64}$. Checked before anything is created, so nothing was consumed. The response carries the pattern and the length it received, and deliberately does not echo the value back.
unauthorized401Bad signature, timestamp skew, unknown or disabled key, replayed nonce, or a merchant that is not enabled. Deliberately one code for all of them.
forbidden403Your source IP is not on the allowlist.
merchant_domain_not_ready409You have no enabled player domain yet. Both wallet models share this prerequisite, and taking a session is no exception — even though this call builds no game_url. Without it you would end up half-live: able to trade through your backend while unable to mint a single entry link for a player. Register a host and have it activated first; the same code answers POST /b2b/v1/auth/launch-token for the same reason.
player_forbidden403The player exists and is frozen. A distinct code on purpose: a frozen player is a business state you can surface, and folding it into 401 would make it read as a credentials fault.
merchant_currency_unset409Your merchant has no registered settlement currency, so the platform refuses to create the player rather than guessing one. Currency is the root of every ledger row and cannot be corrected afterwards. Configuration state, not transient: retrying never clears it.
payload_too_large413Body over 1 MiB. Refused before verification — the body is never truncated, because a truncated body would not match the signature you computed.

One player, one session — including yours

The session you hold is the same kind the embedded game-client holds, not a separate backend-only credential, and a player has exactly one at a time. Taking a session revokes that player's previous one immediately. Within one integration model that is invisible — you take a session, you use it, you take another when it lapses. It becomes visible only if you mix the two models for one player: drive them through your backend while they also have the game embedded in a browser, and each new session ends the other. That is the single-session rule working, not a fault, and the fix is to pick one model per player rather than to hold two.

One direction is closed off entirely, and it is worth knowing you cannot take a shortcut through it: a session taken here cannot be handed to a browser. Sessions carry the origin they were opened for, this one has none, and the player-facing surface refuses a session whose origin does not match the host serving it. So there is no way to turn a backend session into a browser session — mint a launch token for that, as described in Launch & sessions.

Keeping it alive

Ordinary calls slide the idle window on their own, so a busy session needs nothing. What POST /b2b/v1/auth/refresh adds is a way to slide it without making a business call, and to read back what the operator currently believes about the session. It takes HMAC and the Bearer, like any other player-scoped endpoint, and echoes the player_id the session resolves to — the value to assert against your own record if you want to catch a mis-wired session early.

There is no logout. To end a session, take a new one for that player — the previous one dies in the same call. Sessions also end on their own when a player is frozen, which is what makes freezing take effect rather than waiting out the window.

Taking a session creates the player

There is no endpoint that registers a player. The first session taken for a given player_id creates that player under your merchant, and every session after it resolves to the same record. An id the operator has never seen is therefore not an error — so a typo, a changed id format, or a staging id leaking into production does not fail loudly. It quietly opens a second account with no positions and no history, and the player you meant is still sitting where you left it. Treat the id the way you would a primary key: derive it once, never reformat it.

Creation is also where the player's ledger currency is decided, and it is decided once. It is taken from your currency_base at that moment and never revisited — the lookup only runs on the create path. Your base currency is fixed at onboarding and no endpoint changes it, so in practice this is not something you can trip over through the API; it matters because it makes the base currency a decision to get right before you onboard anyone rather than after. If currency_base cannot be read or is empty at that moment, the call refuses with 409 merchant_currency_unset and creates nothing. It used to fall back to USDT; that fallback is gone, because a guessed currency is written into the ledger permanently and cannot be corrected once the player exists.

The shape of the id is validated, and knowing exactly how much is validated is what stops you designing around the wrong risk. It must match ^[A-Za-z0-9._-]{1,64}$: letters, digits, dot, underscore and hyphen, at most 64 characters. Anything outside that — a space, an accent, an @, an id longer than the column — is refused with 400 invalid_player_id before anything is created, so it fails loudly and a corrected retry works.

What is not checked is whether two ids you consider different mean different players. The uniqueness check is case-insensitive, so Player1 and player1 are the same account, while player_1 is a different one. That is the part that bites: inside the permitted character set, the same class of typo sometimes merges and sometimes forks, and neither outcome returns an error. Derive the id once, from a source you control, and never reformat it downstream.

  • player1account A
  • Player1account A · same
  • player_1account B
  • pláyer1no account
  • player1no account · same
  • player1the id you meant
  • Player1case is not distinguished — resolves to the same account
  • player_1one extra character, inside the permitted set — a new account, zero balance, no history, and no error
  • pláyer1outside the character set — 400 invalid_player_id, and nothing is created
  • player1a leading space is outside it too — refused rather than forked
The bottom two used to fork silently and now fail loudly; the middle one still forks, because it is a legitimate id that simply is not the one you meant. That is the line to hold in your head: validation catches ids that are malformed, never ids that are merely wrong. The signature does not help either — a typo happens inside your own signing service, so what arrives is validly signed.

One more id-shaped failure is distinct from all of these and does return an error: a frozen player answers player_forbidden (403), at the moment you take the session and again on any call made with a session they held when they were frozen.

The canonical string

Join six fields with a single \n — six fields and five separators, not six newlines. PATH is the path only (no query string). RAWQUERY is the query string exactly as sent, without the leading ? (empty string if none). X-Nonce is the value of that header. BODY is the raw request body, or an empty string for GET — signed raw, never hashed.

  1. X-Timestampunix seconds, ±300s of server time
  2. METHODuppercase — POST, GET
  3. PATHpath only, no query string — the decoded path
  4. RAWQUERYexactly as sent, no leading ? — "" if none
  5. X-Nonceunique per request, per API key
  6. BODYraw bytes as sent — "" for GET, and then the string ends with the newline above

    BODY ends the string — nothing is appended after it

B2B canonicaltext
canonical = X-Timestamp + "\n" +
            METHOD      + "\n" +   // uppercase, e.g. POST
            PATH        + "\n" +   // e.g. /b2b/v1/orders  (no query)
            RAWQUERY    + "\n" +   // e.g. status=all  (no leading "?", "" if none)
            X-Nonce     + "\n" +   // unique per request
            BODY                    // raw JSON, or "" for GET

// The Authorization header is NOT here. A session token is presented, not signed:
// it never enters the canonical, so holding one session across many requests and
// re-signing each request are independent of each other.

PATH and RAWQUERY point in opposite directions on purpose, and getting either wrong fails as a 401 — indistinguishable from a bad key, so the afternoon goes into checking credentials that were fine. Rebuilding the query from a parameter map, sorting its keys or re-encoding it all produce a string that differs from the one on the wire.

Two empty things carry weight, and both are worth reading twice because they are the most common reason a correct implementation still fails. An empty RAWQUERY still occupies its line — skipping the line instead of joining an empty value produces a string one newline shorter, and a signature that verifies against nothing. And an empty BODY leaves the canonical ending in a newline, because BODY is last: printf, command substitution and here-strings all strip or add trailing newlines, so a signer that routes the canonical through a shell signs a different string than the one it printed.

Signing — Node.js

sign.jsjavascript
import crypto from "node:crypto";

function signB2B({ apiKey, secret, method, path, rawQuery = "", body = "", sessionToken }) {
  const ts = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(16).toString("hex"); // fresh, single-use per request
  // Order is exact, and there are SIX fields: ts, METHOD, PATH, RAWQUERY, X-Nonce, BODY
  const canonical = [ts, method.toUpperCase(), path, rawQuery, nonce, body].join("\n");
  const sig = crypto.createHmac("sha256", secret).update(canonical).digest("hex");
  return {
    "X-Api-Key": apiKey,
    "X-Timestamp": ts,
    "X-Nonce": nonce,
    "X-Signature": sig,
    // Presented, not signed — it is not part of the canonical above.
    ...(sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {}),
    "Content-Type": "application/json",
  };
}

// 1. Take a session. Signed, no Bearer — this is the call that produces one.
const takeBody = JSON.stringify({ player_id: "player-1001" });
const takeRes = await fetch(`${base}/b2b/v1/auth/session`, {
  method: "POST", body: takeBody,
  headers: signB2B({ apiKey, secret, method: "POST", path: "/b2b/v1/auth/session", body: takeBody }),
});
const { session_token, absolute_expires_at } = await takeRes.json();

// 2. Every player-scoped call: signed AND bearing the session.
//    GET with a query — sign RAWQUERY exactly as sent (no leading "?").
//    status on /orders is the integer state (1..4) — a name like "all" is a 400.
const h = signB2B({ apiKey, secret, method: "GET",
  path: "/b2b/v1/orders", rawQuery: "status=2&limit=20", sessionToken: session_token });

//    POST — sign the exact bytes you send
const body = JSON.stringify({ market_id: "mkt_1", side: 1, shares: "10", idem_key: "..." });
const h2 = signB2B({ apiKey, secret, method: "POST",
  path: "/b2b/v1/orders", body, sessionToken: session_token });

// 3. Re-take on 401 session_invalid, and before absolute_expires_at. Cache the token
//    per player, never per process — one process usually serves many players.

Note what step 3 is not: it is not a retry loop around a bare 401. Distinguish the two codes before you re-take, because they mean opposite things about your credentials. A 401 unauthorized means the signature layer refused and a new session will not help; a 401 session_invalid means the signature was fine and a new session is exactly the fix. Re-taking on the first one turns a signing bug into a loop that mints sessions and gets nowhere.

Replay window

X-Timestamp must be within ±300 seconds of server time. Keep your clock in sync (NTP). Each X-Nonce is single-use within that window — generate a fresh one per request and never replay a signature. Uniqueness is scoped per API key, so two keys may independently use the same nonce value and a rotated key starts with a clean slate. A replayed nonce is 401 unauthorized. If our nonce store is unreachable we reject rather than admit a possible replay, but as 502 upstream_error — so a burst of 502s on otherwise-valid signatures is transient and worth retrying, while a 401 means the nonce was genuinely reused.

Retrying means re-signing, not re-sending. The nonce is consumed before your request reaches the handler, so a call that times out or fails downstream has already spent it — and by then the original timestamp may be outside the window too. Resending the identical signed envelope therefore answers 401, which reads like a credentials problem and is not one. Build the retry as a fresh X-Timestamp, X-Nonce and X-Signature over the same body. The body is what carries idem_key, and that is the field that must not change: it is what stops the retry becoming a second order. Regenerating the whole request, key included, is the mistake that charges a player twice.

IP allowlist

Calls are only accepted from IPs you register in the operator console. A call from an unlisted address is rejected with 403. Two details worth knowing before you switch it on: enforcement is tied to your account's IP policy, and under the strict policy an empty list rejects everything rather than allowing everything — so register your addresses before enabling it, not after. Under the default policy the list is only checked when it has entries.

Wallet hooks sign differently

When the operator calls your wallet hooks, it uses a different canonical — five fields, and the body is hashed rather than signed raw: ts + "\n" + METHOD + "\n" + path + "\n" + RAWQUERY + "\n" + sha256hex(body) with headers X-Wallet-Token / X-Wallet-Ts. Do not reuse the B2B verifier for hooks. See Wallet hooks.

Five fields, not four. The money commands carry no query string, but the empty RAWQUERY line is still part of the string — drop it and every hook signature fails, writes included, which reads on our side as a blanket 401 from your wallet and stalls every money command. The line exists because GET /wallet/balance passes player_id in the query: signing it is what stops a tampered request from reading another player's balance.

Both canonicals have a worked example with a fixed secret and a fixed signature, and a browser tool that recomputes them from your own inputs, on Signature self-check.