Payout orchestration for a digital lender
Failed payouts down from 4.1% to 0.6%, zero double payments, and finance no longer reconciles by hand.
- Role
- Lead backend engineer
- Timeframe
- Oct 2025 – Mar 2026
- Team
- 3 engineers, 1 PM
- Stack
- NestJS, Go, PostgreSQL, Kafka, Redis, AWS
- Links
- Live ↗Write-up ↗
The problem
About 1 in 25 loan payouts failed or got stuck. Some customers were paid twice. Finance spent every morning matching bank reports to a spreadsheet.
What I did
Rebuilt how payouts are recorded, sent, retried and checked, so every payment has one source of truth and can be safely retried without paying twice.
The result
Failures dropped to under 1%, double payments stopped completely, and reconciliation now runs on its own every hour.
Technical deep dive
For engineers: constraints, architecture, trade-offs and what I'd do differently.
The lender disbursed loans to customers' bank accounts through two payment providers. Payouts were fired straight from the API request, retried by a cron job, and reconciled against provider reports in a spreadsheet each morning.
- The API writes the payout and an outbox event in one Postgres transaction. If the request dies, nothing is half-done.
- A relay publishes outbox rows to Kafka, keyed by payout ID so one payout is never handled by two workers at once.
- Go workers dispatch to providers under per-provider rate limits and fall back to the other provider on sustained errors.
- Webhooks and status polling update the ledger. The reconciler compares ledger and provider reports hourly and flags any mismatch.
Transactional outbox over publishing straight to Kafka
Dual writes can commit to the database and fail to publish, or the reverse. The outbox adds about 200ms of delivery delay, which is invisible next to bank settlement times.
Kafka over BullMQ for dispatch
We needed replay after incidents and strict per-payout ordering. BullMQ is simpler and we kept it for notifications, but Redis persistence was a risk for money movement.
Optimistic locking over distributed locks
The database is already the source of truth. A lock in a second system adds a failure mode without adding safety.
UPDATE payouts
SET status = 'dispatched', attempt = attempt + 1
WHERE id = $1
AND status IN ('pending', 'retryable')
AND attempt = $2 -- optimistic lock
RETURNING provider_ref; -- 0 rows = someone else owns itTwo weeks after launch, Provider B started returning timeouts for payouts that had actually succeeded. Our workers marked them retryable, and the optimistic lock stopped a second dispatch, but 212 payouts sat in limbo for six hours until the reconciler caught up.
The fix was to treat a timeout as unknown, not failed, and query the provider's status endpoint before any retry. If I did it again, I'd model "unknown" as a first-class state from day one and alert on how long payouts stay in it.
| Measure | Before | After | Source |
|---|---|---|---|
| Failed payouts | 4.1% | 0.6% | prod · 90d |
| Double payments | ~30 / month | 0 | prod · 10 mo |
| Manual reconciliation | 3 h / day | ~10 min / week | finance team log |
| Stuck payouts > 1 h | ~600 / week | < 10 / week | prod · alerting |