Phoenix Prediction Docs

Host SDK

The recommended way to embed and control Phoenix Prediction from your site

@phoenix-games-io/pmm-embed is the host-side JavaScript SDK. It injects the Phoenix Prediction iframe, runs the handshake, validates every message, and turns the bridge into typed methods and events. This is the recommended integration path - you write application code (embed.on('deposit.requested', …)), never raw postMessage.

Prefer the raw wire protocol (no SDK, any language)? See Parent Bridge. Want to render your own UI instead of the hosted iframe? That is the headless @phoenix-games-io/pmm-sdk data SDK, a separate, more involved integration.

Why the SDK (and the iframe)

The market UI, the player session, and the money connection run inside the iframe, on Phoenix's origin. Your page - and any third-party script on it - cannot read the session, intercept an order, or alter the prices a player sees. The SDK is the thin, secure control layer over that boundary. It also handles the easy-to-get-wrong parts of postMessage for you: exact-origin pinning, source pinning, and envelope validation.

Install

npm install @phoenix-games-io/pmm-embed

Or drop in the UMD build with no bundler - it exposes a PmmEmbed global:

<script src="https://unpkg.com/@phoenix-games-io/pmm-embed"></script>

Quick start

import { PmmEmbed } from '@phoenix-games-io/pmm-embed'

const embed = PmmEmbed.mount({
  container: '#market',                                   // element or selector
  url: 'https://embed.prediction.phoenixverse.io/o/acme', // Phoenix gives you this
  token: signedJwt,                                       // your backend mints this
  sizing: 'auto',                                         // grow the iframe to content
  syncDocumentTitle: true,                                // keep the browser tab title in sync
})

// React to the actions the embed hands back to you:
embed.on('auth.requested', async () => embed.setToken(await mintPlayerToken()))
embed.on('deposit.requested', () => openCashier())

That is a complete integration. Everything below is detail.

Mount options

OptionTypeNotes
containerstring | HTMLElementRequired. Where the iframe mounts.
urlstringRequired. The embed URL for your operator.
tokenstringRequired. A short-lived visitor or player launch JWT.
viewPmmViewInitial view; defaults to the feed.
themePmmThemeOptions{ mode, accent, secondary, radius, neutralHue, neutralChroma, colorblind, yes, no, font, fontUrl }. The embed derives the full contrast-safe palette from these seeds.
sizing'auto' | 'container'auto makes the SDK set the iframe height from the embed's content. container (default) keeps your fixed height and scrolls internally.
density'comfortable' | 'compact'UI density.
parentOriginstringDefaults to window.location.origin. The embed pins replies to it.
syncDocumentTitleboolean | (nav) => stringKeep the browser tab title in sync automatically (see below).
sandboxstring[]Override the iframe sandbox tokens. Defaults are the safe minimum.
allowstringPermissions-Policy. Defaults to clipboard-write; fullscreen.
debugbooleanLog all protocol traffic (and dropped messages) to the console.

Events: what the embed hands back

The embed never owns login, registration, or the cashier. When it needs one, it emits an event and you run your own flow. embed.on(name, cb) returns an unsubscribe function; embed.once(...) fires once.

EventFires whenWhat you do
readyThe handshake completedOptional; the embed is live. Payload carries embedVersion + capabilities.
auth.requestedA visitor triggers a player-only action (e.g. tapping Buy)Log the user in, mint a player token, call embed.setToken(token).
deposit.requestedA player needs fundsOpen your cashier; after you credit them, push the new balance with embed.balanceUpdate (as you should after any balance change). Payload has the currency.
profile.requestedA player opens their account/profileOpen your account UI.
order.placedAn order was acceptedOptional analytics, or refresh your own balance display. Carries { marketId, orderId }.
token.expiringThe launch token is ~1 min from expiryMint a fresh token and call embed.setToken. Carries { secondsRemaining }.
navigationThe view changed inside the embedOptional: mirror your own URL / analytics. Carries { view, path, title }.
resizeContent height changed (sizing: 'auto')Handled for you - the SDK sets the iframe height.
errorAn integration-facing error occurredLog it. Carries { message, code }.

