Skip to content

Dashboard ​

http
GET /v1/bo/dashboard

Returns 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 ​

FigureComputed from
Available balanceavailable_minor
Balance awaiting the dispute windowpending_clearing_minor
Balance locked for withdrawalsreserved_minor
Deposits accepted todaysum of amount_received_minor over settled orders
Bank-verified settlement evidence todaysum 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 valuesum 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 todaysum of fee_total_minor
Into your wallet todaysum of credited_amount_minor
Paid out todaysum of filled_minor over withdrawals
Refunded todaysum 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.

FigureFormulaThreshold
Review-queue rateorders that passed through under_review ÷ all ordersshould be under 5% · above 10% means the sending-account form is broken
Share using a verified saved accountorders sending sender_id ÷ all ordershigher is better · orders using sender_id almost never hit the queue
Deposit success ratesettled orders ÷ all created ordersread alongside the expiry rate
Expiry rateexpired orders ÷ all ordersunusually high means your countdown is wrong or hardcoded
Late-slip rateorders with slip.is_late of truehigh means many users close the browser before uploading
Withdrawal match ratesettled orders ÷ orders that entered matchingorders that expired with filled_minor above 0 count as partial
Webhook signature failure ratecounted 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 ratecounted on your sideabove 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 ​

FigureWhy it has to be there
Withdrawals stuck in pending_approvalauto-cancelled after 24 hours ⇒ alert from 4 hours
Deposits in under_review for more than 4 hoursread together with review.escalated
Unanswered disputesmeasured against dispute.respond_by — no answer means accepting the outcome
Open claimsmeasured against expected_response_at
sequence gaps older than 4 daysfresh gaps are normal · older than 4 days means the event is really lost
failed deliveries in the last 24 hourswith 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.

js
// 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 seeAvailableBecause
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 sequence gap older than 4 days
  • A withdrawal sitting in pending_approval for more than 4 hours
  • A dispute with less than half its window left and no answer sent
  • Any deposit.reversed or balance.debited (page a human, do not merely log it)

Next ​

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01