Dashboard
GET /v1/bo/dashboardReturns the aggregates for your account — total volume processed, match rate, and overall success rate.
Dashboard numbers come from a replica. Never make a money decision from them.
The dashboard is built to be fast, not to be accurate to the second. There are exactly two financially binding sources: GET /v1/pay/balance and the four reconciliation endpoints in Reports and reconciliation.
What belongs on the first screen
Three groups. The first is money, the second is the quality of your integration, the third is the backlog.
Group 1 — money
| Figure | Computed from |
|---|---|
| Available balance | available_minor |
| Balance awaiting the dispute window | pending_clearing_minor |
| Balance locked for withdrawals | reserved_minor |
| Deposits accepted today | sum of amount_received_minor over settled orders |
| Bank-verified settlement evidence today | sum of transferred_amount_minor; may exceed accepted deposits on same-slip overpayment; a later transfer stays separately in overpayment.amount_minor and is not counted twice |
| Unresolved excess case value | sum of Claim.amount_minor over overpayment claims in open, under_review, refund_pending, or return_pending_payout; an operational exposure, not the ledger liability |
| Fees today | sum of fee_total_minor |
| Into your wallet today | sum of credited_amount_minor |
| Paid out today | sum of filled_minor over withdrawals |
| Refunded today | sum of refunded_minor |
Verified transfer, accepted amount, fee, and wallet credit must remain separate
amount_received_minor = min(transferred_amount_minor, ordered_amount_minor) and amount_received_minor − fee_total_minor = credited_amount_minor on every order. A dashboard showing only one of them is a dashboard your accounting team cannot reconcile against, and it is the root cause of picking up the wrong number to credit the user with.
Group 2 — integration quality
These figures tell you whether your own UI has a problem. They say nothing about whether our system does.
| Figure | Formula | Threshold |
|---|---|---|
| Review-queue rate | orders that passed through under_review ÷ all orders | should be under 5% · above 10% means the sending-account form is broken |
| Share using a verified saved account | orders sending sender_id ÷ all orders | higher is better · orders using sender_id almost never hit the queue |
| Deposit success rate | settled orders ÷ all created orders | read alongside the expiry rate |
| Expiry rate | expired orders ÷ all orders | unusually high means your countdown is wrong or hardcoded |
| Late-slip rate | orders with slip.is_late of true | high means many users close the browser before uploading |
| Withdrawal match rate | settled orders ÷ orders that entered matching | orders that expired with filled_minor above 0 count as partial |
| Webhook signature failure rate | counted on your side | 🔴 must be 0. Anything else means somebody is sending forgeries, or you have not picked up a rotated key. |
| Non-2xx handler rate | counted on your side | above 1% over 5 minutes ⇒ alert |
The review-queue rate is the single most important integration KPI
It is not a measure of "how good our system is". It is a measure of whether users are entering the right sending account. The most effective fix by far is to let users pick from GET /v1/pay/senders instead of making them type. During a migration, if this rate goes above 10% ⇒ 🔴 do not raise your traffic share until the form is fixed.
Group 3 — backlog that needs a human
| Figure | Why it has to be there |
|---|---|
Withdrawals stuck in pending_approval | auto-cancelled after 24 hours ⇒ alert from 4 hours |
Deposits in under_review for more than 4 hours | read together with review.escalated |
| Unanswered disputes | measured against dispute.respond_by — no answer means accepting the outcome |
| Open claims | measured against expected_response_at |
sequence gaps older than 4 days | fresh gaps are normal · older than 4 days means the event is really lost |
failed deliveries in the last 24 hours | with zero successes and more than 50 failures, the system disables the endpoint automatically |
Computing the numbers so they match the ledger
The figures on your dashboard should be computed from the same set reconciliation uses, not accumulated from events as they arrive.
// daily totals — computed from the reconciliation set, not from an event table that may be incomplete
async function dailyTotals(dayIso) {
const from = `${dayIso}T00:00:00.000Z`
const to = new Date(Date.parse(from) + 86_400_000).toISOString().replace(/\.\d{3}Z$/, '.000Z')
const totals = {
deposit_count: 0,
transferred_amount_minor: 0,
amount_received_minor: 0,
fee_total_minor: 0,
credited_amount_minor: 0,
under_review_count: 0
}
let cursor = null
for (;;) {
const params = { limit: 200, status: 'settled', updated_after: from }
if (cursor) params.cursor = cursor
const body = await call('/v1/pay/deposits', params)
for (const d of body.data) {
if (d.settled_at < from || d.settled_at >= to) continue
const amounts = [d.ordered_amount_minor, d.transferred_amount_minor,
d.amount_received_minor, d.fee_total_minor, d.credited_amount_minor]
if (!amounts.every(Number.isSafeInteger) || d.ordered_amount_minor <= 0 ||
d.transferred_amount_minor <= 0 || d.amount_received_minor <= 0 ||
d.amount_received_minor !== Math.min(d.transferred_amount_minor, d.ordered_amount_minor) ||
d.fee_total_minor < 0 || d.fee_total_minor > d.amount_received_minor) {
throw new Error(`deposit ${d.id} violates the X/V/A/fee invariant`)
}
if (d.amount_received_minor - d.fee_total_minor !== d.credited_amount_minor) {
throw new Error(`deposit ${d.id} does not satisfy A - fee = credited`)
}
totals.deposit_count += 1
totals.transferred_amount_minor += d.transferred_amount_minor
totals.amount_received_minor += d.amount_received_minor
totals.fee_total_minor += d.fee_total_minor
totals.credited_amount_minor += d.credited_amount_minor
if (d.settled_via === 'review') totals.under_review_count += 1
}
if (!body.has_more) break
cursor = body.next_cursor
}
// 🔴 re-check the totals after asserting A - fee = credited on every row
if (totals.amount_received_minor - totals.fee_total_minor !== totals.credited_amount_minor) {
throw new Error('totals do not add up — incomplete fetch or an anomalous order')
}
if (totals.transferred_amount_minor < totals.amount_received_minor) throw new Error('verified transfers are below accepted deposits')
return totals
}
// Current backlog comes from Claims; Deposit.overpayment is an immutable flag-open snapshot.
async function unresolvedOverpaymentMinor() {
let total = 0
for (const status of ['open', 'under_review', 'refund_pending', 'return_pending_payout']) {
let cursor = null
for (;;) {
const params = { kind: 'overpayment', status }
if (cursor) params.cursor = cursor
const body = await call('/v1/bo/claims', params)
for (const claim of body.data) {
if (!Number.isSafeInteger(claim.amount_minor) || claim.amount_minor <= 0) {
throw new Error(`claim ${claim.claim_id} has an invalid amount`)
}
total += claim.amount_minor
}
if (!body.has_more) break
cursor = body.next_cursor
}
}
return total
}Never sum Deposit.overpayment.amount_minor for the current backlog: that flag does not follow the Claim's resolution, and one deposit may receive multiple later-transfer claims. The Claim is the source of truth. This is case value, not OVERPAYMENT_REFUND_PAYABLE: during return_pending_payout, the original-transfer return may already have extinguished the liability while a payout/recovery race still needs monitoring.
Always show when the numbers were computed
Every figure on screen should carry a "data as of …" line, taken from the as_of of GET /v1/pay/balance or from the completion time of your own job. A dashboard with no timestamp is a dashboard people argue over the moment two numbers disagree.
Numbers you will not get — and why
| You want to see | Available | Because |
|---|---|---|
| System-wide liquidity / how many withdrawers are waiting | ❌ | competitive information belonging to the provider above you · what you can see is liquidity.estimated_wait_ms for your own order only |
| The deadline staff work to | ❌ | the support queue belongs to the tier above · what you get is review.estimated_resolution_bucket |
| Fees broken down by who receives a share | ❌ | fee_breakdown[] splits by component (the kind of fee) and nothing else |
| The identity of the fallback route in use | ❌ | the only word you ever see is liquidity_source, whose value is either internal or external_gateway |
| Information about the counterparty | ❌ | the system does not expose data across accounts or providers · the fill id you see (leg_ref) is a different value on each side and cannot be correlated across them |
Never make a money decision from liquidity_source
It exists for statistics and for answering support. The condition for crediting is one thing only: status = 'settled'.
Alerts you want from day one
- The review-queue rate crossing your threshold (a signal that the sending-account form has a problem)
- A webhook signature verification failure, even one
- Non-2xx handler responses above 1% over 5 minutes
- The daily reconciliation job exiting with a non-zero code
- A
sequencegap older than 4 days - A withdrawal sitting in
pending_approvalfor more than 4 hours - A dispute with less than half its window left and no answer sent
- Any
deposit.reversedorbalance.debited(page a human, do not merely log it)
Next
- Reports and reconciliation — the data set every figure on this page should be computed from
- Wallet and balances — the three buckets that must be shown separately
- Logs and audit trail — where the webhook figures come from
- The sending account (required) — how to cut the review-queue rate
- Go-live checklist
