When I built a P2P QR payment system at DPL, with both static and dynamic codes, the first requirement was the standard: follow the EMV® QR Code specification, so any compliant scanner can read the code. Under the pixels, the payload is a plain string of TLV fields, and scanning one parses those fields back into a transaction.
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 are templates that contain TLV fields of their own, like merchant account information.
The fields 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) says who to pay; it's generated once and reused, and the payer enters the amount. A dynamic code (01 = 12) is generated for one transaction and also says how much, in field 54.
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 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 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 generating a code to starting a transfer from a scan, gets simpler.