ทุกครั้งที่เปิดแอปธนาคารแล้วสแกน 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

plaintext
00020101021229370016A000000677010111011300668123456785802TH53037645406100.506304F88B

แยกออกเป็นช่องตามกฎ tag–length–value จะได้

TagLengthValueความหมาย
000201เวอร์ชันของรูปแบบข้อมูล
010212ชนิดของ QR: 12 = dynamic (กำหนดจำนวนเงินแล้ว), 11 = static (ผู้จ่ายกรอกเอง)
29370016A000000677010111011300668123456785ข้อมูลพร้อมเพย์ (ซ้อน TLV ข้างใน)
5802THประเทศ
5303764รหัสสกุลเงิน ISO 4217: 764 = บาท
5406100.50จำนวนเงิน มีเฉพาะ QR แบบ dynamic
6304F88BCRC ของข้อความทั้งหมดก่อนหน้า

ช่องที่สำคัญที่สุดคือ 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 หลักตรงๆ
03e-Wallet15 หลัก

สังเกตว่าความยาวของ 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 จะได้

json
{
  "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

plaintext
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 ไม่ต้องสมัคร

Terminal
curl -X POST https://reefsight.tech/api/v1/tools/promptpay \
  -H "Content-Type: application/json" \
  -d '{"target":"081-234-5678","amount":"100.5"}'

คำตอบ

json
{
  "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

js
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

Terminal
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 มีสามแบบหลัก

  1. CRC ไม่ตรง (ข้อความถูกแก้หรือคัดลอกไม่ครบ) ยังอ่านช่องอื่นให้
  2. เป็น EMV QR ที่ถูกต้อง แต่ tag 29 ไม่มี AID ของพร้อมเพย์ เช่น 00020101021153037645802TH6304CE37 จะได้เหตุผล valid EMV QR but not a PromptPay credit transfer
  3. ไม่ใช่โครงสร้าง 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 ตัวเดียวกับที่คุณเรียกได้เอง

คุยกับทีม Reefsight