Predictu
Casino Integration

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.

Two-way communication. Messages flow in both directions. The parent sends initialization and balance commands to the iframe. The iframe sends status, balance, trade, and navigation events to the parent.

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.

DirectionMessage TypePurposeTrigger
Parent → Iframepredictu:initAuthenticate player, set balance, identify operatorAfter iframe load event
Parent → Iframepredictu:depositAdd funds to player's in-iframe balancePlayer deposits via casino cashier
Parent → Iframepredictu:withdrawRemove funds from player's in-iframe balancePlayer withdraws via casino cashier
Iframe → Parentpredictu:readySignal that iframe is authenticated and readyAfter processing predictu:init
Iframe → Parentpredictu:balanceReport current player balanceOn every balance change (trade, settlement, deposit)
Iframe → Parentpredictu:tradeNotify parent that a trade was executedAfter successful buy or sell execution
Iframe → Parentpredictu:navigateReport iframe route changesOn 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).

Always specify the target origin. Never use "*" 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:ready message followed by a predictu:balance message.
Re-initialization. You can send 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/deposit with 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:balance message 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/withdraw with 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:balance to confirm).
  • On success, the balance is refetched and a predictu:balance message 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

ThreatMitigation
Message spoofing - malicious script on parent page sends fake predictu:initThe 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 iframeThe 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:initThe 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 iframeThe 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:depositThe 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.
PostMessage is not a secure channel. Any JavaScript running on the parent page can send messages to the iframe and read messages from it. For this reason, the iframe never trusts balance or identity data from PostMessage alone - it always verifies against the server. S2S operators should use the S2S Protocol for financial operations.

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.origin and 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/deposit call was made and its response status.