When I designed a P2P QR payment system at DPL, the first requirement was interoperability: any compliant app should be able to read any compliant code. That's what the EMVCo QR Code specification is for. Under the pixels, the payload is a plain string built from TLV fields.
Tag, length, value
Every field has three parts: a two-digit ID, a two-digit length, and the value. The ID 59 with the value ANEES STORE (11 characters) becomes 5911ANEES STORE. Some fields contain nested TLV fields of their own, like merchant account information. The ones you'll use most:
00Payload format indicator, always0101Point of initiation:11for static,12for dynamic26-51Merchant account information (nested TLV)52Merchant category code53Transaction currency, ISO 4217 numeric (for example834for TZS)54Transaction amount, used in dynamic codes58Country code,59merchant name,60merchant city63CRC, always the last field
Static vs dynamic
A static code (01 = 11) is printed once and reused, and the payer enters the amount. A dynamic code (01 = 12) is generated per transaction and carries the amount in field 54, which is what checkout flows and payment requests need.
The checksum
Field 63 holds a CRC-16/CCITT-FALSE checksum (polynomial 0x1021, initial value 0xFFFF). It's calculated over the whole payload, including the 6304 ID and length of the CRC field itself, and written as four uppercase hex characters.
static string Tlv(string id, string value) =>
$"{id}{value.Length:D2}{value}";
static string Crc16(string payload)
{
ushort crc = 0xFFFF;
foreach (var b in Encoding.ASCII.GetBytes(payload))
{
crc ^= (ushort)(b << 8);
for (var i = 0; i < 8; i++)
crc = (crc & 0x8000) != 0
? (ushort)((crc << 1) ^ 0x1021)
: (ushort)(crc << 1);
}
return crc.ToString("X4");
}
var payload =
Tlv("00", "01") +
Tlv("01", "12") +
Tlv("26", Tlv("00", "com.example.wallet") + Tlv("01", "MERCHANT-001")) +
Tlv("52", "5411") +
Tlv("53", "834") +
Tlv("54", "25000.00") +
Tlv("58", "TZ") +
Tlv("59", "ANEES STORE") +
Tlv("60", "DAR ES SALAAM") +
"6304";
payload += Crc16(payload);
What bites in production
- Lengths are character counts. Validate merchant names and cities before encoding, and keep them within the spec's limits.
- Parse, don't split. Read the ID and length, then consume exactly that many characters. Values can contain digits that look like tags.
- Verify the CRC first when scanning, before trusting any field in the payload.
- Treat nested templates as their own TLV strings, with their own lengths.
Once the encoder and parser are solid and well tested, everything built on top of them, from merchant onboarding to request-to-pay flows, gets simpler.