Overview
A payout service disburses funds to recipients via multiple rails: ACH (batch, 1-2 day settlement), wire (same-day, high fee), instant (real-time, via push-to-card or RTP), and third-party wallets like PayPal. Each rail has different latency, cost, and compliance requirements.
Core Schema
Recipient Table
Recipient (
id BIGINT PRIMARY KEY,
user_id BIGINT,
method ENUM('ach','wire','instant','paypal'),
account_details_encrypted TEXT,
verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP
)
Payout Table
Payout (
id BIGINT PRIMARY KEY,
recipient_id BIGINT REFERENCES Recipient(id),
amount_cents BIGINT,
currency CHAR(3),
method ENUM('ach','wire','instant','paypal'),
status ENUM('pending','processing','sent','settled','failed'),
scheduled_at TIMESTAMP,
processed_at TIMESTAMP,
external_txn_id VARCHAR(128),
failure_reason TEXT
)
ACH Batch Processing
- Collect all
pendingACH payouts scheduled for the current processing window. - Generate a NACHA-formatted file grouping entries by originating account.
- Submit the file to the originating bank via SFTP or API.
- Poll for settlement confirmation over 1-2 business days.
- On settlement, update
status = settledand setexternal_txn_id.
Instant Payout Flow
Call the payment processor API synchronously. On success, mark status = settled immediately. Instant payouts carry a higher per-transaction fee and are subject to per-recipient velocity limits to control cost.
Compliance Checks
OFAC Sanctions Screening
Before the first payout to any recipient, screen their name and account details against the OFAC SDN list. Block and flag any match for manual review. Re-screen periodically and on any recipient data change.
KYC/AML
Require verified = TRUE on the Recipient record before processing payouts above a threshold (e.g., $500 lifetime or $200 single transaction). Verification integrates with an identity provider (e.g., Persona, Stripe Identity).
Velocity Limits
Enforce per-recipient limits checked at payout creation time:
SELECT SUM(amount_cents)
FROM Payout
WHERE recipient_id = ?
AND status IN ('pending','processing','sent','settled')
AND scheduled_at >= NOW() - INTERVAL 1 DAY;
Reject payouts that would breach the daily or weekly cap.
Failure Handling and Retry
- Transient failures (network timeout, processor 5xx): exponential backoff retry with jitter, up to a configured maximum (e.g., 5 attempts over 24 hours).
- Permanent failures (invalid account number, closed account): set
status = failedimmediately, populatefailure_reason, notify recipient.
ACH Return Handling
Banks return ACH transactions with R-codes when settlement fails:
- R01 (insufficient funds): notify recipient, allow retry after 5 business days.
- R02 (account closed): mark recipient account as invalid, require recipient to update banking details.
- R10 (unauthorized): escalate to compliance for review.
Ledger Integration
Each payout event generates journal entries in the ledger service:
On payout creation:
DEBIT payout_liability amount_cents
CREDIT bank_account amount_cents
On ACH return / failure reversal:
DEBIT bank_account amount_cents
CREDIT payout_liability amount_cents
Reconciliation
A daily job matches all payouts in status = sent against the bank statement. Unmatched payouts after 3 business days trigger an alert and are escalated for manual investigation. See the Payment Reconciliation Service design for the full matching algorithm.
Scheduling and Idempotency
Payouts are inserted with a unique (recipient_id, amount_cents, scheduled_at) constraint to prevent duplicates from retry storms. The worker locks a batch of pending payouts using SELECT ... FOR UPDATE SKIP LOCKED for safe concurrent processing across multiple worker instances.
See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems
See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering
See also: Coinbase Interview Guide