Seamless Wallet: Five Callbacks and a $370,000 Reconciliation Gap
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.
| Model | Balance lives | Fund flow | Typical use |
|---|---|---|---|
| Seamless | Your server | Real-time callback per bet | ~90% of slot and live-casino content |
| Transfer | Provider's server | Player transfers in, plays, transfers out | Lottery, 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:
- No external calls inside
/bet(no risk engine, no blacklist lookups) - Risk checks go async — debit first, review after, freeze if suspicious
- Warm the connection pool
③ /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:
| Trigger | Description |
|---|---|
| Round voided | Dealer misdeal, system integrity check |
| Player disconnects | Bet placed, round never completed |
| Provider-side failure | Settlement 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:
- Dual approval (requester + approver)
- Full audit trail (who, when, why, how much)
- Per-transaction limits (above threshold → escalation)
- No negative balance (validate before applying)
IV. Four engineering constraints
① Idempotency: database constraints, not application logic
| Endpoint | Idempotency key |
|---|---|
/bet | betId |
/settle | betId + state machine |
/rollback | rollbackId |
/adjustment | adjustmentId |
-- 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:
- Every endpoint must tolerate out-of-order arrival
settlearriving beforebetis normal — buffer it, reconcile when the bet lands- Never return 500 on a business rejection — return a specific error so the provider stops retrying
// 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:
- HMAC-SHA256, not MD5 (collisions are trivial)
- Include a timestamp and enforce a window (±5 minutes) against replay
timingSafeEqual, not===(timing attacks are real)
V. The state machine
Every bet transition must collapse into this:
┌─────────────┐
│ placed │
└──────┬──────┘
│
┌──────────────┼──────────────┐
↓ ↓ ↓
┌────────────┐ ┌──────────┐ ┌────────────┐
│ settled │ │cancelled │ │ dispute │
└────────────┘ └──────────┘ └────────────┘
│ │ │
└─────────────────┴──────────────┘
↓
┌─────────┐
│ final │ (immutable)
└─────────┘
| Current state | Allowed transitions |
|---|---|
placed | settle / rollback |
settled | dispute only (manual) |
cancelled | none (terminal) |
dispute | manual 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
| Metric | Definition |
|---|---|
| Turnover | Sum of all bet amounts — the settlement base |
| Payout | Sum of all settle amounts |
| GGR | Turnover − 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
| # | Trap | Consequence | Fix |
|---|---|---|---|
| 1 | Rollback idempotent on betId | Repeated refunds, direct loss | Use rollbackId as PK |
| 2 | Floating-point balances | Cents-level gaps nobody can trace | Integers in minor units |
| 3 | Synchronous risk checks in /bet | Timeout cascade at peak | Make risk async |
| 4 | Check-then-act idempotency | Duplicate debits under concurrency | DB unique constraint |
| 5 | Settled bets rolled back | Negative balances, complaints | State machine guard |
| 6 | Test data in production reconciliation | Permanent unexplained difference | Prefix isolation |
| 7 | No T+1 reconciliation | Three months of losses before detection | Daily 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:
- Every request may be a duplicate (retry logic)
- Every request may arrive out of order (network conditions)
- Every request may be forged (signature verification)
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.