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.

The current version is 0.1.5. Prefer the raw wire protocol (no SDK, any language)? See Parent Bridge.

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@0.1.5

The package is public on npmjs.com and needs no token. If your project routes every @phoenix-games-io package to GitHub Packages, point this one back at https://registry.npmjs.org/.

No bundler? Load the browser build. It exposes a PmmEmbed global; call PmmEmbed.mount(...) on it:

<script src="https://unpkg.com/@phoenix-games-io/pmm-embed@0.1.5/dist/pmm-embed.umd.cjs"></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: launchToken,                                     // your backend mints this
  sizing: 'auto',                                         // grow the iframe to content
  syncDocumentTitle: true,                                // keep the browser tab title in sync
})

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

// Keep the session alive (see Sessions and Token Refresh):
embed.on('token.expiring', async () => embed.setToken(await mintCurrentSessionToken()))
embed.on('session.expired', async () => embed.setToken(await mintCurrentSessionToken()))

await embed.whenReady() // rejects after 15 seconds if the embed never answers

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 visitor or player launch token.
operatorstringYour operator code, if it is not already in url.
viewPmmViewInitial view, for example { name: 'portfolio' } or { name: 'listing', slug }. Defaults to the feed.
themePmmThemeOptionsInitial appearance, over the look saved in the back office. See Customization.
sizing'auto' | 'container'container (default) keeps your height and scrolls inside the iframe; the SDK sets the iframe to 100% of its container. auto makes the SDK set the iframe height from the embed's content. See Sizing.
density'comfortable' | 'compact'UI density, over your look's spacing.
parentOriginstringDeprecated and ignored by current embeds, which take your page's origin from the browser and talk only to the sites the environment lists.
syncDocumentTitleboolean | (nav) => stringKeep the browser tab title in sync automatically (see below).
titlestringThe iframe's accessible title. Defaults to Prediction market.
classNamestringClass for the iframe element.
sandboxstring[]Override the iframe sandbox tokens. The defaults are the safe minimum.
allowstringPermissions-Policy. Defaults to clipboard-write; fullscreen.
debugbooleanLog protocol traffic to the console (tokens are redacted).

The plain SDK has no language option: start the language from the launch token, and switch it with setLanguage. The React bindings take a language prop (see React).

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; embed.off(name, cb) removes a listener.

EventFires whenWhat you do
readyThe handshake completed (again after any iframe reload)Optional. Carries { embedVersion, protocolVersion, operator, capabilities, language, views }. views lists the views this session offers; see Your Site's Menu.
auth.requestedA visitor taps a player-only action. reason is sign_in, place_order, bookmark, portfolio or savedSign the user in, mint a player token, call embed.setToken(token).
deposit.requestedA player taps Deposit. reason is header, order_ticket or portfolio; currency is the session currencyOpen your cashier; afterwards push the new balance with balanceUpdate.
profile.requestedA player opens their account. reason is header or menuOpen your account page.
order.placedAn order was accepted, a cash out included (it is a sale of the bet). Carries { marketId, orderId }Optional analytics.
notification.receivedA signed-in player gets a new notification, for example an order filled or a market settled. Carries { id, kind, marketId, title, unreadCount }Optional: show a badge or a toast on your page.
token.expiringThe launch token is about a minute from expiry, and again on each return to the tab after that. Carries { secondsRemaining }Mint a fresh token for the same session and call embed.setToken.
session.expiredThe token has expired and the embed needs to connect: the connection is down (in either order), or a language switch has to reconnect. The player sees "Your session ended"Mint a fresh token and call embed.setToken. The embed reconnects without a reload.
language.changedThe session now reads in another language, whether the player picked it in the embed or you called setLanguage. Carries { language }Optional: keep your own language switcher in step. ready carries the language the session started in.
reload.requestedThe player tapped Reload on a screen that offers it, such as "session ended". Carries { reason }: session_ended or session_failedNothing: after your listeners run, the SDK reloads your page so it loads with a fresh token.
navigationThe view changed inside the embed. Carries { view, path, title }Optional: mirror your own URL or analytics.
resizeContent height changed (sizing: 'auto'). Carries { height }Handled for you: the SDK sets the iframe height.
errorA command was refused (a setLanguage, or a navigate to a view the operator switched off), or the session stopped for a reason other than an expired token. Carries { message, code }: a refused navigate has the code view_off; for a stopped session the code is Phoenix's refusal code (see Refusals You Can Read) or unreachableLog it. For a stopped session the embed shows the player why, and a fresh embed.setToken starts the session again.

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

Commands: Driving the Embed

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

embed.navigate('portfolio')                  // 'feed' | 'portfolio' | 'leaderboard' | 'activity' | 'saved'
embed.navigate({ name: 'listing', slug })    // or a full view
embed.openMarket('btc-daily-close')          // open an event by its slug
embed.openTicket('btc-daily-close', 'A')     // open the order ticket on a side
embed.setToken(freshToken)                   // swap the session in place, no reload
embed.setLanguage('fr')                      // switch language, when your token names it
embed.setTheme({ mode: 'light' })            // re-theme at runtime: only the fields you name change
embed.setBranding({ logoUrl, wordmark })     // header logo or wordmark
embed.balanceUpdate({ amount, asOf })        // push the new balance after a change on your side
embed.refresh()                              // reload the current view's data
embed.back(); embed.forward()                // step the embed's own history
const { view, path } = await embed.getState() // where the embed is now (rejects after 5 seconds)
embed.destroy()                              // remove the iframe and listeners

