Parent Bridge
The raw postMessage wire protocol between the iframe and the operator page
This is the low-level postMessage contract between the Phoenix Prediction iframe and your page. Most integrations should use the Host SDK: it implements everything here, including the security checks. Use this page if you cannot add the dependency, are integrating from a non-JS stack, or want to understand exactly what crosses the boundary.
The iframe does not own login, registration, cashier, or deposit. When it needs one of those, it emits an event; your page runs the flow.
Envelope
Every message, in both directions, is one namespaced, versioned object. The receiver's first job is to drop anything that is not a pmm message of version 1.
{
v: 1, // protocol version: always 1
ns: 'pmm', // namespace: ignore anything else
kind: 'hello' | 'ready' | 'event' | 'command' | 'reply',
// the other fields depend on kind (below)
}kind | Direction | Fields |
|---|---|---|
hello | iframe to host | protocolVersion, embedVersion, operator, capabilities, language, views |
ready | host to iframe | protocolVersion, optional capabilities |
event | iframe to host | name, payload |
command | host to iframe | name, payload, optional id |
reply | iframe to host | id, then ok: true with result, or ok: false with error: { code, message } |
Handshake
Once the session first settles, live or refused, the iframe sends hello, and repeats it every 400 ms (8 times in all) until your page answers ready. The iframe sends no other event before its hello; anything that happens first is held and goes out right after it.
// iframe to host
{ v: 1, ns: 'pmm', kind: 'hello', protocolVersion: 1, embedVersion: '...', operator: 'acme',
capabilities: ['nav.events', 'nav.commands', 'history', 'theme.runtime', 'ticket.open',
'action.events', 'wallet.balance', 'language.runtime', 'language.changed',
'session.expired'],
language: 'en',
views: ['feed', 'listing', 'portfolio', 'leaderboard', 'activity', 'saved'] }
// host to iframe
{ v: 1, ns: 'pmm', kind: 'ready', protocolVersion: 1, capabilities: ['reload.requested'] }capabilitiesinhellolists what this embed supports, so you can check before relying on a feature:language.runtimeforsetLanguage,language.changedfor thelanguage.changedevent,session.expiredfor the session events.languageis the session's language at the handshake. Each switch after it comes as alanguage.changedevent.viewslists the views this session offers. A view the operator switched off in Curation, Features (leaderboard,activity) is not in it, so a menu of your own can match what the embed offers (see Your Site's Menu). An embed that predates feature switches sends noviews: offer every view then.- Put
reload.requestedin yourreadycapabilities only if your page reloads itself when it receives thereload.requestedevent. Only then does the "session ended" screen offer a Reload button (see Sessions and Token Refresh). - After your first
ready, the iframe sends anavigationevent with its current view.
Events: Iframe to Host
{ v: 1, ns: 'pmm', kind: 'event', name, payload }
name | Payload | Parent behavior |
|---|---|---|
navigation | { view, path, title } | Optional: mirror your URL, <title> or analytics. |
resize | { height } | Set the iframe height (only sent with sizing=auto). |
auth.requested | { reason }: sign_in, place_order, bookmark, portfolio or saved | Sign the user in, mint a player token, send setToken. |
deposit.requested | { reason, currency }: header, order_ticket or portfolio | Open your cashier. |
profile.requested | { reason }: header or menu | Open your account page. |
order.placed | { marketId, orderId }, for every order the server accepts, a cash out included | Optional analytics. UI hint only. |
notification.received | { id, kind, marketId, title, unreadCount } | Optional: badge or toast. UI hint only. |
token.expiring | { secondsRemaining } | Mint a fresh token and send setToken. |
session.expired | {} | Mint a fresh token and send setToken. Sent when the launch token has expired and the embed needs to connect: a lost connection, or a language switch, which reconnects. |
language.changed | { language } | Optional: keep your site's language switcher in step. Sent each time the session settles in another language, whether the player picked it in the embed or you sent setLanguage. hello carries the language the session started in. |
reload.requested | { reason }: session_ended or session_failed | Reload your page (only sent if your ready listed reload.requested). |
error | { message, code } | Log it. Sent for a refused command (setLanguage, or a navigate to a view the operator switched off, with code view_off), and when the session stops for any reason other than an expired token: code is Phoenix's refusal code (invalid_signature, player_banned, ... see Refusals You Can Read) or unreachable. The embed shows the player why and waits for a fresh setToken. |
Commands: Host to Iframe
{ v: 1, ns: 'pmm', kind: 'command', name, payload }
name | Payload | Purpose |
|---|---|---|
navigate | { view } | Go to a view. view is an object: { name: 'feed' }, { name: 'portfolio' }, { name: 'leaderboard' }, { name: 'activity' }, { name: 'saved' }, or { name: 'listing', slug }. A view the operator switched off (leaderboard or activity) is refused: the embed stays where it is and sends an error event with code view_off. |
openMarket | { slug } | Open an event by its slug. |
openTicket | { slug, side } | Open the order ticket for the event's market, on side A or B. |
setToken | { token } | Swap the session in place (after sign-in, or a refresh). Sending the current token again does nothing. It also starts the session again after any failure. A token Phoenix refuses ends the session (see Sessions and Token Refresh). |
setLanguage | { language } | Switch the player's language when your launch token names one. A refusal comes back as an error event with code language_not_host_owned, language_not_enabled, or session_not_live while no session is live. See Languages. |
setTheme | Theme fields | Re-theme at runtime. Only the fields you name change; a field set to null drops your own value of it. See Changing the Look at Runtime. |
setBranding | { logoUrl?, logoDarkUrl?, logoHeight?, wordmark? } | Set the header logo or wordmark. Replaces all four fields. |
refresh | {} | Reload the current view's data. |
balanceUpdate | { amount, asOf } | Push the player's new total balance after it changes on your side. amount is a decimal string; asOf is your wallet's processed_at for it. The embed keeps the highest asOf. |
back / forward | {} | Step the embed's own browser history. |
getState | {} with an id | Ask for the current view; answered with a reply. |
Commands the iframe does not know are ignored.
Correlated Request (getState)
Send a command with an id; the iframe answers with a reply carrying the same id.
// host to iframe
{ v: 1, ns: 'pmm', kind: 'command', id: 'abc', name: 'getState', payload: {} }
// iframe to host
{ v: 1, ns: 'pmm', kind: 'reply', id: 'abc', ok: true, result: { view: { name: 'feed' }, path: '/' } }Visitor to Player Flow
1. The iframe starts with a visitor token.
2. The visitor taps Buy and the iframe emits auth.requested.
3. Your site signs the user in; your backend mints a player token.
4. Your page sends a setToken command.
5. The iframe reconnects as the player.Example Listener
const iframe = document.querySelector('#phoenix-prediction-embed')
const phoenixOrigin = 'https://embed.prediction.phoenixverse.io'
const post = (message) =>
iframe?.contentWindow?.postMessage({ v: 1, ns: 'pmm', ...message }, phoenixOrigin)
const command = (name, payload) => post({ kind: 'command', name, payload })
window.addEventListener('message', async (event) => {
// 1) exact origin, 2) only the embed's window, 3) the pmm envelope of our version
if (event.origin !== phoenixOrigin) return
if (event.source !== iframe?.contentWindow) return
const m = event.data
if (!m || m.ns !== 'pmm' || m.v !== 1) return
if (m.kind === 'hello') {
post({ kind: 'ready', protocolVersion: 1, capabilities: ['reload.requested'] })
return
}
if (m.kind !== 'event') return
switch (m.name) {
case 'auth.requested':
command('setToken', { token: await loginAndMintPlayerToken() })
break
case 'token.expiring':
case 'session.expired':
command('setToken', { token: await mintCurrentSessionToken() })
break
case 'reload.requested':
window.location.reload()
break
case 'deposit.requested':
openCashier()
break
case 'resize':
iframe.style.height = `${m.payload.height}px`
break
}
})Security Requirements
The Host SDK enforces all of these for you.
- Check
event.originwith exact equality (notstartsWith) before trusting any message, and confirmevent.sourceis the embed'scontentWindow. - Send to the exact Phoenix origin, never
*. - Validate the envelope (
ns === 'pmm'andv === 1) before readingkindorpayload. - Register allowed origins for each environment (see Allowed Origins). The embed takes the framing page's origin from the browser, sends its events only to a listed origin, ignores commands from any other, and can be framed only by a listed site. Before a session has verified the list (a launch refused at boot), it talks only to the page the browser says is framing it.
- Money truth is server-side. Bridge events coordinate the UI only; never credit, debit, or unlock on a
postMessagealone.
Iframe Sandbox and Permissions
If you build the iframe yourself (rather than with the SDK), use these attributes:
<iframe
src="https://embed.prediction.phoenixverse.io/o/acme?token=JWT"
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms"
allow="clipboard-write; fullscreen"
style="width: 100%; min-height: 720px; border: 0"
></iframe>allow-same-origin is required (the embed needs its own origin for storage and its connection) and is safe because the embed is a different origin from your page. The embed can only be framed by an https page.