Reference Implementation
The Casino Demo is a fully working casino operator backend that implements the S2S callback protocol. It is an Express.js server with a SQLite database that demonstrates how a real casino operator would integrate with Predictu's prediction market widget.
http://localhost:5500/api/callbackArchitecture
casino-demo/
├── server.js # Express server with S2S callback endpoint
├── db.js # SQLite wallet database (players, transactions)
├── jwt-verifier.js # JWT verification (signature + digest + replay check)
├── public/
│ └── index.html # Static casino shell with iframe + PostMessage bridge
├── casino.db # SQLite database file (created on first run)
└── predictu-public-key.pem # Public key for JWT verification (optional)The demo is a single Express server that serves two roles:
- S2S callback handler (
/api/callback) - Receives signed callbacks from Predictu for all wallet operations. - Casino frontend (
/) - Serves a static HTML page that embeds the Predictu widget via iframe with a PostMessage bridge.
Browser (Casino Shell) Casino Demo Server Predictu Platform
────────────────────── ────────────────── ──────────────────
│ │ │
│ Load casino page │ │
├───────────────────────────────►│ │
│ index.html + iframe │ │
│◄───────────────────────────────┤ │
│ │ │
│ iframe loads Predictu widget │ │
├────────────────────────────────────────────────────────────►│
│ │ │
│ Player places a bet │ │
├────────────────────────────────────────────────────────────►│
│ │ │
│ │ POST /api/callback │
│ │ { method: "BET_MAKE" } │
│ │◄─────────────────────────┤
│ │ │
│ │ Debit player wallet │
│ │ Record transaction │
│ │ │
│ │ { status: "OK" } │
│ ├─────────────────────────►│
│ │ │SQLite Database Schema
The demo uses three tables representing the minimal schema a casino operator needs to support S2S integration.
players Table
Player records with wallet balances. All balances are stored in subunits (cents). New players are created with a default balance of 1,000,000 subunits ($10,000.00).
CREATE TABLE IF NOT EXISTS players (
id TEXT PRIMARY KEY, -- Operator's player ID
display_name TEXT NOT NULL, -- Player display name
email TEXT, -- Optional email
password TEXT DEFAULT 'demo', -- Auth (simplified for demo)
balance INTEGER NOT NULL DEFAULT 1000000, -- Balance in subunits (cents)
created_at TEXT DEFAULT (datetime('now')) -- ISO timestamp
);| Column | Type | Default | Description |
|---|---|---|---|
id | TEXT PK | - | The operator's unique player identifier. This is what Predictu sends as player_id. |
display_name | TEXT | - | Human-readable name for display in the admin panel. |
email | TEXT | null | Optional email address. |
password | TEXT | 'demo' | Simplified auth for the demo. Real operators use their existing auth system. |
balance | INTEGER | 1000000 | Current balance in subunits. $10,000.00 starting balance. |
created_at | TEXT | datetime('now') | When the player was created. |
transactions Table
Complete audit trail of every S2S callback processed. The predictu_request_idcolumn has a UNIQUE constraint for idempotency checking.
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY, -- Operator's transaction UUID
predictu_request_id TEXT UNIQUE, -- Idempotency key (request_id)
method TEXT NOT NULL, -- S2S method (BET_MAKE, BET_WIN, etc.)
player_id TEXT REFERENCES players(id), -- FK to players table
amount INTEGER NOT NULL DEFAULT 0, -- Signed amount (negative = debit)
balance_after INTEGER, -- Player balance after this operation
parent_transaction_id TEXT, -- Links to originating BET_MAKE
market_id TEXT, -- Related market ID
metadata TEXT, -- JSON string with full method params
created_at TEXT DEFAULT (datetime('now')) -- ISO timestamp
);| Column | Type | Description |
|---|---|---|
id | TEXT PK | Operator-generated UUID for this transaction. |
predictu_request_id | TEXT UNIQUE | Predictu's request_id. UNIQUE constraint enables idempotency. |
method | TEXT | Which S2S method created this record. |
player_id | TEXT FK | Which player's wallet was affected. |
amount | INTEGER | Signed amount in subunits. Negative for debits (BET_MAKE), positive for credits (BET_WIN, BET_SELL). |
balance_after | INTEGER | Snapshot of player's balance after the operation. |
parent_transaction_id | TEXT | Links settlement/refund/rollback to the original BET_MAKE. |
market_id | TEXT | For filtering transactions by market. |
metadata | TEXT | Full method params serialized as JSON for audit. |
seen_jtis Table
JWT replay protection. Every JWT ID (jti) is recorded after first use to prevent replay attacks.
CREATE TABLE IF NOT EXISTS seen_jtis (
jti TEXT PRIMARY KEY, -- JWT ID (= request_id UUID)
created_at TEXT DEFAULT (datetime('now')) -- When first seen
);S2S Callback Handler
The core of the integration is the POST /api/callback endpoint. It follows a strict three-step flow for every incoming request.
Handler Flow
{ status: "ERROR", error_message: "JWT verification failed: ..." }.request_id in the transactions table. If a matching record exists, return DUPLICATE_TRANSACTION with the cached balance and transaction ID. Do not re-process the wallet operation.method field and dispatch to the appropriate handler function (handlePing, handleBalance, handleBetMake, etc.).Implementation
app.post("/api/callback", async (req, res) => {
const startMs = Date.now();
const authHeader = req.headers.authorization;
const bodyString = req.rawBody || JSON.stringify(req.body);
// Step 1: Verify JWT signature
const verification = await verifyS2SCallback(authHeader, bodyString);
if (!verification.valid) {
console.error(`[S2S] JWT verification failed: ${verification.error}`);
return res.json({
status: "ERROR",
error_message: `JWT verification failed: ${verification.error}`
});
}
const { method, request_id, operator_id, params } = req.body;
console.log(`[S2S] ${method} | request_id=${request_id}`);
// Step 2: Check idempotency
if (request_id) {
const existingTx = findTransactionByRequestId(request_id);
if (existingTx) {
console.log(`[S2S] Duplicate request_id=${request_id}`);
const player = params?.player_id ? getPlayer(params.player_id) : null;
return res.json({
status: "DUPLICATE_TRANSACTION",
balance: player?.balance ?? existingTx.balance_after,
transaction_id: existingTx.id,
});
}
}
// Step 3: Route to method handler
try {
let result;
switch (method) {
case "PING": result = handlePing(); break;
case "BALANCE": result = handleBalance(params); break;
case "BET_MAKE": result = handleBetMake(params, request_id); break;
case "BET_WIN": result = handleBetWin(params, request_id); break;
case "BET_LOST": result = handleBetLost(params, request_id); break;
case "BET_REFUND": result = handleBetRefund(params, request_id); break;
case "BET_ROLLBACK":result = handleBetRollback(params, request_id); break;
case "BET_SELL": result = handleBetSell(params, request_id); break;
default:
result = { status: "ERROR", error_message: `Unknown method: ${method}` };
}
const durationMs = Date.now() - startMs;
console.log(`[S2S] ${method} => ${result.status} (${durationMs}ms)`);
return res.json(result);
} catch (err) {
console.error(`[S2S] ${method} handler error:`, err);
return res.json({ status: "ERROR", error_message: err.message });
}
});Method Handler Implementations
handlePing
Simplest handler - just return OK.
function handlePing() {
return { status: "OK" };
}handleBalance
Returns the player's current balance. Auto-creates the player if not found (common pattern for first-time balance checks during embed initialization).
function handleBalance(params) {
const { player_id } = params;
const player = getPlayer(player_id);
if (!player) {
// Auto-create on first balance check
const newPlayer = getOrCreatePlayer(player_id, `Player ${player_id}`, null);
return { status: "OK", balance: newPlayer.balance };
}
return { status: "OK", balance: player.balance };
}handleBetMake
The most critical handler. Debits the player's wallet and records the transaction. Returns INSUFFICIENT_FUNDS or PLAYER_NOT_FOUND on failure.
function handleBetMake(params, requestId) {
const { player_id, transaction_id, amount, bet } = params;
// Debit player (returns error status if insufficient funds)
const result = debitPlayer(player_id, amount);
if (!result.success) {
return {
status: result.error, // "INSUFFICIENT_FUNDS" or "PLAYER_NOT_FOUND"
error_message: `Cannot debit player: ${result.error}`
};
}
// Record transaction for idempotency + audit
const txId = recordTransaction({
predictu_request_id: requestId,
method: "BET_MAKE",
player_id,
amount: -amount, // Negative = debit
balance_after: result.balance,
market_id: bet?.market_id,
metadata: { transaction_id, bet },
});
return {
status: "OK",
balance: result.balance,
transaction_id: txId,
};
}handleBetWin
Credits the player for a winning bet after market resolution.
function handleBetWin(params, requestId) {
const { player_id, amount, parent_transaction_id, settlement } = params;
const result = creditPlayer(player_id, amount);
if (!result.success) {
return { status: result.error, error_message: `Cannot credit player: ${result.error}` };
}
const txId = recordTransaction({
predictu_request_id: requestId,
method: "BET_WIN",
player_id,
amount, // Positive = credit
balance_after: result.balance,
parent_transaction_id,
market_id: settlement?.market_id,
metadata: { settlement },
});
return { status: "OK", balance: result.balance, transaction_id: txId };
}handleBetLost
Records a losing bet. No money movement - just bookkeeping.
function handleBetLost(params, requestId) {
const { player_id, parent_transaction_id, settlement } = params;
const player = getPlayer(player_id);
if (!player) return { status: "PLAYER_NOT_FOUND" };
// No debit/credit - just record for audit
const txId = recordTransaction({
predictu_request_id: requestId,
method: "BET_LOST",
player_id,
amount: 0,
balance_after: player.balance,
parent_transaction_id,
market_id: settlement?.market_id,
metadata: { settlement },
});
return { status: "OK", balance: player.balance, transaction_id: txId };
}handleBetRefund
Full refund of a voided market bet. Credits the original cost basis back.
function handleBetRefund(params, requestId) {
const { player_id, amount, parent_transaction_id, reason } = params;
const result = creditPlayer(player_id, amount);
if (!result.success) {
return { status: result.error, error_message: `Cannot refund: ${result.error}` };
}
const txId = recordTransaction({
predictu_request_id: requestId,
method: "BET_REFUND",
player_id,
amount,
balance_after: result.balance,
parent_transaction_id,
metadata: { reason },
});
return { status: "OK", balance: result.balance, transaction_id: txId };
}handleBetRollback
Reverses a failed BET_MAKE debit. Compensating transaction for atomicity.
function handleBetRollback(params, requestId) {
const { player_id, amount, parent_transaction_id, reason } = params;
const result = creditPlayer(player_id, amount);
if (!result.success) {
return { status: result.error, error_message: `Cannot rollback: ${result.error}` };
}
const txId = recordTransaction({
predictu_request_id: requestId,
method: "BET_ROLLBACK",
player_id,
amount,
balance_after: result.balance,
parent_transaction_id,
metadata: { reason },
});
return { status: "OK", balance: result.balance, transaction_id: txId };
}handleBetSell
Credits the player for selling a position before market resolution.
function handleBetSell(params, requestId) {
const { player_id, amount, parent_transaction_id, sale } = params;
const result = creditPlayer(player_id, amount);
if (!result.success) {
return { status: result.error, error_message: `Cannot credit sell: ${result.error}` };
}
const txId = recordTransaction({
predictu_request_id: requestId,
method: "BET_SELL",
player_id,
amount,
balance_after: result.balance,
parent_transaction_id,
market_id: sale?.market_id,
metadata: { sale },
});
return { status: "OK", balance: result.balance, transaction_id: txId };
}Balance Operations
The debitPlayer and creditPlayer functions are atomic balance operations that read the current balance, compute the new balance, and update it in a single step. SQLite's WAL mode ensures consistency.
debitPlayer
function debitPlayer(playerId, amountSubunits) {
const player = getPlayer(playerId);
if (!player) return { success: false, error: "PLAYER_NOT_FOUND" };
if (player.balance < amountSubunits) {
return { success: false, error: "INSUFFICIENT_FUNDS" };
}
const newBalance = player.balance - amountSubunits;
db.prepare("UPDATE players SET balance = ? WHERE id = ?")
.run(newBalance, playerId);
return { success: true, balance: newBalance };
}debitPlayer returns"PLAYER_NOT_FOUND" and "INSUFFICIENT_FUNDS" as error codes. These are passed directly to Predictu as the status field and determine whether Predictu will retry (it won't for these two codes).creditPlayer
function creditPlayer(playerId, amountSubunits) {
const player = getPlayer(playerId);
if (!player) return { success: false, error: "PLAYER_NOT_FOUND" };
const newBalance = player.balance + amountSubunits;
db.prepare("UPDATE players SET balance = ? WHERE id = ?")
.run(newBalance, playerId);
return { success: true, balance: newBalance };
}Note that creditPlayer never returns INSUFFICIENT_FUNDS - credits always succeed as long as the player exists.
Running the Demo
Prerequisites
- Node.js 18+
- npm or yarn
Setup Steps
cd casino-demo
npm installnode server.jshttp://localhost:5500. The page loads the Predictu widget in an iframe.$ node server.js
Casino Demo - S2S Reference Implementation
─────────────────────────────────────────────────
Casino Frontend : http://localhost:5500
S2S Callback : http://localhost:5500/api/callback
─────────────────────────────────────────────────
[S2S] BALANCE | request_id=abc-123 | player=player_456
[S2S] BALANCE => OK (3ms) balance=1000000
[S2S] BET_MAKE | request_id=def-456 | player=player_456
[S2S] BET_MAKE => OK (5ms) balance=948000PostMessage Bridge
The static HTML frontend embeds Predictu's widget in an iframe and communicates via the window.postMessage API. The bridge handles:
- Embed initialization - Sends the operator ID, player ID, and session token to the iframe on load.
- Balance synchronization - The casino shell can push balance updates to the widget after external deposits/withdrawals.
- Navigation events - The widget notifies the casino shell when the player navigates between markets.
- Trade notifications - The widget sends trade confirmation events that the casino shell can display in its own UI.
<!-- Casino shell: embed initialization -->
<iframe
id="predictu-widget"
src="https://casino.predictu.com/embed?operator=op_abc123"
style="width: 100%; height: 100vh; border: none;"
></iframe>
<script>
const iframe = document.getElementById("predictu-widget");
// Initialize the widget after it loads
iframe.addEventListener("load", () => {
iframe.contentWindow.postMessage({
type: "embed-init",
payload: {
operator_id: "op_abc123",
player_id: "player_456",
session_token: "sess_xyz",
display_name: "John D.",
}
}, "https://casino.predictu.com");
});
// Listen for events from the widget
window.addEventListener("message", (event) => {
if (event.origin !== "https://casino.predictu.com") return;
switch (event.data.type) {
case "trade-confirmed":
console.log("Trade:", event.data.payload);
refreshBalance();
break;
case "balance-request":
// Widget is requesting the latest balance
fetchBalanceAndPush();
break;
}
});
</script>Demo API Endpoints
The demo server exposes several convenience endpoints for the casino admin panel:
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/login | Login or create a player. Body: { playerId, displayName, email } |
| GET | /api/balance/:playerId | Get a player's current balance. |
| GET | /api/players | List all players with balances. |
| GET | /api/players/:id | Get player details + transaction history. |
| GET | /api/transactions | List recent transactions. Filter with ?player_id= or ?method=. |
| GET | /api/stats | Aggregate stats: total wagered, paid out, house edge, player count. |
| POST | /api/callback | The S2S callback endpoint. This is what Predictu calls. |
Stats Response Example
GET /api/stats
{
"players": 3,
"totalBalance": 2847200,
"totalWagered": 156000,
"totalPaidOut": 103200,
"houseEdge": 52800,
"totalCallbacks": 47,
"betsPlaced": 12,
"betsWon": 5,
"betsLost": 4
}Adapting for Production
The demo is intentionally simplified. Here is what you need to change for a production integration:
| Demo Simplification | Production Requirement |
|---|---|
| SQLite database | PostgreSQL, MySQL, or your existing wallet database with proper row-level locking. |
| JWT bypass mode (no public key) | Always verify JWTs in production. Fetch your public key from Predictu's API. |
| No authentication on admin endpoints | Protect /api/players, /api/stats, etc. with operator admin auth. |
| Auto-create players on BALANCE | Map Predictu player IDs to your existing user system. Reject unknown players. |
| Default $10,000 starting balance | Use real player balances from your existing wallet system. |
| Single-process server | Deploy behind a load balancer with shared database for horizontal scaling. |
| No rate limiting | Add rate limiting on the callback endpoint to prevent abuse. |
| Console logging | Use structured logging (JSON) with request IDs for observability. |
| No IP whitelisting | Restrict /api/callback to Predictu's IP addresses (provided during onboarding). |
