VietQR: How Vietnamese Banking QR Codes Actually Work

Published: May 15, 2025
Updated: June 6, 2026
9 min read

🎯

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 ID A000000727
  • 01 β€” 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) or QRIBFTTC (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.