Design Roblox's DevEx Payout System
Hello Interview structure · list format, one idea per line
Understanding the Problem
💸 What is DevEx?
- Creators earn Robux when players spend in their games.
- DevEx converts earned Robux into real US dollars via an external payment processor.
- It is the only place the closed in-game economy touches real money.
Functional Requirements
Core Requirements
- Developers should be able to see Robux earnings per game in near real time.
- Developers should be able to submit a DevEx request (a minimum cashout amount and eligibility rules apply).
- The system should convert at the rate in effect at submission, not at processing.
- Developers should be able to track request status and full history.
Below the line (out of scope):
- The eligibility rule engine itself. We consume it as a service.
- Tax documentation and reporting.
- Multi-currency rails; the processor internals.
Non-Functional Requirements
Ask about scale first: it splits the problem in half
- Earnings: tens of millions of purchases/day × 3 legs each — hundreds to ~1k ledger writes/sec. Spiky.
- Payouts: thousands/day. Under 0.1/sec. Tiny but high-stakes.
- You are designing two systems with opposite profiles. Say that.
- The system should never allow over-withdrawal. Balance at submission: strongly consistent.
- The system should never lose or mutate a financial record. Every decision explainable years later.
- The system should finish payouts within 5 business days, despite processor downtime, rejections, and partial payments (paid X of a requested Y).
- The system should serve earnings reads cheaply. Eventually consistent display is fine, labeled with a freshness timestamp (asOf).
The Set Up
Defining the Core Entities
- Developer Account: holds the withdrawable Robux balance and a status (ACTIVE / FROZEN).
- Earnings Ledger Entry: one leg (one half of a transfer, + or -) of a Robux movement. Double-entry. The truth.
- DevEx Request: one exchange. Carries a locked rate, an eligibility snapshot, a status.
- Exchange Rate Version: immutable (rate, valid-from) row. Requests point at one.
- Payout Attempt: one processor call and its outcome. Immutable.
- System Accounts:
DEVEX_ESCROWholds in-flight Robux.ROBUX_BURNdestroys it when a payout completes.
💡 The sentence to plant early
"Robux is a closed double-entry economy, and DevEx is the one door out. A request escrows Robux atomically at submission. Burn or refund is decided by what real dollars actually did."
API or System Interface
POST /v1/devex-requests Idempotency-Key: <key> // unique per (developer + key) pair
{ "robux": 100000 }
→ 201 { requestId, PENDING, rateId, usdMinor } // usdMinor = cents; rate + USD locked NOW
→ 422, body: { error: INSUFFICIENT_BALANCE | BELOW_MINIMUM | NOT_ELIGIBLE, reasons }
→ 409, body: { error: KEY_REUSED } // same key, different body. same key + same body: replay the stored 201
GET /v1/devex-requests?cursor= // history
GET /v1/devex-requests/:id // status + every attempt
DELETE /v1/devex-requests/:id // cancel; PENDING only; 409 after
GET /v1/earnings?game=&granularity=day // display; eventual; carries asOf
- The 201 already carries the locked rate and computed USD.
- Both are fixed inside the submission transaction. The amount can never drift after.
High-Level Design

