Phoenix Prediction Docs

Launch Tokens

Mint visitor and player JWTs for Phoenix Prediction iframe launch

Your backend mints Phoenix Prediction launch tokens. The browser receives a ready-to-use token, but it never sees your private signing key.

Phoenix Prediction launch tokens are EdDSA JWTs signed with your Ed25519 private key. Phoenix verifies each token with the public key registered for the environment the token names in env. That is one key, or two while you rotate; a token signed with either verifies (see Environments and Rotating a Key).

Claims

ClaimRequiredDescription
issYesYour Phoenix Prediction operator code
envYesOperator environment: sandbox or prod. Any other value, including production, is refused with invalid_environment
expYesExpiry time, Unix seconds. At most 24 hours after iat (see Token Limits)
currencyYes (visitor and player)The session's money currency, for example KES or USD. It must be one of your operator's currencies. Case does not matter
subPlayer onlyYour player id for this user. Its presence makes the token a player token; see Visitor or Player
namePlayer, recommendedDisplay name Phoenix shows for the player, for example on the leaderboard. Trimmed and capped at 80 characters. A name that is missing or is not a usable string never refuses the launch (see Token Shapes)
avatarOptionalAvatar object. Defaults to { "kind": "none" }. See Avatar Values
languageOptionalThe player's language, for example fr. Send it only if your site has its own language switcher. See Player Language
cfgOptionalPer-user UI policy object. The embed reads deposit and profile: false hides that button. Omitted means everything is shown
iatRecommendedIssued-at time, Unix seconds. Must not be in the future. Without it, exp is measured from the moment Phoenix receives the token
jtiOptionalA unique id for this token. See Token Ids
audOptionalNot checked. If you send it, use the constant pmm_default
typOptionalNot checked. sub alone decides visitor or player

The JWT header is { "alg": "EdDSA", "typ": "JWT" } plus an optional kid (see Key Id).

Every token needs a currency

currency is required on visitor tokens too, not only on player tokens: prices, order books and volumes are kept per currency, so a visitor needs one to browse. A token without it is refused with missing_currency, and one whose currency your operator does not offer with unsupported_currency.

Visitor or Player

sub decides who the session belongs to, and nothing else does:

subSession
Absent or nullA visitor: browses markets, prices, charts, activity and the leaderboard, but cannot trade, see a portfolio or save events
A non-empty stringA player: your player with that id
Anything else (an empty string, a number)Refused with invalid_subject

When a visitor taps a player-only action, the embed asks your page to sign the user in (see Host SDK).

Token Limits

Phoenix refuses a token that breaks any of these limits. The launch does not start, and the answer names the limit that failed (see Refusals You Can Read).

LimitRuleRefusal
ShapeThree base64url parts: a JSON header, JSON claims and the signaturemalformed_token
Operator and environmentiss names your operator, env is sandbox or prod, and your operator has registered that environmentinvalid_claim (iss), unknown_operator, invalid_environment, environment_not_found
Key idA kid header, when present, names one of the environment's keysunknown_key
SignatureEdDSA, verified with the environment's key, or with either of its two keys during a rotation. With a kid, only the key it names is triedinvalid_signature
Expiryexp is an integer in the future. No clock skew is toleratedtoken_expired, or invalid_claim (exp)
Lifetimeexp minus iat is at most 24 hours (86,400 seconds). With no iat, exp minus the time Phoenix receives the token is checked insteadlifetime_too_long
Issued-atiat is an integer (Unix seconds) and is not in the future. 60 seconds of clock skew is toleratedissued_in_future, or invalid_claim (iat)
Environment statusThe environment is Activeenvironment_not_active
Token idA jti claim, when present, is a string of 1 to 256 bytes that no other token has usedjti_reused, or invalid_claim (jti)

A day is the ceiling, not a target. Tokens are bearer credentials: anyone holding one can launch as that player until it expires, so mint them for minutes. Read Sessions and Token Refresh before choosing a lifetime: the token's lifetime also decides how long a dropped connection can come back without a reload.

Refusals You Can Read

The embed's boot configuration call (GET /embed/api/config) answers a refused launch with a JSON body that names the reason, so you can see it in the browser's network panel while you integrate:

