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.

12 min readUpdated 6 August 2026admin, payments, refunds, test-mode, expiry

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.
Paginated payment list across all merchants
One payment with merchant, customer, refunds and webhook deliveries

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=TEST

mode=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_4f9c2a1b8e7d6053f1a2b3c4

The 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:

How long an unpaid checkout session survives, in minutes. Used when the merchant does not send its own expiresIn

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 PENDING payments past expiresAt, oldest expiry first, up to 500 per run.
  • It flips each one to EXPIRED with a conditional update, so a session that moved to PROCESSING in the meantime is left alone rather than expired out from under a buyer mid-payment.
  • It sends payment.expired to the payment's own webhookUrl — the URL the merchant supplied on POST /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 a webhookSecret to 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 allocations array: 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_refund row against this payment, with its re_ 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.

Refunds a payment on the merchant's behalf. Accepts either the pi_ identifier or the row UUID

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.

  1. Open the payment and confirm its status is COMPLETED or PARTIALLY_REFUNDED. If the button is absent, it is neither, or nothing is left to refund.

  2. 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.

  3. Pick a reason. REQUESTED_BY_CUSTOMER, DUPLICATE, FRAUDULENT or OTHER. It is stored on the refund row and sent in the refund.completed webhook, so the merchant's own system sees it.

  4. Add a description if you want an internal note on the row. Optional, and not sent to the merchant.

  5. Confirm. The screen reloads the payment; the new refund appears in the Refunds card and the status moves to REFUNDED or PARTIALLY_REFUNDED.

What it does, in one transaction

  1. Creates a gateway_refund row at COMPLETED with a fresh re_ id.
  2. Works out the proportional share of the platform fee: refundAmount x (feeAmount / amount).
  3. For each wallet allocation on the original payment, in proportion:
    • Debits the merchant's pending balance 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 REFUND operation. 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.
  4. Moves the payment to REFUNDED or PARTIALLY_REFUNDED.
  5. Sends refund.completed to the payment's webhookUrl, if that payment carried one — the URL is per payment, taken from the original payment/create call, and the delivery is signed with the merchant's webhookSecret. 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 testMode is 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 allocations cannot 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:

  1. The payment completes. The buyer's wallets are debited, your fee is credited to the first Super Admin's wallet and booked in adminProfit as GATEWAY_PAYMENT, and amount − fee is added to gateway_merchant_balance.pendingper allocation currency, so a payment invoiced in USD but paid from a USDT wallet credits a USDT balance row.
  2. 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.
  3. The hourly payout job proposes a payout for the part of pending no open payout already claims, if it clears the merchant's threshold.
  4. An admin approves it. Only then does pending move to available and a real wallet row get credited as an INCOMING_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.