Predictu
S2S Protocol

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.

Use this as your starting point. The demo handles JWT verification, idempotency checking, method routing, balance operations, and transaction recording. Adapt it to your language, framework, and database of choice.
POSThttp://localhost:5500/api/callback

Architecture

  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:

  1. S2S callback handler (/api/callback) - Receives signed callbacks from Predictu for all wallet operations.
  2. 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
);
ColumnTypeDefaultDescription
idTEXT PK -The operator's unique player identifier. This is what Predictu sends as player_id.
display_nameTEXT -Human-readable name for display in the admin panel.
emailTEXTnullOptional email address.
passwordTEXT'demo'Simplified auth for the demo. Real operators use their existing auth system.
balanceINTEGER1000000Current balance in subunits. $10,000.00 starting balance.
created_atTEXTdatetime('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
);
ColumnTypeDescription
idTEXT PKOperator-generated UUID for this transaction.
predictu_request_idTEXT UNIQUEPredictu's request_id. UNIQUE constraint enables idempotency.
methodTEXTWhich S2S method created this record.
player_idTEXT FKWhich player's wallet was affected.
amountINTEGERSigned amount in subunits. Negative for debits (BET_MAKE), positive for credits (BET_WIN, BET_SELL).
balance_afterINTEGERSnapshot of player's balance after the operation.
parent_transaction_idTEXTLinks settlement/refund/rollback to the original BET_MAKE.
market_idTEXTFor filtering transactions by market.
metadataTEXTFull 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

1
Verify JWT signature - Extract the Bearer token, verify the signature with your public key, check issuer/expiration, verify JTI not replayed, verify body digest. If verification fails, return { status: "ERROR", error_message: "JWT verification failed: ..." }.
2
Check idempotency - Look up 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.
3
Route to method handler - Switch on the 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 };
}
Return the correct error status. Notice how 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

1
Install dependencies
cd casino-demo
npm install
2
Start the server
node server.js
3
Open the casino shell - Navigate to http://localhost:5500. The page loads the Predictu widget in an iframe.
4
Place trades - Every trade triggers S2S callbacks. Watch the server logs to see callbacks arriving and being processed.
$ 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=948000

PostMessage 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:

MethodPathDescription
POST/api/auth/loginLogin or create a player. Body: { playerId, displayName, email }
GET/api/balance/:playerIdGet a player's current balance.
GET/api/playersList all players with balances.
GET/api/players/:idGet player details + transaction history.
GET/api/transactionsList recent transactions. Filter with ?player_id= or ?method=.
GET/api/statsAggregate stats: total wagered, paid out, house edge, player count.
POST/api/callbackThe 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 SimplificationProduction Requirement
SQLite databasePostgreSQL, 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 endpointsProtect /api/players, /api/stats, etc. with operator admin auth.
Auto-create players on BALANCEMap Predictu player IDs to your existing user system. Reject unknown players.
Default $10,000 starting balanceUse real player balances from your existing wallet system.
Single-process serverDeploy behind a load balancer with shared database for horizontal scaling.
No rate limitingAdd rate limiting on the callback endpoint to prevent abuse.
Console loggingUse structured logging (JSON) with request IDs for observability.
No IP whitelistingRestrict /api/callback to Predictu's IP addresses (provided during onboarding).
Start from the demo, iterate to production. The demo covers 100% of the S2S protocol surface area. The changes above are operational hardening, not protocol changes. The callback handler logic remains the same.