PostMessage Bridge
The PostMessage bridge is the communication layer between the casino operator's parent page and the embedded Predictu iframe. It uses the browser's window.postMessage API with a structured, typed protocol. All messages are prefixed with predictu: for namespace isolation, and every incoming message is validated against the operator's registered origin whitelist.
Message Protocol Overview
Every PostMessage payload is a plain JavaScript object with a type field that identifies the message kind. The type always starts with predictu: to distinguish Predictu messages from any other PostMessage traffic on the page.
| Direction | Message Type | Purpose | Trigger |
|---|---|---|---|
| Parent → Iframe | predictu:init | Authenticate player, set balance, identify operator | After iframe load event |
| Parent → Iframe | predictu:deposit | Add funds to player's in-iframe balance | Player deposits via casino cashier |
| Parent → Iframe | predictu:withdraw | Remove funds from player's in-iframe balance | Player withdraws via casino cashier |
| Iframe → Parent | predictu:ready | Signal that iframe is authenticated and ready | After processing predictu:init |
| Iframe → Parent | predictu:balance | Report current player balance | On every balance change (trade, settlement, deposit) |
| Iframe → Parent | predictu:trade | Notify parent that a trade was executed | After successful buy or sell execution |
| Iframe → Parent | predictu:navigate | Report iframe route changes | On every Next.js route change |
Parent → Iframe Messages
These messages are sent from the casino operator's page to the Predictu iframe using iframe.contentWindow.postMessage(payload, targetOrigin).
"*" as the target origin in production. Always specify "https://app.predictu.com" to prevent messages from being intercepted by a malicious iframe on the same page.predictu:init
The initialization message. This is the first and most important message you send. It authenticates the player inside the iframe, sets their starting balance, and identifies which operator's branding and configuration to load.
Schema
{
type: "predictu:init",
user: {
id: string, // Your internal player ID (unique per player)
displayName: string, // Player's display name (shown in the UI)
email: string, // Player's email address
},
balance: number, // Current balance in dollars (e.g. 500.00)
operatorId: string, // Your operator UUID (from onboarding)
}TypeScript Interface
interface InitMessage {
type: "predictu:init";
user: {
id: string;
displayName: string;
email: string;
};
balance: number;
operatorId: string;
}Example
const iframe = document.getElementById("predictu-iframe");
const PREDICTU_ORIGIN = "https://app.predictu.com";
iframe.addEventListener("load", () => {
iframe.contentWindow.postMessage({
type: "predictu:init",
user: {
id: "usr_abc123",
displayName: "Jane Smith",
email: "jane.smith@example.com",
},
balance: 1250.50,
operatorId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
}, PREDICTU_ORIGIN);
});Behavior
- The PostMessage Bridge in the iframe receives this message and creates or updates the user record in Supabase, linked to the operator via
operator_id. - The Branding Injector fetches the operator's branding configuration and applies CSS custom properties to
<html>. - The casino store is hydrated with the user session and balance.
- Once complete, the iframe sends back a
predictu:readymessage followed by apredictu:balancemessage.
predictu:init multiple times (e.g. after the parent page reloads or if the user's balance changes externally). The iframe will re-authenticate and update the balance each time.predictu:deposit
Adds funds to the player's balance inside the iframe. Use this when the player deposits money through your casino's cashier and you want the prediction markets balance to reflect the new amount.
Schema
{
type: "predictu:deposit",
amount: number, // Amount in dollars to add (e.g. 100.00)
}TypeScript Interface
interface DepositMessage {
type: "predictu:deposit";
amount: number;
}Example
// Player deposited $100 via your cashier
function onPlayerDeposit(amount) {
const iframe = document.getElementById("predictu-iframe");
iframe.contentWindow.postMessage({
type: "predictu:deposit",
amount: amount,
}, "https://app.predictu.com");
}Behavior
- The PostMessage Bridge calls
/api/casino/depositwith the authenticated user's token. - The wallet adapter sends a deposit callback to the operator’s S2S server to credit the player’s balance.
- On success, the balance is refetched and a
predictu:balancemessage is sent back to the parent with the updated amount.
predictu:withdraw
Removes funds from the player's balance inside the iframe. Use this when the player withdraws money through your casino's cashier.
Schema
{
type: "predictu:withdraw",
amount: number, // Amount in dollars to remove (e.g. 50.00)
}TypeScript Interface
interface WithdrawMessage {
type: "predictu:withdraw";
amount: number;
}Example
// Player requested $50 withdrawal via your cashier
function onPlayerWithdraw(amount) {
const iframe = document.getElementById("predictu-iframe");
iframe.contentWindow.postMessage({
type: "predictu:withdraw",
amount: amount,
}, "https://app.predictu.com");
}Behavior
- The PostMessage Bridge calls
/api/casino/withdrawwith the authenticated user's token. - The wallet adapter sends a withdrawal callback to the operator’s S2S server to debit the player’s balance.
- If the player has insufficient balance, the withdraw will fail silently (check the updated
predictu:balanceto confirm). - On success, the balance is refetched and a
predictu:balancemessage is sent back to the parent.
Iframe → Parent Messages
These messages are sent from the Predictu iframe to the parent casino page. Listen for them on the parent's window using addEventListener("message", handler).
predictu:ready
Sent once after the iframe has processed the predictu:init message, authenticated the user, loaded operator branding, and is ready for interaction. This is the signal to hide your loading skeleton.
Schema
{
type: "predictu:ready"
}TypeScript Interface
interface ReadyMessage {
type: "predictu:ready";
}Example Listener
window.addEventListener("message", (event) => {
if (event.origin !== "https://app.predictu.com") return;
if (event.data?.type === "predictu:ready") {
console.log("Predictu iframe is authenticated and ready");
document.getElementById("loading-skeleton").style.display = "none";
document.getElementById("predictu-iframe").style.opacity = "1";
}
});predictu:balance
Sent whenever the player's balance changes inside the iframe. This happens after every trade execution, settlement payout, deposit, and withdrawal. Use this to keep your casino shell's balance display in sync.
Schema
{
type: "predictu:balance",
balance: number, // Current balance in dollars (e.g. 487.50)
}TypeScript Interface
interface BalanceMessage {
type: "predictu:balance";
balance: number;
}Example Listener
window.addEventListener("message", (event) => {
if (event.origin !== "https://app.predictu.com") return;
if (event.data?.type === "predictu:balance") {
const newBalance = event.data.balance;
// Update your casino shell's balance display
document.getElementById("player-balance").textContent =
"$" + newBalance.toFixed(2);
}
});predictu:trade
Sent after every successful trade execution (buy or sell). Contains full trade details including side, outcome, amount, shares, execution price, and market title. Use this for logging, notifications, or triggering animations in your casino shell.
Schema
{
type: "predictu:trade",
marketTitle: string, // Human-readable market name
side: string, // "buy" or "sell"
outcome: string, // "yes" or "no"
amount: number, // USD cost (for buys) or proceeds (for sells)
shares: number, // Number of shares traded
executionPrice: number, // Execution price in cents (0-100)
}TypeScript Interface
interface TradeMessage {
type: "predictu:trade";
marketTitle: string;
side: string;
outcome: string;
amount: number;
shares: number;
executionPrice: number;
}Example Listener
window.addEventListener("message", (event) => {
if (event.origin !== "https://app.predictu.com") return;
if (event.data?.type === "predictu:trade") {
const { side, outcome, amount, shares, executionPrice, marketTitle } =
event.data;
console.log(
`Trade executed: ${side.toUpperCase()} ${shares.toFixed(2)} ` +
`${outcome.toUpperCase()} shares @ ${executionPrice}c ($${amount.toFixed(2)})`
);
console.log("Market:", marketTitle);
// Example: show a toast notification in your casino shell
showToast({
type: side === "buy" ? "success" : "info",
message: `${side === "buy" ? "Bought" : "Sold"} ${shares.toFixed(1)} ` +
`${outcome.toUpperCase()} shares for $${amount.toFixed(2)}`,
});
// Example: log to your analytics
analytics.track("prediction_trade", {
side,
outcome,
amount,
shares,
executionPrice,
marketTitle,
});
}
});predictu:navigate
Sent whenever the iframe's internal route changes (Next.js App Router navigation). Contains the new path. Use this for deep linking, analytics, or dynamic iframe sizing.
Schema
{
type: "predictu:navigate",
path: string, // e.g. "/discover", "/event/will-btc-hit-100k", "/portfolio"
}TypeScript Interface
interface NavigateMessage {
type: "predictu:navigate";
path: string;
}Example Listener
window.addEventListener("message", (event) => {
if (event.origin !== "https://app.predictu.com") return;
if (event.data?.type === "predictu:navigate") {
const path = event.data.path;
// Sync browser URL for deep linking (optional)
const url = new URL(window.location.href);
url.searchParams.set("market", path);
window.history.replaceState({}, "", url.toString());
// Track page view
analytics.page("prediction_markets", { path });
}
});Complete Parent Implementation
Here is a complete, production-ready implementation combining all message types with proper error handling and origin validation:
class PredictuBridge {
constructor(iframeId, config) {
this.iframe = document.getElementById(iframeId);
this.origin = config.origin || "https://app.predictu.com";
this.operatorId = config.operatorId;
this.callbacks = config.callbacks || {};
this.ready = false;
this._listen();
}
// ── Send messages to iframe ───────────────────────────────
init(user, balance) {
this._send({
type: "predictu:init",
user,
balance,
operatorId: this.operatorId,
});
}
deposit(amount) {
if (!this.ready) {
console.warn("[PredictuBridge] Cannot deposit before ready");
return;
}
this._send({ type: "predictu:deposit", amount });
}
withdraw(amount) {
if (!this.ready) {
console.warn("[PredictuBridge] Cannot withdraw before ready");
return;
}
this._send({ type: "predictu:withdraw", amount });
}
// ── Internal ──────────────────────────────────────────────
_send(payload) {
if (!this.iframe?.contentWindow) {
console.error("[PredictuBridge] Iframe not found or not loaded");
return;
}
this.iframe.contentWindow.postMessage(payload, this.origin);
}
_listen() {
window.addEventListener("message", (event) => {
// SECURITY: Validate origin
if (event.origin !== this.origin) return;
const data = event.data;
if (!data?.type?.startsWith("predictu:")) return;
switch (data.type) {
case "predictu:ready":
this.ready = true;
this.callbacks.onReady?.();
break;
case "predictu:balance":
this.callbacks.onBalance?.(data.balance);
break;
case "predictu:trade":
this.callbacks.onTrade?.(data);
break;
case "predictu:navigate":
this.callbacks.onNavigate?.(data.path);
break;
}
});
}
}
// ── Usage ──────────────────────────────────────────────────
const bridge = new PredictuBridge("predictu-iframe", {
origin: "https://app.predictu.com",
operatorId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
callbacks: {
onReady: () => {
console.log("Predictu ready");
document.getElementById("loader").style.display = "none";
},
onBalance: (balance) => {
document.getElementById("balance").textContent =
"$" + balance.toFixed(2);
},
onTrade: (trade) => {
console.log("Trade:", trade.side, trade.outcome, "$" + trade.amount);
},
onNavigate: (path) => {
console.log("Nav:", path);
},
},
});
// Initialize after iframe loads
document.getElementById("predictu-iframe").addEventListener("load", () => {
bridge.init(
{ id: "player_123", displayName: "John", email: "john@casino.com" },
500.00
);
});Origin Validation
Origin validation is the most critical security mechanism in the PostMessage bridge. Both sides must validate origins to prevent unauthorized message injection.
Parent-Side Validation
Your casino page must check event.origin before processing any message from the iframe:
const PREDICTU_ORIGIN = "https://app.predictu.com";
window.addEventListener("message", (event) => {
// CRITICAL: Always validate the origin
if (event.origin !== PREDICTU_ORIGIN) return;
// CRITICAL: Verify the message has the predictu namespace
if (!event.data?.type?.startsWith("predictu:")) return;
// Safe to process
handlePredictuMessage(event.data);
});Iframe-Side Validation
The Predictu iframe validates incoming messages from the parent against the operator's registered origin whitelist. The validation logic in src/lib/post-message.ts:
// Set from operator config on init
let allowedParentOrigin: string | null = null;
function isOriginAllowed(origin: string): boolean {
// Exact match against the configured parent origin
if (allowedParentOrigin && origin === allowedParentOrigin) return true;
// Development convenience: allow localhost
if (isDevelopment() && isLocalhostOrigin(origin)) return true;
// All other origins are rejected
return false;
}
function listener(event: MessageEvent) {
// Drop messages from unauthorized origins
if (!isOriginAllowed(event.origin)) return;
// Drop messages without predictu namespace
if (!event.data?.type?.startsWith("predictu:")) return;
// Process valid message
handler(event.data);
}Outbound Target Origin
When the iframe sends messages to the parent, it uses the configured allowed origin as the target origin for postMessage. This prevents the message from being delivered to an unexpected parent window:
function getTargetOrigin(): string {
// If an allowed origin is configured, target it specifically
if (allowedParentOrigin) return allowedParentOrigin;
// In development, allow any origin
if (isDevelopment()) return "*";
// In production with no config, refuse to send
return "null";
}
function sendToParent(message: IframeToParentMessage) {
if (!window.parent || window.parent === window) return;
const targetOrigin = getTargetOrigin();
window.parent.postMessage(message, targetOrigin);
}Security Considerations
| Threat | Mitigation |
|---|---|
Message spoofing - malicious script on parent page sends fake predictu:init | The iframe validates event.origin against the operator's registered whitelist. Only messages from the exact registered origin are processed. |
| Eavesdropping - third-party script on parent reads messages from iframe | The iframe sends messages with a specific targetOrigin (not "*"), so only the intended parent origin receives them. However, any script running on the parent page can still read them - standard PostMessage limitation. |
Replay attacks - attacker captures and resends a valid predictu:init | The init message contains the player's current balance and identity. Replaying an old init would set an outdated balance, but subsequent trades would use the actual DB balance. The balance field in init is a convenience, not the source of truth. |
| Clickjacking - transparent overlay captures clicks intended for the iframe | The iframe's sandbox prevents top-level navigation. Additionally, all trade execution requires explicit user interaction within the iframe's UI (trade ticket confirmation). |
Balance manipulation - attacker sends fake predictu:deposit | The deposit/withdraw PostMessages call authenticated API endpoints that verify the user’s session. The operator’s wallet server is the source of truth for balance - PostMessage only triggers an API call, it does not directly modify the balance. |
React Implementation
If your casino site is built with React, here is a typed hook that wraps the PostMessage bridge:
import { useEffect, useRef, useCallback, useState } from "react";
interface PredictuUser {
id: string;
displayName: string;
email: string;
}
interface PredictuTradeEvent {
marketTitle: string;
side: string;
outcome: string;
amount: number;
shares: number;
executionPrice: number;
}
const PREDICTU_ORIGIN = "https://app.predictu.com";
export function usePredictuBridge(
iframeRef: React.RefObject<HTMLIFrameElement>,
operatorId: string
) {
const [ready, setReady] = useState(false);
const [balance, setBalance] = useState(0);
const [lastTrade, setLastTrade] = useState<PredictuTradeEvent | null>(null);
const [currentPath, setCurrentPath] = useState("/");
// Listen for iframe messages
useEffect(() => {
function handler(event: MessageEvent) {
if (event.origin !== PREDICTU_ORIGIN) return;
if (!event.data?.type?.startsWith("predictu:")) return;
switch (event.data.type) {
case "predictu:ready":
setReady(true);
break;
case "predictu:balance":
setBalance(event.data.balance);
break;
case "predictu:trade":
setLastTrade(event.data);
break;
case "predictu:navigate":
setCurrentPath(event.data.path);
break;
}
}
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
// Send init
const init = useCallback(
(user: PredictuUser, initialBalance: number) => {
iframeRef.current?.contentWindow?.postMessage(
{
type: "predictu:init",
user,
balance: initialBalance,
operatorId,
},
PREDICTU_ORIGIN
);
},
[iframeRef, operatorId]
);
// Send deposit
const deposit = useCallback(
(amount: number) => {
iframeRef.current?.contentWindow?.postMessage(
{ type: "predictu:deposit", amount },
PREDICTU_ORIGIN
);
},
[iframeRef]
);
// Send withdraw
const withdraw = useCallback(
(amount: number) => {
iframeRef.current?.contentWindow?.postMessage(
{ type: "predictu:withdraw", amount },
PREDICTU_ORIGIN
);
},
[iframeRef]
);
return { ready, balance, lastTrade, currentPath, init, deposit, withdraw };
}Debugging PostMessages
Tips for debugging PostMessage communication:
- Chrome DevTools → Console: Add
window.addEventListener("message", e => console.log("MSG:", e.origin, e.data))on both the parent page and inside the iframe (via DevTools) to see all messages. - Check origins: If messages are being silently dropped, the most likely cause is an origin mismatch. Log
event.originand compare with your whitelist. - Iframe DevTools: In Chrome, right-click the iframe content, select "Inspect", and you get a separate DevTools panel for the iframe's execution context.
- Network tab: After sending
predictu:deposit, check the Network tab in the iframe's DevTools to verify the/api/casino/depositcall was made and its response status.
