A field-by-field breakdown of the VietQR standard: the EMVCo TLV structure, the Napas merchant section, the CRC-16 checksum everyone gets wrong, and how to generate a scannable bank-transfer QR code from scratch.
Introduction
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 did when I built 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. Read left to right: tag 00, length 02, so take the next 2 characters β 01. 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: it contains sub-fields for the Napas GUID, the bank, and the account number.
Building the Payload Field by Field
Here are the fields a VietQR transfer payload uses, in order:
| Tag | Field | Example |
|---|---|---|
00 |
Payload format indicator | 01 |
01 |
Point of initiation method | 11 static / 12 dynamic |
38 |
Merchant account info (Napas template) | nested, see below |
53 |
Currency (VND = ISO 4217 704) |
704 |
54 |
Transaction amount (optional) | 50000 |
58 |
Country code | VN |
62 |
Additional data (optional, e.g. message) | nested |
63 |
CRC checksum | computed last |
The nested tag 38 (merchant account info) is the heart of it:
00β GUID: the Napas application IDA00000072701β beneficiary org, itself nested:00β the acquirer/bank ID (the bank's 6-digit Napas BIN)01β the beneficiary account number
02β service code:QRIBFTTA(transfer to account) orQRIBFTTC(transfer to card)
Putting it together:
function buildVietQR(opts: {
bankBin: string // e.g. "970415" for Vietinbank
accountNumber: string
amount?: number // omit for a static "any amount" code
message?: string
}): string {
const beneficiary =
tlv('00', opts.bankBin) +
tlv('01', opts.accountNumber)
const merchantInfo =
tlv('00', 'A000000727') + // Napas GUID
tlv('01', beneficiary) + // nested bank + account
tlv('02', 'QRIBFTTA') // transfer-to-account service
let payload =
tlv('00', '01') + // format indicator
tlv('01', opts.amount ? '12' : '11') + // dynamic if amount fixed
tlv('38', merchantInfo) + // Napas merchant section
tlv('53', '704') + // VND
(opts.amount ? tlv('54', String(opts.amount)) : '') +
tlv('58', 'VN') // country
if (opts.message) {
payload += tlv('62', tlv('08', opts.message)) // additional data β purpose
}
// CRC is computed over the payload PLUS the CRC tag+length header
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. This is where most homemade VietQR generators fail, because of one subtle rule:
The CRC is calculated over the entire payload including the CRC field's tag and length (6304), but not the checksum value itself. That's why, in the code above, I append the literal '6304' before computing the CRC, then append the result.
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 // keep it 16-bit
}
}
return crc.toString(16).toUpperCase().padStart(4, '0')
}
Get any parameter wrong β the 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, and it's invisible until you scan with a real app.
Generating the QR Image
Once you have the payload string, the image is the easy part β feed it to any QR library at error-correction level M (the EMVCo-recommended level, a good balance of density and resilience):
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,
})
That PNG is now scannable by every Vietnamese banking app.
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 it once, stick it on the wall, reuse it forever. Perfect for a shop or a donation jar.12β dynamic: amount (and often a message) fixed in the code. One code per transaction. Perfect for checkout and invoices, where the payer should pay an exact figure and not have to think.
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
A checklist born from things that actually broke for me:
- Always verify with a real banking app, not just a QR decoder. A decoder shows the string is readable; only a bank app confirms the payment fields are valid.
- Use the correct Napas BIN, not the bank's SWIFT or hotline number. Each bank has a specific 6-digit Napas ID; the wrong one routes nowhere.
- Lengths must match exactly. An off-by-one in any length byte shifts the entire parse and silently corrupts everything after it.
- Compute the CRC last, over
β¦6304. Re-read the CRC section. 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.
That's exactly the realisation behind qrbank: the whole standard fits in a couple hundred lines, and understanding it turns a mysterious black square into something you can generate, validate, and build on. The next time you scan a QR at a street stall, you'll know precisely what those black squares are saying.