Quickstart
ตรวจขอบเขตก่อนเริ่ม
การเชื่อมต่อเงินจริงให้อ่าน Live P2P implementation 0092 ซึ่งยังรอตรวจ/deploy การทดสอบใช้ Sandbox ที่รองรับ ขั้นตอน checkout และสลิปใน quickstart ด้านล่างเป็น Target contract ไม่ใช่ความสามารถของ simulator ปัจจุบันทั้งหมด
เป้าหมาย: เห็นรายการฝากหนึ่งใบเดินจาก awaiting_payment ไปจนถึง settled และรับ webhook ที่ verify ลายเซ็นผ่าน — ภายใน 5 นาที
สิ่งที่ต้องมี: bash + curl + openssl + Node.js 18 ขึ้นไป · คีย์ sandbox จากผู้ให้บริการ White Label ของคุณ
ทุกอย่างในหน้านี้อยู่บน sandbox
รายการที่สร้างจะมี livemode: false ทุกใบ · event ของ sandbox ไม่มีวันไปถึง endpoint ของ live และกลับกัน — ระบบบังคับที่ระดับฐานข้อมูล ไม่ใช่ตรรกะในโค้ด
ขั้นที่ 0 — เตรียม environment (30 วินาที)
ขอคีย์ sandbox จากผู้ให้บริการ White Label ของคุณ คุณจะได้ค่า 3 อย่าง: base URL, API key, secret key สำหรับเซ็น
export PG_API='https://api.example-wl.com' # เปลี่ยนเป็นโดเมนของ WL คุณ
export PG_API_KEY='sk_test_01K2QF7H3X9M4TB0YR6WCZ8V2N' # ใส่ใน Authorization: Bearer
export PG_SECRET_KEY='sk_test_01K2QF7H3X9M4TB0YR6WCZ8V2N' # ใช้เซ็น PG-Signature
export PG_VERSION='2026-08-01'นาฬิกาต้องตรง
PG-Timestamp ที่เพี้ยนเกิน ±300 วินาทีจะได้ 401 timestamp_out_of_window ทันที · เช็คก่อนเริ่ม: date -u ต้องตรงกับเวลาจริง ±5 นาที
สคริปต์เซ็น — เซฟเป็น pg-sign.sh แล้ว chmod +x pg-sign.sh ใช้ตลอดทั้งหน้านี้
#!/usr/bin/env bash
# pg-sign.sh METHOD PATH [SORTED_QUERY] [BODY_FILE] [IDEMPOTENCY_KEY]
# พิมพ์ header ที่ curl ต้องใช้ ออกมาบรรทัดละ 1 argument
set -euo pipefail
METHOD="$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')"
REQ_PATH="$2"
QUERY="${3:-}"
BODY_FILE="${4:-}"
IDEM="${5:-}"
: "${PG_SECRET_KEY:?ตั้ง export PG_SECRET_KEY=... ก่อน}"
PG_VERSION="${PG_VERSION:-2026-08-01}"
TS="$(date -u +%s)"
NONCE="$(openssl rand -hex 13 | tr 'a-f' 'A-F')"
if [ -n "$BODY_FILE" ]; then
BODY_SHA="$(openssl dgst -sha256 < "$BODY_FILE" | awk '{print $NF}')"
else
BODY_SHA="$(printf '' | openssl dgst -sha256 | awk '{print $NF}')"
fi
# 9 บรรทัด ปิดท้ายด้วย \n ทุกบรรทัดรวมบรรทัดสุดท้าย
SIG="$(printf 'v1\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \
"$TS" "$NONCE" "$METHOD" "$REQ_PATH" "$QUERY" "$BODY_SHA" "$IDEM" "$PG_VERSION" \
| openssl dgst -sha256 -hmac "$PG_SECRET_KEY" | awk '{print $NF}')"
printf -- '-H\nPG-Timestamp: %s\n-H\nPG-Nonce: %s\n-H\nPG-Version: %s\n-H\nPG-Signature: v1=%s\n' \
"$TS" "$NONCE" "$PG_VERSION" "$SIG"
[ -n "$IDEM" ] && printf -- '-H\nIdempotency-Key: %s\n' "$IDEM"
exit 0รายละเอียดว่าทำไม canonical string ถึงเป็น 9 บรรทัดนี้ อยู่ที่ การยืนยันตัวตนของ request
ขั้นที่ 1 — พิสูจน์ว่าลายเซ็นผ่าน ก่อนแตะเรื่องเงิน (30 วินาที)
ยิง endpoint แบบอ่านอย่างเดียวก่อน ถ้าอันนี้ผ่าน แปลว่าการเซ็นของคุณถูกทั้งระบบ
mapfile -t H < <(./pg-sign.sh GET /v1/pay/banks)
curl -sS "$PG_API/v1/pay/banks" \
-H "Authorization: Bearer $PG_API_KEY" \
"${H[@]}" | head -c 400ผลลัพธ์ที่ควรเห็น
{
"data": [
{ "bank_code": "002", "name_th": "ธนาคารกรุงเทพ", "name_en": "Bangkok Bank",
"available": true, "side": ["sender", "receiver"] },
{ "bank_code": "004", "name_th": "ธนาคารกสิกรไทย", "name_en": "Kasikornbank",
"available": true, "side": ["sender", "receiver"] },
{ "bank_code": "014", "name_th": "ธนาคารไทยพาณิชย์", "name_en": "Siam Commercial Bank",
"available": false, "message_th": "ธนาคารปิดปรับปรุงระบบชั่วคราว", "side": ["receiver"] }
]
}ได้ 401 ตรงนี้ ให้หยุดแล้วแก้ก่อน
code | แก้อย่างไร |
|---|---|
signature_invalid | เทียบ canonical string ของคุณกับ test vector ทีละไบต์ |
timestamp_out_of_window | sync NTP |
nonce_replayed | PG-Nonce ต้องใหม่ทุก attempt (ต่างจาก Idempotency-Key ที่ต้องคงเดิม) |
api_key_invalid | ตรวจว่าใช้คีย์ sandbox ไม่ใช่คีย์ live |
เก็บ available: false ไว้ใช้เทาปุ่มธนาคารในฟอร์มผู้โอน ไม่งั้นผู้ใช้จะเจอ 409 ที่ขั้นสุดท้าย
ขั้นที่ 2 — สร้างรายการฝากใบแรก (1 นาที)
ยอด 100.00 บาท เป็นยอดพิเศษของ sandbox ที่เดินเส้นทาง happy path ให้อัตโนมัติ (ตารางยอดทั้งหมด)
cat > body.json <<'JSON'
{
"amount_minor": 10000,
"currency": "THB",
"merchant_reference": "ORD-QS-0001",
"end_user_ref": "u_88213",
"sender": {
"bank_code": "002",
"account_number": "1234567890",
"account_name_th": "สมชาย ใจดี",
"account_name_en": "SOMCHAI JAIDEE"
},
"client": {
"device_fingerprint": "df_9a1c4e77b2",
"ip": "203.0.113.44",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X)"
},
"return_url": "https://shop.example.com/topup/ORD-QS-0001",
"metadata": { "order_id": "ORD-QS-0001" }
}
JSON
IDEM='qs-ORD-QS-0001' # 🔴 ของจริงต้องเขียนลง DB ก่อนยิง แล้ว retry อ่านค่าเดิมจาก DB
mapfile -t H < <(./pg-sign.sh POST /v1/pay/deposits '' body.json "$IDEM")
curl -sS -X POST "$PG_API/v1/pay/deposits" \
-H "Authorization: Bearer $PG_API_KEY" \
-H "Content-Type: application/json" \
"${H[@]}" --data-binary @body.json | tee created.jsonผลลัพธ์ที่ควรเห็น — HTTP 201
{
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 2,
"status": "awaiting_payment",
"is_terminal": false,
"merchant_reference": "ORD-QS-0001",
"amount_minor": 10000,
"currency": "THB",
"created_at": "2026-08-21T04:10:47.902Z",
"expires_at": "2026-08-21T04:30:47.902Z",
"checkout_url": "https://pay.example-wl.com/c/9f2c1ab6e0d74f",
"slip_upload": {
"token": "pgsut_eyJraWQiOiJvcmRlc...",
"expires_at": "2026-08-22T04:30:47.902Z",
"upload_url": "https://slip-api.example.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/slip",
"max_bytes": 4194304,
"content_types": ["image/jpeg", "image/png", "image/webp", "application/json"]
},
"liquidity_source": "internal",
"leg_count": 1,
"legs": [
{
"leg_ref": "leg_7QF2H8KM",
"sequence": 1,
"amount_minor": 10000,
"expires_at": "2026-08-21T04:30:47.902Z",
"leg_status": "pending",
"method": "promptpay_qr",
"qr_payload": "00020101021229370016A00000067701011201150105560012345530376454060100.005802TH6304A1B2",
"qr_image_url": "https://pay.example-wl.com/qr/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/1.png",
"receiver": {
"bank_code": "014",
"bank_name_th": "ธนาคารไทยพาณิชย์",
"account_number_masked": "xxx-x-x1234",
"account_name_masked": "ส*** ใ***"
}
}
],
"sender": {
"bank_code": "002",
"account_number_masked": "xxx-x-x7890",
"account_name_th_masked": "ส*** ใ***"
},
"metadata": { "order_id": "ORD-QS-0001" }
}เก็บ id ไว้ใช้ต่อ:
DEP_ID="$(node -e "console.log(require('./created.json').id)")"
SLIP_UPLOAD_TOKEN="$(node -e "console.log(require('./created.json').slip_upload.token)")"
SLIP_UPLOAD_URL="$(node -e "console.log(require('./created.json').slip_upload.upload_url)")"
echo "$DEP_ID"ห้ากับดักในขั้นเดียวนี้
- ไม่ส่ง
senderหรือsender_id⇒422 sender_requiredสร้างรายการไม่ได้เลย นี่คือความต่างที่ทำให้โค้ดเดิมของคุณพังเป็นอย่างแรก 202ไม่ใช่ error — ถ้ายังไม่มีสภาพคล่อง คุณจะได้202พร้อมstatus: "awaiting_liquidity",legs: []และliquidity.suggested_amounts_minor⇒ แสดงหน้ารอ ห้ามยิง POST ซ้ำ รอ webhookdeposit.matchedlegs.length > 1เกิดขึ้นได้ ⇒ UI ต้องรองรับ "ขั้นที่ 1 จาก 2" ·legs[0]ไม่ใช่ทั้งหมดqr_payloadห้าม log ห้ามเก็บลง DB ใช้แสดงผลแล้วทิ้ง · เก็บได้แค่expires_atslip_uploadเป็น capability ของรายการเดียว ห้าม log token/analytics/ใส่ URL · POST ไปที่upload_urlที่คืนมาเท่านั้น (ห้ามประกอบ host/path เอง), precheck ด้วยmax_bytes+content_typesและไม่ต้องเซ็น HMAC ซ้ำ
เส้น 202 awaiting_liquidity จะยังไม่มี slip_upload เพราะยังไม่มี reservation หลังได้ deposit.matched ให้ backend เซ็น POST /v1/pay/deposits/{id}/slip-token ด้วย merchant HMAC และรับ {token, expires_at, upload_url, max_bytes, content_types} ก่อนแสดง payment instructions
ไม่ส่ง client{} ไม่ได้จริง ๆ
ถ้าไม่ส่ง device_fingerprint + ip เพดานรายการค้างต่อผู้ใช้จะเหลือ 1 ใบ และ rate limit ของคุณจะตกไปอยู่ใน bucket หยาบที่ แชร์กับผู้ใช้ทุกคนของ merchant ⇒ ชน 429 ตั้งแต่นาทีแรกที่มี traffic จริง
ขั้นที่ 3 — เปิดหน้า checkout (30 วินาที)
echo "$(node -e "console.log(require('./created.json').checkout_url)")"เปิด URL นั้นในเบราว์เซอร์ คุณจะเห็นหน้าจ่ายเงินที่โฮสต์บนโดเมนของ WL พร้อม QR, countdown จาก expires_at, กล่อง "โอนจากบัญชีนี้เท่านั้น" และปุ่มอัปสลิป — ครบโดยที่คุณไม่ต้องเขียนอะไรเลย
นี่คือทางที่แนะนำให้เริ่ม อ่านข้อดีข้อเสียเทียบกับการทำหน้าเองที่ Hosted checkout
ถ้าจะทำหน้าเอง ใช้ legs[] จาก response โดยตรง และอย่าลืม:
- countdown อ่านจาก
expires_atห้าม hardcode (บาง route หมดอายุจริงใน ~4 นาที) - กล่อง "โอนจากบัญชีนี้เท่านั้น" ต้องเด่นกว่าตัว QR
- ข้อความ "QR นี้ใช้ได้ครั้งเดียวสำหรับรายการนี้ ห้ามบันทึกภาพไว้"
ขั้นที่ 4 — อัปโหลดสลิปตัวอย่าง (1 นาที)
สร้างไฟล์ภาพ 1×1 px ไว้ทดสอบ (sandbox ไม่สนใจเนื้อภาพ แต่ client ยังต้องตรวจชนิดและขนาดตาม capability)
printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' \
| base64 -d > slip.png
# precheck decoded bytes + media type จาก capability; server จะตรวจ magic bytes ซ้ำ
node - 'image/png' <<'NODE'
const fs = require('node:fs')
const capability = require('./created.json').slip_upload
const mime = process.argv[2]
if (!capability.content_types.includes(mime)) throw new Error(`media type not allowed: ${mime}`)
if (fs.statSync('slip.png').size > capability.max_bytes) throw new Error('slip exceeds capability.max_bytes')
NODE
curl -sS -X POST "$SLIP_UPLOAD_URL" \
-H "Authorization: Bearer $SLIP_UPLOAD_TOKEN" \
-H "Idempotency-Key: slip-$DEP_ID-1" \
-F "file=@slip.png;type=image/png"ผลลัพธ์ที่ควรเห็น — HTTP 202
{
"receipt_id": "rcpt_01K3M8N2QW7ZR4TY6BD0HXKV9C",
"status": "accepted",
"received_at": "2026-08-21T04:12:28.410Z"
}นี่คือ durable receipt ไม่ใช่สถานะ deposit · consumer จะ claim receipt แล้วเปลี่ยนเป็น slip_processing แบบ async
นี่คือจุดที่ต่างจากตลาดมากที่สุด — รับเข้าแบบ durable สำเร็จเป็น 202 ไม่มี 409 duplicate_slip แบบทันที
gateway ส่วนใหญ่ตรวจสลิปซ้ำแบบ synchronous แล้วตอบ 409 กลับมาทันที เราไม่ทำแบบนั้น
เราตอบ 202 หลังเขียน object storage และยืนยัน queue สำเร็จ เพราะ ณ วินาทีนี้เงินของผู้ใช้ออกจากบัญชีไปแล้ว ห้ามมีเส้นทางไหนที่ทำให้หลักฐานหาย ถ้ายังยืนยัน durable intake ไม่ครบจะตอบ retryable 503; ส่ง logical evidence เดิมซ้ำด้วย key เดิม · evidence_fingerprint ไม่เปลี่ยนจาก boundary/key order/base64 formatting และการตรวจ duplicate slip เกิดขึ้นทีหลังใน pipeline
คุณต้องทำเพิ่ม: ผลของการตรวจซ้ำมาทาง webhook deposit.under_review ที่มี review.reasons[].code = "DUPLICATE_SLIP" หรืออ่านจาก GET /v1/pay/deposits/{id} — ห้ามรอ HTTP status ของการอัปโหลดมาบอก · หน้าจอหลังกดส่งต้องเขียนว่า "กำลังตรวจสอบสลิป" ไม่ใช่ "สำเร็จ"
รูปแบบอื่นที่ endpoint นี้รับ
| ส่งอะไร | ใช้เมื่อ | ผล |
|---|---|---|
multipart field file (ชนิด/ขนาดต้องอยู่ใน content_types/max_bytes) | ปกติ | เข้า pipeline ตรวจอัตโนมัติ |
JSON {"image_base64": "...", "payload": "..."} | client ถอด mini-QR บนสลิปได้เอง | เหมือนบน แต่แม่นขึ้น |
JSON {"trans_ref": "...", "sending_bank_code": "002", "occurred_at": "..."} | ผู้ใช้ทำสลิปหาย มีแต่เลขอ้างอิงจากแอปธนาคาร | 🔴 บังคับเข้า under_review ['NO_SLIP_IMAGE'] เสมอ ไม่มี auto |
หมดหน้าต่าง late-slip แล้วจะได้ 410 ไม่ใช่ 202
สลิปที่มาหลังปิดหน้าต่าง 24 ชั่วโมงจะได้ 410 late_slip_window_closed พร้อม details.claim_url ⇒ เปิดเรื่องผ่าน POST /v1/bo/claims แทน · จำลองเคสนี้ได้ด้วยยอด 112.00
sandbox เดินเส้นทางเองด้วย
ยอด 100.00 จะถูกขับไปจนถึง settled ภายใน ~5 วินาทีนับจากสร้างรายการ ถ้าคำสั่งอัปสลิปของคุณไปถึงช้ากว่านั้น อาจเห็นสถานะเดินไปข้างหน้าแล้ว — ไม่ใช่ error · ยึดค่าที่ได้จาก GET /v1/pay/deposits/{id} เสมอ
ขั้นที่ 5 — อ่านสถานะจริง (30 วินาที)
mapfile -t H < <(./pg-sign.sh GET "/v1/pay/deposits/$DEP_ID")
curl -sS "$PG_API/v1/pay/deposits/$DEP_ID" \
-H "Authorization: Bearer $PG_API_KEY" \
"${H[@]}"ผลลัพธ์ที่ควรเห็น เมื่อเดินจบแล้ว
{
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 7,
"status": "settled",
"is_terminal": false,
"merchant_reference": "ORD-QS-0001",
"ordered_amount_minor": 10000,
"amount_minor": 10000,
"currency": "THB",
"transferred_amount_minor": 10000,
"amount_received_minor": 10000,
"fee_total_minor": 250,
"fee_breakdown": [
{ "component": "processing_fee", "amount_minor": 130, "rate_bps": 130 },
{ "component": "fixed_fee", "amount_minor": 100 },
{ "component": "vat", "amount_minor": 20 }
],
"credited_amount_minor": 9750,
"overpayment": null,
"partial": false,
"settled_via": "auto",
"liquidity_source": "internal",
"balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"settled_at": "2026-08-21T04:12:33.702Z",
"clearing_available_at": "2026-08-24T04:12:33.702Z",
"metadata": { "order_id": "ORD-QS-0001" }
}4 ยอดหลัก + ค่าธรรมเนียมและยอดกระเป๋า
| ตัวเลข | ค่า | เอาไปทำอะไร |
|---|---|---|
ordered_amount_minor (X) | 10000 | ยอดที่สร้างรายการ |
transferred_amount_minor (V) | 10000 | ยอดที่ธนาคารยืนยันว่าโอนจริง |
amount_received_minor (A) | 10000 | 🔴 เครดิตผู้ใช้ปลายทางด้วยยอดที่ระบบรับเข้า |
overpayment.amount_minor (E) | ไม่มี (0) | ยอดเกินใน claim; ถ้ามีให้ติดต่อ support และห้ามเครดิตเพิ่ม |
fee_total_minor | 250 | บันทึกเป็นค่าใช้จ่าย |
credited_amount_minor | 9750 | ยอดที่เข้ากระเป๋า merchant ของคุณ |
กรณี same-slip overpay จะเป็น V = A + E และ A = X; event ยังเป็น deposit.settled เพียงใบเดียวพร้อม flag ไม่ใช่ deposit.overpaid
is_terminal ยังเป็น false ทั้งที่ settled
settled ออกได้อีกทางเดียวคือ reversed ⇒ อย่าลบรายการทิ้งจากระบบเมื่อเห็น settled · และ clearing_available_at บอกว่ายอดนี้จะถอนออกได้เมื่อไหร่ (T+72 ชั่วโมง) ไม่ใช่ถอนได้ทันที
ขั้นที่ 6 — รับ webhook แล้ว verify ลายเซ็น (2 นาที)
6.1 ดึง public key (ไม่ต้อง auth)
curl -sS "$PG_API/v1/webhooks/signing-keys"{
"keys": [
{ "kid": "whk_2026a", "alg": "Ed25519", "public_key": "MCowBQYDK2VwAyEA...",
"format": "spki_der_base64", "raw_public_key": "5Nn2q...", "status": "active",
"not_before": "2026-02-01T00:00:00.000Z", "not_after": null }
]
}6.2 receiver ขั้นต่ำที่ verify จริง — ตัวอย่างตัดการต่อ DB ออก; ให้ implement persistWebhookAtomically ตาม transactional inbox/outbox ใน หน้าตรวจลายเซ็น ก่อนรัน ห้ามแทนด้วย in-memory Set
import http from 'node:http';
import crypto from 'node:crypto';
const API = process.env.PG_API;
let keys = new Map();
async function loadKeys() {
const res = await fetch(`${API}/v1/webhooks/signing-keys`);
const { keys: list } = await res.json();
keys = new Map(list.map((k) => [k.kid, crypto.createPublicKey({
key: Buffer.from(k.public_key, 'base64'), format: 'der', type: 'spki',
})]));
}
await loadKeys();
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', async () => {
const raw = Buffer.concat(chunks); // 🔴 raw bytes ห้าม parse ก่อน verify
// 1) parse header แบบ key=value ห้าม parse ด้วยตำแหน่ง
const header = req.headers['x-pg-signature'] || '';
const parts = Object.fromEntries(
header.split(',').map((s) => s.trim()).filter((s) => s.includes('='))
.map((s) => { const i = s.indexOf('='); return [s.slice(0, i), s.slice(i + 1)]; }));
const { kid, t, s: sig } = parts;
if (!header.startsWith('v1,') || !kid || !t || !sig) return res.writeHead(400).end();
// 2) หน้าต่าง replay ±300 วินาที
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return res.writeHead(400).end();
// 3) verify บน raw bytes
const eventId = req.headers['x-pg-event-id'] || '';
const signed = Buffer.concat([Buffer.from(`v1.${t}.${eventId}.`, 'utf8'), raw]);
let key = keys.get(kid);
if (!key) { await loadKeys(); key = keys.get(kid); } // kid ใหม่ → refetch 1 ครั้ง
if (!key) return res.writeHead(401).end();
if (!crypto.verify(null, signed, key, Buffer.from(sig, 'base64url'))) return res.writeHead(401).end();
// 4) verify ผ่านแล้วค่อย parse — และใช้ค่าจาก body เสมอ ไม่ใช่จาก header
const event = JSON.parse(raw.toString('utf8'));
if (event.id !== eventId) return res.writeHead(400).end();
// 5) helper นี้ INSERT/dedupe inbox + INSERT outbox ใน DB transaction เดียว
// ห้าม markSeen แล้ว enqueue คนละขั้น · schema อยู่ที่ /webhooks/signature
await persistWebhookAtomically({ event, raw });
console.log(event.type, event.data.object.status ?? '-', 'v' + (event.data.object.version ?? '-'));
if (event.type === 'deposit.settled') {
console.log(' เครดิตผู้ใช้ =', event.data.object.amount_received_minor);
console.log(' เข้ากระเป๋า =', event.data.object.credited_amount_minor);
}
res.writeHead(200).end(); // ตอบหลัง DB commit เท่านั้น
});
}).listen(8787, () => console.log('รอ webhook ที่ :8787'));6.3 เปิดให้ภายนอกเรียกถึง — endpoint ต้องเป็น https:// cert จริง (self-signed ไม่ผ่าน) ใช้บริการ tunnel ที่ให้ URL https แล้วลงทะเบียน URL นั้นเป็น deposit_url ใน dashboard ของ WL
ผลลัพธ์ที่ควรเห็นใน console
deposit.created v1
deposit.matched awaiting_payment v2
deposit.slip_received slip_processing v5
deposit.settled settled v7
เครดิตผู้ใช้ = 10000
เข้ากระเป๋า = 9750
balance.credited - v-สามอย่างที่ทำให้ receiver ตัวนี้พังเมื่อขึ้น production
- ใช้
express.json()บน path นี้ ⇒ raw bytes หายไป verify ไม่มีวันผ่าน - ไม่มี version guard ⇒
deposit.expiredv6 ที่ retry มาถึงทีหลัง จะทับsettledv7 ที่เครดิตไปแล้ว — ลำดับ event ไม่การันตี หน้าต่าง retry ยาวถึง ~92 ชั่วโมง - ตัดสินใจจาก
event.type⇒ ตัวที่เป็นความจริงคือdata.object.status·event.typeบอกแค่ว่ามีอะไรเกิดขึ้น
event 5 ตัวนี้ปิดไม่ได้ ต้องมี handler ตั้งแต่วันแรก
deposit.reversed · withdrawal.fill_reversed · balance.debited · webhook.key.rotating · webhook.endpoint.disabled
สามตัวแรกคือการ เอาเงินออกจากกระเป๋าคุณ · สองตัวหลังคือสิ่งที่ทำให้ webhook ของคุณหยุดทำงานเงียบ ๆ ถ้าไม่รู้ตัว · ระบบบังคับให้ subscribe ที่ระดับฐานข้อมูล ปิดไม่ได้ · merchant ที่ไม่มี handler จะรู้ตัวตอนสิ้นเดือน
ต่อไปทำอะไร ก่อนขึ้น production
| ✔ | สิ่งที่ต้องมี | อ่านที่ |
|---|---|---|
| ☐ | canonical string ผ่าน test vector ครบ 3 ชุด | authentication |
| ☐ | เขียน Idempotency-Key ลง DB ก่อนยิง · retry อ่านค่าเดิม | idempotency |
| ☐ | version guard เป็น atomic UPDATE ... WHERE version < $new | ลำดับ webhook |
| ☐ | transactional inbox มี UNIQUE(event_id) เก็บ ≥ 31 วัน · เจอซ้ำตอบ 200 | ตรวจลายเซ็น |
| ☐ | UI ครบ 9 สถานะของรายการฝาก + 7 ของรายการถอน | สถานะรายการฝาก |
| ☐ | ผ่านยอด 103.00 / 108.00 / 109.00 / 116.00 และ replay-out-of-order | sandbox |
| ☐ | reconcile job รายวัน | เช็กลิสต์ก่อน go-live |
ขั้นต่อไป
- ยังไม่เข้าใจว่าเงินวิ่งทางไหน → ภาพรวมระบบ
- ติด
401อยู่ → การยืนยันตัวตนของ request - อยากจำลอง
under_review/ ธนาคารปิด / หมดเวลา → Sandbox - ไม่อยากทำหน้า QR เอง → Hosted checkout
- เจอศัพท์ที่ไม่รู้จัก → อภิธานศัพท์
