Phoenix Prediction Docs

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)
}
kindDirectionFields
helloiframe to hostprotocolVersion, embedVersion, operator, capabilities, language, views
readyhost to iframeprotocolVersion, optional capabilities
eventiframe to hostname, payload
commandhost to iframename, payload, optional id
replyiframe to hostid, 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'] }
  • capabilities in hello lists what this embed supports, so you can check before relying on a feature: language.runtime for setLanguage, language.changed for the language.changed event, session.expired for the session events.
  • language is the session's language at the handshake. Each switch after it comes as a language.changed event.
  • views lists 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 no views: offer every view then.
  • Put reload.requested in your ready capabilities only if your page reloads itself when it receives the reload.requested event. Only then does the "session ended" screen offer a Reload button (see Sessions and Token Refresh).
  • After your first ready, the iframe sends a navigation event with its current view.

Events: Iframe to Host

{ v: 1, ns: 'pmm', kind: 'event', name, payload }

namePayloadParent 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 savedSign the user in, mint a player token, send setToken.
deposit.requested{ reason, currency }: header, order_ticket or portfolioOpen your cashier.
profile.requested{ reason }: header or menuOpen your account page.
order.placed{ marketId, orderId }, for every order the server accepts, a cash out includedOptional 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_failedReload 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 }

namePayloadPurpose
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.
setThemeTheme fieldsRe-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 idAsk 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.origin with exact equality (not startsWith) before trusting any message, and confirm event.source is the embed's contentWindow.
  • Send to the exact Phoenix origin, never *.
  • Validate the envelope (ns === 'pmm' and v === 1) before reading kind or payload.
  • 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 postMessage alone.

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.

Common Questions