If you've ever wondered why most online gaming platforms take 3–7 business days to process a withdrawal while your bank app moves money in seconds, the answer isn't regulation. It isn't even a fraud risk, mostly.
It's that the average iGaming backend is a stack of legacy services duct-taped to payment processors from 2014, running synchronous flows with manual review queues bolted onto them. The technology to do this in minutes has existed for years. The architect will haven't.
I've been working on the payments layer at 6ense - a new iGaming platform licensed by the Curaçao GCB - and I want to walk through how we approached the withdrawal pipeline, because it's a more interesting problem than it gets credit for in most engineering discussions.
This post is about the architecture, not the product. If you build payment systems, fintech, or anything where "fast and safe" pull in opposite directions, the patterns here generalize.
The Core Tension
Withdrawals in iGaming have to satisfy three things that fight each other:
- Speed - players expect their money quickly. Slow payouts are the single biggest trust signal in the category.
- Compliance - KYC checks, AML thresholds, sanctions screening, jurisdiction rules.
- Fraud prevention - multi-accounting, bonus abuse, stolen-card top-ups followed by withdrawal to a clean wallet.
The legacy approach optimizes for #2 and #3 by making everything synchronous and human-reviewed. Player requests withdrawal → sits in queue → operator reviews → payment processor called → funds eventually leave. Total elapsed time: days.
The modern approach decomposes the problem. Each concern runs in parallel, scores independently, and converges on a decision. If the decision is "approve," money moves immediately. If it's "review," it goes to a human. If it's "decline," the player gets a clear reason.
The architecture looks roughly like this:
┌─────────────────┐
│ Withdrawal │
│ Request (API) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ State Machine │ ← single source of truth
│ (PENDING) │
└────────┬────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Risk │ │ Compliance│ │ Liquidity│
│ Scoring │ │ Engine │ │ Check │
└────┬─────┘ └────┬──────┘ └────┬─────┘
│ │ │
└───────────────┼───────────────┘
▼
┌─────────────────┐
│ Decision Engine │
│ APPROVE/REVIEW/ │
│ DECLINE │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Payment Router │ ← picks rail by amount/region
└─────────────────┘
Let's walk through the parts that matter.
The State Machine
Anyone who has built a payments system the second time knows that the first thing you build the second time is a proper state machine. Withdrawals can't be modeled as Boolean flags on a row.
Our states:
type WithdrawalState =
| 'PENDING' // initial
| 'RISK_SCORING' // parallel checks running
| 'AWAITING_REVIEW' // flagged for human review
| 'APPROVED' // ready to dispatch to payment rail
| 'DISPATCHED' // sent to processor, awaiting confirmation
| 'SETTLED' // money has left our system
| 'FAILED' // processor rejected
| 'DECLINED' // we declined (fraud/compliance)
| 'CANCELLED'; // player cancelled before dispatch
const validTransitions: Record<WithdrawalState, WithdrawalState[]> = {
PENDING: ['RISK_SCORING', 'CANCELLED'],
RISK_SCORING: ['APPROVED', 'AWAITING_REVIEW', 'DECLINED'],
AWAITING_REVIEW: ['APPROVED', 'DECLINED'],
APPROVED: ['DISPATCHED', 'CANCELLED'],
DISPATCHED: ['SETTLED', 'FAILED'],
SETTLED: [],
FAILED: ['APPROVED'], // retryable on a different rail
DECLINED: [],
CANCELLED: [],
};
function transition(
current: WithdrawalState,
next: WithdrawalState
): void {
if (!validTransitions[current].includes(next)) {
throw new InvalidTransitionError(current, next);
}
}
The transitions are persisted as an event log, not just current-state mutations on the row. This matters because:
- You can replay a withdrawal's history for debugging.
- Compliance audits become trivial.
- Recovery from partial failures is bounded - you know exactly where you were.
A surprising number of iGaming platforms still model this as status: string on a withdrawals table and update it in place. That's how you end up with money "already sent," but the row still shows pending because two services raced.
Idempotency or Death
The single most important property of the entire pipeline is that every external call is idempotent.
When you're moving money, network failures are not edge cases. They are the case. Your payment processor will time out. Your fraud-scoring service will return 502. Your queue will redeliver. If any of these can cause double-spending, you don't have a payment system; you have a lawsuit waiting.
Every external operation gets an idempotency key derived deterministically from the withdrawal:
function idempotencyKey(
withdrawalId: string,
operation: string,
attempt: number
): string {
return `wd:${withdrawalId}:${operation}:${attempt}`;
}
// Usage with a payment processor
async function dispatchToProcessor(
withdrawal: Withdrawal,
attempt: number
) {
const key = idempotencyKey(withdrawal.id, 'dispatch', attempt);
return await processor.createPayout({
amount: withdrawal.amount,
currency: withdrawal.currency,
destination: withdrawal.destination,
idempotency_key: key, // processor dedupes server-side
});
}
The attempt field is critical. If a payout legitimately fails on rail A and you retry on rail B, you want a new idempotency key - not a replay of the failed attempt. Most processors will return the cached failure if you reuse the key.
Parallel Risk Scoring
The legacy iGaming pattern is sequential: KYC check, then AML check, then fraud check, then liquidity check, each waiting on the last. Total time: minutes.
We run them in parallel and combine the scores:
async function scoreWithdrawal(
withdrawal: Withdrawal
): Promise<RiskDecision> {
const [risk, compliance, liquidity, velocity] = await Promise.all([
riskEngine.score(withdrawal), // ML model on player behavior
complianceEngine.check(withdrawal), // sanctions, AML thresholds
liquidityCheck(withdrawal), // do we have funds on this rail?
velocityCheck(withdrawal), // recent withdrawal patterns
]);
// Hard fails short-circuit immediately
if (compliance.status === 'BLOCKED') {
return { decision: 'DECLINED', reason: compliance.reason };
}
if (!liquidity.sufficient) {
return { decision: 'DECLINED', reason: 'INSUFFICIENT_LIQUIDITY' };
}
// Combine soft signals
const compositeScore =
risk.score * 0.5 +
velocity.score * 0.3 +
compliance.softScore * 0.2;
if (compositeScore < THRESHOLD_AUTO_APPROVE) {
return { decision: 'APPROVED' };
}
if (compositeScore < THRESHOLD_REVIEW) {
return { decision: 'AWAITING_REVIEW', score: compositeScore };
}
return { decision: 'DECLINED', reason: 'HIGH_RISK_SCORE' };
}
The thresholds are tunable per jurisdiction and per player tier - a verified player with a year of clean history gets different thresholds than a brand-new account. This is where you spend most of your time as the system matures.
Payment Rail Routing
The other thing legacy platforms do badly: they pick a single payment processor and route everything through it. When the processor degrades (and they all do, regularly), withdrawals back up.
Modern routing is multi-rail with active health checking:
interface Rail {
id: string;
supportsCurrency(c: Currency): boolean;
supportsRegion(r: Region): boolean;
feeFor(amount: Amount): Amount;
estimatedSettlement(): Duration;
healthScore(): number; // 0-1, updated by background prober
}
function selectRail(
withdrawal: Withdrawal,
rails: Rail[]
): Rail {
const eligible = rails
.filter(r => r.supportsCurrency(withdrawal.currency))
.filter(r => r.supportsRegion(withdrawal.region))
.filter(r => r.healthScore() > 0.7);
if (eligible.length === 0) {
throw new NoRailsAvailableError();
}
// Score by cost + speed + health
return eligible.sort((a, b) => {
const scoreA = railScore(a, withdrawal);
const scoreB = railScore(b, withdrawal);
return scoreB - scoreA;
})[0];
}
The health score is updated by a background process that does small probe transactions and watches for elevated error rates. When a rail starts degrading, traffic shifts away from it automatically - players don't experience the outage, they just get routed to a healthier rail.
What Surprised Me
A few things I didn't expect when we started building this:
Most slow withdrawals are not fraud reviews. They're queuing. When you instrument the legacy flow, the actual time spent on risk and compliance checks is small - usually seconds. The hours and days come from work, sitting in queues waiting for human attention. The fix isn't "faster fraud detection," it's "stop putting things in human queues that don't need to be there."
Idempotency bugs are silent. A double-payout doesn't crash your system. It just quietly costs you money and shows up days later in reconciliation. Test your idempotency by deliberately replaying every external call in your dev environment. If your numbers don't match exactly, you have a bug in production right now.
Compliance is faster than you think when modeled correctly. Most operators treat compliance as a black box that returns "yes/no" after some time. In practice, compliance is a deterministic function of (player profile, transaction details, jurisdiction rules). It can run in milliseconds if you've structured the rules correctly. The slow part is when humans get involved - and humans should only be involved on the edges, not the median case.
Players notice the speed before they notice anything else. This is the part that surprised me as an engineer. We obsessed over the architecture, but the player feedback we got was almost entirely about how fast the payouts felt. Not the games. Not the UI. The thing they cared about most was the thing the industry has historically given them least of.
Why This Matters Beyond iGaming
The patterns above - state-machine modeling, idempotency-by-default, parallel scoring, multi-rail routing - generalize to basically any system that moves money. iGaming is just an industry where the gap between what the technology can do and what operators actually deliver is unusually wide, which makes it interesting to work in if you like building a better version.
If you're working on payments anywhere - fintech, e-commerce, marketplaces - and your withdrawal/payout flow looks like a synchronous queue with manual review, you have low-hanging fruit. The architectural change isn't large. The user-experience change is enormous.
Closing
The "slow withdrawal" problem in online gaming is fundamentally an architectural debt problem dressed up as a regulatory one. The platforms that figure this out are going to define the next decade of the category. The ones that don't will keep losing players to the ones that do.
If you're curious what this looks like as a finished product, 6ense is the platform we're building it on. Curaçao GCB licensed (OGL/2024/431/0231), built around the patterns above. Adults 18+ only, please play responsibly.
Happy to discuss any of the architecture in the comments. I'm particularly interested in how other payment-engineering teams handle the rail-health-scoring problem - there's a lot of room for better approaches there.
Edit: a few people asked about the fraud-scoring model - it's a separate post, will write one up if there's interest. The short version is gradient-boosted trees on behavioral features, retrained weekly, with hand-coded rules layered on top for known fraud patterns.
Top comments (0)