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 - both directions - is a single namespaced, versioned object. The receiver's first job is to drop anything that is not a pmm message of the version it speaks.
{
v: 1, // protocol version
ns: 'pmm', // namespace - ignore anything else
kind: 'event' | 'command' | 'hello' | 'ready' | 'reply',
name?: string, // event/command name (see tables below)
id?: string, // optional correlation id (command ⇄ reply)
payload?: object,
}kind is the discriminator:
hello- embed → host, the handshake announcement (version + capabilities).ready- host → embed, the handshake acknowledgement.event- embed → host, fire-and-forget (name+payload).command- host → embed (name+payload, optionalid).reply- embed → host, answers a command that carried anid.
Handshake
On load the embed sends hello (retrying briefly so a late listener still catches it). Reply with ready. Events stream regardless, but the handshake lets each side feature-detect.
// embed → host
{ v: 1, ns: 'pmm', kind: 'hello', protocolVersion: 1, embedVersion: '0.1.7',
operator: 'acme', capabilities: ['nav.events', 'theme.runtime', 'ticket.open', ...] }
// host → embed
{ v: 1, ns: 'pmm', kind: 'ready', protocolVersion: 1 }Events: iframe → host
{ v: 1, ns: 'pmm', kind: 'event', name, payload }
name | Payload | Parent behavior |
|---|---|---|
navigation | { view, path, title } | Optional: mirror your URL / <title> / analytics. |
resize | { height } | Set the iframe height (auto sizing). |
auth.requested | { reason } | Open login/register, mint a player token, send setToken. |
deposit.requested | { reason, currency } | Open your cashier. |
profile.requested | { reason } | Open your account/profile UI. |
order.placed | { marketId, orderId } | Optional analytics / refresh your balance. UI hint only. |
token.expiring | { secondsRemaining } | Mint and setToken a fresh launch token. |
error | { message, code } | Log; show a support path if needed. |
Commands: host → iframe
{ v: 1, ns: 'pmm', kind: 'command', name, payload }
name | Payload | Purpose |
|---|---|---|
navigate | { view } | Drive the embed to a view ({ name: 'feed' | 'portfolio' | 'leaderboard' | 'activity' } or { name: 'listing', slug }). |
openMarket | { slug } | Open a market by listing slug. |
openTicket | { slug, side } | Open the order ticket for a market, preselecting a side. |
setToken | { token } | Swap the session JWT in place (post-login, or refresh). |
setTheme | { mode?, accent?, secondary?, radius?, neutralHue?, neutralChroma?, colorblind?, yes?, no?, font?, fontUrl? } | Re-theme at runtime, no reload. The embed derives a contrast-safe palette from these seeds (only the hue is taken from yes/no). |
setBranding | { logoUrl?, logoDarkUrl?, logoHeight?, wordmark? } | Set the header logo / wordmark, no reload. Logo URLs must be HTTPS; a load failure falls back to the wordmark. |
refresh | {} | Re-run the current view's queries. |
balanceUpdate | { amount, asOf } | Push the player's new balance after it changes anywhere on your side (a deposit, a withdrawal, a bet on another of your products, ...). amount is the new total (decimal string); asOf is your wallet's processed_at for it. The embed merges by asOf. |
back / forward | {} | Step the embed's own browser history. |
getState | {} (+ id) | Ask for the current view; answered with a reply. |
Correlated request (getState)
Send a command with an id; the embed answers with a reply carrying the same id.
// host → embed
{ v: 1, ns: 'pmm', kind: 'command', id: 'abc', name: 'getState', payload: {} }
// embed → host
{ v: 1, ns: 'pmm', kind: 'reply', id: 'abc', ok: true, result: { view: { name: 'feed' }, path: '/' } }Visitor → player flow
1. Iframe starts with a visitor JWT.
2. Visitor taps Buy → iframe emits `auth.requested`.
3. Your site logs the user in; your backend mints a player JWT.
4. Host sends a `setToken` command.
5. Iframe reconnects as the player.Example listener (raw)
const iframe = document.querySelector('#phoenix-prediction-embed')
const phoenixOrigin = 'https://embed.prediction.phoenixverse.io'
const send = (kind, name, payload) =>
iframe?.contentWindow?.postMessage({ v: 1, ns: 'pmm', kind, name, payload }, phoenixOrigin)
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
switch (m.kind) {
case 'hello':
send('ready', undefined, undefined) // ack the handshake
break
case 'event':
if (m.name === 'auth.requested') {
const token = await loginAndMintToken()
send('command', 'setToken', { token })
} else if (m.name === 'deposit.requested') {
openCashier()
} else if (m.name === 'resize') {
iframe.style.height = `${m.payload.height}px`
}
break
}
})Security requirements
Three rules are non-negotiable. The Host SDK enforces all of them 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 readingkind/payload. - Register allowed origins in your Phoenix operator config - the embed rejects commands from any other origin.
- Money truth is server-side. Bridge events coordinate UI only; never credit, debit, or unlock on a
postMessagealone.
Iframe sandbox & permissions
If you build the iframe yourself (rather than via the SDK), use these attributes:
<iframe
src="https://embed.prediction.phoenixverse.io/o/acme?token=JWT&parent_origin=https%3A%2F%2Fwww.acme.example"
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 session storage and socket auth) and is safe because the embed is a different origin than your page.