Seamless Wallet: Five Callbacks and a $370,000 Reconciliation Gap

2026-11-12 · vigboard

I. A real failure

An operator, three months after launch, runs month-end reconciliation:

Sum of player balances:      2,847,300
Operator ledger balance:     2,473,900
Gap:                           373,400

$373,400.

Not a hack. Not bonus abuse. Their own code refunded the same money repeatedly.

It took three days to find. The root cause was one handler:

// BROKEN
app.post('/api/game/rollback', async (req, res) => {
  const { betId } = req.body;
  await db.query('UPDATE bets SET status = "cancelled" WHERE bet_id = ?', [betId]);
  await refund(betId);   // ← no check whether this was already refunded
  res.json({ ok: true });
});

During a network incident, the provider retried the same rollback 1,847 times. Every retry issued another refund.

That's where the $373,400 went.

This article works outward from that bug through all five endpoints.

II. Two wallet models — know which one you're integrating

Before writing any code, confirm which model the provider uses.

ModelBalance livesFund flowTypical use
SeamlessYour serverReal-time callback per bet~90% of slot and live-casino content
TransferProvider's serverPlayer transfers in, plays, transfers outLottery, some Asian providers

Seamless flow

Player hits spin
    ↓
Provider server (holds game logic and RNG)
    ↓  HTTP POST
Your API: /bet      → debit
    ↓
Provider settles the round
    ↓  HTTP POST
Your API: /settle   → credit winnings
    ↓
Your frontend polls / WebSocket → balance refreshes

Your server is the bank. The provider is a merchant. On every transaction, the merchant asks the bank whether the card clears.

Transfer flow

Player clicks "transfer into game"
    ↓  your API debits, records
Provider balance +1,000
    ↓  player plays
Round ends
    ↓  provider calls back: return 850
Your API credits

Transfer is far simpler, but the UX is worse (manual in/out) and funds sit with the provider during play.

Everything below assumes Seamless.

III. The five callbacks

Names differ between providers. The semantics don't.

① /balance — read balance

POST /api/game/balance
{
  "playerId": "u_88213",
  "currency": "USDT",
  "token": "eyJhbGci..."
}
{ "balance": 1250.00, "currency": "USDT" }

Simplest endpoint. Two traps.

Trap 1 — concurrent reads that write.

A player opens three game windows. Three /balance calls land at once. If you lazily create the account inside this handler, you get deadlocks or duplicate rows.

Rule: /balance is read-only. Account missing → return balance: 0. Never create anything here.

Trap 2 — floating point.

// ❌ floats
let balance = 1250.10;
balance -= 0.10;        // 1249.9999999999998

// ✅ integers in minor units
let balance = 125010;   // cents
balance -= 10;          // 125000

This one won't explode on day one. It will surface three months later as a few cents of unreconcilable difference, and cost you a week to chase.

② /bet — debit

POST /api/game/bet
{
  "betId": "b_7f3a9c21",
  "roundId": "r_20261112_0042",
  "playerId": "u_88213",
  "amount": 10.00,
  "gameCode": "vs20fortunetiger",
  "currency": "USDT",
  "timestamp": 1762905600123,
  "sign": "a3f9..."
}
{ "ok": true, "balance": 1240.00, "betId": "b_7f3a9c21" }

The most important endpoint in the system. Three hard requirements.

Requirement 1 — idempotency on betId

CREATE TABLE bets (
  bet_id      VARCHAR(64) PRIMARY KEY,   -- the idempotency key
  round_id    VARCHAR(64) NOT NULL,
  player_id   VARCHAR(64) NOT NULL,
  amount      DECIMAL(18,2) NOT NULL,
  status      ENUM('placed','settled','cancelled') NOT NULL,
  created_at  BIGINT NOT NULL
);
// ✅ let the primary key do the work
try {
  await db.query(
    'INSERT INTO bets (bet_id, round_id, player_id, amount, status, created_at) VALUES (?,?,?,?,?,?)',
    [betId, roundId, playerId, amount, 'placed', Date.now()]
  );
} catch (e) {
  if (e.code === 'ER_DUP_ENTRY') {
    return res.json({ ok: true, balance: await getBalance(playerId), betId });
  }
  throw e;
}

Idempotency is not "check then insert".

// ❌ check-then-act has a race window
const exists = await db.query('SELECT 1 FROM bets WHERE bet_id = ?', [betId]);
if (!exists) {
  await db.query('INSERT INTO bets ...');   // two concurrent calls both reach here
}

Only a database constraint is atomic. Application-level checks are not.

