"Robux is a closed double-entry economy, and DevEx is the one door out of it. A request escrows Robux atomically at submission, and what happens to that escrow — destroyed (burned) or refunded — is decided by what real dollars actually did."
Requirements
4 minFour questions. State your assumption for each so the interview moves even without answers.
- "Real time" earnings: exact-and-instant, or seconds-stale display over an exact ledger? Assume the latter. Only the balance at submission must be exact.
- Can the payment processor (the external service that moves real dollars) pay X out of Y (partials)? Assume yes, and partials must settle cleanly.
- Is exchanged Robux destroyed, or returned to a pool? Assume destroyed, via a burn account. Either way it is recorded as a ledger transfer.
- Who owns eligibility rules (account age, identity checks)? Assume a separate service; we snapshot its answer, we do not re-implement it.
Earnings: tens of millions of player purchases/day ≈ hundreds of writes/sec, spiky. Payouts: thousands/day ≈ under 0.1/sec. Completion within 5 business days. All amounts in integer minor units.
No over-withdrawal, ever. The Robux-to-USD rate is locked at submission and can never drift. Every state change lands in an append-only audit trail.
Core entities
1 min- Developer account: holds a withdrawable Robux balance.
- Earnings ledger entry: one leg of a Robux movement. A player purchase splits into the developer's share and the platform's cut. This is the truth.
- DevEx request: one exchange, carrying a locked rate, an eligibility snapshot, and a lifecycle.
- Exchange rate version: an immutable (rate, valid-from) row. Requests point at one.
- Payout attempt: one immutable row per processor call, committed BEFORE the call (state SENDING). Closed with the outcome when one arrives; a timeout leaves it SENDING — the missing outcome IS the record.
- System accounts: DEVEX_ESCROW holds in-flight Robux; ROBUX_BURN is where completed exchanges destroy it.
API
3 min
POST /v1/devex-requests Idempotency-Key: key // scoped (developer, key)
{ robux: 100000 }
→ 201 { requestId, PENDING, rateId, usdMinor } // rate + USD locked NOW
→ 422, body: { error: INSUFFICIENT_BALANCE | BELOW_MINIMUM | NOT_ELIGIBLE, reasons }
→ 409, body: { error: KEY_REUSED } // same key, different body
GET /v1/devex-requests?cursor= // history
GET /v1/devex-requests/{id} // status + every attempt
DELETE /v1/devex-requests/{id} // cancel; only while PENDING; 409 once processing
GET /v1/earnings?game=&granularity=day // display; eventual; carries asOf (freshness timestamp)
- The 201 already contains the locked rate and computed USD, because both are fixed inside the submission transaction. Nothing about the amount can drift afterward.
- Earnings displays are allowed to lag and say so. Only submission touches the exact balance.
High-level design
10 min, end to end, no dives yetplayer purchases → ROBUX LEDGER (truth) → CDC (change data capture: the commit-log stream) → earnings rollups (display) -- row 1: the economy developer → API → submission txn: eligibility snapshot + rate lock + escrow debit -- row 2: the door worker claims PENDING → processor (idempotency key = requestId) → outcome settles escrow: burn / refund / split the processor's settlement report → daily reconciliation; separately, CDC → write-once archive -- row 3: the audit floor

