ต่อ Open API ธนาคารออก QR รับเงิน — จาก sandbox ถึงวงจรสถานะ
QR ที่ประกอบเองได้ตามมาตรฐาน EMVCo จบแค่ "ออกรูปให้ลูกค้าสแกน" แต่ระบบรับเงินจริงต้องรู้ต่อว่ารายการไหนจ่ายแล้ว รายการไหนหมดอายุ รายการไหนถูกคืนเงิน ตรงนั้นต้องให้ธนาคารเป็นคนออก QR ให้ แล้วเราตามสถานะกลับมาเอง
สรุปใน 1 นาที
- อาการ/ปัญหา
- QR พร้อมเพย์แบบ static ไม่บอกว่าลูกค้าจ่ายหรือยัง และยิง Open API ไม่ผ่านทั้งที่ payload ถูกหมด
- ใครควรอ่าน
- นักพัฒนาที่จะต่อ Open API ของธนาคารเพื่อรับชำระด้วย QR แบบ dynamic
- ทางแก้แบบสั้น
- ขอ token แบบ client credentials ใน sandbox ออก QR dynamic และจัดค่า requestDt ให้ถูก แล้วติดตามสถานะ REQUESTED, PAID, CANCELLED, VOIDED
พอดีผมทำหน้ารับเงินสนับสนุนบนเว็บตัวหนึ่ง ตอนแรกใช้ QR พร้อมเพย์แบบ static ที่ผมประกอบสตริง EMVCo เองตามที่เคยเขียนไว้ใน สร้าง QR พร้อมเพย์ด้วย Python ล้วน ใช้ได้ดีจนถึงวันที่มีคนโอนมาสิบกว่ารายการในวันเดียว แล้วผมต้องมานั่งไล่สลิปเทียบยอดเองทีละใบ ปัญหาไม่ได้อยู่ที่ QR แต่อยู่ที่ ไม่มีใครบอกระบบผมว่ารายการไหนจ่ายแล้ว เลยต้องไปสมัคร Open API ของธนาคารไทยเจ้าหนึ่งแล้วนั่งไล่ sandbox ของเขาให้ครบทุกข้อ บทความนี้ผมเล่าเฉพาะรูปแบบ API ที่เอกสารสาธารณะระบุไว้ กับกับดักที่ผมเจอเองระหว่างทาง ส่วนค่า credential ทุกตัวในนี้เป็นตัวแทนหมด
ผมเทียบเฉพาะ ค่าธรรมเนียมกับข้อจำกัดทางเทคนิค ของช่องทางรับเงิน ไม่ใช่การแนะนำว่าควรเลือกเจ้าไหน และไม่เกี่ยวกับเรื่องผลตอบแทนใด ๆ ตัวเลขค่าธรรมเนียมเปลี่ยนตามสัญญาของแต่ละราย ให้ยึดใบเสนอราคาที่ตัวเองได้รับเป็นหลักเสมอ
static QR จบตรงไหน แล้วทำไมต้อง dynamic
QR แบบ static คือสตริง EMVCo ที่ผูกกับพร็อกซีพร้อมเพย์ของเรา จะพิมพ์แปะหน้าร้านหรือเรนเดอร์บนเว็บก็ได้ ข้อดีคือไม่ต้องต่อใคร ข้อเสียคือมันไม่มีเลขอ้างอิงรายการ พอเงินเข้ามาก้อนหนึ่ง ระบบเราไม่รู้ว่าเป็นของใบสั่งซื้อไหน
QR แบบ dynamic ที่ธนาคารออกให้จะต่างตรงที่ทุกครั้งที่เรียก API เราส่ง รหัสรายการฝั่งเรา แนบไปด้วย แล้วธนาคารคืน payload ที่ผูกกับรายการนั้น เลยตามสถานะกลับมาได้ตรงใบ
ประกอบเอง ไม่ต้องต่อใคร
- ค่าธรรมเนียมฝั่งผู้รับ 0 สำหรับพร้อมเพย์บุคคลธรรมดา
- ไม่มีเลขอ้างอิงรายการ ต้องกระทบยอดด้วยมือ
- ล็อกจำนวนเงินได้ แต่ไม่รู้ว่าใครจ่าย
- บัตรต่างประเทศสแกนไม่ได้
ธนาคารออกให้ ผูกกับใบสั่งซื้อ
- มีค่าธรรมเนียมต่อรายการตามสัญญา
- มีรหัสรายการสองฝั่ง ตามสถานะได้
- ยกเลิก/คืนเงินผ่าน API ได้
- ต้องสมัคร ผ่านการตรวจเอกสาร และมี sandbox ให้ทดสอบ
เทียบค่าธรรมเนียมกับข้อจำกัดของแต่ละช่องทาง
ผมเอาตัวเลขจากใบเสนอราคาและจาก response ของ sandbox มาวางเทียบกันเป็นช่วง ๆ ไม่ได้ยึดเป็นราคาตายตัว
ข้อจำกัดทางเทคนิคที่ผมว่าสำคัญกว่าตัวเลขค่าธรรมเนียม มีสามข้อ
- พร้อมเพย์บุคคลธรรมดาไม่มีช่องทาง API ให้ยืนยันการชำระ ถ้าอยากได้ต้องขยับไปเป็นบัญชีนิติบุคคลกับผลิตภัณฑ์รับชำระของธนาคาร
- เกตเวย์ต่างประเทศคิดเป็นเปอร์เซ็นต์บวกค่าคงที่ต่อรายการ รายการเล็ก ๆ ระดับหลักสิบบาทจะโดนส่วนคงที่กินหนัก
- QR ผ่าน Open API ของธนาคารมี MDR อยู่ราวหนึ่งเปอร์เซ็นต์ และใน response จะแตก fee ให้ดูเป็นบรรทัด ๆ ว่าส่วนไหนเป็น MDR ส่วนไหนเป็น VAT
ขอ token ก่อน: client credentials + โหมดทดสอบ
ทุก endpoint ต้องมี bearer token และ token ได้มาจาก OAuth 2.0 แบบ client credentials คู่ค่าที่ใช้คือ consumer id กับ consumer secret ที่พอร์ทัลออกให้ตอนสร้างแอป เอามาต่อกันด้วยเครื่องหมายโคลอนแล้ว base64 เป็น Basic auth
# อย่าฝัง credential ลงสคริปต์ อ่านจาก environment แทน
BASIC=$(printf '%s' "$CONSUMER_ID:$CONSUMER_SECRET" | base64 -w0)
curl -sS -X POST 'https://openapi-sandbox.example.com/v2/oauth/token' \
-H "Authorization: Basic $BASIC" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'x-test-mode: true' \
-d 'grant_type=client_credentials'
{
"token_type": "Bearer",
"client_id": "<CONSUMER_ID>",
"access_token": "<ACCESS_TOKEN>",
"expires_in": "1799",
"status": "approved"
}
สองข้อที่ต้องจำตั้งแต่บรรทัดแรก — token อายุประมาณ 1799 วินาที คือราวสามสิบนาที และใช้ซ้ำได้จนหมดอายุ ไม่ต้องขอใหม่ทุกครั้งที่ยิง ที่สำคัญกว่านั้นคือฝั่ง sandbox จำกัดการขอ token ไว้ประมาณห้าครั้งต่อสามสิบนาทีต่อผู้ใช้ ถ้าเขียนโค้ดให้ขอ token ใหม่ทุก request แล้วเทสรัว ๆ จะโดน throttle แล้วเสียเวลานั่งเดาว่าโค้ดผิดตรงไหน
เก็บ token พร้อมเวลาหมดอายุลงแคช แล้วหักเวลาเผื่อไว้สัก 60 วินาทีก่อนถือว่าหมดอายุ วิธีนี้กันทั้ง throttle และกันเคสที่ token หมดอายุกลางทางระหว่าง request กำลังเดินทาง
ออก QR แบบ dynamic
ตัว endpoint ออก QR รับพารามิเตอร์หลักไม่กี่ตัว คือรหัสรายการฝั่งเรา รหัสร้าน ชนิด QR จำนวนเงิน สกุลเงิน และช่องอ้างอิงอีกสี่ช่องที่เอาไว้ยัดเลขใบสั่งซื้อของเราเอง ชนิด QR ที่เป็นไทยคิวอาร์พร้อมเพย์กับชนิดที่เป็นบัตรเครดิตใช้คนละค่า และ payload ที่ได้กลับมาก็ยาวไม่เท่ากัน
{
"partnerTxnUid": "TXN-20261119-000117",
"partnerId": "<PARTNER_ID>",
"partnerSecret": "<PARTNER_SECRET>",
"requestDt": "2026-11-19T16:54:56+07:00",
"merchantId": "<MERCHANT_ID>",
"qrType": 3,
"txnAmount": 100.00,
"txnCurrencyCode": "THB",
"reference1": "INV-000117",
"reference2": "SUPPORT",
"reference3": "WEB",
"reference4": "INV-000117"
}
{
"partnerTxnUid": "TXN-20261119-000117",
"statusCode": "00",
"accountName": "<ACCOUNT_NAME>",
"qrCode": "00020101021230810016A000000677010112...5406100.005802TH6304xxxx",
"sof": ["PP"]
}
สังเกตว่าค่าที่บอกว่า "สำเร็จ" ของ API ตระกูลนี้ไม่ใช่ HTTP status แต่เป็น statusCode ในบอดี ค่า 00 คือผ่าน ค่าอื่นคือไม่ผ่านแล้วต้องไปอ่าน errorCode กับ errorDesc ต่อ ใครเขียนโค้ดแล้วเช็คแค่ HTTP 200 จะคิดว่าสำเร็จทั้งที่รายการไม่เกิด
อีกจุดที่ผมชอบคือ qrCode ที่ได้กลับมาเป็น payload EMVCo ตรง ๆ แปลว่าเรนเดอร์เป็นรูปด้วยไลบรารีอะไรก็ได้เหมือนเดิม และอ่าน field ข้างในตรวจเองได้ด้วยว่าจำนวนเงินกับรหัสประเทศตรงกับที่ส่งไปหรือเปล่า
# แกะ TLV กลับมาดูว่าจำนวนเงิน (tag 54) กับประเทศ (tag 58) ตรงที่ขอไหม
def tlv(payload):
out, i = {}, 0
while i < len(payload):
tag, ln = payload[i:i+2], int(payload[i+2:i+4])
out[tag] = payload[i+4:i+4+ln]
i += 4 + ln
return out
fields = tlv(resp['qrCode'])
assert fields['54'] == '100.00' and fields['58'] == 'TH'
requestDt — ที่ผมพลาดรอบแรก
อันนี้คือกับดักที่กินเวลาผมนานสุดในรอบนั้น ผมยิง cancel ไปแล้วได้ statusCode เป็น 10 กลับมาพร้อมข้อความสั้น ๆ ว่า requestDt ผิด ทั้งที่ payload ทุก field ถูกหมด ตรวจสิบรอบก็ยังเหมือนเดิม
สาเหตุคือ API ตระกูลนี้เอา requestDt ไปเทียบกับเวลาจริงของเซิร์ฟเวอร์ธนาคาร ใส่เวลาที่เก่าเกินหรืออนาคตเกินหน้าต่างที่เขายอมรับ จะโดนตีกลับทันที ที่ผมพลาดคือนาฬิกาเครื่องที่ผมยิงเทสอยู่ ช้าไปหนึ่งวันเต็ม ๆ แล้วผมก็นั่งแก้ payload แทนที่จะไปแก้นาฬิกา
# สร้าง requestDt จากเวลาจริงเสมอ อย่า hardcode ค่าที่เคยใช้ผ่าน
TZ='Asia/Bangkok' date +%Y-%m-%dT%H:%M:%S+07:00
# เทียบนาฬิกาเครื่องกับ NTP ก่อนโทษ payload
timedatectl status | grep -i 'synchronized\|time zone'
{
"statusCode": "10",
"errorCode": "<ERROR_CODE>",
"errorDesc": "wrong requestDt"
}
API ที่มี timestamp ใน payload จะปฏิเสธ request ที่เวลาเพี้ยนเงียบ ๆ โดยบอกเหตุผลกว้าง ๆ ก่อนไล่ debug payload ให้เช็กก่อนว่านาฬิกาเครื่องที่ยิงตรง NTP จริงและ timezone ไม่หลุด เรื่องนี้กินเวลาผมไปครึ่งวันโดยไม่จำเป็น
วงจรสถานะ: REQUESTED, PAID, CANCELLED, VOIDED
พอออก QR ได้แล้ว งานที่เหลือคือตามสถานะ ฝั่ง inquiry รับรหัสรายการเดิมที่เราส่งไปตอนสร้าง แล้วคืนสถานะมาสี่แบบ
{
"partnerTxnUid": "TXN-20261119-000117",
"statusCode": "00",
"txnStatus": "PAID",
"channel": "mobile-banking",
"txnAmount": "100.00",
"txnCurrencyCode": "THB",
"reference1": "INV-000117"
}
กติกาทางธุรกิจที่ผมสรุปได้จาก error code ที่ sandbox ตีกลับมา มีสองข้อที่ต้องเอาไปเขียนเป็นเงื่อนไขในโค้ดตรง ๆ
- ยกเลิก QR ได้เฉพาะตอนสถานะยังเป็น REQUESTED ถ้าจ่ายไปแล้วหรือคืนเงินไปแล้ว ระบบจะตอบกลับว่าทำไม่ได้พร้อมรหัสเหตุผล
- คืนเงินได้เฉพาะรายการที่จ่ายแล้วและอยู่ในวันเดียวกัน ข้ามวันไปแล้วหรือเข้ารอบ settlement ไปแล้ว ต้องไปทำเป็นการโอนคืนตามกระบวนการปกติ ไม่ใช่ผ่าน endpoint นี้
แปลว่าปุ่ม "ยกเลิก" กับ "คืนเงิน" บนหน้าแอดมินของเราต้องซ่อนหรือปิดตามสถานะจริงที่ inquiry คืนมา ไม่ใช่เปิดทิ้งไว้ทุกใบแล้วรอให้ธนาคารเป็นคนบอกว่าทำไม่ได้
ตามสถานะยังไงไม่ให้ระบบล่ม
sandbox ที่ผมเล่นไม่มี webhook ให้ลอง เลยต้องทำ polling ก่อน หลักที่ผมใช้คือ ถี่ช่วงแรก แล้วห่างออก เพราะรายการส่วนใหญ่จ่ายภายในนาทีสองนาทีแรก ถ้าตั้ง interval คงที่ทุกห้าวินาทีจนหมดอายุ จะกินโควตาฟรี ๆ
// ถี่ช่วงแรกแล้วห่างออก + หยุดเมื่อถึงสถานะปลายทาง
$steps = [5, 5, 10, 10, 20, 30, 60, 60, 120];
foreach ($steps as $wait) {
$r = $qr->inquiry($txnUid);
if ($r['statusCode'] !== '00') { Log::warning('inquiry failed', $r); break; }
if (in_array($r['txnStatus'], ['PAID', 'CANCELLED', 'VOIDED'], true)) {
Payment::markFromGateway($txnUid, $r); // idempotent เขียนซ้ำได้
break;
}
sleep($wait);
}
ตัวฟังก์ชันที่เขียนผลกลับ DB ต้องเขียนซ้ำได้โดยไม่เพี้ยน เพราะ polling กับ webhook (ถ้าวันหลังเปิด) จะยิงเข้ามาชนกันได้ ผมใช้วิธีอัปเดตแบบมีเงื่อนไขบนสถานะเดิม แทนที่จะเขียนทับดื้อ ๆ
UPDATE payments
SET status = 'paid', paid_at = ?, gateway_ref = ?
WHERE txn_uid = ? AND status IN ('pending', 'requested');
-- affected = 0 แปลว่ามีคนอื่นอัปเดตไปก่อนแล้ว ไม่ใช่ error
ค่าธรรมเนียมที่ response บอกเราเอง
ตอน inquiry ผลิตภัณฑ์ฝั่งรับชำระจะแตกค่าธรรมเนียมมาให้ดูด้วย อันนี้มีประโยชน์มากเวลาทำบัญชี เพราะไม่ต้องไปเดาว่ายอดที่เข้าบัญชีจริงจะเหลือเท่าไร
{
"paymentStatus": "PAID",
"amount": "1000.50",
"feeBreakdown": {
"total": "10.70000",
"mdr": "10.00000",
"vat": "0.70000",
"wht": "0.30000"
},
"payoutStatus": "NOT READY",
"reconcileStatus": { "reconciledFlag": false }
}
จากก้อนนี้อ่านได้ว่ายอด 1,000.50 บาท โดนค่าธรรมเนียมรวม 10.70 บาท แยกเป็น MDR สิบบาทกับ VAT เจ็ดสิบสตางค์ คิดเป็นราวหนึ่งเปอร์เซ็นต์ และมีภาษีหัก ณ ที่จ่ายแยกอีกบรรทัด ส่วน payoutStatus ที่ยังเป็น NOT READY แปลว่าเงินยังไม่ถึงรอบโอนออก ตรงนี้สำคัญเวลาทำหน้ารายงาน อย่าเอา PAID ไปแสดงว่า "เงินเข้าบัญชีแล้ว" เพราะมันคนละขั้น
เอาขึ้นจริงต้องเตรียมอะไรบ้าง
- แยก credential ของ sandbox กับ production ออกจากกันคนละชุด แล้วอ่านจาก environment ทั้งคู่ ห้ามมีค่าไหนอยู่ในซอร์ส
- ทำ wrapper ตัวเดียวที่ทุก endpoint เรียกผ่าน แล้วบังคับ log ทั้ง request id และ statusCode ทุกครั้ง โดยตัด field ที่เป็นความลับออกก่อนเขียน log
- เขียนสถานะปลายทางลง DB แบบ idempotent และเช็ค affected row ทุกครั้ง
- ตั้ง timeout ต่อ request ให้ยาวกว่าที่คิด แล้วแยก retry เฉพาะ endpoint ที่ยิงซ้ำได้ปลอดภัย
- ทดสอบเคสที่ตั้งใจให้ล้มเหลวให้ครบ ทั้งยกเลิกรายการที่จ่ายแล้ว คืนเงินรายการที่ยังไม่จ่าย และคืนเงินข้ามวัน
- ก่อนสลับไป production ให้ถือว่าทุกอย่างที่ sandbox บอกเป็นเพียงรูปแบบ ไม่ใช่พฤติกรรมจริง
ข้อสุดท้ายผมย้ำเพราะเจ็บมาแล้ว ทั้งเรื่องเวลาตอบกลับ ทั้งเรื่องยอดที่ถูกตัด ทั้งเรื่องรายการที่สร้างสำเร็จแต่ไม่โผล่ในรายการค้นหา ผมแยกไปเขียนเป็นอีกตอนไว้ที่ ผ่าน sandbox ไม่ได้แปลว่าใช้ได้จริง
consumer secret, partner secret, merchant id และ access token ห้ามอยู่ในซอร์ส ห้ามอยู่ใน log ห้ามอยู่ในภาพหน้าจอที่เอาไปโพสต์ ผมเขียนตัวกรองไว้ที่ชั้น log formatter เลย ให้แทนที่ค่าที่ตรงกับรายชื่อ key ต้องห้ามด้วยดาวก่อนเขียนลงไฟล์ทุกครั้ง
สรุปคือ QR แบบ static ที่ประกอบเองยังคุ้มอยู่สำหรับงานเล็ก ๆ ที่กระทบยอดด้วยมือไหว แต่พอเริ่มมีรายการหลายสิบใบต่อวัน การให้ธนาคารเป็นคนออก QR แล้วเราตามสถานะกลับมา จบเรื่องกระทบยอดได้จริง แลกกับค่าธรรมเนียมราวหนึ่งเปอร์เซ็นต์กับงานเขียนโค้ดเพิ่มอีกชั้น ใครจะเลือกทางไหนก็ตามปริมาณงานที่มีจริงครับ ตรงไหนผมอธิบายตกหล่นหรือรูปแบบ API ฝั่งธนาคารเปลี่ยนไปจากนี้ เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย