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-embedOr 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
| Option | Type | Notes |
|---|---|---|
container | string | HTMLElement | Required. Where the iframe mounts. |
url | string | Required. The embed URL for your operator. |
token | string | Required. A short-lived visitor or player launch JWT. |
view | PmmView | Initial view; defaults to the feed. |
theme | PmmThemeOptions | { 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. |
parentOrigin | string | Defaults to window.location.origin. The embed pins replies to it. |
syncDocumentTitle | boolean | (nav) => string | Keep the browser tab title in sync automatically (see below). |
sandbox | string[] | Override the iframe sandbox tokens. Defaults are the safe minimum. |
allow | string | Permissions-Policy. Defaults to clipboard-write; fullscreen. |
debug | boolean | Log 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.
| Event | Fires when | What you do |
|---|---|---|
ready | The handshake completed | Optional; the embed is live. Payload carries embedVersion + capabilities. |
auth.requested | A visitor triggers a player-only action (e.g. tapping Buy) | Log the user in, mint a player token, call embed.setToken(token). |
deposit.requested | A player needs funds | Open 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.requested | A player opens their account/profile | Open your account UI. |
order.placed | An order was accepted | Optional analytics, or refresh your own balance display. Carries { marketId, orderId }. |
token.expiring | The launch token is ~1 min from expiry | Mint a fresh token and call embed.setToken. Carries { secondsRemaining }. |
navigation | The view changed inside the embed | Optional: mirror your own URL / analytics. Carries { view, path, title }. |
resize | Content height changed (sizing: 'auto') | Handled for you - the SDK sets the iframe height. |
error | An integration-facing error occurred | Log 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 + listenersThe 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
})| Field | Notes |
|---|---|
logoUrl | Brand logo image (HTTPS). Rendered in the header in place of the wordmark; used on light surfaces when logoDarkUrl is also set. |
logoDarkUrl | Optional logo variant for dark surfaces. |
logoHeight | Rendered logo height in px (default ~22). |
wordmark | Text 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.