Treat order.placed and any money signal as UI hints only. The authoritative truth is your backend + the Phoenix wallet webhooks - never a postMessage. Do not credit, debit, or unlock anything based solely on a bridge event.

Commands: driving the embed

Every method is typed and safe to call before the handshake finishes - calls are queued and flushed once the embed is ready.

embed.navigate('portfolio')                  // 'feed' | 'portfolio' | 'leaderboard' | 'activity'
embed.navigate({ name: 'listing', slug })    // or a full view
embed.openMarket('btc-daily-close')          // sugar: open a listing by slug
embed.openTicket('btc-daily-close', 'A')     // open the order ticket on a side
embed.setToken(freshJwt)                      // swap the session in place, no reload
embed.setTheme({ mode: 'light' })             // re-theme at runtime, no reload
embed.setBranding({ logoUrl, wordmark })      // set the header logo / wordmark, no reload
embed.refresh()                               // re-run the current view's queries
embed.balanceUpdate({ amount, asOf })         // push the new balance after any change on your side
embed.back(); embed.forward()                 // step the embed's own history
const { view, path } = await embed.getState() // ask where the embed is now
embed.destroy()                               // tear down the iframe + listeners

The visitor → player flow

The most important handoff. The embed starts with a visitor token; the moment a player-only action is attempted, you take over auth:

embed.on('auth.requested', async () => {
  const player = await yourLoginFlow()           // your UI: login or register
  const token = await yourBackend.mintPlayerToken(player)
  embed.setToken(token)                           // the embed reconnects as the player
})
1. Embed loads with a visitor JWT.
2. Visitor taps Buy → embed emits `auth.requested`.
3. You log the user in and your backend mints a player JWT.
4. embed.setToken(playerJwt).
5. Embed reconnects as the player and the action continues.

Keeping the balance in sync

The player's wallet is yours, and the prediction embed shows the same balance the rest of your platform does. Whenever that balance changes outside the embed - a deposit, a withdrawal, a transfer, a bet on another of your games, a bonus, anything - push the new total so the embed reflects it instantly, no reconnect and no round-trip:

// after ANY balance change on your side, push the player's new total:
embed.balanceUpdate({ amount, asOf })

One common trigger is the embed's own deposit.requested event - the player taps Add Funds, you open your cashier, credit them, then push the new balance - but it is just one case; call balanceUpdate after any change, from anywhere on your platform:

embed.on('deposit.requested', () => openCashier())

async function onAnyBalanceChange() {
  const { amount, asOf } = await yourWallet.currentBalance(playerId) // a deposit, a bet elsewhere, ...
  embed.balanceUpdate({ amount, asOf })
}

