Build games that earn with iCoin
Drop a single <script> tag into your HTML5 game and your players can spend their iWin Coins on in-game items. No Stripe integration, no PCI burden, no payment processor delay.
<!-- One line. That's the whole integration. --> <script src="https://cdn.iwin.com/sdk/iwin-bridge-v1.js"></script> // Then, in your game: await window.iwin.spend({ sku: "extra_life", clientTxId: "run_42", });
Get the SDK
Add this one line to your game's HTML <head>. The bridge installs window.iwin immediately and starts handshaking with the parent iWin page.
<script src="https://d22heec96vs4ra.cloudfront.net/sdk/iwin-bridge-v1.js"></script>DownloadVersion 1.1.0 Β· Published 5/31/2026, 1:36:31 AM Β· SHA-256 6fd2913a6be3b2d5β¦
What is the iCoin SDK?
iCoin is iWin's universal in-game currency. The iCoin SDK is a small JavaScript file you include in your HTML5 game; once loaded, your game gets a window.iwinobject that lets it spend the signed-in player's iCoin balance on items you define.
Under the hood, the SDK speaks to the iWin parent page via postMessage. Your game never sees the player's auth token. The parent makes the authenticated wallet API call on the player's behalf and returns a structured result.
Features
- β’ Zero-config auth handoff β parent runs the Cognito session.
- β’ Idempotent
spend()viaclientTxId. - β’ Two pricing modes: iWin catalog (SKUs we host) or signed quote (your backend signs prices).
- β’ Built-in rate limiting + origin guards.
- β’ Push events for balance + user changes via
iwin.on(). - β’ Stay-in-game top-up β modal over the iframe instead of full-page redirect (v1.1.0).
- β’ Two transports β postMessage SDK file (iframe) or direct
window.iwininstall (canvas-hosted Unity WebGL). Same API. - β’ Versioned URLs (
-v1,-v2) β additive changes within a major; v1.1.0 is back-compat with v1.0.0. - β’ ~12 KB, plain ES5, no dependencies.
Benefits for developers
- β’ No Stripe integration. No PCI burden.
- β’ Instant settlement β no payment-processor payout delay.
- β’ Unified player wallet across the iWin catalog of games.
- β’ Refunds + chargebacks handled at the platform layer.
- β’ Free admin console at
/admin/games/<slug>for SKU/price edits. - β’ Works on any HTML5 / WebGL game embedded in iframes.
Player identity β no login required
Your game never builds a login screen. The player signs in to iwin.com once (Cognito / Google / Apple / Facebook), and the SDK delivers their identity to your game automatically at handshake time.
What works signed-out vs signed-in
| Game tries to⦠| Signed in | Signed out |
|---|---|---|
| iwin.ready() | resolves with user: { sub, displayName } | resolves with user: null β game loads + runs normally |
| iwin.getBalance() | returns current iCoin balance | returns balance: null |
| iwin.getCatalog() | returns SKUs (or empty for quote-mode) | same β catalog is public |
| iwin.spend() | debits the wallet | rejects with code: "NOT_AUTHENTICATED" β game prompts login then retries |
| Game keeps playing (no wallet calls) | β | β β completely fine |
The handshake (when the player IS signed in)
- Player signs in to iwin.com (one time, owned by iWin's auth).
- Player opens any game at
/online-games/play/<slug>. iWin embeds your game in an iframe. - Your game's
<script src="...iwin-bridge-v1.js">loads.window.iwinappears. - Game calls
await window.iwin.ready()β resolves with the handshake payload:{ sdkVersion: "1.0.0", user: { sub: "abc-uuid", displayName: "Pete" } | null, balance: 200, catalog: [...], iapMode: "iwin-catalog" | "quote", gameSlug: "your-slug" } - Game reads
hello.user.subβ that's the Player ID. Done.
Typical game-side boot code
// Typical game-side boot flow. No login code needed.
const hello = await window.iwin.ready();
if (hello.user) {
// Player is signed in. hello.user.sub is the Player ID β stable
// UUID, same value shown on the player's /profile page.
startGameForPlayer(hello.user.sub, hello.user.displayName);
} else {
// Signed out β start the game ANYWAY in anonymous mode. The player
// can play normally. We'll only prompt login when they try to do
// something that requires identity (buy iCoin items, etc.).
startGameAnonymously();
}
// When the player tries to buy something while signed out:
async function onBuyClick(sku) {
try {
return await window.iwin.spend({ sku });
} catch (e) {
if (e.code === "NOT_AUTHENTICATED") {
// Prompt login, then retry. The post-login "user" event below
// also fires so the rest of your UI updates.
const { signedIn } = await window.iwin.requestLogin();
if (signedIn) return window.iwin.spend({ sku });
}
throw e;
}
}
// Subscribe to identity changes (sign-in / sign-out mid-session).
// The user.sub on the payload is the player's real Cognito Player ID β
// same UUID shown on their /profile page, same key in iWin's wallet.
window.iwin.on("user", ({ user }) => {
if (user) {
console.log("player signed in as", user.sub, user.displayName);
// Game can now refresh balance, unlock signed-in UI, etc.
} else {
console.log("player signed out");
showLockedShopUI();
}
});What's available to the game
| Field | In SDK? | Notes |
|---|---|---|
| user.sub | β | The player's Player ID β a stable UUID. Use this as your game's primary key for the player. Same value shown on their /profile page. |
| user.displayName | β | Friendly name. May be null if the player hasn't set one β fall back to "Player" or hide. |
| β | Not exposed. Email is PII; we can add to the handshake if a game has a concrete need (e.g. transactional emails). Ask. | |
| Avatar / profile picture URL | β | Not exposed. Easy to add β ask if your game wants it. |
| Cognito JWT / auth token | π« | Never crosses into an iframe β the parent keeps the token and makes wallet API calls on the player's behalf. This is intentional. (Canvas-mode first-party builds, which run inside iWin's own document, do receive it on window.iwin.getBoot().) |
| VIP status / subscription | β | Not exposed today. Add if a game wants to gate features. |
For games with their own backend
If your game has a backend (and needs to associate iWin players with a game-internal user record), the canonical pattern is:
- Trust the SDK's
subdirectly.Game's frontend POSTs{ sub }(or whatever player state you need to sync) to your backend; your backend creates/looks up a row keyed onsub. Simple. Trusts the parent β which is iwin.com, so it's safe. - Stronger proof: opt into spend receipts.Each successful spend can include a KMS-signed JWT containing the player's
sub+ the wallet ledger entry id. Your backend verifies the signature against/v1/wallet/receipt-public-key. See the Spend receipts section for the verification pattern. Skip unless you genuinely need server-verified identity claims.
user.sub + user.displayName for free at handshake. No login code. Most games trust sub directly and call it a day.Quick Start
Two pricing modes. Pick the one that fits your game.
iWin catalog mode
For lightweight games. iWin admins define your SKUs + iCoin prices in /admin/games/<slug>. The SDK fetches the active list for you.
// In your HTML5 game (loaded inside the iWin player frame):
await window.iwin.ready();
const { items } = await window.iwin.getCatalog();
renderShop(items);
// On "Buy" click:
const res = await window.iwin.spend({ sku: "gems_500" });
if (res.ok) {
grantInventory("gems_500"); // your game's own state
} else if (res.code === "INSUFFICIENT_FUNDS") {
await window.iwin.openTopUp();
}Signed-quote mode
For games with their own backend. Your server signs a short-lived ES256 JWT per purchase. Register the matching public key in the admin once at onboarding.
// In your game's frontend:
const quote = await fetch("/api/iap-quote", {
method: "POST",
body: JSON.stringify({ sku: "gems_500" }),
}).then(r => r.text());
const res = await window.iwin.spend({ sku: "gems_500", quote });
if (res.ok) grantInventory("gems_500");Quote-signing snippet (Node + jose)
Your game's backend signs the JWT. Keep the private key in your own secrets store; iWin only sees the public half.
// In your backend (Node example with 'jose'):
import { SignJWT, importPKCS8 } from "jose";
const key = await importPKCS8(process.env.MY_GAME_PRIVATE_KEY_PEM, "ES256");
app.post("/api/iap-quote", async (req, res) => {
const { sku } = req.body;
const coins = lookupPrice(sku); // your pricing logic
const jwt = await new SignJWT({ slug: "my-game", sku, coins, nonce: uuid() })
.setProtectedHeader({ alg: "ES256" })
.setIssuedAt()
.setExpirationTime("5m")
.sign(key);
res.type("text/plain").send(jwt);
});Unity / WebGL integration
Unity WebGL games speak to window.iwinthrough Unity's standard .jslibplugin pattern. Drop two files into your project and you're done. Magic Match is the reference user.
1. The JavaScript bridge
Add this file at Assets/Plugins/WebGL/IWinBridge.jslib. Unity compiles it into the WebGL build automatically. Each method routes its async result back to Unity via SendMessage().
// Assets/Plugins/WebGL/IWinBridge.jslib
//
// Bridges Unity C# DllImports to window.iwin. Each call routes the
// async result back into Unity via SendMessage(gameObjectName,
// "OnIWinResult", json).
mergeInto(LibraryManager.library, {
IWin_Ready: function (goNamePtr, reqIdPtr) {
var goName = UTF8ToString(goNamePtr);
var reqId = UTF8ToString(reqIdPtr);
if (!window.iwin) {
SendMessage(goName, "OnIWinResult", JSON.stringify({ requestId: reqId, ok: false, code: "NO_SDK" }));
return;
}
window.iwin.ready()
.then(function (h) { SendMessage(goName, "OnIWinResult", JSON.stringify(Object.assign({ requestId: reqId, ok: true, type: "ready" }, h))); })
.catch(function (e) { SendMessage(goName, "OnIWinResult", JSON.stringify({ requestId: reqId, ok: false, code: e.code || "ERROR", message: e.message || String(e) })); });
},
IWin_GetBalance: function (goNamePtr, reqIdPtr) {
var goName = UTF8ToString(goNamePtr);
var reqId = UTF8ToString(reqIdPtr);
if (!window.iwin) {
SendMessage(goName, "OnIWinResult", JSON.stringify({ requestId: reqId, ok: false, code: "NO_SDK" }));
return;
}
window.iwin.getBalance()
.then(function (r) { SendMessage(goName, "OnIWinResult", JSON.stringify(Object.assign({ requestId: reqId, ok: true, type: "balance" }, r))); })
.catch(function (e) { SendMessage(goName, "OnIWinResult", JSON.stringify({ requestId: reqId, ok: false, code: e.code || "ERROR", message: e.message || String(e) })); });
},
IWin_Spend: function (goNamePtr, reqIdPtr, argsJsonPtr) {
var goName = UTF8ToString(goNamePtr);
var reqId = UTF8ToString(reqIdPtr);
var args;
try { args = JSON.parse(UTF8ToString(argsJsonPtr)); }
catch (e) {
SendMessage(goName, "OnIWinResult", JSON.stringify({ requestId: reqId, ok: false, code: "BAD_ARGS" }));
return;
}
if (!window.iwin) {
SendMessage(goName, "OnIWinResult", JSON.stringify({ requestId: reqId, ok: false, code: "NO_SDK" }));
return;
}
window.iwin.spend(args)
.then(function (r) { SendMessage(goName, "OnIWinResult", JSON.stringify(Object.assign({ requestId: reqId, ok: true, type: "spend" }, r))); })
.catch(function (e) { SendMessage(goName, "OnIWinResult", JSON.stringify({ requestId: reqId, ok: false, code: e.code || "ERROR", message: e.message || String(e) })); });
},
IWin_OpenTopUp: function (goNamePtr, reqIdPtr) {
var goName = UTF8ToString(goNamePtr);
var reqId = UTF8ToString(reqIdPtr);
if (!window.iwin) {
SendMessage(goName, "OnIWinResult", JSON.stringify({ requestId: reqId, ok: false, code: "NO_SDK" }));
return;
}
window.iwin.openTopUp()
.then(function (r) { SendMessage(goName, "OnIWinResult", JSON.stringify(Object.assign({ requestId: reqId, ok: true, type: "topup" }, r))); })
.catch(function (e) { SendMessage(goName, "OnIWinResult", JSON.stringify({ requestId: reqId, ok: false, code: e.code || "ERROR", message: e.message || String(e) })); });
},
});2. The C# wrapper
Add this at Assets/Scripts/IWinBridge.cs and mount it as a GameObject named IWinBridge in your boot scene (with DontDestroyOnLoad). The name must match β SendMessage resolves the target by name.
// Assets/Scripts/IWinBridge.cs
//
// Mount as a GameObject named "IWinBridge" in your boot scene. Make
// it DontDestroyOnLoad so it survives scene transitions.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using UnityEngine;
public class IWinBridge : MonoBehaviour
{
public static IWinBridge Instance { get; private set; }
#if UNITY_WEBGL && !UNITY_EDITOR
[DllImport("__Internal")] private static extern void IWin_Ready(string goName, string requestId);
[DllImport("__Internal")] private static extern void IWin_GetBalance(string goName, string requestId);
[DllImport("__Internal")] private static extern void IWin_Spend(string goName, string requestId, string argsJson);
[DllImport("__Internal")] private static extern void IWin_OpenTopUp(string goName, string requestId);
#endif
private readonly Dictionary<string, TaskCompletionSource<string>> _pending = new();
private void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
}
// ββ Async API used from your gameplay code ββββββββββββββββββββββββ
public Task<HelloResult> Ready() => Call<HelloResult>(reqId => Native.Ready(name, reqId));
public Task<BalanceResult> GetBalance() => Call<BalanceResult>(reqId => Native.GetBalance(name, reqId));
public Task<SpendResult> Spend(string sku, string quote = null, string clientTxId = null, bool receipt = false)
{
var args = JsonUtility.ToJson(new SpendArgs {
sku = sku,
quote = quote,
clientTxId = clientTxId ?? Guid.NewGuid().ToString(),
receipt = receipt,
});
return Call<SpendResult>(reqId => Native.Spend(name, reqId, args));
}
public Task<TopUpResult> OpenTopUp() => Call<TopUpResult>(reqId => Native.OpenTopUp(name, reqId));
// ββ Internals βββββββββββββββββββββββββββββββββββββββββββββββββββββ
private Task<T> Call<T>(Action<string> invoke)
{
var reqId = Guid.NewGuid().ToString();
var tcs = new TaskCompletionSource<string>();
_pending[reqId] = tcs;
try { invoke(reqId); }
catch (Exception e) { tcs.SetException(e); _pending.Remove(reqId); }
return tcs.Task.ContinueWith(t => {
var json = t.Result;
var env = JsonUtility.FromJson<Envelope>(json);
if (!env.ok) throw new IWinException(env.code, env.message);
return JsonUtility.FromJson<T>(json);
});
}
// Called by .jslib via SendMessage(name, "OnIWinResult", json).
public void OnIWinResult(string json)
{
try {
var env = JsonUtility.FromJson<Envelope>(json);
if (_pending.TryGetValue(env.requestId, out var tcs)) {
_pending.Remove(env.requestId);
tcs.SetResult(json);
}
} catch (Exception e) {
Debug.LogError($"IWinBridge: parse failed: {e.Message}");
}
}
// ββ Wire to the .jslib (no-ops outside WebGL builds) ββββββββββββββ
private static class Native
{
#if UNITY_WEBGL && !UNITY_EDITOR
public static void Ready(string n, string r) => IWin_Ready(n, r);
public static void GetBalance(string n, string r) => IWin_GetBalance(n, r);
public static void Spend(string n, string r, string j) => IWin_Spend(n, r, j);
public static void OpenTopUp(string n, string r) => IWin_OpenTopUp(n, r);
#else
public static void Ready(string n, string r) => throw new IWinException("NO_PARENT", "iCoin SDK only works in WebGL builds");
public static void GetBalance(string n, string r) => throw new IWinException("NO_PARENT", "iCoin SDK only works in WebGL builds");
public static void Spend(string n, string r, string j) => throw new IWinException("NO_PARENT", "iCoin SDK only works in WebGL builds");
public static void OpenTopUp(string n, string r) => throw new IWinException("NO_PARENT", "iCoin SDK only works in WebGL builds");
#endif
}
// ββ Serializable result types (Unity's JsonUtility only handles top-level) ββ
[Serializable] private class Envelope { public string requestId; public bool ok; public string code; public string message; public string type; }
[Serializable] private class SpendArgs { public string sku; public string quote; public string clientTxId; public bool receipt; }
[Serializable] public class HelloResult { public string sdkVersion; public long balance; public string iapMode; public string gameSlug; }
[Serializable] public class BalanceResult { public long balance; public string currency; }
[Serializable] public class SpendResult { public long balance; public string ledgerSk; public bool idempotent; public string sku; public long coins; public string receipt; }
[Serializable] public class TopUpResult { public bool opened; }
}
public class IWinException : Exception
{
public string Code { get; }
public IWinException(string code, string message) : base(message) { Code = code; }
}3. Use it from gameplay code
For signed-quote mode (Magic Match's case), fetch a fresh JWT from your own backend immediately before each spend. iCoin amount is read server-side from the verified payload β you cannot be charged a different amount than your backend signed.
// In any gameplay script, after IWinBridge is in the scene:
//
// IWinBridge.Instance must be reachable from the moment your shop UI
// appears. Mounting it in your boot scene (with DontDestroyOnLoad)
// is the standard pattern.
using UnityEngine;
using System.Threading.Tasks;
public class ShopController : MonoBehaviour
{
public async void OnGemsClicked()
{
// 1. Fetch a fresh quote from YOUR backend (Magic Match's case:
// POST https://magicmatch.example.com/v1/iap-quote)
var quote = await MyGameBackend.FetchIapQuote("gems_500");
try
{
var result = await IWinBridge.Instance.Spend("gems_500", quote);
// result.coins = how many iCoins were debited (server-authoritative)
// result.ledgerSk = wallet ledger entry id (audit trail)
GrantGems(500);
ShowToast($"Spent {result.coins} coins Β· balance now {result.balance}");
}
catch (IWinException e) when (e.Code == "INSUFFICIENT_FUNDS")
{
await IWinBridge.Instance.OpenTopUp(); // opens modal or /store per game's catalog topUpMode
}
catch (IWinException e) when (e.Code == "WALLET_FROZEN")
{
ShowError("Your wallet is frozen pending a chargeback investigation.");
}
catch (IWinException e)
{
ShowError($"Purchase failed: {e.Code}");
}
}
}[DllImport("__Internal")] entry points only resolve inside a WebGL build. The provided wrapper throws IWinException("NO_PARENT") in the Unity Editor and in standalone targets so your code can detect-and-degrade β stub out your shop UI when Application.platform != WebGL during local dev.Spend receipts (optional)
For games that have their own backend and want cryptographic proof a spend happened β instead of trusting the parent page's word β opt into a receipt JWT per spend. iWin signs it with KMS; your backend verifies against our published ES256 public key.
Most games don't need this. The parent page's { ok: true } response is already trustworthy because the parent is iwin.com. Receipts are a defense-in-depth layer for game backends that want to grant items off a verified server-side message rather than a browser-side claim.
1. Request a receipt
// In your game:
const result = await window.iwin.spend({
sku: "gems_500",
receipt: true, // β opt-in
});
// result.receipt is a compact JWT string:
// "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IiJ9.<payload>.<signature>"
//
// Pass it to your game's backend (over HTTPS) for verification before
// you trust the player to have actually paid.
sendReceiptToYourBackend(result.receipt);2. Verify it server-side
Fetch GET /v1/wallet/receipt-public-keyonce, cache the PEM, verify each receipt locally. Same pattern as iWin's DRM public-key flow.
// In your game's backend (Node + jose):
import { jwtVerify, importSPKI } from "jose";
// Fetch + cache iWin's public key once at startup:
const pkRes = await fetch("https://api.iwin.com/stage/v1/wallet/receipt-public-key");
const { publicKeyPem } = await pkRes.json();
const verifier = await importSPKI(publicKeyPem, "ES256");
// Per request:
app.post("/api/verify-receipt", async (req, res) => {
try {
const { payload } = await jwtVerify(req.body.receipt, verifier, {
issuer: "iwin-wallet",
});
// payload = {
// iss: "iwin-wallet",
// sub: <player cognitoSub>,
// slug: "magic-match",
// sku: "gems_500",
// coins: 500,
// ledgerSk: "2026-05-28T19:00:00.000Z#abc-123",
// refType: "game",
// refId: "magic-match:gems_500",
// clientTxId: "run_42",
// iat: 1748466000,
// exp: 1748466300,
// }
grantItemInYourBackend(payload.sub, payload.sku, payload.coins);
res.json({ ok: true });
} catch (e) {
res.status(400).json({ error: "invalid_receipt", detail: e.message });
}
});Receipt claims
| Claim | Type | Notes |
|---|---|---|
| iss | "iwin-wallet" | Always this string. Use as a sanity check. |
| sub | string | The player's Cognito sub UUID (their Player ID). |
| slug | string | Game slug from refId. Only present for refType="game" spends. |
| sku | string | Item SKU. Only present for refType="game" spends. |
| coins | integer | Coin amount debited from the player's wallet. |
| ledgerSk | string | Unique wallet-ledger entry ID. Use this for anti-replay on your side β never honor the same ledgerSk twice. |
| refType | string | "game" | "store" | "subscription" | "other" |
| refId | string | Caller-defined reference. For game spends: "slug:sku". |
| clientTxId | string | The clientTxId the parent submitted with the spend. |
| iat | integer (seconds) | Signed-at timestamp. |
| exp | integer (seconds) | Expiry β iat + 300 (5min). Receipts older than that should be rejected. |
exp = iat + 300 seconds) but a 5-minute window is plenty of time to replay. Always dedup by ledgerSk on your side β that field is unique per wallet ledger entry and cannot be reissued.Modal top-up (v1.1.0)
v1.0.0 reacted to iwin.openTopUp() by redirecting the parent page to /store. That kills the iframe β and your game session with it. v1.1.0 adds a second mode: render a modal over the game iframe, with content authored in Builder.io per game. When the player picks a coin pack, Stripe Checkout opens embedded inside the same modal (no new tab, no redirect). On payment success the modal detects the wallet credit and auto-closes, and your game resumes immediately.
1. Direct call
Game asks for the modal explicitly. The promise resolves when the modal closes (funded or dismissed) so you can branch your retry logic on the outcome.
// Explicit per-call request for the modal experience.
const result = await window.iwin.openTopUp({
mode: "modal", // β "modal" | "redirect" | omit to use catalog default
shortfall: 250, // optional β surface in modal copy
});
// Resolves AFTER the player closes the modal (or it auto-closes on
// successful top-up). result shape:
// { opened: true, mode: "modal",
// funded: true|false,
// newBalance?: <coins>, // present when funded
// dismissed?: true } // present when the player closed
// // without paying
if (result.funded) {
// Wallet was credited β retry the failed spend.
await window.iwin.spend({ sku: "gems_500" });
}2. Auto recovery macro
Pass autoTopUp to spend() and the SDK handles the INSUFFICIENT_FUNDS recovery flow for you. The same clientTxIdis reused on the retry so a network blip can't double-charge.
// One-call ergonomics: auto-open the modal on INSUFFICIENT_FUNDS
// and auto-retry the spend once the wallet is funded.
const result = await window.iwin.spend({
sku: "gems_500",
autoTopUp: {
mode: "modal", // "modal" | "redirect"
retry: true, // default β set false to fund without retrying
},
});
// Three terminal states:
// 1. Spend went through first try β result is the normal spend result.
// 2. Spend insufficient β modal opened β player funded β SDK retried
// the spend β result is the retry's spend result.
// 3. Player dismissed the modal β original INSUFFICIENT_FUNDS
// rejection propagates to your catch block.
//
// With retry=false the second state surfaces as a resolved object:
// { ok: false, code: "INSUFFICIENT_FUNDS",
// toppedUp: true, newBalance: <coins> }
// β your game can decide whether to re-fire the spend itself.Mode resolution
- Per-call
openTopUp({ mode: "modal" | "redirect" })wins. - Otherwise the parent reads the per-game catalog
topUpModeβ admins set this at/admin/games/<slug>. Three values:"modal","redirect", or unset. - Default β v1.0.0 behavior:
router.push("/store").
Authoring the modal content
Modal contents are authored in Builder.io against the top-up-modal model. The fetch includes userAttributes: { game, shortfall } so editors can target a specific entry per game and per shortfall band. If no entry is published, the modal renders a built-in fallback with a single coin pack β fail-safe but boring.
Three custom Builder blocks are available to drop into the entry:
- Coin packages (top-up modal) β the 5-package grid. Each Buy button swaps the modal body to an embedded Stripe Checkout form (no new tab); the host page + your game iframe stay mounted throughout.
- Shortfall hint β dynamic copy with
{shortfall}/{shortfallFormatted}placeholders, so you can author copy like "You need 250 more iCoins to continue." - Dismiss button (top-up modal) β closes the modal with
dismissed: true; same behavior as the X / Esc.
mode get the per-game catalog setting (or the legacy redirect) β no game-code change required to opt in via the admin console.Modal content target: https://stage-prime.iwin.com/developers/icoin-sdk documents the SDK; Builder.io Studio (model top-up-modal) is where the in-game modal actually lives.
Canvas-hosted games (Unity WebGL)
If iWin renders your game as a <canvas> in our page chrome, you do NOT include the iwin-bridge-v1.js script tag. The host page installs window.iwin directly on the same document where your build runs. The API surface is byte-for-byte identical to the iframe SDK β same method names, same { ok: true, ... } response shape, same IwinError reject contract.
For Unity-WebGL builds the typical wiring is a .jslib file that bridges between your C# spend code and window.iwin.spend(...):
// In your Unity build's .jslib (PlayerInternal.jslib or similar):
mergeInto(LibraryManager.library, {
IwinSpend: function(skuPtr, clientTxIdPtr, quotePtr) {
var sku = UTF8ToString(skuPtr);
var clientTxId = UTF8ToString(clientTxIdPtr);
var quote = quotePtr ? UTF8ToString(quotePtr) : undefined;
// window.iwin is installed by iWin's host page BEFORE your build's
// jslib executes. No <script src=".../iwin-bridge-v1.js"> tag
// needed for canvas-hosted games β that file is for iframe games.
if (!window.iwin) {
// Defensive: log and reject. If this ever fires, the iWin host
// page didn't mount the bridge above the canvas (regression).
console.error("window.iwin missing");
return;
}
window.iwin.spend({ sku: sku, clientTxId: clientTxId, quote: quote })
.then(function(r) {
// Send the result back to C# via SendMessage.
SendMessage("IwinController", "OnSpendSuccess", JSON.stringify(r));
})
.catch(function(err) {
// err.code is one of the SDK error codes (INSUFFICIENT_FUNDS,
// QUOTE_INVALID, etc.) β same contract as the iframe SDK.
SendMessage("IwinController", "OnSpendError", err.code || "ERROR");
});
},
IwinOpenTopUp: function(shortfall) {
if (!window.iwin) return;
window.iwin.openTopUp({ shortfall: shortfall, mode: "modal" })
.then(function(r) {
SendMessage("IwinController", "OnTopUpResolve", JSON.stringify(r));
});
},
});Boot / identity handshake
Canvas builds get the player identity from window.iwin.getBoot() (equivalently the window.iwin.boot property), populated synchronously by the host page before your build boots. This is the canonical surface β new builds should read it instead of any game-specific global.
// Canvas builds read their player-identity payload from the canonical
// SDK surface BEFORE the first frame. iWin's host page populates this
// synchronously, before your build's code runs.
const boot = window.iwin.getBoot(); // or window.iwin.boot
// {
// schema: "iwin.boot/1",
// playerId, // signed-in Player ID, or a minted guest UUID
// isGuest, // bool
// platform: "webgl",
// appVersion, // your build's version string
// email, // canvas/first-party only β omitted for iframe
// loginProvider, // canvas/first-party only ("google"/"email"/...)
// cognitoToken, // canvas/first-party only β raw Cognito ID token
// previousGuestPlayerId, // present once, for the guestβlogin merge
// }
startGameForPlayer(boot.playerId, { isGuest: boot.isGuest });cognitoToken / email / loginProvider. An iframe-hosted build instead receives a REDACTED subset on the ready() payload as hello.boot (playerId, isGuest, platform, appVersion) β never the raw Cognito token, which must not cross into a separate origin.Optional handshake
The iframe SDK requires await iwin.ready()because the parent's iwin.hello arrives asynchronously over postMessage. Canvas-mode ready() resolves immediately with the same payload (user / balance / catalog / iapMode), so the call is optional β but recommended for symmetry with the iframe SDK if your build supports both transports.
// Optional: await the handshake before issuing the first spend. In
// canvas mode the handshake resolves synchronously (no postMessage
// round-trip), so this is essentially a guard against a misconfigured
// host page rather than a perf concern.
if (window.iwin && window.iwin.ready) {
window.iwin.ready().then(function(payload) {
// payload.user, payload.balance, payload.iapMode all populated.
});
}What's different in canvas mode
- No origin allowlist.Your build runs in iWin's window (same document), so the postMessage origin checks aren't applicable. There's no cross-document boundary to police.
- No
<script>tag, no SHA-256 pin. The host page is the source of truth forwindow.iwin; you can't subresource-integrity-pin a function the host installs. The publishediwin-bridge-v1.jsintegrity meta is for iframe games only. - Top-up modal lives in iWin's UI.Even in fullscreen β the modal's portal mounts inside the fullscreen target so it stays visible. Your build doesn't need to handle the top-up UI itself.
- One
window.iwinper page. If your build attempts to install its ownwindow.iwin, iWin's will overwrite yours (and vice versa). Build-time guards on your side (only assigning when!window.iwin) are good hygiene.
window.parent === window: if true, you're canvas-hosted (no parent frame); if false, you're iframe- hosted and the standard SDK script tag handshake applies.API reference
Every method on window.iwin. All methods return promises; errors throw an IwinError with a .code field from the error-code table below.
- window.iwin.ready() β Promise<HelloPayload>
- Resolves when the parent's handshake message arrives. Always await this before calling other methods (the SDK queues until it resolves anyway, but ready() lets you confirm signed-in state, balance, and catalog at startup).
- Returns:
{ sdkVersion, user, balance, catalog, gameSlug, iapMode, environment } - window.iwin.getBalance() β Promise<{ balance, currency }>
- Reads the player's current iCoin balance. Returns null balance if the player isn't signed in.
- window.iwin.getCatalog() β Promise<{ items, iapMode }>
- Returns the SKU list for iWin-catalog mode games. For signed-quote mode games, items is empty β your game already knows its catalog.
- Returns:
{ items: [{ sku, title, description, coins, imageUrl, sort }], iapMode: 'iwin-catalog' | 'quote' } - window.iwin.spend({ sku, clientTxId?, quote?, gameContext?, receipt?, autoTopUp? }) β Promise<SpendResult>
- Charge the player's wallet for the given SKU. clientTxId auto-generates via crypto.randomUUID() if you omit it. For signed-quote mode games, pass the JWT your backend signed as `quote`. gameContext is opaque metadata stored in the ledger. Set `receipt: true` to get back a KMS-signed JWT proving the debit happened. v1.1.0: pass `autoTopUp: { mode, retry }` to auto-open the top-up flow on INSUFFICIENT_FUNDS and (default) retry the spend once funded; same clientTxId is reused on the retry β see Modal top-up section.
- Returns:
{ ok: true, balance, ledgerSk, idempotent, sku, coins, receipt? } OR { ok: false, code, message } OR (with autoTopUp.retry=false) { ok: false, code: 'INSUFFICIENT_FUNDS', toppedUp, newBalance } - window.iwin.openTopUp({ shortfall?, mode? }) β Promise<TopUpResult>
- Open the player's top-up flow. v1.0.0 just navigated the parent to /store. v1.1.0 adds `mode: 'modal' | 'redirect'`; 'modal' renders a Builder.io-authored dialog over your game iframe and holds this promise until the player closes it. Omit `mode` to use the per-game catalog default. Useful after an INSUFFICIENT_FUNDS error β or use the `autoTopUp` macro on spend() instead.
- Returns:
{ opened: true, mode: 'modal' | 'redirect', funded: boolean | null, newBalance?, dismissed? } - window.iwin.requestLogin() β Promise<{ signedIn }>
- Triggers the parent's sign-in modal. Returns the post-attempt signed-in state.
- window.iwin.on(event, callback) β unsubscribe
- Subscribe to parent push events: 'balance' (after spend/topup/refund), 'user' (sign-in/out), 'catalog' (admin republish), 'error'. Returns an unsubscribe function.
- window.iwin.off(event, callback)
- Manual unsubscribe (rarely needed; on() returns the same function).
Error codes
Every IwinError.code value, what causes it, and the recommended response.
| Code | Cause | Recommended response |
|---|---|---|
| INSUFFICIENT_FUNDS | The player's wallet balance is less than the SKU's coin price. | Surface a friendly 'not enough coins' UI; call window.iwin.openTopUp() to route them to the store. |
| WALLET_FROZEN | The player has a chargeback in progress on a prior purchase; the wallet is frozen until Stripe resolves it. | Show a 'wallet frozen' message and disable purchases for the session. |
| UNKNOWN_SKU | The SKU isn't in your game's iWin-catalog. Either the catalog is stale, the SKU was removed, or you sent a typo. | Refresh getCatalog() and reconcile your UI. Don't retry the same SKU. |
| NOT_AUTHENTICATED | The player isn't signed in to iWin. | Call window.iwin.requestLogin() β opens the parent's sign-in modal. Retry the spend after onUserChanged fires with a signed-in user. |
| RATE_LIMITED | More than 10 spends in a 60-second window from this iframe. | Back off and retry after a few seconds. Usually indicates a UI bug spamming clicks. |
| QUOTE_REQUIRED | This game is in signed-quote mode but your spend() call didn't include a quote JWT. | Fetch a fresh quote from your game's backend and pass it as { sku, quote }. |
| QUOTE_INVALID | The quote JWT failed signature verification or had bad claims (wrong slug, missing fields, etc.). | Make sure your backend is signing with the correct ES256 private key whose public half is registered in /admin/games/<slug>. |
| QUOTE_EXPIRED | The quote JWT's exp claim is in the past. | Don't reuse quotes; sign a fresh one immediately before each spend() call. Default exp is 5 minutes. |
| IDEMPOTENCY_CONFLICT | You sent the same clientTxId with a different SKU. The wallet returned the original spend, but the SKU mismatch surfaces here. | Generate a fresh clientTxId for each distinct purchase intent (or omit and let the SDK do it). |
| NETWORK | Network error or 5xx from the wallet API. | Retry with the same clientTxId β the wallet's idempotency layer ensures at-most-once charging. |
| NO_PARENT | The game is loaded outside the iWin player frame (no parent window). | Detect this in standalone testing; degrade gracefully (hide your store UI). Production iframe embedding never hits this. |
| HANDSHAKE_TIMEOUT | Parent didn't send iwin.hello within 10 seconds of the SDK's iwin.ready broadcast. | Treat as NO_PARENT β likely loaded outside iWin. Don't retry; reload won't help. |
Security model
Things you don't need to worry about β iWin handles them for you:
- Coin amounts are never trusted from the iframe.The parent looks up the price server-side from either the SKU catalog or a JWT signed by your registered public key. Even if your game's JavaScript is tampered with, the worst a player can do is request a different SKU.
- Origin allowlist on every postMessage. The parent only accepts messages from the games CDN (and any extra origins your game is registered for).
- Idempotent spends. If the network drops mid-spend and the game retries with the same
clientTxId, the player is only charged once. - Rate limit. Max 10 spends per minute per iframe instance. Rejects with
RATE_LIMITED. - Auth never crosses the iframe. The Cognito session token stays on the iWin parent; your iframe-hosted game never sees it. The boot/identity handshake is transport-redacted: iframe games get a
playerId-only subset, while canvas-mode first-party builds (running inside iWin's own document) receive the full payload viawindow.iwin.getBoot(). - Frozen wallets. If a player has a chargeback in progress, spends reject with
WALLET_FROZENuntil resolved.
Versioning policy
The SDK URL includes the major version: iwin-bridge-v1.js. Within a major we only ship additive changes β any code written against v1.0.0 keeps working verbatim against v1.1.0+. Breaking changes ship as -v2.js. Your <script src> tag stays pinned to the version you tested against.
The handshake includes a sdkVersionfield; mismatch between the SDK and the parent's expected version is logged but doesn't block the bridge.
1.2.0Β· 2026-05-31 β boot/identity handshake: canvas builds readwindow.iwin.getBoot()(schemaiwin.boot/1); iframe builds get a transport-redactedhello.bootsubset. Per-gameintegrationMode(canvas/iframe) configurable in admin.1.1.0+canvasΒ· 2026-05-29 β adds canvas transport: same API, installed directly onwindow.iwinfor Unity WebGL builds rendered as a canvas in iWin's page chrome (no iframe, no script tag). See the Canvas-hosted games section.1.1.0Β· 2026-05-29 β addsopenTopUp({ mode })with"modal"support,spend({ autoTopUp })recovery macro, and per-gametopUpModeadmin setting.1.0.0Β· 2026-05-28 β initial release.
Getting your game listed
Third-party games hosted outside iWin's games CDN need a catalog row + an allowedOrigins entry. Email devs@iwin.com with your game URL and we'll provision the integration.