Requirement 2 — check balance and debit in one transaction

BEGIN;
  SELECT balance FROM accounts WHERE player_id = ? FOR UPDATE;
  UPDATE accounts SET balance = balance - ? WHERE player_id = ? AND balance >= ?;
COMMIT;

The AND balance >= ? clause is not optional. It makes the database return affectedRows = 0 on insufficient funds, eliminating the race.

Requirement 3 — respond in under 500ms

The provider's timeout is usually 3–5 seconds, but your P99 must stay under 500ms, or retries snowball during peak hours.

How:

③ /settle — credit winnings

POST /api/game/settle
{
  "betId": "b_7f3a9c21",
  "winAmount": 95.00,
  "jackpotAmount": 0,
  "currency": "USDT",
  "sign": "..."
}
{ "ok": true, "balance": 1335.00 }

The state transition carries the idempotency:

UPDATE bets
SET status = 'settled', win_amount = ?, settled_at = ?
WHERE bet_id = ? AND status = 'placed';
-- affectedRows = 0 → already settled or already cancelled

That AND status = 'placed' is the whole guard. Never settle twice.

If the bet was already rolled back:

{ "ok": false, "error": "bet_already_cancelled" }

④ /rollback — cancel a bet (where the money leaks)

POST /api/game/rollback
{
  "betId": "b_7f3a9c21",
  "rollbackId": "rb_9e2d",
  "amount": 10.00,
  "reason": "round_cancelled",
  "sign": "..."
}

This is the source of the $373,400.

Three things trigger a rollback:

TriggerDescription
Round voidedDealer misdeal, system integrity check
Player disconnectsBet placed, round never completed
Provider-side failureSettlement failed, retry from scratch

Two requirements:

Requirement 1 — idempotency on rollbackId, not betId

CREATE TABLE rollbacks (
  rollback_id VARCHAR(64) PRIMARY KEY,   -- the idempotency key
  bet_id      VARCHAR(64) NOT NULL,
  amount      DECIMAL(18,2) NOT NULL,
  created_at  BIGINT NOT NULL
);
async function handleRollback({ rollbackId, betId, amount }) {
  return await db.transaction(async (trx) => {
    // 1. idempotency
    try {
      await trx.query(
        'INSERT INTO rollbacks (rollback_id, bet_id, amount, created_at) VALUES (?,?,?,?)',
        [rollbackId, betId, amount, Date.now()]
      );
    } catch (e) {
      if (e.code === 'ER_DUP_ENTRY') return { ok: true, note: 'already_rolled_back' };
      throw e;
    }

    // 2. state guard
    const r = await trx.query(
      `UPDATE bets SET status = 'cancelled', cancelled_at = ?
       WHERE bet_id = ? AND status = 'placed'`,
      [Date.now(), betId]
    );

    if (r.affectedRows === 0) return { ok: true, note: 'not_rollbackable' };

    // 3. refund
    await trx.query(
      `UPDATE accounts SET balance = balance + ?
       WHERE player_id = (SELECT player_id FROM bets WHERE bet_id = ?)`,
      [amount, betId]
    );

    return { ok: true, refunded: amount };
  });
}

rollbackId is the idempotency key. betId is the business key.

Using betId for idempotency misses two cases: partial rollbacks (the same bet cancelled in pieces), and re-placed bets reusing an ID namespace.

Requirement 2 — a settled bet must not be rollback-able

This is where implementations go wrong.

A bet is settled, the player has been paid, and the provider sends a rollback saying "that round was void."

Correct response: reject it and force the provider into a manual process.

{ "ok": false, "error": "bet_already_settled" }

Why not handle it automatically? Because the winnings may already have been withdrawn. Auto-reversing creates negative balances and complaints.

Escalating to manual is the provider's problem, not yours.

⑤ /adjustment — manual balance correction

POST /api/game/adjustment
{
  "adjustmentId": "adj_771",
  "playerId": "u_88213",
  "amount": -50.00,
  "reason": "bonus_reclaim",
  "operator": "cs_agent_12"
}

Used for support credits, promotion payouts and dispute resolution.

This is the only endpoint triggered by a human, so it needs:

IV. Four engineering constraints

① Idempotency: database constraints, not application logic

EndpointIdempotency key
/betbetId
/settlebetId + state machine
/rollbackrollbackId
/adjustmentadjustmentId
-- every idempotency key is a primary or unique key
ALTER TABLE xxx ADD UNIQUE KEY uk_xxx (idempotency_key);

Application code always has a race window. Only the database is atomic.