setTheme and setBranding are covered in Customization, setLanguage in Languages.

A navigate to a view the operator has switched off is refused. The leaderboard and activity pages are off when the operator switched off the Leaderboard or Activity feature in Curation (see Features). The embed stays where it is and answers with an error event whose code is view_off:

embed.on('error', ({ code }) => {
  if (code === 'view_off') console.warn('Your menu offered a page the operator switched off')
})
embed.navigate('leaderboard')

Your Site's Menu

When your look's Header puts the links in Your site's menu, the embed draws no links to Activity, Leaderboard, Portfolio or Saved. Your own menu opens them with navigate, and every inner page shows a link back to the markets.

Build the menu from views, which the embed sends in its handshake. The SDK hands it to you in the ready info (embed.whenReady() and embed.info carry it too). It lists the views this session offers, for example ['feed', 'listing', 'portfolio', 'saved'] when the operator has switched off the leaderboard and activity. A menu built from it never links to a page that would be refused:

const MENU = [
  { view: 'feed', label: 'Markets' },
  { view: 'activity', label: 'Activity' },
  { view: 'leaderboard', label: 'Leaderboard' },
  { view: 'portfolio', label: 'Portfolio' },
  { view: 'saved', label: 'Saved' },
] as const

embed.on('ready', ({ views }) => {
  const offered = MENU.filter((item) => !views || views.includes(item.view))
  renderMenu(offered, (item) => embed.navigate(item.view)) // renderMenu is your own menu code
})
  • Rebuild the menu on every ready. It fires again after an iframe reload, so a change the operator made is picked up with the next load.
  • views is null from an embed that predates feature switches. Offer every page then.
  • views is the same for visitors and players. Portfolio and Saved ask a visitor to sign in (auth.requested).
  • listing is in views too. You open an event with openMarket, not from a menu.

The Visitor-to-Player Flow

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

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. The embed loads with a visitor token.
2. The visitor taps Buy and the embed emits auth.requested.
3. You sign the user in and your backend mints a player token.
4. embed.setToken(playerToken).
5. The embed reconnects as the player.

While the new session connects, the embed keeps showing the current one, then swaps once the new session is live.

Sessions

Phoenix has no session token of its own: every reconnect uses the launch token. Handle token.expiring and session.expired with setToken as in the quick start. Sessions and Token Refresh explains what players see and how the Reload button works.

Keeping the Balance in Sync

The player's wallet is yours, and the embed shows the same balance the rest of your platform does. Whenever that balance changes outside the embed (a deposit, a withdrawal, a bet on another of your games, a bonus), push the new total so the embed shows it at once, with no reconnect:

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

async function onAnyBalanceChange() {
  const { amount, asOf } = await yourWallet.currentBalance(playerId)
  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 version a money move carries (see processed_at). The embed keeps the highest version, 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. Trades inside the embed update the balance by themselves from your wallet's answers; you only push for changes that happen elsewhere.

Browser Tab Title

A cross-origin iframe cannot set your page's document.title. The SDK runs in your page, so it can: set syncDocumentTitle and the tab title follows the embed.

PmmEmbed.mount({ /* ... */ syncDocumentTitle: true })
PmmEmbed.mount({ /* ... */ syncDocumentTitle: (nav) => `${nav.title ?? 'Markets'} · Acme` })

The same navigation event carries a path (for example /markets/btc-daily-close) so you can mirror it into your own address bar.

Browser History and the Back Button

In-embed navigation (feed, event, portfolio) adds entries to the browser history, so the device Back button walks back through the embed's views before it leaves your page. To drive it yourself, call embed.back() and embed.forward().

React

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}
  language={language}
  sizing="auto"
  syncDocumentTitle
  on={{
    'auth.requested': handleSignIn,
    'deposit.requested': openCashier,
    'token.expiring': refreshToken,
    'session.expired': refreshToken,
  }}
  style={{ width: '100%', minHeight: 720 }}
/>

Changes to token, theme and language are pushed to the running embed without reloading it (language only when your launch token names a language), and changes to your on handlers apply at once. A change of url or operator mounts a new iframe; other props apply at mount only. className and style style the wrapper element.

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 integrations get wrong; you own the rest.

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 and shape), and never using a * target origin.

Your responsibilities:

  • Register your origins. List every page origin that frames the embed on its environment (see Allowed Origins).
  • HTTPS everywhere. The embed can only be framed by an https page. Never put a launch token in a non-HTTPS URL, logs, or analytics.
  • Money truth is server-side. Bridge events coordinate the 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 storage and connection, and is safe here because the embed is a different origin from your page.

Diagnostics

Pass debug: true to trace the handshake and every command and event:

const embed = PmmEmbed.mount({ /* ... */ debug: true })
const info = await embed.whenReady() // { embedVersion, protocolVersion, operator, capabilities, language, views }

whenReady(timeoutMs) waits 15 seconds by default. If it rejects with "handshake timed out", the embed never started: check the embed URL and that your page's origin is listed on the environment, since a site that is not listed cannot frame the embed (see Allowed Origins). A refused launch does not time out: the embed shows the player why inside the iframe and sends your page an error event with Phoenix's refusal code. The /embed/api/config call in the browser's network panel answers with the same code and its details (see Refusals You Can Read).

embed.info holds the handshake result once ready, so you can check capabilities before relying on a newer feature: language.runtime for setLanguage, language.changed for the language event, session.expired for the session events.

Common Questions