Payouts and refunds
How merchant balances become real money — the hourly payout job, the approval queue and its SLA, what rejecting actually does, and how admin refunds move funds back to a buyer.
This is the screen where money leaves your custody. A completed payment credits a merchant's gateway balance, which is a ledger row and not a wallet. Turning that into spendable funds takes a scheduled job to propose the payout and a human to approve it.
The balance model
Three numbers per merchant, per currency, per wallet type, in
gateway_merchant_balance:
| Column | Meaning |
|---|---|
pending |
Earned and unpaid. The source of truth for what you owe. |
available |
Released by an approved payout. A historical marker, not a claim |
reserved |
Present in the schema, unused by any current path |
A completed payment adds amount − fee to pending. A refund subtracts from
pending. An approved payout moves an amount from pending to available and,
in the same transaction, credits the merchant's real platform wallet through the
wallet service with a stable idempotency key.
pending is invisible to trading, withdrawals and transfers. A merchant with
50,000 USDT pending cannot spend a cent of it. Merchants who do not understand
this generate the most support tickets of any part of the product.
How a payout gets proposed
processGatewayPayouts runs hourly in the cron worker. Per active merchant,
per balance row with pending > 0:
- Is a payout due?
INSTANTandDAILYare candidates on every run.WEEKLYandMONTHLYfire when due-or-overdue — the job asks whether a payout already exists for the most recently closed period, so a missed run self-heals on the next tick rather than skipping a whole month. - How much is unclaimed? Open payouts (
PENDINGorPROCESSING) already claim part ofpending. Only the difference is proposed. This is what stops anINSTANTmerchant being issued the same balance twelve times a day. - Does it clear the threshold? Below the merchant's
payoutThresholdthe job logs and moves on. - Create the record at
PENDING, with the period, the gross, the fee, the payment count and the refund count for that window, and notify the merchant.
No funds move at this point. pending is untouched.
There is no auto-approve setting, for any schedule. INSTANT means "proposed
within the hour", not "paid instantly". If nobody works this queue, merchants
are never paid and the only symptom is a growing pending column.
The approval queue
Extensions → Payment Gateway → Payouts (/admin/gateway/payout). The list
endpoint needs view.gateway.payout; approving and rejecting need
edit.gateway.payout.
The dashboard ages the queue against a 7-day SLA, in three buckets: total pending, due (over half the SLA, so past 3.5 days) and breached (over 7 days), each with its own count and summed amount, plus the age of the oldest pending payout. A count alone hides the problem — twelve payouts queued this morning and twelve queued nine days ago are the same number and completely different mornings.
Approving
Only a PENDING payout can be approved. Before anything moves, the endpoint
re-checks that the merchant's gateway balance still holds the amount — funds may
have been refunded since the payout was proposed. If it is short you get a 400
naming both figures, and nothing happens.
Inside one transaction the approval locks the payout row, moves the amount from
pending to available, increments totalPaidOut, finds or creates the
merchant's wallet in that currency and wallet type, and credits it as an
INCOMING_TRANSFER referencing the payout id. The payout goes COMPLETED with
the approving admin's id and timestamp in its metadata, and the merchant gets a
notification.
Rejecting
A reason is required — an empty one is a 400. The payout goes CANCELLED with
the rejecting admin, the timestamp and the reason recorded, and the merchant is
notified with the reason quoted.
A rejection leaves the funds in pending. It blocks this payout for this
period; the merchant still holds the balance and a later period will propose it
again. For INSTANT merchants, whose period slides with every run, a rejection
additionally suppresses re-creation for 24 hours.
If your intent is to withhold funds permanently, rejecting a payout is not the mechanism. Suspend the merchant and deal with the balance deliberately.
If the balance has already been refunded away when you reject, the endpoint logs a warning about the mismatch and proceeds. That is intentional: the funds are gone either way, and a payout you cannot reject is worse than one you can.
Refunds
Refunds exist in two places and take the same path.
- The merchant calls
POST /api/gateway/v1/refundwith a secret key, or uses the refund button on their own payment screen. - You use the refund action on the admin payment detail screen — see The payments desk.
The admin route accepts either the pi_… identifier or the payment's UUID, which
matters when you are working from a database export rather than the UI.
What a refund does, in one transaction:
- Creates a
gateway_refundrow atCOMPLETED. - Debits the merchant's
pendingbalance. - Credits the buyer's original wallets, in their original currencies, in the same proportions they paid. A buyer who covered a USD invoice from three crypto balances gets three credits back, not one.
- Returns the proportional share of your platform fee from the admin wallet to the buyer. Refund half a payment and half the fee goes back.
- Moves the payment to
REFUNDEDorPARTIALLY_REFUNDED. - Sends a
refund.completedwebhook if the payment carried awebhookUrl.
Rules the UI enforces for you:
- Only
COMPLETEDandPARTIALLY_REFUNDEDpayments are refundable. - The refund cannot exceed the amount not already refunded.
- Test-mode payments moved no money, so refunding one only writes the records.
The refund is drawn from the merchant's pending. If the payment has already
been paid out and the merchant has no other balance in that currency, the
transaction rolls back and nobody is refunded. Your options are to top the
merchant up, or to settle with the buyer outside the gateway. The way to avoid
this is to keep the payout SLA long enough that a refund window has closed
before funds are released.
That endpoint exists and is permissioned, but there is no admin screen bound to it. Refunds are visible on each payment's detail view. If you want a cross-merchant refund report, it is an API call.
Monitoring the whole flow
The gateway dashboard (/admin/gateway) shows, per mode:
- Merchant counts: total, active, pending.
- Payment counts by status, and volume grouped by currency — the flat total is also returned but a EUR payment and a USDT payment cannot be added together, so trust the breakdown.
- Refund totals and refunded/partially-refunded payment counts.
- The aged payout queue described above, plus pending payouts by currency.
processGatewayPayouts skips its entire run when gatewayEnabled is off, while
completed payments keep crediting pending. If you disable the gateway during
an incident, expect a backlog of unproposed payouts when you turn it back on —
and note that the hourly job will then propose them all at once.
Next: Gateway settings.