Loading
← All postsFintech, July 21, 2026

Encoding EMV QR payloads with TLV, step by step

An EMV merchant QR code is a string of tag-length-value fields with a checksum at the end. Here's how the format works, and how to build one in C#.

Filed underEMV QR, C#, Payments and 7 min read

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:

  • 00 Payload format indicator, always 01
  • 01 Point of initiation: 11 for static, 12 for dynamic
  • 26-51 Merchant account information (nested TLV)
  • 52 Merchant category code
  • 53 Transaction currency, ISO 4217 numeric (for example 834 for TZS)
  • 54 Transaction amount, used in dynamic codes
  • 58 Country code, 59 merchant name, 60 merchant city
  • 63 CRC, 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.

keep readingkeep reading
keep in touchkeep in touch

New notes go up here first, then on LinkedIn.

Short write-ups on problems I run into while building payment and backend systems. Follow along, or tell me what you'd like me to write about next.

Let's talk