Skip to content

Reports and reconciliation ​

This page covers two things with two different purposes. Do not mix them up.

What you wantWhich set to use
Pull data for a screen / for accounting / to answer supportGET /v1/bo/reports/*
Reconcile — prove your numbers equal oursGET /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:

http
GET /v1/bo/reports/financial?scope_id={scope}&period=last_7_days&view=subtree&drilldown=direct_children&livemode=true&limit=50

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

LevelSelfWhole subtreeDirect-child breakdown
OwnerYesYesWhite Labels
White LabelYesYesIts own Agents
AgentYesYesIts own Merchants
MerchantYes——

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_fill projections 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_bps is the commercial rate configured for a downline, kept separate from effective_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 office

They all take the same query parameters.

QueryValueChangeable mid-loop
cursoropaque string from the previous page's next_cursor—
limit1..200✅
from / toRFC3339 UTC ending in Z❌
type / statusdepends on the report❌
ordersort order❌
bash
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.

json
{
  "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.

json
{ "data": [], "has_more": true, "next_cursor": "cur_01K3PA2M8Q4RW7YT0NBVXH5DKC" }

Four rules

  1. cursor is opaque — never parse it, never construct one, never keep one overnight.
  2. limit may change mid-loop (it is not part of the filter hash) — useful when you hit 429 and want smaller pages.
  3. from / to / type / status / order may not change mid-loop → 400 cursor_filter_mismatch ⇒ start the loop over from page one.
  4. Stop when has_more === false, not when data.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

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

js
// 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) ​

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

  • sequence increases with the time the event was created, not the time it reached you.
  • Gaps in sequence are 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_minor

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

NumberWhat it is for
ordered_amount_minor (X)amount the order was created for
transferred_amount_minorbank-verified transferred amount, for evidence/overpayment reconciliation
amount_received_minorcredit 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_minorthe fee you pay · arrives with a fee_breakdown[] split by component
credited_amount_minoryour 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/balance

Every entry carries direction ∈ credit | debit plus bucket_from / bucket_to ⇒ every bucket can be computed from the movements alone.

typeEffect on buckets
deposit_settledpending_clearing +
clearing_releasedpending_clearing − · available +
withdrawal_reservedavailable − · reserved +
withdrawal_settledreserved −
withdrawal_releasedreserved − · available +
feeper that entry's bucket_from / bucket_to
reversalper bucket_from (inside 72 hours that is pending_clearing)
adjustmentper 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.

js
// 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.

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

ReportNumbers it needsSource
Daily depositsorder 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 rateorders that passed through under_review divided by all ordersyour own stored event stream
Daily withdrawalssum of amount_total_minor · filled_minor · refunded_minor · fee_recognized_minor/v1/pay/withdrawals
Under-filled withdrawalsorders expired/canceled with filled_minor > 0/v1/pay/withdrawals
Wallet statementevery balance_transaction ordered by sequence/v1/pay/balance/transactions
Reversalsevery 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 sequence for both /events and /balance/transactions
  • [ ] You upsert by id behind a version guard, rather than appending
  • [ ] balance_transaction is deduped by btxn_id and accumulated; balance_after is never used as an overwrite
  • [ ] Every switch over balance_transaction.type and fee_breakdown[].component has a default:
  • [ ] Three consecutive clean days before you raise your traffic share

Next ​

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