The payments desk
The admin payment list and detail screen — the LIVE/TEST filter and what really sets a payment's mode, the status ladder, which payments can be refunded, and what an admin refund does to a merchant's balance.
Every support question about one specific payment is answered here. A merchant asking why an order was never marked paid, a buyer asking where their money went, a chargeback conversation that needs the wallets a charge was actually drawn from — none of that is on the merchant screen or the payout queue, and none of it is in the database in a shape you would want to read by hand.
Two screens, reached from Payments in the gateway admin nav:
/admin/gateway/payment— every payment across every merchant, paginated twelve to a page./admin/gateway/payment/<id>— one payment in full, with the refund action on it.
The list screen is gated on access.gateway.payment and its data on
view.gateway.payment. The detail screen has no page-level permission of its
own — it is protected only by the permission on the endpoint it calls, so a
staff member without view.gateway.payment who follows a link to a payment gets
the page frame with an error in it rather than a clean 403. Grant the pair
together.
On a demo install, customer.email and merchant.email are masked on both
endpoints. Those are placeholders, not missing rows.
LIVE and TEST, and what actually decides which
This is the first thing to get right, because getting it wrong makes a real payment look like it does not exist.
The switch is in the gateway admin header, top right, beside the LIVE/TEST chip.
It is a browser preference, stored in localStorage under
admin-gateway-mode, defaulting to LIVE. It is not attached to your account,
so it does not follow you to another machine, and a colleague looking at "the
same screen" may not be.
The list always sends the current mode:
GET /api/admin/gateway/payment?mode=LIVE
GET /api/admin/gateway/payment?mode=TESTmode=TEST matches testMode = true, mode=LIVE matches testMode = false.
There is no "show me everything" view. A payment is in exactly one of the two
lists.
gateway_payment.testMode is written once, when the payment session is created,
from the prefix of the key the merchant authenticated with:
| Key prefix | Payment is |
|---|---|
sk_test_ · pk_test_ |
testMode = true → the TEST list |
sk_live_ · pk_live_ |
testMode = false → the LIVE list |
Nothing else influences it. The gatewayTestMode switch on
Settings → General is stored and read by no code path — it does not move
payments between the two lists, and it does not put the platform into test mode.
The merchant's own testMode flag on their record does not either. See
Gateway settings.
So the single most common "the payment is missing" ticket is a merchant who
integrated with their sk_test_ key while you are looking at LIVE. Flip the
switch before you look for the row anywhere else. The reverse is worth watching
for too: a dashboard or a payment list left on TEST shows a business that is not
transacting.
Test-mode payments walk the entire checkout and move no money at all — no
wallet is debited, no fee is collected, and no merchant balance row is created or
credited. The amount, feeAmount and netAmount columns are still filled in,
which is why a TEST list can look convincingly like revenue.
Reading the list
| Column | Notes |
|---|---|
| Payment ID | The row's UUID, not the pi_ identifier. Searchable |
| Merchant | Name and email, joined from gateway_merchant |
| Customer | The signed-in platform account that paid. Name and email |
| Amount | With the payment's currency beside it |
| Status | The ladder below. Filterable |
| Created at | Default sort, newest first |
| Order ID · Currency · Fee · Net amount · Completed at | Expanded-row columns |
Two of those columns are wired to keys the API does not return: Order ID
reads orderId where the payload carries merchantOrderId, and Fee reads
fee where the payload carries feeAmount. Both draw an em dash in the table.
The values themselves are correct — the merchant's order reference is in the
row's view dialog, and the fee is on the detail screen.
The Customer column also draws an avatar plate beside the name, and on this list
it is always the placeholder: the list endpoint selects only the customer's
id, first name, last name and email, so there is no avatar in the payload to
draw. That is a blank plate, not a customer without a profile picture.
Expanding a row gives you View details, which opens the detail screen for that payment's UUID. There is no create, edit or delete action on this table, by design: a payment is a record of something that happened.
The pi_… id a merchant quotes is not a list column, so searching for it
returns nothing. The detail endpoint accepts either identifier, so go
straight to the URL:
/admin/gateway/payment/pi_4f9c2a1b8e7d6053f1a2b3c4The same is true of the refund endpoint. That is the path to use when you are working from a merchant's email or a database export rather than from the table.
The status ladder
gateway_payment.status is an eight-value enum. In practice a payment walks a
much shorter path than the enum suggests.
| Status | What it means | Money |
|---|---|---|
PENDING |
Session created, nobody has paid | None moved |
PROCESSING |
A confirmation is running inside the settlement transaction | Being debited |
COMPLETED |
Settled. Buyer debited, fee taken, merchant's balance credited | Moved |
CANCELLED |
The merchant cancelled the session, or the buyer cancelled at checkout | None moved |
EXPIRED |
The session passed expiresAt unpaid |
None moved |
REFUNDED |
Fully refunded | Moved, then returned |
PARTIALLY_REFUNDED |
Part refunded, part still the merchant's | Partly returned |
FAILED |
Defined in the enum | — |
No code path sets gateway_payment.status = "FAILED". A confirmation that
throws reverts the row from PROCESSING back to PENDING so the buyer can
retry, and the session then ends as EXPIRED or CANCELLED like any other
unpaid one.
The FAILED filter on this list therefore normally returns nothing, and an
empty result is not evidence that nothing is going wrong. The dashboard's stats
payload carries a payments.failed count for the same reason, but no tile on
/admin/gateway displays it — do not send anybody looking for a Failed
figure there. To find failing checkouts, use the success-rate declines panel
on the gateway dashboard, which counts CANCELLED and
EXPIRED as unsuccessful.
The payment.failed webhook is a separate thing and does fire, but only on a
5xx — a 4xx leaves the session retryable and deliberately sends nothing. See
Webhooks.
Only COMPLETED and PARTIALLY_REFUNDED can be refunded. Everything else is
rejected by the endpoint with Payment with status <X> cannot be refunded, and
the refund button does not render on the detail screen at all.
Expiry, and the job that enforces it
An unpaid session carries an expiresAt fixed at creation:
The merchant's expiresIn on POST /v1/payment/create wins where it is
supplied; this setting is the fallback, and 30 minutes is the fallback's
fallback if the setting has no row.
Passing expiresAt does not by itself change the status. processGatewayPaymentExpiry
runs every 5 minutes in the cron worker and is what moves a lapsed checkout out
of PENDING:
- It selects
PENDINGpayments pastexpiresAt, oldest expiry first, up to 500 per run. - It flips each one to
EXPIREDwith a conditional update, so a session that moved toPROCESSINGin the meantime is left alone rather than expired out from under a buyer mid-payment. - It sends
payment.expiredto the payment's ownwebhookUrl— the URL the merchant supplied onPOST /v1/payment/create, not a setting on the merchant record, which has no webhook URL column at all. It sends only when that payment carried a URL and the merchant has awebhookSecretto sign the delivery with; either one missing and the row is expired silently. - It stops starting new rows after 60 seconds of wall clock and defers the rest to the next tick. One merchant whose webhook host black-holes packets costs 30 seconds per row, and without the budget that merchant would block every other merchant's expiries behind it.
Expiry used to happen only when somebody reloaded the checkout page — and nobody
reloads a checkout they abandoned. With the cron worker stopped, PENDING grows
without bound on this list and on every merchant's own dashboard, and the
payment.expired webhook never fires for anyone. Confirm the job in
Admin → System → Cron or see
Scheduled jobs.
The detail screen
/admin/gateway/payment/<id> is the whole record. What is on it, and why you
would look:
- The money, three ways — Gross, Fee and Net, all in the payment's own
currency, with a copyable
pi_identifier in the header and a TEST badge when the payment is a test one. - Payment wallets — the
allocationsarray: every wallet the buyer actually paid from, with its wallet type, its currency, the exact amount debited to eight decimal places, and its equivalent in the payment currency. This is the answer to "which of my balances did this come out of", and it is what a refund is distributed back across. - Order items — the merchant's
lineItems, if they sent any. - Refunds — every
gateway_refundrow against this payment, with itsre_id, status, reason and date. - Webhooks — every delivery attempt: the event name, the delivery status, the attempt count and the last attempt time. This is where you look when a merchant says they were never notified.
- Merchant, customer, timeline — in the sidebar, with links through to the merchant record and to the buyer's user account.
- Quick actions — copy the payment ID, export the whole payload as JSON, and Issue refund.
Two things the detail screen does not show: the merchant's own order reference
(the sidebar reads orderId, but the payload carries merchantOrderId, so the
block never renders) and the billing address unless the merchant collected one.
Both are in the list's view dialog.
Refunding on a merchant's behalf
There is no "cancel refund" action, no reversal endpoint and no draft state. The
refund row is created already COMPLETED, inside one transaction that has
already credited the buyer's wallets by the time you see the toast. The only way
back is a fresh payment.
It is also drawn from the merchant's balance, not from yours. Refunding a payment the merchant has already been paid out for can fail, or can leave them short. Read the balance rule below before you use this button.
manage.gateway.payment is the key, and it is separate from
view.gateway.payment — a support role can be given read of this whole desk
without being able to move money. edit.gateway.payment sounds like the right
key and is not: it is seeded and no route uses it.
-
Open the payment and confirm its status is
COMPLETEDorPARTIALLY_REFUNDED. If the button is absent, it is neither, or nothing is left to refund. -
Press Issue refund. The amount field is pre-filled with the full remaining refundable amount — the payment amount less every completed refund already against it. Reduce it for a partial refund.
-
Pick a reason.
REQUESTED_BY_CUSTOMER,DUPLICATE,FRAUDULENTorOTHER. It is stored on the refund row and sent in therefund.completedwebhook, so the merchant's own system sees it. -
Add a description if you want an internal note on the row. Optional, and not sent to the merchant.
-
Confirm. The screen reloads the payment; the new refund appears in the Refunds card and the status moves to
REFUNDEDorPARTIALLY_REFUNDED.
What it does, in one transaction
- Creates a
gateway_refundrow atCOMPLETEDwith a freshre_id. - Works out the proportional share of the platform fee:
refundAmount x (feeAmount / amount). - For each wallet allocation on the original payment, in proportion:
- Debits the merchant's
pendingbalance by the NET — the refunded share of that allocation less its share of the fee. That is exactly what the merchant was credited when the payment completed. - Credits the buyer's wallet with the GROSS — the full refunded share, in
the original currency and wallet type, as a
REFUNDoperation. A buyer who covered a USD invoice from three crypto balances gets three credits back, not one. - Debits the Super Admin wallet for the fee share, returning your cut.
- Debits the merchant's
- Moves the payment to
REFUNDEDorPARTIALLY_REFUNDED. - Sends
refund.completedto the payment'swebhookUrl, if that payment carried one — the URL is per payment, taken from the originalpayment/createcall, and the delivery is signed with the merchant'swebhookSecret. A webhook failure here is logged and does not roll the refund back.
What it does to the merchant's balance
The refund is drawn from gateway_merchant_balance.pending for that currency and
wallet type — the same column a payout draws from, and the source of truth for
what you owe the merchant.
pending goes down by the net; totalRefunded goes up by the gross.
available is not touched, so a payout that has already been approved is not
clawed back by a refund.
If the merchant's pending in that currency is less than the net being
reclaimed, the transaction rolls back with
Insufficient merchant gateway balance for refund in <CURRENCY> and nobody is
refunded. The usual cause is that the payment has already been paid out and
the merchant has taken the funds.
Your options are then to top the merchant's balance up, or to settle with the buyer outside the gateway. The way to avoid it is to keep the payout SLA long enough that your refund window has closed before funds are released — see Payouts and refunds.
Two smaller behaviours worth knowing before you are surprised by them:
- A test-mode payment refunds records only. The route skips all fund
movement when
testModeis true, so the refund row and the status change are written and nothing else happens. - The fee return is skipped silently if your own wallet is short. The Super Admin wallet in that currency is only debited when it holds the fee. The buyer is credited the gross either way and the merchant is still only debited the net, so a Super Admin wallet you have swept to zero turns the fee share into a platform loss that nothing on screen names.
- A payment with no
allocationscannot be refunded — the endpoint returns "Payment has no allocation data for refund processing". In practice only hand-edited or pre-allocation rows are in this state.
From a completed payment to money in a merchant's hands
The chain behind every figure on this desk, and the one an operator has to be able to recite when a merchant asks where their money is:
- The payment completes. The buyer's wallets are debited, your fee is
credited to the first Super Admin's wallet and booked in
adminProfitasGATEWAY_PAYMENT, andamount − feeis added togateway_merchant_balance.pending— per allocation currency, so a payment invoiced in USD but paid from a USDT wallet credits a USDT balance row. - The balance row is a ledger entry, not a wallet. The merchant can see it on their own dashboard and can spend none of it. It is invisible to trading, transfers and withdrawals.
- The hourly payout job proposes a payout for the part of
pendingno open payout already claims, if it clears the merchant's threshold. - An admin approves it. Only then does
pendingmove toavailableand a realwalletrow get credited as anINCOMING_TRANSFER. There is no auto-approval on any schedule.
A refund reverses step 1 and nothing else — which is why a refund after step 4 is the case that fails.
Next: the gateway dashboard, which totals this desk, or Payouts and refunds for the queue that turns these payments into money.