{
  "error": "unknown_key",
  "message": "The kid header names none of the environment's keys.",
  "details": { "kid": "0123456789abcdef" }
}

Branch on error. message is a sentence for a developer, and details names what was wrong (it is {} when there is nothing to add). The checks run in the order of this table and the first one that fails is the answer:

HTTPerrordetailsMeaning
401missing_tokenNo token was sent: no Authorization: Bearer header and no ?token=
401malformed_tokenThe token is not a signed JWT
401invalid_environmentallowed: the accepted valuesenv is not sandbox or prod
401invalid_claimclaim: iss, exp, iat or jtiThat claim is missing or has the wrong type
404unknown_operatoriss names no operator
404environment_not_foundenvironmentYour operator has not registered the environment env names
401unknown_keykidThe kid header names none of the environment's keys
401invalid_signatureNo key registered for the environment verifies the signature
401token_expiredexp has passed
401issued_in_futureclock_skew_siat is further ahead than the clock skew tolerated
401lifetime_too_longmax_lifetime_sThe token lives longer than a token may
403environment_not_activestatusThe environment is Suspended or Archived
401jti_reusedA different token already used this jti
401invalid_subjectsub is present but is not a non-empty string
403operator_suspendedYour operator, or your client account, is not active
403player_bannedYour support team has blocked this player in the back office
401missing_currencyThe token has no currency
401unsupported_currencycurrency: what the token stated, supported: your currenciesYour operator does not offer that currency
403operator_mismatchThe operator code in the embed URL is not the token's iss
403origin_not_allowedThe browser origin that called the config endpoint is not allowed. The hosted iframe calls it from Phoenix's embed host, so you only see this when calling the endpoint from your own page (see Allowed Origins)

An operator code and an environment appear in every launch URL, so anyone can learn that one does not exist. Every reason from token_expired down is told only for a token whose signature verified, so a forged token learns nothing about your environment's status, your standing, your players or your currencies.

The live connection applies the same token checks but gives no reason: a refused connection is a plain failed WebSocket. The embed reports the reason it read here to your page (see Parent Bridge).

Key Id (kid)

kid is an optional JWT header. When you send it, it must be the id of one of the Ed25519 public keys registered for the token's env, and only that key is tried; a token whose kid names any other key is refused with unknown_key. It is a useful guard against signing with the wrong environment's key, and during a rotation it says which of the two keys signed the token.

The id is the first 8 bytes of the SHA-256 of the raw 32-byte public key, as 16 lowercase hex characters. In Node:

import { createHash, createPublicKey } from 'node:crypto';

export function keyId(keyPem: string) {
  const spki = createPublicKey(keyPem).export({ format: 'der', type: 'spki' });
  const raw = spki.subarray(spki.length - 32);
  return createHash('sha256').update(raw).digest('hex').slice(0, 16);
}

createPublicKey accepts either the public key PEM you registered or your private key PEM (it derives the public half), so both give the same id.

Token Ids (jti)

jti is optional. When you send it, Phoenix remembers it per operator and environment until the token expires, and applies one rule: a jti names one token.

  • The same token presented again is accepted. The iframe presents its token several times (the boot configuration, the live connection, and every reconnect), so Phoenix does not treat a second sight of the same token as a replay.
  • A different token that carries a jti Phoenix has already seen is refused until the first token expires.

Generate a fresh random jti (a UUID) for every token you mint, including every refresh. A fixed value such as the player id or the session id makes every token after the first one be refused.

jti stops two different tokens sharing an id. It does not make a token single-use: a copied token keeps working until exp, so keep tokens short-lived and out of logs.

Environment Claim

env is not just metadata. It selects which of your operator environments receives the launch, and so which public keys verify the token, which sites may frame the embed and which wallet it moves money with.

envMeaning
sandboxTest environment for integration work, test wallets and staff training
prodProduction environment for real players and real money

Mint sandbox tokens only for testing traffic, and prod tokens only from production systems that are ready for real players.

Token Shapes

Use a visitor token when the user is not signed in on your site.

{
  "iss": "acme",
  "env": "sandbox",
  "currency": "KES",
  "iat": 1779100000,
  "exp": 1779100900
}

A visitor token has no sub. It still needs currency.

Use a player token after your site has signed the user in.

