Inside a PromptPay QR, and the slip QR that carries no amount
What was tried
Built the PromptPay QR for the /support page end to end, to the Thai QR Payment standard and with no payment gateway, then took it apart field by field in this entry. The text inside the QR is a run of TLV fields (a 2-digit tag, a 2-digit length, then the value) closed by a CRC16. The table splits the test samples into rows; pointing at a row shows where that field sits in the string, and the string itself can be edited. The second half does the same for the small QR in the corner of a transfer slip: a slip image of your own can be decoded on the device, and only the text inside its QR is sent to the server for a check.
PromptPay QR
text inside the QR
00020101021129370016A000000677010111011300668123456785802TH530376463045D82
| tag | len | value | |
|---|---|---|---|
| 00 | 02 | 01payload format version — always 01 | payload format version — always 01 |
| 01 | 02 | 1111 = reusable QR · 12 = one-off, amount locked | 11 = reusable QR · 12 = one-off, amount locked |
| 29 | 37 | 0016A00000067701011101130066812345678receiver account for a PromptPay credit transfer — its value is nested TLV | receiver account for a PromptPay credit transfer — its value is nested TLV |
| 29.00 | 16 | A000000677010111PromptPay credit-transfer application id — fixed by the Bank of Thailand | PromptPay credit-transfer application id — fixed by the Bank of Thailand |
| 29.01 | 13 | 0066812345678mobile number: 0066 in place of the leading 0 | mobile number: 0066 in place of the leading 0 |
| 58 | 02 | THcountry | country |
| 53 | 03 | 764currency — 764 is the baht | currency — 764 is the baht |
| 63 | 04 | 5D82CRC16 over every character before it, including its own 6304 | CRC16 over every character before it, including its own 6304 |
CRC matches — everything before it computes to 5D82, the same as written
Change any one digit and watch the CRC line below
The QR on a slip
The small QR in the corner of a transfer slip carries only the sending bank and a reference — no amount, no receiver, no time. Pick a slip image of your own to decode it; the image is read on this device and goes nowhere.
Sample from the reference notes (bank 002)
text inside the QR
004000060000010103002021900021231231212000115102TH91049C30
| tag | len | value | |
|---|---|---|---|
| 00 | 40 | 0006000001010300202190002123123121200011slip data — its value is nested TLV | slip data — its value is nested TLV |
| 00.00 | 06 | 000001slip-verification API id — 000001 | slip-verification API id — 000001 |
| 00.01 | 03 | 002 → BBLsending bank code from the Bank of Thailand list | sending bank code from the Bank of Thailand list |
| 00.02 | 19 | 0002123123121200011transaction reference — something to ask the bank about, not an amount | transaction reference — something to ask the bank about, not an amount |
| 51 | 02 | THcountry | country |
| 91 | 04 | 9C30CRC16, same formula as PromptPay tag 63 | CRC16, same formula as PromptPay tag 63 |
CRC matches — everything before it computes to 9C30, the same as written
The sample cannot be sent to the server — the checker refuses reused references, so the first click would burn the sample for everyone · try a slip of your own instead
What was learned
The first lesson came from numbers that did not match. The reference notes give CRC 823E for a no-amount QR, while this site's builder produced 5D82 from the same phone number. Neither was wrong: the promptparse library places the currency field (53) before the country field (58), whereas promptpay-qr and this site place them the other way round. The standard does not fix the order, but the CRC is computed over the characters in the order they are laid out, so a different order gives a different CRC and both are valid. The 30-baht samples in this entry behave the same way (C3C0 versus 42EB) and decode to identical fields, so QRs have to be compared after splitting them into fields, never as strings. The second lesson is that the QR on a slip carries no amount, no receiver and no time — only the sending bank's code and a reference — so a self-check can reach no further than "well-formed", which anyone can forge because the CRC formula is public. That is why the slip checker moved off the payment page and into this entry. The third is that masking the receiver's number on screen is cosmetic: the QR must embed the full number, so anyone who scans it sees it, and the number configured for the payment page has to be one that is fine to show from the start. Confirmed by screenshotting the QR on the real /support page and decoding the image back with jsQR, which returned the full test number with a passing CRC; the 30-baht QR the page builds matches promptpay-qr's output character for character.
What fell short
Not scanned with a single real banking app yet — what is confirmed is that the QR text matches two reference libraries, not that a bank app accepts it. The checker's "bank confirmed" level has never run for real, because it needs a key for a paid slip-verification service; that path exists in the code but has only been tested against fake answers. An individual still cannot confirm incoming money for free in code (the banks' APIs are for registered businesses). And slip QRs may differ between banks — SCB's own sample, for instance, has a 3-character CRC; the code pads it with a leading zero and reports the repair, but that was tested on synthetic samples only, not on real slips from every bank.