Skip to main content
Every successful charge credits the merchant’s balance. A payout moves that balance to the merchant’s own bank account. The whole flow is built around one hard rule: a merchant can only pay out money that has actually settled (T+1), and a transfer that fails on the bank rail is refunded to the balance atomically, so a payout can never quietly lose money. The merchant surface lives under /v1/payouts and /v1/balance; the transfer itself runs in the billing worker.

The balance, and why it splits

The balance is a single integer-kobo figure per tenant (MerchantBalance), moved only through an append-only MerchantBalanceTransaction ledger. A successful renewal or checkout posts an invoice_settlement credit; a payout posts a payout debit; a failed transfer posts a reversal credit. Every row carries a reference and is @@unique([tenantId, reference]), so the same settlement or payout is only ever applied once. But the raw balance is not what a merchant can withdraw. Card money settles on T+1, so today’s earnings are not yet spendable. MerchantBalanceService.breakdown() splits the balance in two:
  • pendingKobo is the sum of invoice_settlement credits with createdAt on or after the start of today in Lagos (Africa/Lagos, UTC+1, computed in startOfDayLagos()). This is money earned today that has not yet cleared T+1.
  • availableKobo is balance - pendingKobo, floored at zero. This is the only figure a payout can draw against.
The split is date-boundary based, not a per-transaction clock. Everything credited today (Lagos) counts as pending; at the next midnight it rolls into available. This is deliberately simple and predictable for the merchant, at day-granularity.
Both figures ride on the payouts list response and on the balance endpoint.

GET /v1/balance - the wallet view

Returns the merchant’s current balance, currency, and the last 30 balance transactions (each with type, reason, amount, balanceAfter, memo, createdAt). The reasons a merchant will see:

Bank accounts (added behind an email OTP)

A payout destination can be an ad-hoc bank account on a single payout, or a saved account. Saving (or removing) an account is a sensitive action, so it is gated by a 6-digit code emailed to the business owner (the same second factor is required to remove one). The flow, endpoint by endpoint:
  • POST /v1/payouts/accounts/otp emails a 6-digit code to the tenant owner. The code is stored hashed (SHA-256) in the cache with a 10-minute TTL and a 5-attempt ceiling; a fresh request resets the attempt counter. (Requires invoice:write.)
  • POST /v1/payouts/accounts takes a body { bankCode, accountNumber (10 digits), code (6 digits), makeDefault? }. Before saving, Duro resolves the account name against the bank (so a saved account always has a verified name), rejects a bank it doesn’t support, and rejects a duplicate (tenant, bankCode, accountNumber). The first account saved becomes the default automatically; makeDefault: true re-points the default. (Requires invoice:write.)
  • GET /v1/payouts/accounts lists saved accounts, default first, then newest, each with bankCode, bankName, accountNumber, accountName, isDefault.
  • POST /v1/payouts/accounts/{id}/remove takes a body { code }. Removing also requires the OTP; if the removed account was the default, the next most recent account is promoted to default. (Requires invoice:write.)

Account-name resolution & the bank list

Two read endpoints back the “who owns this account?” step, and can be used on their own:
  • GET /v1/payouts/banks returns the supported bank list ({ code, name }). In live mode it pulls the list from Nomba and falls back to a built-in list of Nigerian banks; in test mode it is always the built-in list.
  • POST /v1/payouts/banks/resolve takes { bankCode, accountNumber } and returns { accountName } for a 10-digit account. In live mode this is a real Nomba bank-account lookup; in test mode it returns a deterministic mock name derived from the account number (so the same test number always resolves to the same name). A number that can’t be resolved returns a 400.

Initiating a payout

POST /v1/payouts moves available balance to a bank account. You provide either a saved accountId or an ad-hoc { bankCode, accountNumber } (with an optional accountName; if omitted, Duro resolves it). (Requires invoice:write.) The amount is validated as a positive integer, then checked against availableKobo; over-drawing is rejected with a message that names the reason (money still settling). The balance is debited immediately at initiation (not at transfer time), under the same overspend-proof guard used everywhere: a conditional UPDATE ... WHERE balance >= amount, so two concurrent payouts can’t both spend the same naira. The Payout row is created processing, a payout_initiated event is recorded, and the owner is emailed a “payout requested” notice.
POST /v1/payouts returns as soon as the balance is debited and the processing row is written. The bank transfer has not happened yet; a worker performs it. Watch the payout’s status (or the payout events) for the terminal outcome.

The transfer, and its safety net

A worker scans for processing payouts and runs each one exactly once (deduped on payout_<id>). PayoutService.process() performs the bank transfer and drives the row to a terminal state:
  • On success, the row becomes completed with a completedAt and the Nomba reference (nombaReference), and a payout_completed event fires. The owner is emailed a confirmation.
  • On failure, the balance is credited back with reason: reversal (reference <original>:rev), the row becomes failed with a failureReason, and a payout_failed event fires. The owner is emailed the failure. Because the reversal reuses the idempotency ledger, a retry can’t double-refund.
In test mode the transfer is mocked (a MOCK-POUT-… reference) so the full lifecycle is exercisable end to end without moving real money; live mode calls Nomba’s bank-transfer API.

Payout events

Three events mark a payout’s life, delivered to any subscribed webhook endpoint and visible in the events stream:

Listing payouts

GET /v1/payouts returns the balance split and the payout history in one call: availableKobo, pendingKobo, currency, and items (newest first). Each item carries the full payout view: amount, status, bankName, accountNumber, accountName, reference, nombaReference, failureReason, createdAt, completedAt. Next: the queue & worker topology that runs the transfer, or the webhook catalog for the payout events.