ACB bank QR API (dynamic VietQR) per order
A MONA Pay payment QR is a dynamic VietQR code generated by ACB: the account number (or VA), amount and order code are pre-filled, so the customer scans it with any banking app and pays the exact amount with the exact reference, nothing typed by hand. Call POST /api/v1/acb/qr-payment/generate with orderId, amount (VND, integer, up to 1,000,000,000) and the VA prefix; MONA Pay returns qr_data_url for you to render on the checkout page. When the customer pays, ACB reports it and MONA Pay fires the webhook exactly like money arriving in a VA. A QR created by mistake can be cancelled through the cancellation endpoint before the customer scans it.
If you would rather not call the API, the dashboard has a Create QR section: pick a VA, enter the amount, download the image to print or send to the customer.
POST /api/v1/acb/qr-payment/generate
Needs Bearer + X-Client-Secret. Requirement: a linked ACB account with a VA (see Virtual accounts).
| Field | Type | Required | Notes |
|---|---|---|---|
ownerNumber |
string | yes | The receiving ACB account number |
ownerType |
PER / ORG |
yes | PER personal, ORG business |
merchantId |
string | yes | Merchant code issued by ACB when the QR service was opened. The correct value is shown in the dashboard under Create QR |
terminalId |
string | yes | Terminal code issued by ACB, shown next to merchantId |
orderId |
string | yes | Your order code, unique per payment |
virtualAccountPrefix |
string (1-10) | yes | VA prefix |
beneficiaryName |
string (1-100) | yes | The beneficiary name shown in the customer’s banking app |
amount |
integer | yes | VND amount, 0 to 1,000,000,000. 0 = the customer enters the amount |
description |
string (≤255) | no | Transfer note pre-filled for the customer |
traceNumber |
string | no | Your own tracking code, recommended for lookups |
userId |
string | no | Customer id in your system |
voucherCode, loyaltyCode |
string | no | Discount / loyalty codes if you want them stored |
additionalInfo |
array [{key, value}] |
no | Extra key-value data |
Sample body:
{
"ownerNumber": "123456789",
"ownerType": "ORG",
"merchantId": "MC00012345",
"terminalId": "TM0001",
"orderId": "DH10234",
"virtualAccountPrefix": "MONA",
"beneficiaryName": "CONG TY ABC",
"amount": 2500000,
"description": "Thanh toan DH10234",
"traceNumber": "DH10234-20260828"
}
Response 200: data is the QR record; the important fields are qr_data_url and virtual_account_number.
{
"success": true,
"message": "Success",
"data": {
"id": "0190c0d1-...",
"virtual_account_id": "0190b0c3-...",
"owner_number": "123456789",
"owner_type": "ORG",
"merchant_id": "MC00012345",
"terminal_id": "TM0001",
"user_id": null,
"order_id": "DH10234",
"virtual_account_prefix": "MONA",
"beneficiary_name": "CONG TY ABC",
"amount": 2500000,
"voucher_code": null,
"loyalty_code": null,
"description": "Thanh toan DH10234",
"additional_info": null,
"virtual_account_number": "MONA0000010234",
"trace_number": "DH10234-20260828",
"qr_data_url": "00020101021238...6304ABCD",
"created_at": "2026-08-28T10:40:00",
"updated_at": null
}
}
qr_data_url is the standard VietQR (EMVCo) data string. Feed it to any QR rendering library (for example qrcode for Node, endroid/qr-code for PHP) to get an image. Keep id in case you need to cancel.
cURL
curl -X POST https://api.monapay.vn/api/v1/acb/qr-payment/generate \
-H "Authorization: Bearer $MONA_TOKEN" -H "X-Client-Secret: $MONA_SECRET" \
-H 'Content-Type: application/json' \
-d '{"ownerNumber":"123456789","ownerType":"ORG","merchantId":"MC00012345","terminalId":"TM0001","orderId":"DH10234","virtualAccountPrefix":"MONA","beneficiaryName":"CONG TY ABC","amount":2500000,"description":"Thanh toan DH10234"}'
PHP
<?php
$ch = curl_init('https://api.monapay.vn/api/v1/acb/qr-payment/generate');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . getenv('MONA_TOKEN'), 'X-Client-Secret: ' . getenv('MONA_SECRET')],
CURLOPT_POSTFIELDS => json_encode([
'ownerNumber' => '123456789', 'ownerType' => 'ORG', 'merchantId' => getenv('ACB_MERCHANT_ID'), 'terminalId' => getenv('ACB_TERMINAL_ID'),
'orderId' => $order->code, 'virtualAccountPrefix' => 'MONA', 'beneficiaryName' => 'CONG TY ABC',
'amount' => (int) $order->total, 'description' => 'Thanh toan ' . $order->code,
]),
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($res['success'])) throw new RuntimeException($res['message'] ?? 'QR generation failed');
$qrString = $res['data']['qr_data_url']; // feed to a QR rendering library
$qrId = $res['data']['id']; // keep to cancel if the order is cancelled
Node
import QRCode from 'qrcode'; // npm i qrcode
const r = await fetch('https://api.monapay.vn/api/v1/acb/qr-payment/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.MONA_TOKEN}`, 'X-Client-Secret': process.env.MONA_SECRET },
body: JSON.stringify({
ownerNumber: '123456789', ownerType: 'ORG', merchantId: process.env.ACB_MERCHANT_ID, terminalId: process.env.ACB_TERMINAL_ID,
orderId: order.code, virtualAccountPrefix: 'MONA', beneficiaryName: 'CONG TY ABC', amount: order.total, description: `Thanh toan ${order.code}`,
}),
});
const { success, data, message } = await r.json();
if (!success) throw new Error(message);
const pngDataUrl = await QRCode.toDataURL(data.qr_data_url); // <img src=...>
DELETE /api/v1/acb/qr-payment/{qr_code_id}/cancellation
Cancels a QR that was created but not yet paid (order cancelled, amount changed). Needs Bearer + X-Client-Secret. The body must repeat the details used at creation:
| Field | Type | Required |
|---|---|---|
ownerNumber |
string | yes |
ownerType |
PER / ORG |
yes |
orderId |
string | yes |
amount |
integer | yes |
traceNumber |
string | no |
curl -X DELETE https://api.monapay.vn/api/v1/acb/qr-payment/0190c0d1-.../cancellation \
-H "Authorization: Bearer $MONA_TOKEN" -H "X-Client-Secret: $MONA_SECRET" \
-H 'Content-Type: application/json' \
-d '{"ownerNumber":"123456789","ownerType":"ORG","orderId":"DH10234","amount":2500000}'
Response: {"success": true, "message": "Success", "data": null}.
What happens after the customer scans
ACB records the incoming money, notifies MONA Pay, and MONA Pay fires the webhook to your configured URL with the same payload as a VA transaction:
{"amount":2500000,"description":"Thanh toan DH10234","transfer_date":"10:45:12 28/08/2026","transaction_code":"FT26240001234","account_number":"MONA0000010234","bank_name":"ACB","type":"income"}
Match the order by account_number (the VA attached to the QR) or by the orderId inside description. Compare amount with the order total before marking it paid.
Common problems
| Symptom | Cause | Fix |
|---|---|---|
| 400 on creation | The account has no VA registered with this prefix, or merchantId/terminalId is wrong |
Check the dashboard under Create QR; create one code on the web first |
| 422 | Missing required field, amount above 1,000,000,000, virtualAccountPrefix longer than 10 characters |
Fix according to detail[] |
| Customer can scan but no webhook arrives | The VA is not registered for notifications (steps 3, 4 of VA creation) | See Virtual accounts |
| QR expired in the banking app | Every dynamic QR has a validity period set by ACB | Cancel the old code and create a new one with the same orderId |