EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Linux / Generate PromptPay QR (EMVCo) in Pure Python — No External Libraries
Linux

Generate PromptPay QR (EMVCo) in Pure Python — No External Libraries

by Ton-Or ·15 Jul 2026·8 min read
Generate PromptPay QR (EMVCo) in Pure Python — No External Libraries

A PromptPay QR code that banking apps can scan is really just an EMVCo-standard text string with a checksum appended. You can assemble it yourself in a few lines without connecting to anyone's API, as long as you understand the TLV structure and CRC rules correctly.

I needed to generate PromptPay payment QR codes automatically in a point-of-sale system. I did not want the hassle of integrating a payment gateway because I only needed to display a QR code for customers to scan and transfer money directly to my own number/account number. At first, I planned to drop in a library, but after sitting down and reading the specification, I found that it was just a matter of assembling an EMVCo-standard string and appending a checksum — something I could write in pure Python with no dependencies at all. So I thought I would write it up here.

PromptPay QR = text, not an image

The QR code itself is just a wrapper for the "payload string" inside. That string is what needs to be correct; rendering it as an image is the final step, which any QR library can handle.

Structure: TLV (Tag-Length-Value)

A Thai QR payload is a series of fields joined together. Every field has exactly the same format: a 2-digit tag + a 2-digit length + a value of that length. For example, the version field 000201 reads as tag 00, length 02, value 01.

Structure of 1 fieldTag2 digits+Length2 digits+ValueAs long as length

The main fields needed for a PromptPay transfer:

  • 00 = payload version (fixed value 01)
  • 01 = type — 11 is a static QR (no fixed amount; enter it after scanning), 12 is dynamic (fixed amount)
  • 29 = PromptPay information (contains another layer of sub-fields — phone number/national ID number/e-wallet)
  • 53 = currency (764 = baht)
  • 54 = amount (included for dynamic QR)
  • 58 = country (TH)
  • 63 = CRC checksum (must always come last)

Tag 29 contains nested sub-fields — 00 = PromptPay Application ID (A000000677010111), followed by the proxy type: 01 = mobile number, 02 = national ID/tax ID number, 03 = e-wallet ID

Mobile Numbers Need Converting First

PromptPay stores phone numbers in an international format. Remove the leading 0 and add the country code 66, making 13 digits in total — 0812345678 becomes 0066812345678.

def format_target(target):
    t = target.replace('-', '').replace(' ', '')
    if len(t) >= 15:          # e-wallet id 15 หลัก
        return '03', t
    if len(t) == 13:          # เลขบัตร ปชช. / เลขผู้เสียภาษี
        return '02', t
    # เบอร์มือถือ -> 0066 + 9 หลักท้าย
    return '01', '0066' + t[-9:]

CRC16 — The Most Common Checksum Mistake

Field 63 is CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF), and the part people get wrong is that the CRC must cover the entire string, including tag 63's own tag and length. That means appending 6304 to the payload first, then running CRC over the whole thing. The result is 4 uppercase hex digits, which you then append to the actual payload.

CRC covers the string through 6304; append the result000201Version010212Type 122937...PromptPay5303764Baht5406100.00Amount5802THCountry6304tag+len CRCCRC 4 hex0016A0000006770101110113006681234567800 = Application IDfor PromptPay01 = Mobile number0066 + last 9 digitsPut the full string into QR as is. No URL-encoding or added spaces.
Example using number 0812345678 and amount 100.00 for a dynamic QR, with field 29 expanded to show its sub-fields
def crc16(data: str) -> str:
    crc = 0xFFFF
    for ch in data.encode('ascii'):
        crc ^= ch << 8
        for _ in range(8):
            crc = ((crc << 1) ^ 0x1021) & 0xFFFF if (crc & 0x8000) else (crc << 1) & 0xFFFF
    return format(crc, '04X')

Putting It All Together

def field(tag, value):
    return f'{tag}{len(value):02d}{value}'

def promptpay_payload(target, amount=None):
    kind, acc = format_target(target)
    merchant = field('00', 'A000000677010111') + field(kind, acc)
    payload = (
        field('00', '01') +
        field('01', '12' if amount else '11') +
        field('29', merchant) +
        field('53', '764') +
        (field('54', f'{amount:.2f}') if amount else '') +
        field('58', 'TH')
    )
    payload += '6304'                 # tag+len ของ CRC ต้องรวมในตอนคำนวณ
    return payload + crc16(payload)

if __name__ == '__main__':
    import sys
    tgt = sys.argv[1]
    amt = float(sys.argv[2]) if len(sys.argv) > 2 else None
    print(promptpay_payload(tgt, amt))
One strict rule

Put the resulting string into the QR code in full, exactly as is. Do not URL-encode it, turn it into a link, or add spaces — if you modify it, banking apps will immediately fail to read it or display "Invalid QR".

Converting It to an Image

The payload is ready. Render it as a PNG with any library you like. I use segno because it is pure-python and does not require Pillow:

pip install segno
import segno
qr = segno.make(promptpay_payload('0812345678', 100.00), error='m')
qr.save('pay.png', scale=8, border=2)

Testing to Make Sure It Works

Do not assume it is correct — open a banking app on your phone and actually scan it once before putting it into production. This matters because even a single incorrect CRC character will make the app reject it. I compared my own payload against one generated by a standard tool, byte for byte, until they matched before I was confident.

  1. Generate the payload -> compare the string with a reference so every character matches
  2. Render PNG -> scan it with 1-2 actual banking apps
  3. Check that the recipient account name is correct + the amount matches (if dynamic)
  4. Then put it into the POS/web system

So PromptPay QR is not as mysterious as I thought. It is just TLV + CRC that I can assemble myself. The advantage of doing it myself is avoiding a dependency that might stop being maintained one day, and understanding every bit I send out. Anyone who wants to integrate it into their own sales system can adapt it as needed. If I have left anything out or made a typo, I will come back and fix it later.

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first