Reconciling transactions with the MONA Pay API
Reconciliation compares the transactions MONA Pay recorded with the transactions table in your own system, to fill in anything the webhook did not deliver (server maintenance, network outage, a code bug). You call GET /api/v1/acb/virtual-account/transactions page by page, at most 100 transactions per page, and compare by transaction_code. Run it hourly, or at least daily.
Why reconcile when you already have webhooks
Webhooks are the real-time channel, but they depend on your server being alive the moment money arrives. A 2-minute deploy, an expired SSL certificate, a locked database, and a payment is missed. MONA Pay currently resends by hand (automatic retries are in progress, see Retries and error handling), so periodic reconciliation is the safety net you want from day one.
The transaction list API
GET https://api.monapay.vn/api/v1/acb/virtual-account/transactions
| Query parameter | Required | Meaning |
|---|---|---|
virtual_account_number |
yes | The VA whose transactions you want |
page |
no | Page number, starting at 1 |
limit |
no | Transactions per page, at most 100 |
It is a GET, so only Authorization: Bearer <access_token> is needed, no X-Client-Secret.
curl "https://api.monapay.vn/api/v1/acb/virtual-account/transactions?virtual_account_number=1234567890&page=1&limit=100" \
-H "Authorization: Bearer $TOKEN"
The response uses the common envelope {"success": true, "message": "...", "data": {...}}, where data is a pagination block (verified in the backend code on 28/08/2026): data.data is the array of transactions, alongside current_page, per_page, total and last_page. Each transaction in data.data carries the same information as the webhook payload: transaction code, amount, time, reference, receiving account number. Stop looping when current_page >= last_page. Field details are in https://monapay.vn/openapi.json (section /api/v1/acb/virtual-account/transactions).
The API does not yet have a since_id parameter or date-range filter. The working approach is to pull page by page from page 1 (newest first) and stop when you hit a transaction_code already in your table and the page contains no new codes.
Reconciliation algorithm
- For each VA in use, call page 1 with
limit=100. - For each transaction, check whether
transaction_codealready exists in your table. - Not there: insert it and run exactly the same processing as when a webhook arrives (update the order, send email…). Use the same function so the order is updated identically whichever way the transaction came in.
- If the whole page already exists, stop. If there were new codes, call the next page.
- Record the run time and the number of transactions added, so you know whether webhooks are being missed.
# Quick shell reconciliation: list transaction_code on page 1 for an eyeball check
curl -s "https://api.monapay.vn/api/v1/acb/virtual-account/transactions?virtual_account_number=1234567890&page=1&limit=100" \
-H "Authorization: Bearer $TOKEN" | python3 -c '
import sys, json
d = json.load(sys.stdin)["data"]
items = d if isinstance(d, list) else d.get("data") or [] # data.data = array of transactions
for t in items:
print(t.get("transaction_code"), t.get("amount"), t.get("transaction_date") or t.get("transfer_date"))
'
<?php
// reconcile.php: run hourly from cron. Adds transactions the webhook did not deliver.
$token = getenv('MONA_ACCESS_TOKEN');
$va = '1234567890';
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'));
$check = $pdo->prepare('SELECT 1 FROM transactions WHERE transaction_code = ?');
for ($page = 1; $page <= 50; $page++) {
$url = "https://api.monapay.vn/api/v1/acb/virtual-account/transactions?virtual_account_number=$va&page=$page&limit=100";
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"]]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
$items = $res['data']['data'] ?? []; // data.data = array of transactions, with current_page/last_page
if (!$items) break;
$added = 0;
foreach ($items as $t) {
$check->execute([$t['transaction_code']]);
if ($check->fetch()) continue; // already there, the webhook arrived
processTransaction($t); // same function as the webhook handler, UNIQUE prevents duplicates
$added++;
}
if ($added === 0) break; // whole page already known, stop
}
// reconcile.js: run hourly from cron (node reconcile.js)
const TOKEN = process.env.MONA_ACCESS_TOKEN;
const VA = '1234567890';
async function reconcile() {
for (let page = 1; page <= 50; page++) {
const url = `https://api.monapay.vn/api/v1/acb/virtual-account/transactions?virtual_account_number=${VA}&page=${page}&limit=100`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
const body = await res.json();
let items = body.data?.data ?? []; // data.data = array of transactions, with current_page/last_page
if (items.length === 0) break;
let added = 0;
for (const t of items) {
const exists = await db.exists('transactions', { transaction_code: t.transaction_code });
if (exists) continue;
await processTransaction(t); // same function as the webhook handler
added++;
}
if (added === 0) break;
}
}
reconcile().catch((e) => { console.error(e); process.exit(1); });
Note that the access_token expires (expires_in at login). Cron scripts should log in again on 401; see Authentication.
Suggested schedule
| System type | Frequency | Why |
|---|---|---|
| Instant-delivery stores, ticket sales, courses unlocked immediately | Every 15 minutes | Customers are waiting right after paying |
| Management software, tuition, service fees | Hourly | Fast enough, low resource use |
| End-of-day accounting | 23:30 daily | Closing the books, printing reports |
Combine both: a quick page-1 reconciliation every hour, and a full pass through every page at the end of the day.
Reconciling from the dashboard
No code needed: open Transactions in the dashboard, filter by VA and date range, export CSV and compare with your books in Excel. The transaction_code column is the key; use VLOOKUP or Power Query. This suits accountants closing weekly or monthly.
Common problems
The API returns 401. The token expired. Log in again for a new token.
Page 1 is empty although there are transactions. Wrong virtual_account_number, or the money went to the main account rather than that VA. Check the VA number in the dashboard under Banks & VA.
Reconciliation inserts duplicates. The table has no UNIQUE constraint on transaction_code, or the webhook handler and the reconciliation job write to two different tables. Use one function and one table.
The script runs too long. It pulls every page every time. Stop as soon as a page has no new codes, as in the algorithm above.