{
  "iss": "acme",
  "env": "sandbox",
  "sub": "operator-player-123",
  "currency": "KES",
  "name": "Player Name",
  "avatar": { "kind": "none" },
  "iat": 1779100000,
  "exp": 1779100300
}

sub is your stable player identifier. Phoenix keeps an internal player id of its own, but wallet operations, the back office and support investigations all use the id you supplied in sub.

name and avatar update the player's profile on every connection, each on its own. They never refuse a launch:

  • A name that is missing or is not a usable string (a number, an object, blank text) is ignored. The saved name stays, and a player launching for the first time has no display name until a token carries one.
  • An avatar that is invalid is ignored and the saved one stays. A missing avatar is saved as { "kind": "none" }.
  • The language a player picked in the embed is always saved, whatever name and avatar say.

Send name as a string on every player token so the leaderboard shows the player by name.

Avatar Values

ShapeExample
No avatar{ "kind": "none" }
An image{ "kind": "image", "url": "https://cdn.acme.example/avatars/123.png" } (an http or https URL)
A generated avatar{ "kind": "dicebear", "style": "bottts", "index": 7 }, where style is one of adventurer, avataaars, big-ears, bottts, lorelei, micah, notionists, pixel-art, shapes, thumbs and index is 0 to 99

Session Currency

Phoenix is multi-currency. The currency claim selects the money currency for the whole session: the prices and volumes a visitor sees, and the books a player trades, are all in that currency.

  • currency must be one of your operator's currencies.
  • It is required on every token, including for single-currency operators.
  • Set it per launch to route a player to a specific currency (for example a KES player and a USD player of the same operator).

Player Language

The optional language claim states the language the embed shows. Send it only when your site has its own language switcher, so the player's language has one owner:

  • With language, your site owns it. The embed opens in that language if your operator offers it, else in your default language, and shows no language picker of its own. When the player switches language on your site, call setLanguage on the host SDK; you do not need a new token. A code your operator does not offer never refuses the launch.
  • Without language, the embed owns it. It shows a picker when your operator offers two or more languages, and remembers the player's choice for their next launch.

Codes are short language codes (en, fr, sw); a regional form such as fr-FR is read as fr. See Languages for the full rules.

Node.js Example

import { createHash, createPublicKey, randomUUID } from 'node:crypto';
import { SignJWT, importPKCS8 } from 'jose';

const privateKeyPem = process.env.PHOENIX_PREDICTION_OPERATOR_PRIVATE_KEY_PEM!;
const key = await importPKCS8(privateKeyPem, 'EdDSA');

const kid = (() => {
  const spki = createPublicKey(privateKeyPem).export({ format: 'der', type: 'spki' });
  const raw = spki.subarray(spki.length - 32);
  return createHash('sha256').update(raw).digest('hex').slice(0, 16);
})();

export async function mintPlayerToken(player: {
  id: string;
  name: string;
  phoenixEnv: 'sandbox' | 'prod';
  currency: string; // one of your operator's currencies, e.g. 'KES'
  language?: string; // only if your site has its own language switcher
}) {
  const now = Math.floor(Date.now() / 1000);

  return new SignJWT({
    env: player.phoenixEnv,
    sub: player.id,
    currency: player.currency,
    name: player.name,
    avatar: { kind: 'none' },
    ...(player.language ? { language: player.language } : {}),
  })
    .setProtectedHeader({ alg: 'EdDSA', kid })
    .setIssuer('acme')
    .setJti(randomUUID())
    .setIssuedAt(now)
    .setExpirationTime(now + 30 * 60)
    .sign(key);
}

Compute now inside the function, on every call. A value computed once at startup would give every later token an expiry in the past.

Lifetime, Reconnects and Refresh

Phoenix issues no session token of its own. The launch token is the session's credential for its whole life: the embed presents it again on every reconnect after a network drop. A connection that is already open stays open after the token expires, but a reconnect with an expired token is refused.

So the lifetime you choose is a trade-off:

  • A short token (minutes) limits what a leaked token can do, but a player whose connection drops after it expires sees "Your session ended" until your page gives the embed a fresh token.
  • Your page can hand over a fresh token at any time without a reload: the embed warns you a minute before expiry (token.expiring) and again if the session ends (session.expired). Answer both with setToken.

Sessions and Token Refresh covers the events and the code.