Sunset Deadline Alert: 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).

VietQR-Hub — Verifiable Drop-In /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) →

1. Two-Minute .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"}'

2. Live Migration Playground 10 req / 10 min / IP

Submit the form to render an in-memory PNG & verify EMVCo CRC16 in browser.

3. Annual-First Prepaid Pricing (Dogfooded VietQR Checkout — Zero KYC)

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.

4. Trust-Minimized Client-Side STK & CRC16 Verifier Snippets

Verify every returned data.qrCode in 20 lines before displaying or emailing — mathematically guaranteeing acqId, accountNo, amount, and CRC-16/CCITT-FALSE integrity.

JavaScript / TypeScript

// 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)

<?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 (FastAPI / Django)

# 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