The earnings side is a closed Robux economy: every purchase writes double-entry legs splitting the spend, and dashboards are derived rollups off the ledger's change stream → dive A (in the reserve list, next section). The payout side is deliberately tiny and serial: submission is one transaction that snapshots eligibility, locks the rate, computes the USD, and moves Robux into escrow under a no-overdraft guard → dive B. Workers claim requests with leases and call the processor using the request id as the idempotency key → dive D; the outcome settles the escrow: burn on paid, refund on failed, split on partial → dive E. Daily reconciliation diffs the processor's report, and every change streams to write-once storage for compliance.
- If they just nod and wait: "the riskiest part is the submission transaction, shall I open it?"
- Running behind: protect dives B and E. Cut A first (it reuses the ledger design from the wallet-transfers board, separate prep material).
Deep dives: the core four
~20 min, interviewer steers- Look up (developer, key). Seen, body hash matches: return the stored answer verbatim. Retries must replay even if eligibility has since changed.
- Seen, body differs: 409 KEY_REUSED. A key names one exact request, forever.
- Call the eligibility service (slow, external) and keep its full answer.
- Not eligible: return 422 with the reasons. Nothing was started.
- 1. Insert the request row, PENDING. ON CONFLICT (developer, key) catches two racing FIRSTs: the loser re-reads and replays.
- 2. Read the current rate row (immutable) and compute the USD. Store both on the request.
- 3. Debit the developer and credit DEVEX_ESCROW, with the guard fused into the debit: balance minus robux must stay at or above zero.
- 4. Write the two ledger legs and the eligibility snapshot.
- Zero rows from the guard: mark REJECTED, commit, 422. No Robux ever moved, so there is nothing to refund. The anchor (the request row, our idempotency record) survives for retries.
- Two submissions race for a 100K balance, 80K each.
- The second waits on the row lock, then re-checks against the already-debited balance.
- It matches zero rows and is rejected. No check-then-act window exists.
- Say the punchline: "strongly consistent balance" is one line of SQL here, not a distributed protocol.
- A rate change is a NEW immutable row, never an update.
- The request stores which rate row it used and the USD it computed.
- Processing days later reads the request and cannot see any other rate.
- "Why did this pay $350?" is answered by the request row itself: it stores rateId and usdMinor.
- The full check result (rules passed, identity status, checked-at) is stored on the request.
- Compliance asks "why was this approved": the answer is the snapshot, immune to later rule changes.
- Re-check policy, explicit: once, at submission. The snapshot IS the decision; re-checking later would let a rule change rewrite an already-priced request.
- Checked at the door: static validation before the eligibility call, 422 BELOW_MINIMUM. Nothing racy about it, so it needs no guard; the balance check is the only racy one.
- Workers sweep PENDING rows. A claim locks its row; others SKIP locked rows instead of waiting (FOR UPDATE SKIP LOCKED).
- The claim also writes a lease and an attempt number, and sets PROCESSING.
- A reaper flips expired-lease rows back to PENDING (lease expiry is the one PROCESSING → PENDING loop) and stamps the next retry time.
- At thousands/day, ONE worker is enough. The machinery exists for crash-safety. Saying that is the judgment point.
- The idempotency key IS the request id.
- So every retry, across crashes and expired leases, collapses to at most one real disbursement.
- Per-attempt keys are the double-payout bug. Name it before they do.
- We called the processor, then crashed before recording the answer.
- The row is still PROCESSING; when the lease expires, the next attempt retries with the SAME key, or asks the processor's read-only status endpoint what happened.
- Only a definitive processor answer moves the row.
- The 5-day budget is why this is calm: retries at 15m, 1h, 6h, 1d, 2d all fit inside it.
- Every attempt is an immutable row, committed BEFORE its call: attempt number, key (= the requestId, always), state, outcome once one arrived, processor reference, timestamps.
- The history endpoint returns this table verbatim.
- COMPLETED: escrow → ROBUX_BURN, all of it. Robux leaves the economy exactly when dollars left the platform.
- FAILED: escrow → developer, full refund. Notification out (email/webhook, after COMMIT, outside the money path).
- CANCELLED (only while PENDING): same full refund, guarded so it cannot race a claim.
- PARTIAL (paid X of Y): burn floor(robux × X/Y), refund the rest.
- Ambiguous X: CAS to ON_HOLD, park in the DLQ (dead-letter queue, the holding table). Alerts fire, NO legs move, a human picks one of the endings above.
- REJECTED is the odd one out: born at submit when the guard says no. Nothing was ever debited, so there is nothing to refund — an ending with no legs.
- ESCROW's balance = the sum of all in-flight requests' Robux, checkable continuously.
- And every burn has a matching line in the processor settlement report.
- One sentence: money left the platform if and only if Robux left the economy.
- PENDING → PROCESSING → COMPLETED | PARTIAL | FAILED | ON_HOLD. PENDING → CANCELLED.
- Lease expiry loops PROCESSING back to PENDING. REJECTED is born terminal at submit; it never enters the loop.
- Every transition is an atomic compare-and-set on the status, committed together with its settlement legs. No transition can skip the money.
Three more dives
rehearse after the core four- A player spends 400 Robux in game G: minus 400 player, plus 280 developer share, plus 120 platform cut.
- The split percentage is config, versioned. Legs sum to zero; conservation holds.
- Earnings panels are derived rollups off the ledger change stream: seconds stale, labeled asOf.
- The withdrawable balance is the account row: exact, and read strongly only where it gates money.
- Hundreds of purchase writes/sec, sharded by account. DevEx adds under 0.1/sec.
- Sizing the two sides separately is the first judgment point of the whole question.
- Trip a circuit breaker; leave rows PENDING; no attempt churn.
- On recovery, drain oldest first, paced to the processor's limits.
- The 5-day budget absorbs multi-hour outages. Dashboard: age of the oldest PENDING.
- A definitive no from the processor maps to FAILED. (Different word, different thing: REJECTED is the submit-time guard saying no.)
- Before submission commits: nothing exists. After: escrow holds the Robux, PENDING survives.
- Mid-payout: lease expiry plus the same idempotency key. No state is ever torn, because every transition is one transaction.
- Their report has a line we do not: repair from processor truth, page the on-call at SEV-2.
- We say COMPLETED and their report is silent: page at SEV-1. Until explained, that is missing money.
- Plus, continuously: the escrow invariant and no-negative-balances, checked off the ledger stream.
- The ledger legs and the attempts table already ARE an append-only event log; every balance and status derives from them.
- Full event sourcing (state only by replay) adds cost without adding auditability here.
- Say it as: "event-sourced where it matters — the money; conventional rows elsewhere, with every change streamed to write-once archive storage."
- Submission p99 under 1s: the eligibility call dominates it (own 2s timeout); the transaction itself is single-digit ms.
- 99.9% of payouts terminal within 5 business days. Over-withdrawals: zero, proven by the invariants.
- Alerts: oldest-PENDING age, dead-letter arrivals, processor error rate, reconciliation mismatches (any nonzero pages), escrow drift, eligibility-service latency (the only external call on the submission path).
The five hardest probes
show all (interview mode)Read the Step 4 walkthrough aloud once, timing yourself. If it runs past 90 seconds, cut words until it fits.