1) Developers should be able to see earnings in near real time
- Every purchase writes double-entry legs: minus player, plus developer share, plus platform cut.
- Legs sum to zero. The economy conserves by construction.
- Earnings displays: derived rollups off the ledger change stream (CDC: change data capture). Seconds stale, labeled asOf.
- The withdrawable balance: exact, read strongly only where it gates money, at submission.
2) Developers should be able to submit a DevEx request
- Replay check first: (developer, key) seen with matching body hash → return the stored answer, even if eligibility has since changed. Body differs → 409.
- Then the eligibility check (external call). Full result snapshotted.
Then ONE transaction:
- Insert the request row. This is the idempotency anchor (the record a retry lands on).
- Read the current rate version. Compute the USD. Store both on the request.
- Debit developer, credit ESCROW. The no-overdraft guard is fused into the debit (one statement does check and change: deep dive 1).
If the guard matches zero rows (the branch of step 3):
- Re-read the account once to say WHY: frozen account or insufficient balance. One error code each.
- Mark REJECTED, commit, 422. Nothing was debited, so there is nothing to refund.
- The anchor survives for retries (unique index on developer + key makes it findable).
3) The rate is the one at submission
- A rate change is a NEW immutable row. Never an update.
- The request stores which rate row it used and the computed USD.
- Processing days later reads the request. It cannot see any other rate.
- The guarantee is a foreign key, not a convention.
4) Developers should be able to track status and history
- State machine: PENDING → PROCESSING → COMPLETED | PARTIAL | FAILED | ON_HOLD. Plus PENDING → CANCELLED.
- REJECTED is born terminal at submit (the guard said no; nothing escrowed). Lease expiry loops PROCESSING back to PENDING.
- Every processor call: one immutable attempt row.
- The history endpoint returns that table verbatim. Audit trail and UI at once.
Potential Deep Dives
1) How do we guarantee a developer can never over-withdraw?
- The race: two submissions, one 100K balance, 80K each. Both pass app-level checks.
Bad Solution: check-then-act in the application
- Read balance, compare in code, then debit.
- Between read and write, the other request commits.
- Both pass. You paid 160K from 100K. Interviewers plant this deliberately.
Good Solution: serializable transactions
- Run everything at the strongest isolation level, retry on conflict.
- Correct, but you now handle retry storms in the app.
- And you pay the maximum price for a path needing exactly one guarantee.
Great Solution: fuse the guard into the debit statement
UPDATE accounts SET balance = balance - :robux WHERE id = :dev AND status = 'ACTIVE' AND balance - :robux >= 0
- The minimum-cashout check is NOT here: it is static validation at the API edge (a constant cannot be raced). The guard holds only the racy checks.
- Check and mutation are ONE atomic statement, under the row lock.
- The second transaction waits, re-checks the already-debited balance, matches zero rows, rejected.
- No check-then-act window exists.
- "Strongly consistent balance" = one line of SQL, not a distributed protocol.
2) How is every payout decision auditable years later?
Bad Solution: mutable rows plus application logs
- Update the request in place; log what happened.
- Logs rotate. Updates destroy history.
- "Why did this pay $3,500 in 2024?" has no answer a regulator accepts.
Good Solution: event-source everything
- Rebuild ALL state only by replaying an event log.
- Fully auditable, but you adopted a whole architecture: snapshots, upcasting (migrating old stored events to new schemas), replay tooling.
- This domain can answer the question more cheaply.
Great Solution: event-sourced where it matters, immutable inputs everywhere
- The ledger legs + attempts table already ARE an append-only event log.
- Rates: immutable versions. Eligibility: snapshotted onto the request.
- Request status: a normal column, every change streamed via CDC to write-once, read-many (WORM) archive.
- Say it as: "event-sourced where it matters, the money. Archived change stream everywhere else."
3) How do we finish in 5 business days when the processor fails, rejects, or half-pays?
- Volume check first: thousands/day. ONE worker is enough.
- The machinery below exists for crash-safety, not throughput. Saying that is the judgment signal.
Claiming work
- Workers sweep PENDING with FOR UPDATE SKIP LOCKED. The claim takes a lease and an attempt number and sets PROCESSING.
- A reaper flips expired-lease rows back to PENDING — the one backward transition. A definitive answer is the only way forward.
- "ONE worker" means one logical consumer; run a spare — SKIP LOCKED makes extras safe.
Calling the processor
- The idempotency key IS the requestId — a SECOND key, one hop out: the client's Idempotency-Key dedupes developer→us; this one dedupes us→processor. Every retry collapses to at most one disbursement.
- Per-attempt keys are the double-payout bug. Name it before they do.
The ambiguous moment: we called, then crashed before recording
- The row stays PROCESSING.
- On lease expiry: retry with the SAME key (the processor replays its answer), or query their status endpoint.
- Only a definitive processor answer moves the row.
- Retry schedule: 15m, 1h, 6h, 1d, 2d. Fits inside 5 business days with room.
Terminal states settle the escrow, in the same transaction as the status change
- COMPLETED → burn all of it.
- FAILED → refund all of it.
- PARTIAL (paid X of Y) → burn floor(robux × X/Y), refund the rest.
- CANCELLED (only while PENDING) → refund all of it. Every terminal state settles its escrow; none may strand it.
- Ambiguous X (their report says PARTIAL but X is missing or bigger than Y) → CAS to ON_HOLD, park in the dead-letter TABLE (a parking table, not queue infrastructure). No legs move; a human picks an ending above.
Reconciliation, daily, both directions
- Their report has a line, we have no row: repair from processor truth, page the on-call at SEV-2.
- We say COMPLETED, their report is silent: SEV-1. Until explained, that is missing money.
🔒 The invariant that audits the whole system
- ESCROW balance = SUM of in-flight requests' Robux. Continuously checkable.
- Every burn has a matching line in the processor's settlement report.
- Money left the platform if and only if Robux left the economy.
4) How do we handle the scale asymmetry?
- The earnings ledger is the high-scale piece. Hundreds of writes/sec, sharded. It exists regardless of DevEx.
- The payout side is ONE Postgres and a worker. Keep it that way at 1000x volume.
- Component count is earned by the split, not by the payout path.
- The interviewer is checking: do you size the two sides separately, or reach for queues everywhere.
Final Design
- Closed double-entry Robux economy; DevEx is the one audited exit.
- Submission: one transaction, fused guard, frozen inputs (rate version, eligibility snapshot).
- Payout: leased worker, requestId as the processor idempotency key.
- Terminal states settle escrow as ledger transfers, atomic with the status change.
- Daily reconciliation + continuous escrow invariant keep it honest. CDC to WORM for compliance.
What is Expected at Each Level?
Mid-level
- Working end-to-end: ledger, request table with statuses, processor call with retries.
- Knows the balance check must be transactional.
- Hand-waving on idempotency and reconciliation is forgivable.
Senior
- Fused guard produced unprompted. Request-scoped processor key, plus the crash-before-recording walkthrough.
- Escrow settlement tied atomically to state transitions.
- Rate locked by foreign key. Drives the double-spend race probe (deep dive 1) instead of being led.
Staff
- Scale-split framing up front. Inputs frozen by construction, sold as an auditability strategy.
- Event-sourcing answered with judgment, not adoption.
- Partials and rounding surfaced as finance-team decisions, mechanism ready.
- The payout path stays small on purpose, and says so.