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