② Concurrency: serialise per player

A player's balance is a serial resource. Two concurrent requests both read 100 and each debit 80, and you land at −60.

const lockKey = `lock:player:${playerId}`;
const locked = await redis.set(lockKey, '1', 'NX', 'PX', 3000);
if (!locked) {
  return res.status(429).json({ error: 'busy' });   // provider will retry
}
try {
  // process
} finally {
  await redis.del(lockKey);
}

Or use a row lock (SELECT ... FOR UPDATE) — same effect, different failure modes.

Lock granularity is playerId, never global. A global lock serialises your whole platform under load.

③ Timeouts: you must tolerate out-of-order delivery

A typical provider retry schedule:

Attempt 1: immediate
Attempt 2: +1s
Attempt 3: +3s
Attempt 4: +9s
Attempt 5: +27s

A bet may be retried 40 seconds later — by which time settle has already arrived.

So:

// business failure — do not retry
{ "ok": false, "error": "insufficient_balance", "retryable": false }

// transient failure — safe to retry
{ "ok": false, "error": "system_busy", "retryable": true }

Telling the provider whether to retry is worth more than any documentation.

④ Signatures: HMAC, not MD5 concatenation

const crypto = require('crypto');

function verifySignature(params, secret) {
  const keys = Object.keys(params).filter(k => k !== 'sign').sort();
  const payload = keys.map(k => `${k}=${params[k]}`).join('&');

  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(params.sign, 'hex'),
    Buffer.from(expected, 'hex')
  );
}

Three non-negotiables:

V. The state machine

Every bet transition must collapse into this:

                  ┌─────────────┐
                  │   placed    │
                  └──────┬──────┘
                         │
          ┌──────────────┼──────────────┐
          ↓              ↓              ↓
   ┌────────────┐  ┌──────────┐  ┌────────────┐
   │  settled   │  │cancelled │  │  dispute   │
   └────────────┘  └──────────┘  └────────────┘
        │                 │              │
        └─────────────────┴──────────────┘
                       ↓
                  ┌─────────┐
                  │  final  │   (immutable)
                  └─────────┘
Current stateAllowed transitions
placedsettle / rollback
settleddispute only (manual)
cancellednone (terminal)
disputemanual resolution

Any automatic settled → cancelled transition must be rejected at the API boundary.

VI. Reconciliation

Everything above exists to make this section boring.

T+1 full reconciliation

Every day at 02:00:

1. Pull the provider's full bet report for yesterday (reporting API)
2. Join against local bets on betId
3. Classify differences:
   - Provider-only  → missed callback (your endpoint failed at some point)
   - Local-only     → orphan record (test data or duplicate push)
   - Amount mismatch → critical, page someone
4. Differences enter a manual queue

Three numbers you must track

MetricDefinition
TurnoverSum of all bet amounts — the settlement base
PayoutSum of all settle amounts
GGRTurnover − payout — your gross margin

If these three don't reconcile daily, your books are fiction.

The test-data trap

❌ test bets written to the production ledger
✅ test playerIds prefixed with `test_`, filtered out of reconciliation

Many operators discover, three months in, that half their reconciliation gap was pre-launch test data.

VII. Seven traps, ranked by frequency

#TrapConsequenceFix
1Rollback idempotent on betIdRepeated refunds, direct lossUse rollbackId as PK
2Floating-point balancesCents-level gaps nobody can traceIntegers in minor units
3Synchronous risk checks in /betTimeout cascade at peakMake risk async
4Check-then-act idempotencyDuplicate debits under concurrencyDB unique constraint
5Settled bets rolled backNegative balances, complaintsState machine guard
6Test data in production reconciliationPermanent unexplained differencePrefix isolation
7No T+1 reconciliationThree months of losses before detectionDaily batch

Items 1 and 4 are the direct cause of the $373,400.

VIII. Closing

Seamless Wallet looks like five HTTP endpoints. What it actually is:

You are exposing control of your ledger to a third party, who operates it via HTTP requests you don't control the timing of.

Which means:

Your endpoints must therefore be idempotent, concurrency-safe, and signed.

Get those three right and reconciliation becomes a formality. Get them wrong and you get the opening story: $373,400, one handler, three days of forensics.


Code in this article is illustrative. Implementations must match your stack and the provider's actual specification. Before going live, run the full idempotency and concurrency test suite in a sandbox.

Terminology note: providers differ in naming — placeBet, settleBet, cancelBet, refundBet all appear in the wild. Map them onto the five semantics above before writing any code.