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.

5 min readUpdated 3 August 2026admin, payouts, refunds, balances, cron

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:

  1. Is a payout due? INSTANT and DAILY are candidates on every run. WEEKLY and MONTHLY fire 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.
  2. How much is unclaimed? Open payouts (PENDING or PROCESSING) already claim part of pending. Only the difference is proposed. This is what stops an INSTANT merchant being issued the same balance twelve times a day.
  3. Does it clear the threshold? Below the merchant's payoutThreshold the job logs and moves on.
  4. 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

Releases a payout into the merchant's wallet

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

Cancels a payout with a mandatory reason

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/refund with 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.
Refunds a payment on the merchant's behalf

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:

  1. Creates a gateway_refund row at COMPLETED.
  2. Debits the merchant's pending balance.
  3. 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.
  4. 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.
  5. Moves the payment to REFUNDED or PARTIALLY_REFUNDED.
  6. Sends a refund.completed webhook if the payment carried a webhookUrl.

Rules the UI enforces for you:

  • Only COMPLETED and PARTIALLY_REFUNDED payments 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.

Lists every refund across all merchants

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.