ทุกครั้งที่เปิดแอปธนาคารแล้วสแกน QR เพื่อโอนเงินให้เพื่อนหรือจ่ายค่าของที่ร้านเล็กๆ สิ่งที่กล้องอ่านได้จริงไม่ใช่ "รูป" แต่เป็นข้อความบรรทัดเดียวยาวราว 50–80 ตัวอักษร ปัญหาที่นักพัฒนาไทยเจอบ่อยคืออยากใส่ QR รับเงินลงในใบแจ้งหนี้หรือหน้า checkout ของตัวเอง แต่ไม่รู้ว่าข้อความนั้นประกอบขึ้นมาอย่างไร ต้องขออนุญาตใครไหม และจะตรวจได้อย่างไรว่า QR ที่ได้มาจากที่อื่นถูกต้องจริง บทความนี้แกะโครงสร้างให้ดูทีละช่อง แล้วปิดท้ายด้วยวิธีสร้างและถอดรหัสผ่าน API ที่เรียกได้ทันทีโดยไม่ต้องสมัคร
มาตรฐานที่อยู่เบื้องหลัง: EMV QR Code
QR พร้อมเพย์ไม่ได้เป็นรูปแบบเฉพาะของไทย แต่ยืมโครงมาจากมาตรฐาน EMVCo Merchant-Presented QR ซึ่งเป็นมาตรฐานเดียวกับที่ใช้ในหลายประเทศ ข้อดีของการใช้มาตรฐานกลางคือแอปธนาคารทุกแอปอ่านด้วยตัวแยกส่วน (parser) ตัวเดียวกันได้ และระบบของเราก็สร้าง payload เองได้โดยไม่ต้องเชื่อมต่อกับธนาคารใด
หลักการของ EMV QR คือข้อมูลทั้งหมดถูกจัดเป็นช่องต่อกันในรูปแบบ TLV (Tag–Length–Value)
- Tag 2 หลัก บอกว่าช่องนี้คือข้อมูลอะไร
- Length 2 หลัก บอกว่าค่าที่ตามมายาวกี่ตัวอักษร
- Value ค่าของช่องนั้น ยาวตามที่ Length บอก
เพราะทุกช่องมีความยาวกำกับ ตัวอ่านจึงไล่อ่านจากซ้ายไปขวาได้โดยไม่ต้องมีตัวคั่น และบางช่องยังซ้อน TLV อีกชั้นข้างในได้ ซึ่งเป็นสิ่งที่พร้อมเพย์ใช้ใน tag 29
แกะ payload จริงทีละช่อง
ลองใช้ payload ที่ API สร้างจากเบอร์ 081-234-5678 และจำนวนเงิน 100.5
00020101021229370016A000000677010111011300668123456785802TH53037645406100.506304F88Bแยกออกเป็นช่องตามกฎ tag–length–value จะได้
| Tag | Length | Value | ความหมาย |
|---|---|---|---|
00 | 02 | 01 | เวอร์ชันของรูปแบบข้อมูล |
01 | 02 | 12 | ชนิดของ QR: 12 = dynamic (กำหนดจำนวนเงินแล้ว), 11 = static (ผู้จ่ายกรอกเอง) |
29 | 37 | 0016A000000677010111011300668123456785 | ข้อมูลพร้อมเพย์ (ซ้อน TLV ข้างใน) |
58 | 02 | TH | ประเทศ |
53 | 03 | 764 | รหัสสกุลเงิน ISO 4217: 764 = บาท |
54 | 06 | 100.50 | จำนวนเงิน มีเฉพาะ QR แบบ dynamic |
63 | 04 | F88B | CRC ของข้อความทั้งหมดก่อนหน้า |
ช่องที่สำคัญที่สุดคือ tag 29 ซึ่งมี TLV ซ้อนอีกชั้น
00 16 A000000677010111คือ Application Identifier (AID) ที่บอกว่า "นี่คือพร้อมเพย์แบบโอนเข้าบัญชีบุคคล" ถ้า QR ไม่มีค่านี้ แม้โครงสร้างจะถูกต้องตาม EMV ก็ไม่ใช่พร้อมเพย์01 13 0066812345678คือเบอร์โทรในรูปแบบสากล ตัด0ตัวหน้าออกแล้วเติม0066จึงได้ 13 หลัก
ชนิดของผู้รับกำหนดด้วย tag ย่อยตัวนี้
| Tag ย่อยใน 29 | ผู้รับ | รูปแบบค่า |
|---|---|---|
01 | เบอร์โทรศัพท์ | 0066 + เบอร์ที่ตัด 0 หน้า (13 หลัก) |
02 | เลขบัตรประชาชน / เลขผู้เสียภาษี | 13 หลักตรงๆ |
03 | e-Wallet | 15 หลัก |
สังเกตว่าความยาวของ tag 29 เปลี่ยนตามชนิดผู้รับ เบอร์โทรและเลขบัตรให้ 29 37 ส่วน e-Wallet 15 หลักให้ 29 39 เพราะค่าข้างในยาวขึ้น 2 ตัวอักษร
CRC ทำหน้าที่อะไร
tag 63 เป็นค่า CRC-16 ที่คำนวณจากข้อความทั้งหมดตั้งแต่ตัวแรกจนถึง 6304 (คือรวม tag และ length ของตัวมันเองด้วย แต่ไม่รวมค่า 4 ตัวสุดท้าย) หน้าที่ของมันคือกันการแก้ไขหรือคัดลอกไม่ครบ ถ้าเปลี่ยนตัวเลขตรงไหนแม้ตัวเดียว เช่น แก้จำนวนเงินจาก 100.50 เป็น 100.60 ค่า CRC จะไม่ตรง และแอปธนาคารจะปฏิเสธ QR นั้นทันที
เมื่อถอดรหัส payload ที่ถูกแก้ท้ายเป็น FFFF ผ่าน API จะได้
{
"valid": false,
"reason": "CRC does not match; the payload was altered or copied incompletely",
"crc": "FFFF",
"expectedCrc": "F88B",
"amount": "100.50",
"dynamic": true
}ตัวถอดรหัสยังอ่านช่องอื่นให้ครบแม้ CRC ผิด เพื่อให้ดูออกว่าส่วนไหนของข้อความถูกเปลี่ยน
static กับ dynamic ต่างกันตรงไหน
ถ้าไม่ส่งจำนวนเงิน API จะสร้าง QR แบบ static: tag 01 เป็น 11 และไม่มี tag 54 ผู้จ่ายต้องกรอกจำนวนเงินเองในแอป ตัวอย่างจากเบอร์ +66 81 234 5678
00020101021129370016A000000677010111011300668123456785802TH530376463045D82ส่วน QR แบบ dynamic (tag 01 = 12) ล็อกจำนวนเงินไว้ แอปธนาคารส่วนใหญ่จะไม่ให้ผู้จ่ายแก้ จึงเหมาะกับใบแจ้งหนี้ที่รู้ยอดแน่นอน ข้อควรจำคืออย่าส่ง 0 เพื่อหมายถึง "ไม่ระบุ" เพราะ API จะตอบ 422 amount must be at least 0.01 ให้เว้นช่อง amount ว่างแทน
สร้าง QR ด้วย API
endpoint POST /api/v1/tools/promptpay รับ JSON สองฟิลด์คือ target (เบอร์โทร เลขบัตร 13 หลัก หรือ e-Wallet 15 หลัก) และ amount (ไม่บังคับ) ไม่ต้องมี key ไม่ต้องสมัคร
curl -X POST https://reefsight.tech/api/v1/tools/promptpay \
-H "Content-Type: application/json" \
-d '{"target":"081-234-5678","amount":"100.5"}'คำตอบ
{
"data": {
"type": "phone",
"target": "0812345678",
"amount": "100.50",
"payload": "00020101021229370016A000000677010111011300668123456785802TH53037645406100.506304F88B",
"qrUrl": "https://reefsight.tech/api/v1/tools/promptpay/qr.png?target=0812345678&amount=100.50"
}
}สิ่งที่ API จัดการให้โดยไม่ต้องเตรียมเอง
- ขีด ช่องว่าง และ
+66ในเบอร์ถูกแปลงเป็น0812345678 - จำนวนเงิน
100.5ถูกเติมเป็น100.50และ1,250(มีจุลภาค) กลายเป็น1250.00 - เลขไทยอ่านได้ทั้งสองช่อง เช่น
targetเป็น๐๘๑๒๓๔๕๖๗๘และamountเป็น๕๐ให้ผลเป็น50.00 - เลขบัตร 13 หลักถูกตรวจ checksum ก่อน ถ้าหลักที่ 13 ไม่ตรงกับที่คำนวณจาก 12 หลักแรก จะได้ 422
13-digit ID: checksum digit does not matchซึ่งช่วยกันการโอนเงินผิดคนจากการพิมพ์พลาด
เมื่อเรียกจาก JavaScript บนเว็บไซต์ของตัวเอง API ส่ง CORS header มาให้แล้ว จึง fetch ตรงจากเบราว์เซอร์ได้โดยไม่ต้องมี proxy
const res = await fetch("https://reefsight.tech/api/v1/tools/promptpay", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({target: "1234567890121", amount: "1250"}),
});
const {data} = await res.json();
console.log(data.type); // "nationalId"
console.log(data.payload); // 00020101021229370016A000000677010111021312345678901215802TH530376454071250.00630415BBถ้าต้องการรูป ใช้ qrUrl ที่ตอบกลับมาวางใน <img> ได้ทันที หรือถ้าอยากควบคุมสีและขนาดเอง นำ payload ไปวาดด้วยไลบรารี QR ใดก็ได้ โดยใช้โหมด byte และ error correction ระดับ M ขึ้นไป ไม่ต้องใส่ prefix ใดๆ
ถอดรหัส QR ที่ได้มาจากที่อื่น
เมื่อได้รับ QR จากลูกค้าหรือจากระบบอื่นแล้วอยากตรวจก่อนเอาไปใช้ ส่งข้อความที่สแกนได้ไปที่ POST /api/v1/tools/promptpay/decode
curl -X POST https://reefsight.tech/api/v1/tools/promptpay/decode \
-H "Content-Type: application/json" \
-d '{"payload":"00020101021229370016A000000677010111011300668123456785802TH53037645406100.506304F88B"}'จะได้ valid: true, type: "phone", target: "0812345678", amount: "100.50", dynamic: true พร้อม tags ที่แยกทุกช่องให้ดู กรณีที่ตอบ valid: false มีสามแบบหลัก
- CRC ไม่ตรง (ข้อความถูกแก้หรือคัดลอกไม่ครบ) ยังอ่านช่องอื่นให้
- เป็น EMV QR ที่ถูกต้อง แต่ tag 29 ไม่มี AID ของพร้อมเพย์ เช่น
00020101021153037645802TH6304CE37จะได้เหตุผลvalid EMV QR but not a PromptPay credit transfer - ไม่ใช่โครงสร้าง TLV เลย เช่นส่ง
helloจะได้not an EMV QR payload: bad length at position 0
ทั้งสามกรณียังตอบ HTTP 200 เพราะถือว่าถอดรหัสสำเร็จแต่ผลคือไม่ผ่าน มีเพียงการไม่ส่ง payload เลยที่ได้ 422
ข้อควรระวัง
- ไม่ใช่ QR ร้านค้า QR ที่ร้านได้จากธนาคาร (Bill Payment, tag 30) มี Biller ID และเลขอ้างอิง ทำให้ระบบหลังบ้านรู้ว่าใครจ่ายบิลไหนและมีแจ้งเตือนจากธนาคาร ส่วน QR แบบบุคคล (tag 29) จากเครื่องมือนี้ไม่มีสิ่งเหล่านั้น เหมาะกับฟรีแลนซ์ ร้านเล็ก หรือใบแจ้งหนี้ที่ตรวจยอดเข้าบัญชีเอง
- ไม่มีทางรู้ว่าจ่ายแล้วหรือยัง เครื่องมือไม่เก็บเบอร์ เลขบัตร หรือ payload และไม่เชื่อมกับธนาคาร ระบบของคุณต้องตรวจยอดเข้าบัญชีเอง
- ตรวจได้แค่รูปแบบและ checksum API ไม่รู้ว่าเบอร์หรือเลขบัตรนั้นผูกพร้อมเพย์อยู่จริงหรือไม่ แอปธนาคารจะแสดงชื่อผู้รับให้ยืนยันตอนสแกน
- สตางค์มีได้ 2 ตำแหน่ง จำนวนเงิน
1.234ได้ 422 ช่วงที่รับคือ 0.01 ถึง 999999999.99 - รูป QR มีโควตา การสร้างและถอดรหัส payload ไม่จำกัดจำนวน แต่
qr.pngจำกัด 60 ครั้งต่อ IP ทุก 15 นาที ถ้าต้องสร้างรูปจำนวนมาก ให้วาด QR เองจาก payload ฝั่งคุณ
คำถามที่พบบ่อย
ปลอดภัยไหมที่จะส่งเบอร์โทรหรือเลขบัตรไปที่ API
คำขอไม่ถูกเก็บและไม่ถูก log เนื้อหา รูป QR ตอบแบบ no-store เพื่อไม่ให้ cache ระหว่างทางเก็บไว้ ถ้ายังไม่สบายใจ ให้ประกอบ payload ตามโครงสร้างในบทความนี้แล้ววาด QR เองโดยไม่เรียก qr.png เลยก็ได้
ทำไมเลขบัตรของฉันสร้าง QR ไม่ได้
หลักที่ 13 ของเลขบัตรประชาชนและเลขนิติบุคคลเป็น checksum ของ 12 หลักแรก พิมพ์ผิดหลักเดียวจะไม่ผ่าน ลองตรวจเลขก่อนที่ /api/v1/tools/thai/id ซึ่งจะบอก reason ให้ว่าผิดตรงไหน
ใส่จำนวนเงินแล้วผู้จ่ายแก้ได้ไหม
ในแอปธนาคารส่วนใหญ่ QR แบบ dynamic (tag 01 = 12) จะล็อกจำนวนไว้ ถ้าต้องการให้ผู้จ่ายกรอกเอง ให้เว้น amount เพื่อให้ได้ QR แบบ static
ลองสร้างและถอดรหัสได้เลย
หน้า reefsight.tech › promptpay มีกรอบจำลองหน้าจอแอปธนาคารที่แสดง QR ทันทีที่กรอก แยกสี payload ให้ดูทีละ tag พร้อมช่องวาง payload เพื่อถอดรหัส และเอกสาร API ที่ตัวอย่างทุกตัวถูกรันตรวจกับระบบจริง ใช้ฟรี ไม่ต้องสมัคร และหน้าเว็บเรียก API ตัวเดียวกับที่คุณเรียกได้เอง
