Frontend Integration
Build the iframe bridge that receives Upside player context and controls parent-shell modals, toasts, balances, and match history.
Frontend Integration
Your game frontend runs inside an Upside iframe. It should be a normal web app with one extra responsibility: exchange trusted postMessage events with the Upside parent shell.
Responsibilities
Do
- Listen for parent messages from
https://upside.win. - Send the player JWT only to your own backend.
- Let the backend decide outcomes and call Upside settlement APIs.
- Ask the parent shell to refresh balance and match history after settlement.
Avoid
- Putting HMAC credentials in browser code.
- Trusting messages from unknown origins.
- Deciding final game outcomes only on the client.
- Rendering arbitrary modal HTML from untrusted user-generated content.
Parent Context
Upside sends the iframe a snapshot with:
| Field | Type | Meaning |
|---|---|---|
balance | string | Estimated player WIN balance in atomic units. WIN uses 18 decimals. |
token | string | Player JWT. Forward this to your backend as Authorization: Bearer {token}. |
locale | string | Active Upside locale, also appended to the iframe URL as ?locale=.... |
Upside also sends { type: "localeChange", locale } when the player changes language.
React Bridge Hook
tsximport { useCallback, useEffect, useMemo, useState } from "react";const UPSIDE_ORIGINS = new Set(["https://upside.win", "https://www.upside.win"]);type UpsideContext = { balance: string; token: string | null; locale: string;};type ToastVariant = "success" | "error" | "warning" | "info";export function useUpsideBridge() { const [parentOrigin, setParentOrigin] = useState("https://upside.win"); const [context, setContext] = useState<UpsideContext>({ balance: "0", token: null, locale: "en", }); useEffect(() => { const onMessage = (event: MessageEvent) => { if (!UPSIDE_ORIGINS.has(event.origin)) return; setParentOrigin(event.origin); const data = event.data ?? {}; if (data.type === "localeChange" && typeof data.locale === "string") { setContext(current => ({ ...current, locale: data.locale })); return; } setContext(current => ({ balance: typeof data.balance === "string" ? data.balance : current.balance, token: typeof data.token === "string" ? data.token : current.token, locale: typeof data.locale === "string" ? data.locale : current.locale, })); }; window.addEventListener("message", onMessage); for (const origin of UPSIDE_ORIGINS) { window.parent.postMessage({ type: "ready" }, origin); } return () => window.removeEventListener("message", onMessage); }, []); const postToParent = useCallback( (payload: Record<string, unknown>) => { window.parent.postMessage(payload, parentOrigin); }, [parentOrigin], ); return useMemo( () => ({ ...context, showWinModal: (wins: string, htmlContent?: string) => postToParent({ type: "showWinModal", wins, htmlContent }), showLossModal: (loss: string, htmlContent?: string) => postToParent({ type: "showLossModal", loss, htmlContent }), showCustomModal: (htmlContent: string) => postToParent({ type: "showCustomModal", htmlContent }), showToast: (message: string, options?: { description?: string; duration?: number; variant?: ToastVariant }) => postToParent({ type: "showToast", message, ...options }), refetchBalance: () => postToParent({ type: "refetchBalance" }), refreshMatchHistory: () => postToParent({ type: "refreshMatchHistory" }), }), [context, postToParent], );}
Calling Your Backend
The iframe uses the player token only to call your game backend. The backend is responsible for signing Upside API requests.
tsximport { useState } from "react";import { useUpsideBridge } from "./useUpsideBridge";const ONE_WIN = "1000000000000000000";export function PlayButton() { const upside = useUpsideBridge(); const [loading, setLoading] = useState(false); async function play(prediction: "heads" | "tails") { if (!upside.token || loading) return; setLoading(true); try { const response = await fetch("/api/play/coin-flip", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${upside.token}`, }, body: JSON.stringify({ prediction, betAmount: ONE_WIN }), }); if (!response.ok) throw new Error(await response.text()); const result = await response.json(); if (result.outcome === "win") { upside.showWinModal(result.payoutAmount); } else { upside.showLossModal(result.betAmount); } upside.refetchBalance(); upside.refreshMatchHistory(); } catch (error) { upside.showToast("Game failed", { description: error instanceof Error ? error.message : "Unknown error", variant: "error", }); } finally { setLoading(false); } } return ( <div> <p>WIN balance: {upside.balance}</p> <button disabled={!upside.token || loading} onClick={() => play("heads")}>Heads</button> <button disabled={!upside.token || loading} onClick={() => play("tails")}>Tails</button> </div> );}
Parent Commands
| Command | Payload | Effect |
|---|---|---|
ready | { type: "ready" } | Tells Upside to resend balance, token, and locale. |
showWinModal | { type, wins, htmlContent? } | Opens the Upside win modal and refreshes match history. |
showLossModal | { type, loss, htmlContent? } | Opens the Upside loss modal and refreshes match history. |
showCustomModal | { type, htmlContent } | Opens a custom Upside modal. htmlContent must be a string. |
showToast | { type, message, description?, duration?, variant? } | Shows a parent-shell toast. Variant can be success, error, warning, or info. |
refetchBalance | { type: "refetchBalance" } | Asks Upside to reload the player's aggregate WIN balance. |
refreshMatchHistory | { type: "refreshMatchHistory" } | Asks Upside to reload recent sessions in the game sidebar. |
Formatting WIN
Upside balances are atomic WIN strings with 18 decimals. Use viem or ethers for display formatting.
tsimport { formatUnits } from "viem";export function formatWin(amountWei: string) { return `${Number(formatUnits(BigInt(amountWei), 18)).toLocaleString()} WIN`;}
Accessibility Notes
- Keep focus inside your iframe during gameplay.
- Use the parent modal for settlement confirmation, but show the result inside your iframe as well.
- Do not rely only on color for win/loss states.
- Keep buttons disabled while a bet or payout is pending to avoid duplicate player actions.