If you've been to Vietnam recently, you've seen them everywhere: black-and-white QR codes taped to every coffee stand, parking booth, and street-food cart. Scan one with any banking app, the recipient's name and account fill in automatically, you type the amount, and the transfer is done in seconds. That's VietQR — the national standard that turned bank transfers into the default way Vietnamese people pay each other.
What looks like magic is actually a precise, well-documented string format. There's no proprietary API you have to call to generate one — a VietQR code is just a structured text payload, encoded as a QR image, that any bank app knows how to read. Once you understand the structure you can generate them yourself — which is exactly what I built at qrbank. This post is the breakdown: what VietQR is built on, how the payload is structured field by field, the CRC checksum everyone gets wrong, and how to turn it all into a scannable code.
What VietQR Actually Is
VietQR isn't a from-scratch invention. It's a national profile layered on top of the EMVCo QR Code Specification for Payment Systems — the same global standard behind merchant QR payments in many countries — operated in Vietnam through Napas, the national payment switch. That means two things:
The payload follows the EMVCo TLV (Tag–Length–Value) format, so the structure is standardised and parseable.
The Vietnam-specific bits — which bank, which account — live inside a Napas-defined section identified by Napas's application ID.
So generating a VietQR code is really: build an EMVCo TLV string, fill in the Napas merchant section with the bank and account, append a checksum, and render it as a QR image.
The EMVCo TLV Structure
Every field in the payload is encoded as three parts concatenated together:
Tag — a 2-digit ID saying what the field is (
00,38,54, …).Length — a 2-digit count of how many characters the value has.
Value — the actual content.
So the very first field, the payload format indicator, is 00 (tag) + 02 (length) + 01 (value) = 000201. A parser walks the whole string this way. A tiny helper makes building them painless:
// Tag–Length–Value: 2-digit tag, 2-digit zero-padded length, then the value
function tlv(tag: string, value: string): string {
const length = value.length.toString().padStart(2, '0')
return `${tag}${length}${value}`
}
tlv('00', '01') // → "000201"
tlv('53', '704') // → "5303704" (currency VND)Some tags are templates — their value is itself a sequence of nested TLV fields. The Napas merchant-account section (tag 38) is the important one.
Building the Payload Field by Field
Here are the fields a VietQR transfer payload uses, in order:
00— Payload format indicator:0101— Point of initiation method:11(static) or12(dynamic)38— Merchant account info (Napas template, nested)53— Currency: VND = ISO 421770454— Transaction amount (optional)58— Country code:VN62— Additional data (optional, e.g. message)63— CRC checksum (computed last)
The nested tag 38 is the heart of it: 00 = Napas GUID A000000727, 01 = beneficiary (bank BIN + account number), 02 = service code QRIBFTTA (transfer to account).
function buildVietQR(opts: {
bankBin: string // e.g. "970415" for Vietinbank
accountNumber: string
amount?: number
message?: string
}): string {
const beneficiary = tlv('00', opts.bankBin) + tlv('01', opts.accountNumber)
const merchantInfo =
tlv('00', 'A000000727') +
tlv('01', beneficiary) +
tlv('02', 'QRIBFTTA')
let payload =
tlv('00', '01') +
tlv('01', opts.amount ? '12' : '11') +
tlv('38', merchantInfo) +
tlv('53', '704') +
(opts.amount ? tlv('54', String(opts.amount)) : '') +
tlv('58', 'VN')
if (opts.message) {
payload += tlv('62', tlv('08', opts.message))
}
payload += '6304'
return payload + crc16(payload)
}The CRC-16 Checksum (the Part Everyone Gets Wrong)
Tag 63 is a 4-character CRC-16 checksum that lets a scanner verify the code wasn't corrupted. The subtle rule everyone misses: the CRC is calculated over the entire payload including the CRC field's tag and length (6304), but not the checksum value itself.
The algorithm is CRC-16/CCITT-FALSE: polynomial 0x1021, initial value 0xFFFF, no reflection.
function crc16(data: string): string {
let crc = 0xffff
for (let i = 0; i < data.length; i++) {
crc ^= data.charCodeAt(i) << 8
for (let j = 0; j < 8; j++) {
crc = crc & 0x8000 ? (crc << 1) ^ 0x1021 : crc << 1
crc &= 0xffff
}
}
return crc.toString(16).toUpperCase().padStart(4, '0')
}Get any parameter wrong — wrong polynomial, forgetting to include 6304, reflecting the bits — and the string still looks like a valid VietQR code but every banking app rejects it. It's the single most common bug, invisible until you scan with a real app.
Generating the QR Image
Once you have the payload string, feed it to any QR library at error-correction level M. You can try the whole thing live at qrbank.tienng21.com.
import QRCode from 'qrcode'
const payload = buildVietQR({
bankBin: '970415',
accountNumber: '113366668888',
amount: 50000,
message: 'Thanh toan',
})
await QRCode.toFile('vietqr.png', payload, {
errorCorrectionLevel: 'M',
margin: 2,
})Static vs Dynamic Codes
The point-of-initiation method (tag 01) is a small field with a big UX impact:
11— static: no amount baked in. The payer enters the amount. Print once, stick on the wall, reuse forever. Perfect for a shop or donation jar.12— dynamic: amount fixed in the code. One code per transaction. Perfect for checkout and invoices.
The only structural difference is whether you include tag 54, but choosing correctly is the difference between a reusable poster and a per-order checkout flow.
Testing and Pitfalls
Always verify with a real banking app, not just a QR decoder. Only a bank app confirms the payment fields are valid.
Use the correct Napas BIN, not the bank's SWIFT or hotline number. The wrong 6-digit ID routes nowhere.
Lengths must match exactly. An off-by-one shifts the entire parse and silently corrupts everything after it.
Compute the CRC last, over
…6304. It's always the CRC.
Conclusion
VietQR feels like infrastructure magic, but under the hood it's a disciplined, fully open format: EMVCo TLV fields, a Napas merchant section carrying the bank and account, and a CRC-16 checksum stitching it together. There's no secret API — once you can build the TLV string and compute the checksum correctly, you can generate a valid, scannable transfer code entirely on your own. Try it live at qrbank.tienng21.com.
The next time you scan a QR at a street stall, you'll know precisely what those black squares are saying.
If you're building on top of VietQR and need to handle what happens after the money arrives — routing, attribution, and reconciliation — my post on Virtual Accounts in Fintech covers exactly that infrastructure layer.