amount is the player's new total balance (a decimal string), not the change. asOf is the processed_at your wallet reports for that balance - the same monotonic version a money move carries (see wallet operations). The embed merges by asOf, so an update you push and a trade the player makes a moment later can arrive in any order without an older balance overwriting a newer one. The embed keeps its current currency, and there is no setToken or reload - the player's identity has not changed. (Trades inside the embed update the balance automatically from your wallet's move responses; you only push for changes that happen elsewhere.)

Runtime theming

Your site has a dark/light toggle? Flip the embed with it - no reload, no flash:

themeToggle.onChange((mode) => embed.setTheme({ mode })) // 'dark' | 'light'

You pass seeds - mode, accent, an optional secondary highlight, radius, the neutral surface tint (neutralHue / neutralChroma, so a warm accent can sit on a cool navy/charcoal UI), the yes / no outcome colors, colorblind, and the brand font / fontUrl - and the embed derives the full contrast-safe ramp, so an illegible theme is impossible. Only the hue is taken from yes / no, so the win/loss colors stay legible whatever you pass. Initial theme is set via the mount theme option (applied before first paint, no flash).

Branding: logo & wordmark

Set your brand logo and wordmark in the header at runtime - no reload:

embed.setBranding({
  logoUrl: 'https://cdn.acme.example/logo.svg',          // shown in the header
  logoDarkUrl: 'https://cdn.acme.example/logo-dark.svg', // optional dark-surface variant
  logoHeight: 24,                                        // rendered height in px (optional)
  wordmark: 'Acme Markets',                              // shown when there is no logo
})
FieldNotes
logoUrlBrand logo image (HTTPS). Rendered in the header in place of the wordmark; used on light surfaces when logoDarkUrl is also set.
logoDarkUrlOptional logo variant for dark surfaces.
logoHeightRendered logo height in px (default ~22).
wordmarkText shown when no logo is set (replaces your operator display name).

Precedence is logo → wordmark → your operator display name. A logo that fails to load (bad or blocked URL) falls back to the wordmark automatically, so the header is never blank. Logo URLs must be HTTPS.

Browser tab title (handled for you)

A cross-origin iframe cannot set your page's document.title itself - the same-origin policy forbids it. But the SDK runs in your page, so it can. Set syncDocumentTitle and the tab title follows the embed automatically - you write nothing:

PmmEmbed.mount({ /* … */, syncDocumentTitle: true })                       // "Will BTC close above $100k?"
PmmEmbed.mount({ /* … */, syncDocumentTitle: (nav) => `${nav.title ?? 'Markets'} · Acme` })

The same navigation event also carries a path (e.g. /markets/btc-daily-close) so you can mirror it into your own address bar for shareable, SEO-friendly links.

Browser history & the Back button

In-embed navigation (feed → market → portfolio) pushes entries onto the browser's history, so the device Back button walks back through the embed's views before leaving your page - what users expect from a full-page experience. You normally do nothing. To drive it yourself (e.g. your own back control), call embed.back() / embed.forward().

React

First-class bindings ship at @phoenix-games-io/pmm-embed/react.

import { PmmEmbedFrame } from '@phoenix-games-io/pmm-embed/react'

<PmmEmbedFrame
  url="https://embed.prediction.phoenixverse.io/o/acme"
  token={token}
  sizing="auto"
  syncDocumentTitle
  on={{
    'auth.requested': handleSignIn,
    'deposit.requested': openCashier,
  }}
  style={{ width: '100%', minHeight: 720 }}
/>

token and theme changes are pushed to the running embed automatically - they never reload the iframe. For imperative control, use the hook:

import { usePmmEmbed } from '@phoenix-games-io/pmm-embed/react'

function Market({ token }: { token: string }) {
  const { containerRef, embed, ready } = usePmmEmbed({
    url: 'https://embed.prediction.phoenixverse.io/o/acme',
    token,
    sizing: 'auto',
    on: { 'deposit.requested': openCashier },
  })

  return (
    <>
      <button disabled={!ready} onClick={() => embed?.openMarket('btc-daily-close')}>
        Open BTC market
      </button>
      <div ref={containerRef} />
    </>
  )
}

Security model

The SDK enforces the parts operators get wrong; you own two things.

Handled by the SDK: exact-origin checks on every inbound message, pinning the embed's window as the only trusted sender, envelope validation (namespace + version + shape), and never using a * target origin.

Your responsibilities:

  • Register your origins. Add every page origin that frames the embed to your Phoenix operator config (allowed iframe origins). The embed rejects commands from any other origin.
  • HTTPS everywhere. Never put a launch token in a non-HTTPS URL, logs, or analytics.
  • Money truth is server-side. As above - bridge events coordinate UI; they never authorize money.

The default iframe sandbox is allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms. allow-same-origin is required for the embed's own session storage + socket auth and is safe here because the embed is a different origin than your page (the well-known sandbox-escape caveat applies only to same-origin framed content).

Diagnostics

Pass debug: true to trace the handshake, every command/event, and any dropped (untrusted-origin) message:

const embed = PmmEmbed.mount({ /* … */, debug: true })
await embed.whenReady() // resolves with { embedVersion, protocolVersion, capabilities }

embed.info exposes the handshake result (the embed's version + capabilities) once ready, so you can feature-detect against older embeds.

Common questions