POST api.vietqr.io/v2/generate shuts down permanently on 2027-01-01, and img.vietqr.io/*.png already redirects (301) to .svg (breaking Gmail, Zalo ZNS, Google Sheets =IMAGE(), and ESC/POS thermal printers).
/v2/generate Edge Engine
Swap 1 .env URL in 2 minutes: 100% POST /v2/generate schema parity, <5ms branded raster PNG + SVG + ESC/POS thermal bitmap, cryptographic X-VietQR-Signature & client-side STK verification, zero KYC, keep your existing bank account.
Open Self-Serve Tenant Portal & Custom Brand Theme Studio (/app) →
.env Drop-In Migration# Replace legacy api.vietqr.io endpoint in Node / PHP / WooCommerce / Python / Go:
VIETQR_API_URL=https://api.vietqr-hub.vn/v2/generate
VIETQR_API_KEY=vqh_live_your_issued_api_key
# Exact same POST JSON body (compact, compact2, qr_only, print + png/svg/escpos):
curl -X POST "$VIETQR_API_URL" \
-H "Content-Type: application/json" \
-H "x-api-key: $VIETQR_API_KEY" \
-d '{"accountNo":"0011001234567","accountName":"NGUYỄN VĂN ĐỨC","acqId":970436,"amount":250000,"addInfo":"Thanh toán đơn DH1042","template":"compact2"}'
| Plan Tier | Annual Prepaid (Default) | Monthly Auto-Webhook | Included Quotas & Anti-Abuse Limits |
|---|---|---|---|
| Free | 0 ₫ |
0 ₫ |
vqh_free_* key (2 req/s, 300 watermarked renders/mo) + Playground (10 req / 10 min). |
| Starter | 790,000 ₫ / yr (790k/yr) |
99,000 ₫ / mo |
Clean unwatermarked PNG/SVG/Base64/ESC-POS, 20,000 origin renders/mo, 20 req/s, up to 3 beneficiaries. |
| Business | 1,990,000 ₫ / yr (1.99M/yr) |
199,000 ₫ / mo |
100,000 origin renders/mo + immutable CDN-cached signed /img/*.png, 50 req/s, 10 templates. |
| Agency Pack | 2,490,000 ₫ / yr (2.49M/yr) |
Annual Prepaid Only | Up to 25 isolated client beneficiary accounts & brand templates, 100,000 origin renders/mo, 50 req/s. |
Verify every returned data.qrCode in 20 lines before displaying or emailing — mathematically guaranteeing acqId, accountNo, amount, and CRC-16/CCITT-FALSE integrity.
// Zero-dependency Node / Browser VietQR EMVCo TLV + CRC-16/CCITT-FALSE Verifier
function crc16VietQR(str) {
let crc = 0xFFFF;
for (let i = 0; i < str.length; i++) {
crc ^= str.charCodeAt(i) << 8;
for (let b = 0; b < 8; b++) {
crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xFFFF : (crc << 1) & 0xFFFF;
}
}
return crc.toString(16).toUpperCase().padStart(4, "0");
}
function parseEmvcoTlv(raw) {
const map = Object.create(null);
const order = [];
for (let i = 0; i < raw.length; ) {
if (i + 4 > raw.length) throw new Error("Truncated EMVCo TLV");
const tag = raw.slice(i, i + 2), lenStr = raw.slice(i + 2, i + 4);
if (!/^\d{2}$/.test(tag) || !/^\d{2}$/.test(lenStr)) throw new Error("Malformed TLV header");
const len = Number(lenStr);
if (i + 4 + len > raw.length || tag in map) throw new Error("Invalid or duplicate TLV tag " + tag);
map[tag] = raw.slice(i + 4, i + 4 + len);
order.push(tag);
i += 4 + len;
}
return { map, order };
}
export function verifyVietQRClient(qrCode, expected) {
if (typeof qrCode !== "string" || !/^[\x20-\x7E]{20,}$/.test(qrCode)) {
throw new Error("Invalid VietQR ASCII payload");
}
const { map: root, order } = parseEmvcoTlv(qrCode);
if (order[0] !== "00" || root["00"] !== "01" || order[order.length - 1] !== "63") {
throw new Error("Invalid EMVCo root framing");
}
if (!/^[0-9A-F]{4}$/.test(root["63"]) || root["63"] !== crc16VietQR(qrCode.slice(0, -4))) {
throw new Error("VietQR CRC16 integrity check failed");
}
for (const tag of order) {
const n = Number(tag);
if (n >= 2 && n <= 51 && tag !== "38") throw new Error("Competing merchant template tag " + tag);
}
if (!root["38"] || root["53"] !== "704" || root["58"] !== "VN") {
throw new Error("Invalid NAPAS247 currency/country/merchant fields");
}
const { map: t38, order: t38Order } = parseEmvcoTlv(root["38"]);
if (
t38Order.some((k) => k !== "00" && k !== "01" && k !== "02") ||
t38["00"] !== "A000000727" ||
(t38["02"] !== "QRIBFTTA" && t38["02"] !== "QRIBFTTC") ||
!t38["01"]
) {
throw new Error("Invalid NAPAS247 Tag 38 payload");
}
const { map: ben, order: benOrder } = parseEmvcoTlv(t38["01"]);
if (
benOrder.some((k) => k !== "00" && k !== "01") ||
ben["00"] !== String(expected.acqId).trim() ||
ben["01"] !== String(expected.accountNo).trim()
) {
throw new Error("Destination acqId/accountNo mismatch!");
}
const hasExpAmt = expected.amount !== undefined && expected.amount !== null && String(expected.amount).trim() !== "" && Number(expected.amount) > 0;
if (hasExpAmt) {
const expAmtStr = String(expected.amount).trim().replace(/^0+(?=\d)/, "");
if (root["01"] !== "12" || root["54"] !== expAmtStr) {
throw new Error("Dynamic amount mismatch!");
}
} else if (root["01"] !== "11" || "54" in root) {
throw new Error("Expected static VietQR without Tag 54 amount!");
}
return true;
}
<?php
// WooCommerce / Laravel zero-dependency VietQR EMVCo TLV + CRC-16/CCITT-FALSE verifier (PHP 7.4+ / 8.x)
function vietqr_crc16(string $data): string {
$crc = 0xFFFF;
for ($i = 0, $n = strlen($data); $i < $n; $i++) {
$crc ^= (ord($data[$i]) << 8);
for ($b = 0; $b < 8; $b++) {
$crc = ($crc & 0x8000) ? (($crc << 1) ^ 0x1021) & 0xFFFF : ($crc << 1) & 0xFFFF;
}
}
return strtoupper(str_pad(dechex($crc), 4, '0', STR_PAD_LEFT));
}
function vietqr_parse_tlv(string $raw): ?array {
$map = [];
$order = [];
$len = strlen($raw);
for ($i = 0; $i < $len; ) {
if ($i + 4 > $len) return null;
$tag = substr($raw, $i, 2);
$lStr = substr($raw, $i + 2, 2);
if (!ctype_digit($tag) || !ctype_digit($lStr)) return null;
$vLen = (int)$lStr;
if ($i + 4 + $vLen > $len || array_key_exists($tag, $map)) return null;
$map[$tag] = substr($raw, $i + 4, $vLen);
$order[] = $tag;
$i += 4 + $vLen;
}
return ['map' => $map, 'order' => $order];
}
function verify_vietqr_payload(string $qrCode, string $acqId, string $accountNo, $amount = 0): bool {
if (!preg_match('/^[\x20-\x7E]{20,}$/', $qrCode)) return false;
$rootParsed = vietqr_parse_tlv($qrCode);
if (!$rootParsed) return false;
$root = $rootParsed['map'];
$order = $rootParsed['order'];
if (($order[0] ?? '') !== '00' || ($root['00'] ?? '') !== '01' || end($order) !== '63') return false;
if (!preg_match('/^[0-9A-F]{4}$/', $root['63']) || $root['63'] !== vietqr_crc16(substr($qrCode, 0, -4))) return false;
foreach ($order as $tag) {
$n = (int)$tag;
if ($n >= 2 && $n <= 51 && $tag !== '38') return false;
}
if (!isset($root['38']) || ($root['53'] ?? '') !== '704' || ($root['58'] ?? '') !== 'VN') return false;
$t38Parsed = vietqr_parse_tlv($root['38']);
$t38 = $t38Parsed['map'] ?? null;
if (!$t38 || array_diff($t38Parsed['order'], ['00', '01', '02']) || ($t38['00'] ?? '') !== 'A000000727') return false;
if (!in_array($t38['02'] ?? '', ['QRIBFTTA', 'QRIBFTTC'], true) || !isset($t38['01'])) return false;
$benParsed = vietqr_parse_tlv($t38['01']);
$ben = $benParsed['map'] ?? null;
if (!$ben || array_diff($benParsed['order'], ['00', '01'])) return false;
if (($ben['00'] ?? '') !== trim($acqId) || ($ben['01'] ?? '') !== trim($accountNo)) return false;
$rawAmt = trim((string)($amount ?? ''));
if ($rawAmt !== '' && $rawAmt !== '0' && is_numeric($rawAmt) && (float)$rawAmt > 0) {
$amtStr = preg_replace('/^0+(?=\d)/', '', $rawAmt);
return ($root['01'] ?? '') === '12' && ($root['54'] ?? '') === $amtStr;
}
return ($root['01'] ?? '') === '11' && !isset($root['54']);
}
# Python 3.8+ / FastAPI / Django zero-dependency VietQR EMVCo TLV + CRC-16/CCITT-FALSE verifier
import re
def vietqr_crc16(payload: str) -> str:
crc = 0xFFFF
for ch in payload.encode("ascii"):
crc ^= ch << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if (crc & 0x8000) else (crc << 1) & 0xFFFF
return f"{crc:04X}"
def parse_emvco_tlv(raw: str):
out = {}
order = []
i = 0
while i < len(raw):
if i + 4 > len(raw):
raise ValueError("Truncated TLV")
tag, l_str = raw[i:i+2], raw[i+2:i+4]
if not (tag.isdigit() and l_str.isdigit()):
raise ValueError("Malformed TLV header")
v_len = int(l_str)
if i + 4 + v_len > len(raw) or tag in out:
raise ValueError("Invalid or duplicate TLV")
out[tag] = raw[i+4:i+4+v_len]
order.append(tag)
i += 4 + v_len
return out, order
def verify_vietqr(qr_code: str, acq_id: str, account_no: str, amount=0) -> bool:
if not isinstance(qr_code, str) or len(qr_code) < 20 or not all(0x20 <= ord(c) <= 0x7E for c in qr_code):
return False
try:
root, order = parse_emvco_tlv(qr_code)
if order[0] != "00" or root.get("00") != "01" or order[-1] != "63":
return False
crc = root["63"]
if len(crc) != 4 or any(c not in "0123456789ABCDEF" for c in crc) or crc != vietqr_crc16(qr_code[:-4]):
return False
if any(2 <= int(t) <= 51 and t != "38" for t in order):
return False
if "38" not in root or root.get("53") != "704" or root.get("58") != "VN":
return False
t38, t38_order = parse_emvco_tlv(root["38"])
if any(k not in ("00", "01", "02") for k in t38_order):
return False
if t38.get("00") != "A000000727" or t38.get("02") not in ("QRIBFTTA", "QRIBFTTC") or "01" not in t38:
return False
ben, ben_order = parse_emvco_tlv(t38["01"])
if any(k not in ("00", "01") for k in ben_order):
return False
if ben.get("00") != str(acq_id).strip() or ben.get("01") != str(account_no).strip():
return False
amt_str = "" if amount is None else str(amount).strip()
if amt_str not in ("", "0") and float(amt_str) > 0:
clean_amt = re.sub(r"^0+(?=\d)", "", amt_str)
return root.get("01") == "12" and root.get("54") == clean_amt
return root.get("01") == "11" and "54" not in root
except Exception:
return False