Reports and reconciliation
This page covers two things with two different purposes. Do not mix them up.
| What you want | Which set to use |
|---|---|
| Pull data for a screen / for accounting / to answer support | GET /v1/bo/reports/* |
| Reconcile — prove your numbers equal ours | GET /v1/pay/* only |
Reconciliation uses /v1/pay/*, never /v1/bo/*
There is exactly one set of endpoints for reconciliation: the four listed in The reconciliation set (4 endpoints) below. /v1/bo/* belongs to the back office, not to the integration, and must never be treated as the source of truth for money.
The reports
Scope-aware financial summary for the back office
The Owner, White Label, Agent, and Merchant Reports screens use one endpoint:
GET /v1/bo/reports/financial?scope_id={scope}&period=last_7_days&view=subtree&drilldown=direct_children&livemode=true&limit=50This is the closed scope_financial_report_v1 contract. Bootstrap must grant both the reports API contract and the narrow reports:read_summary capability; broad reports:read does not grant access. Brand UI calls it with its opaque same-origin session cookie and X-PG-BO-Client: 1, so an access token never reaches the browser.
| Level | Self | Whole subtree | Direct-child breakdown |
|---|---|---|---|
| Owner | Yes | Yes | White Labels |
| White Label | Yes | Yes | Its own Agents |
| Agent | Yes | Yes | Its own Merchants |
| Merchant | Yes | — | — |
An out-of-membership scope, a cross-White-Label/Agent request, a mode mismatch, and a missing capability/contract all return the same 404 resource_not_found. The database re-checks the hierarchy; a role label or a visible browser menu is never authorization.
period accepts today, last_7_days, last_30_days, or custom. Presets use Asia/Bangkok calendar days. A custom interval requires from and to, uses an exclusive upper bound, and may span at most 366 days. view=self requires drilldown=none; a cursor is valid only for direct_children and is bound to the original scope, mode, and filters.
Financial fields have fixed meanings:
- Accepted deposits are
A=min(V,X): a short payment is accepted at the verified amount and excess overpayment is excluded. - Credited deposits are accepted deposits after the fee snapshot pinned to each transaction.
- Paid withdrawals are successfully paid principal, excluding fees.
- Deposit and withdrawal volume comes from immutable
deposit.settled/withdrawal.partial_fillprojections whose digests and posted-journal linkage are verified. Historical volume therefore does not disappear when a mutable order is later reversed, and webhook delivery state never controls inclusion. - Earned for this scope, deducted from this scope, and reversals come only from posted ledger entries.
net_earned_minor = earned_minor - deducted_minor - reversals_minor.configured_revenue_share_bpsis the commercial rate configured for a downline, kept separate fromeffective_rate_bps, which reflects posted outcomes. Changing a rate today never rewrites history.
Every aggregate money field is a minor-unit decimal string to preserve precision. series is an authoritative daily replica projection; the UI must not invent a trend from the summary. With view=subtree, deposit and withdrawal volume includes descendants, while earned/deducted/reversal/net remain the named scope's own ledger economics. A direct-child row likewise carries that child's subtree volume but only that child's economics—not “parent profit.” This prevents double counting, and White Label/Agent/Merchant responses never expose Owner provider cost or internal margin.
GET /v1/bo/reports/transactions wallet movements (balance_transaction)
GET /v1/bo/reports/deposits deposits
GET /v1/bo/reports/withdrawals withdrawals (including refunds, which are a kind of withdrawal)
GET /v1/bo/reports/credit balances and their change over a period
GET /v1/bo/reports/tickets review-queue cases and claims
GET /v1/bo/reports/action-logs who pressed what in the back officeThey all take the same query parameters.
| Query | Value | Changeable mid-loop |
|---|---|---|
cursor | opaque string from the previous page's next_cursor | — |
limit | 1..200 | ✅ |
from / to | RFC3339 UTC ending in Z | ❌ |
type / status | depends on the report | ❌ |
order | sort order | ❌ |
curl -sS "https://api.example-wl.com/v1/bo/reports/deposits?from=2026-08-20T00:00:00.000Z&limit=200&status=settled&to=2026-08-21T00:00:00.000Z" \
-H "Authorization: Bearer $BO_OIDC_ACCESS_TOKEN"Reports under /v1/bo/* use the operator's OIDC access token, not sk_live_* or PG HMAC. The /v1/pay/* reconciliation set below still uses the merchant API key plus HMAC as usual.
{
"data": [
{
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 9,
"status": "settled",
"is_terminal": false,
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"ordered_amount_minor": 500000,
"transferred_amount_minor": 500000,
"amount_received_minor": 500000,
"fee_total_minor": 7500,
"credited_amount_minor": 492500,
"currency": "THB",
"partial": false,
"settled_via": "auto",
"liquidity_source": "internal",
"balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"clearing_available_at": "2026-08-24T04:12:33.881Z",
"created_at": "2026-08-21T04:10:47.902Z",
"settled_at": "2026-08-21T04:12:33.881Z",
"sender": {
"bank_code": "002",
"account_number_masked": "xxx-x-x5567",
"account_name_th_masked": "ป*** ส***"
},
"metadata": { "user_id": "u_88213", "order_id": "ORD-9981" }
}
],
"has_more": true,
"next_cursor": "cur_01K3PA2M8Q4RW7YT0NBVXH5DKC"
}bank_code 002 is Bangkok Bank. account_name_th_masked is the Thai account name with all but the first character of each part masked.
The report quota is lower than you think
bo_reports = 60 requests / 60 seconds (against bo_read at 300 and pay_read at 1,200). Pulling a whole day at limit=200 with no pacing will hit 429 halfway through. Read RateLimit-Remaining, which ships with every response, and pace yourself. Do not wait for 429 to teach you.
Cursor pagination that stays correct when new rows arrive
The response shape is the same on every cursor endpoint.
{ "data": [], "has_more": true, "next_cursor": "cur_01K3PA2M8Q4RW7YT0NBVXH5DKC" }Four rules
cursoris opaque — never parse it, never construct one, never keep one overnight.limitmay change mid-loop (it is not part of the filter hash) — useful when you hit429and want smaller pages.from/to/type/status/ordermay not change mid-loop →400 cursor_filter_mismatch⇒ start the loop over from page one.- Stop when
has_more === false, not whendata.length < limit(the last page can be exactly full).
We have no page and no offset at all
Plenty of other docs give you page=1,2,3 ⇒ rows created while you are iterating shift every page ⇒ rows silently repeat or silently disappear. We do not offer it — not because it is unbuilt, but because you cannot reconcile with it, and reconciliation is a go-live requirement.
Why a cursor survives new data
A cursor is "where you stopped", not "which page you are on" ⇒ rows created while you iterate do not shift the rows you have not read yet.
One case is left to plan for: rows modified while you iterate. A deposit moving from under_review to settled at 3am, while your loop is on page 7.
The shape that survives everything — upsert by id, do not append
INSERT INTO merchant_deposits (deposit_id, version, status)
VALUES ($1, $2, $3)
ON CONFLICT (deposit_id) DO UPDATE
SET version = EXCLUDED.version, status = EXCLUDED.status
WHERE merchant_deposits.version < EXCLUDED.version;With this, a repeated row costs you nothing, and it is the same version guard you already use for webhooks. balance_transaction has no version (it is immutable) ⇒ use ON CONFLICT (btxn_id) DO NOTHING.
A correct loop
// iterate every page of a cursor endpoint · stop only on has_more === false
const BO_TOKEN = process.env.BO_OIDC_ACCESS_TOKEN
async function* pages(path, params) {
let cursor = null
for (;;) {
const query = buildQuery({ ...params, limit: 200, ...(cursor ? { cursor } : {}) })
const res = await fetch(`${BASE}${path}?${query}`, {
headers: { Authorization: `Bearer ${BO_TOKEN}` }
})
if (res.status === 429) {
const wait = Number(res.headers.get('Retry-After') ?? 5)
await sleep(wait * 1000 * (0.8 + Math.random() * 0.4)) // jitter ±20%
continue // 🔴 same cursor, do not restart
}
if (!res.ok) throw new Error(`${path} → ${res.status} ${await res.text()}`)
const body = await res.json()
yield body.data
if (!body.has_more) return // 🔴 not data.length < limit
cursor = body.next_cursor
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))🔴 The query string you put in the canonical string must be byte-identical to the one appended to the real URL (percent-encode first, then sort by the bytes of the encoded keys). Otherwise you get 401 signature_invalid — see request authentication.
The reconciliation set (4 endpoints)
GET /v1/pay/events?since_sequence=88214&limit=100
GET /v1/pay/deposits?status=settled&updated_after=2026-08-20T00:00:00.000Z&cursor=
GET /v1/pay/withdrawals?updated_after=2026-08-20T00:00:00.000Z&cursor=
GET /v1/pay/balance/transactions?since_sequence=41902/v1/pay/events does not use a cursor
It is the only endpoint that uses since_sequence — store the highest sequence you have successfully processed and resume from there.
sequenceincreases with the time the event was created, not the time it reached you.- Gaps in
sequenceare normal, temporarily — events your endpoint could not accept are still inside the retry cycle (up to ~92 hours). - A gap still open after 4 days is a real loss ⇒ alert on it and chase it from the Logs page.
/v1/pay/balance/transactions uses since_sequence too (a different number series from /events).
Webhooks are not the source of truth
The ledger is the truth; webhooks are notifications ⇒ a daily reconcile job is a go-live requirement, not a nice-to-have. Both /v1/pay/events and /v1/pay/balance/transactions reach back 30 days.
The two reconciliation rules
Rule 1 — every settled deposit must add up
amount_received_minor − fee_total_minor = credited_amount_minorGuaranteed on every order, no exceptions. If you find one that does not hold, that is a bug on our side — send us the deposit_id immediately.
| Number | What it is for |
|---|---|
ordered_amount_minor (X) | amount the order was created for |
transferred_amount_minor | bank-verified transferred amount, for evidence/overpayment reconciliation |
amount_received_minor | credit the end user with this — what was accepted into the order |
overpayment.amount_minor (E) | excess in the claim; zero/absent when there is no overpayment |
fee_total_minor | the fee you pay · arrives with a fee_breakdown[] split by component |
credited_amount_minor | your wallet moves by this one and only this one |
Swap these two and nobody notices until month end
Crediting the user with credited_amount_minor ⇒ the user is short by the fee on every order. Reconciling your wallet with amount_received_minor ⇒ your books run high by the total of every fee for the month. Neither breaks anything. The numbers are just quietly wrong — which is why you assert on every record by machine, not by eye.
Only when overpayment.cause='same_transfer_excess', assert transferred_amount_minor = amount_received_minor + overpayment.amount_minor for the settlement evidence. With additional_transfer, transferred_amount_minor remains the original evidence amount and each new transfer gets its own Claim. Excess stays outside fee/merchant-wallet totals.
Aggregate daily overpayment from the append-only Claim set: deduplicate by claim_id and assign the day using Claim.created_at. Never collapse to one row per deposit_id, and never sum only Deposit.overpayment, which is a latest-flag projection; one deposit can receive several additional_transfers and therefore several claims.
Keep fee_breakdown[] raw as well, so you have something to point at during a fee argument. Read it by component only, and always have a default: branch — it is an open enum.
Rule 2 — the sum of balance_transactions must equal the wallet
sum of balance_transactions for a bucket == that bucket in GET /v1/pay/balanceEvery entry carries direction ∈ credit | debit plus bucket_from / bucket_to ⇒ every bucket can be computed from the movements alone.
type | Effect on buckets |
|---|---|
deposit_settled | pending_clearing + |
clearing_released | pending_clearing − · available + |
withdrawal_reserved | available − · reserved + |
withdrawal_settled | reserved − |
withdrawal_released | reserved − · available + |
fee | per that entry's bucket_from / bucket_to |
reversal | per bucket_from (inside 72 hours that is pending_clearing) |
adjustment | per bucket_from / bucket_to |
balance_transaction.type is an open enum
We may add values without it counting as a breaking change ⇒ your switch must have a default: that records and alerts. Not throw, and not silence. The most robust approach: do not derive the effect from type at all. Derive it from direction + bucket_from / bucket_to, which are present on every entry, and use type only for display.
The daily reconciliation script
Run it once a day after midnight UTC. Make it exit with a status code, not just log, so you can wire it to monitoring.
// reconcile.mjs — daily reconciliation · run with: node reconcile.mjs 2026-08-20
import { createHmac, createHash, randomUUID } from 'node:crypto'
const BASE = 'https://api.example-wl.com'
const KEY = process.env.PG_SECRET_KEY // sk_live_*
const PG_VERSION = '2026-08-01'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// canonical string = 9 lines, every line terminated with \n including the last
function sign({ method, path, query = '', body = '', idempotencyKey = '' }) {
const ts = Math.floor(Date.now() / 1000).toString()
const nonce = randomUUID().replace(/-/g, '')
const canonical = `v1\n${ts}\n${nonce}\n${method}\n${path}\n${query}\n` +
`${createHash('sha256').update(body, 'utf8').digest('hex')}\n${idempotencyKey}\n${PG_VERSION}\n`
return {
'Authorization': `Bearer ${KEY}`,
'PG-Timestamp': ts,
'PG-Nonce': nonce,
'PG-Version': PG_VERSION,
'PG-Signature': `v1=${createHmac('sha256', KEY).update(canonical, 'utf8').digest('hex')}`
}
}
function buildQuery(params) {
return Object.entries(params)
.map(([k, v]) => [encodeURIComponent(k), encodeURIComponent(String(v))])
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, v]) => `${k}=${v}`)
.join('&')
}
async function call(path, params = {}) {
for (let attempt = 0; ; attempt++) {
const query = buildQuery(params)
const url = query ? `${BASE}${path}?${query}` : `${BASE}${path}`
const res = await fetch(url, { headers: sign({ method: 'GET', path, query }) })
if (res.status === 429 || res.status === 503) {
if (attempt >= 6) throw new Error(`${path} gave up after ${attempt} attempts`)
const wait = Number(res.headers.get('Retry-After') ?? 5)
await sleep(wait * 1000 * (0.8 + Math.random() * 0.4))
continue
}
if (!res.ok) throw new Error(`${path} → ${res.status} ${await res.text()}`)
return res.json()
}
}
// 1) walk every wallet movement from where we stopped last time
async function walkBalanceTransactions(sinceSequence) {
const rows = []
let since = sinceSequence
for (;;) {
const body = await call('/v1/pay/balance/transactions', { limit: 200, since_sequence: since })
if (body.data.length === 0) break
rows.push(...body.data)
since = Math.max(...body.data.map((r) => r.sequence))
if (!body.has_more) break
}
return rows
}
// 2) compute every bucket from the movements alone
function foldBuckets(rows, opening = { available: 0, pending_clearing: 0, reserved: 0 }) {
const b = { ...opening }
const seen = new Set()
for (const r of rows) {
if (seen.has(r.id)) continue // 🔴 immutable ⇒ dedupe by id only
seen.add(r.id)
if (r.bucket_from) b[r.bucket_from] -= r.amount_minor
if (r.bucket_to) b[r.bucket_to] += r.amount_minor
}
return b
}
// 3) check every deposit that settled that day, one by one
async function checkSettledDeposits(fromIso, toIso) {
const bad = []
let cursor = null
for (;;) {
const params = { limit: 200, status: 'settled', updated_after: fromIso }
if (cursor) params.cursor = cursor
const body = await call('/v1/pay/deposits', params)
for (const d of body.data) {
if (d.settled_at >= toIso) 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) {
bad.push({ deposit_id: d.id, kind: 'X/V/A/fee/credited must be contract-valid integers' })
continue
}
if (d.amount_received_minor - d.fee_total_minor !== d.credited_amount_minor) {
bad.push({ deposit_id: d.id, kind: 'accepted amount minus fee does not equal wallet credit' })
}
if (d.amount_received_minor !== Math.min(d.transferred_amount_minor, d.ordered_amount_minor)) {
bad.push({ deposit_id: d.id, kind: 'accepted amount must equal min(verified transfer, ordered amount)' })
}
if (d.fee_total_minor < 0 || d.fee_total_minor > d.amount_received_minor) {
bad.push({ deposit_id: d.id, kind: 'fee must be between zero and the accepted amount' })
}
if (d.overpayment?.cause === 'same_transfer_excess' &&
d.transferred_amount_minor !== d.amount_received_minor + d.overpayment.amount_minor) {
bad.push({ deposit_id: d.id, kind: 'same-slip overpayment does not satisfy V = A + E' })
}
}
if (!body.has_more) break
cursor = body.next_cursor
}
return bad
}
// 4) look for gaps in the event sequence
async function checkEventGaps(sinceSequence) {
const seen = []
let since = sinceSequence
for (;;) {
const body = await call('/v1/pay/events', { limit: 100, since_sequence: since })
if (body.data.length === 0) break
seen.push(...body.data.map((e) => e.sequence))
since = Math.max(...body.data.map((e) => e.sequence))
if (body.data.length < 100) break
}
seen.sort((a, b) => a - b)
const gaps = []
for (let i = 1; i < seen.length; i++) {
for (let s = seen[i - 1] + 1; s < seen[i]; s++) gaps.push(s)
}
return gaps // fresh gaps = normal · older than 4 days = really lost
}
// ── main ────────────────────────────────────────────────────────────────────
const day = process.argv[2] // 2026-08-20
const from = `${day}T00:00:00.000Z`
const to = new Date(Date.parse(from) + 86_400_000).toISOString().replace(/\.\d{3}Z$/, '.000Z')
// load these from your own state table
const state = { lastBtxnSequence: 0, lastEventSequence: 0, opening: undefined }
const live = await call('/v1/pay/balance') // 🔴 read the balance first, then walk
const rows = await walkBalanceTransactions(state.lastBtxnSequence)
const upTo = rows.filter((r) => r.created_at <= live.as_of) // drop movements after as_of
const computed = foldBuckets(upTo, state.opening)
const mismatch = [
['available', computed.available, live.available_minor],
['pending_clearing', computed.pending_clearing, live.pending_clearing_minor],
['reserved', computed.reserved, live.reserved_minor]
].filter(([, a, b]) => a !== b)
const badDeposits = await checkSettledDeposits(from, to)
const gaps = await checkEventGaps(state.lastEventSequence)
console.log(JSON.stringify({
day,
as_of: live.as_of,
balance_mismatch: mismatch.map(([bucket, computed_minor, reported_minor]) => ({
bucket, computed_minor, reported_minor, delta_minor: computed_minor - reported_minor
})),
deposits_failing_identity: badDeposits,
event_sequence_gaps: gaps.slice(0, 50)
}, null, 2))
process.exit(mismatch.length === 0 && badDeposits.length === 0 ? 0 : 1)Get the timing right before you panic
GET /v1/pay/balance returns the balance at the second you asked (as_of). The movements you finished walking are as of a different second. If a new transaction lands in between, the two differ by exactly one entry — that is not a bug. The script above handles it by reading the balance first and then discarding movements whose created_at is later than as_of.
Check your own database too
Run these alongside the script above, to catch the case where we are right and you wrote it down wrong.
-- 1) accepted amount / fee / wallet arithmetic must hold on every settled order
SELECT deposit_id, amount_received_minor, fee_total_minor, credited_amount_minor
FROM merchant_deposits
WHERE status = 'settled'
AND amount_received_minor - fee_total_minor <> credited_amount_minor;
-- 2) the user must be credited exactly once per deposit, for the amount accepted into the order
SELECT d.deposit_id
FROM merchant_deposits d
JOIN user_ledger u ON u.deposit_id = d.deposit_id
WHERE d.status = 'settled'
GROUP BY d.deposit_id, d.amount_received_minor
HAVING COUNT(*) <> 1
OR SUM(u.amount_minor) <> d.amount_received_minor;
-- 3) received events must not repeat (a repeat means UNIQUE(event_id) is not working)
SELECT event_id, COUNT(*)
FROM webhook_events_received
GROUP BY event_id
HAVING COUNT(*) > 1;Reports your back office should have
| Report | Numbers it needs | Source |
|---|---|---|
| Daily deposits | order count · sums of settlement-evidence transferred_amount_minor · amount_received_minor · fee_total_minor · credited_amount_minor from deposits; excess summed from append-only Claims deduplicated by claim_id whose created_at falls in that day | /v1/pay/deposits?status=settled + claim queue |
| Review-queue rate | orders that passed through under_review divided by all orders | your own stored event stream |
| Daily withdrawals | sum of amount_total_minor · filled_minor · refunded_minor · fee_recognized_minor | /v1/pay/withdrawals |
| Under-filled withdrawals | orders expired/canceled with filled_minor > 0 | /v1/pay/withdrawals |
| Wallet statement | every balance_transaction ordered by sequence | /v1/pay/balance/transactions |
| Reversals | every entry with type of reversal, plus its reason_code | /v1/pay/balance/transactions |
An expired withdrawal does not mean nothing was paid
Withdraw THB 10,000.00, receive THB 4,000.00, then expire = expired with filled_minor of 400000. A report that counts expired as "wholly unsuccessful" will understate your payouts. ⇒ Always keep amount_total_minor / filled_minor / refunded_minor / fee_recognized_minor separate on every order.
Checklist before you call reconciliation done
- [ ] The job runs automatically every day, and alerts when the exit code is not 0
- [ ] You store the highest processed
sequencefor both/eventsand/balance/transactions - [ ] You upsert by id behind a version guard, rather than appending
- [ ]
balance_transactionis deduped bybtxn_idand accumulated;balance_afteris never used as an overwrite - [ ] Every
switchoverbalance_transaction.typeandfee_breakdown[].componenthas adefault: - [ ] Three consecutive clean days before you raise your traffic share
Next
- Wallet and balances — the three buckets and what
available_atmeans - Logs and audit trail — chasing the events behind a
sequencegap - Transaction lookup — drilling into a single order when reconciliation fails
- Dashboard — the aggregates computed from this same set
- Go-live checklist
