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
| Claim | Required | Description |
|---|---|---|
iss | Yes | Your Phoenix Prediction operator code |
env | Yes | Operator environment: sandbox or prod. Any other value, including production, is refused with invalid_environment |
exp | Yes | Expiry time, Unix seconds. At most 24 hours after iat (see Token Limits) |
currency | Yes (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 |
sub | Player only | Your player id for this user. Its presence makes the token a player token; see Visitor or Player |
name | Player, recommended | Display 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) |
avatar | Optional | Avatar object. Defaults to { "kind": "none" }. See Avatar Values |
language | Optional | The player's language, for example fr. Send it only if your site has its own language switcher. See Player Language |
cfg | Optional | Per-user UI policy object. The embed reads deposit and profile: false hides that button. Omitted means everything is shown |
iat | Recommended | Issued-at time, Unix seconds. Must not be in the future. Without it, exp is measured from the moment Phoenix receives the token |
jti | Optional | A unique id for this token. See Token Ids |
aud | Optional | Not checked. If you send it, use the constant pmm_default |
typ | Optional | Not 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:
sub | Session |
|---|---|
Absent or null | A visitor: browses markets, prices, charts, activity and the leaderboard, but cannot trade, see a portfolio or save events |
| A non-empty string | A 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).
| Limit | Rule | Refusal |
|---|---|---|
| Shape | Three base64url parts: a JSON header, JSON claims and the signature | malformed_token |
| Operator and environment | iss names your operator, env is sandbox or prod, and your operator has registered that environment | invalid_claim (iss), unknown_operator, invalid_environment, environment_not_found |
| Key id | A kid header, when present, names one of the environment's keys | unknown_key |
| Signature | EdDSA, 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 tried | invalid_signature |
| Expiry | exp is an integer in the future. No clock skew is tolerated | token_expired, or invalid_claim (exp) |
| Lifetime | exp minus iat is at most 24 hours (86,400 seconds). With no iat, exp minus the time Phoenix receives the token is checked instead | lifetime_too_long |
| Issued-at | iat is an integer (Unix seconds) and is not in the future. 60 seconds of clock skew is tolerated | issued_in_future, or invalid_claim (iat) |
| Environment status | The environment is Active | environment_not_active |
| Token id | A jti claim, when present, is a string of 1 to 256 bytes that no other token has used | jti_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:
| HTTP | error | details | Meaning |
|---|---|---|---|
401 | missing_token | No token was sent: no Authorization: Bearer header and no ?token= | |
401 | malformed_token | The token is not a signed JWT | |
401 | invalid_environment | allowed: the accepted values | env is not sandbox or prod |
401 | invalid_claim | claim: iss, exp, iat or jti | That claim is missing or has the wrong type |
404 | unknown_operator | iss names no operator | |
404 | environment_not_found | environment | Your operator has not registered the environment env names |
401 | unknown_key | kid | The kid header names none of the environment's keys |
401 | invalid_signature | No key registered for the environment verifies the signature | |
401 | token_expired | exp has passed | |
401 | issued_in_future | clock_skew_s | iat is further ahead than the clock skew tolerated |
401 | lifetime_too_long | max_lifetime_s | The token lives longer than a token may |
403 | environment_not_active | status | The environment is Suspended or Archived |
401 | jti_reused | A different token already used this jti | |
401 | invalid_subject | sub is present but is not a non-empty string | |
403 | operator_suspended | Your operator, or your client account, is not active | |
403 | player_banned | Your support team has blocked this player in the back office | |
401 | missing_currency | The token has no currency | |
401 | unsupported_currency | currency: what the token stated, supported: your currencies | Your operator does not offer that currency |
403 | operator_mismatch | The operator code in the embed URL is not the token's iss | |
403 | origin_not_allowed | The 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
jtiPhoenix 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.
env | Meaning |
|---|---|
sandbox | Test environment for integration work, test wallets and staff training |
prod | Production 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
namethat 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
avatarthat is invalid is ignored and the saved one stays. A missingavataris saved as{ "kind": "none" }. - The language a player picked in the embed is always saved, whatever
nameandavatarsay.
Send name as a string on every player token so the leaderboard shows the player by name.
Avatar Values
| Shape | Example |
|---|---|
| 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.
currencymust 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, callsetLanguageon 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 withsetToken.
Sessions and Token Refresh covers the events and the code.