Sandbox
สถานะการรองรับ
หน้านี้อธิบายเป้าหมาย sandbox รวม advanced slip scenarios ดู Sandbox payment API ที่รองรับ สำหรับขอบเขต simulator ที่มี implementation แล้ว และส่วนที่ยังไม่เชื่อม เช่น signed callbacks
sandbox คือระบบเดียวกับ live ทุกบรรทัด ต่างกันแค่ไม่มีเงินจริงและไม่มีธนาคารจริง — state machine, ลายเซ็น, error code, รูปแบบ payload เหมือนกันเป๊ะ สิ่งที่คุณทดสอบผ่านที่นี่จะทำงานเหมือนกันบน live
ต่างจาก live อย่างไร
| เรื่อง | sandbox | live |
|---|---|---|
livemode ในทุก event | false | true |
| คีย์ | คีย์ sandbox | sk_live_* |
| endpoint รับ webhook | ต้องลงทะเบียนแยกจาก live | — |
| เงิน | ไม่มีเงินจริงเคลื่อนไหว | เข้าบัญชีบุคคลจริง |
| การตรวจสลิปกับธนาคาร | จำลอง — ผลลัพธ์กำหนดด้วยยอดเงิน | ตรวจกับธนาคารจริง 6 มิติ |
| เนื้อภาพสลิป | ไม่ตรวจเนื้อภาพ แต่ยังต้องผ่าน magic bytes และ content_types/max_bytes ของ capability | ตรวจจริงภายใต้ capability เดียวกัน |
| เวลาในเส้นทาง | บีบให้สั้น (หน่วยวินาที) | เวลาจริง |
sandbox กับ live ข้ามกันไม่ได้ และไม่ใช่แค่ตรรกะในโค้ด
event ของ sandbox ไม่มีวันไปถึง endpoint ของ live และกลับกัน — บังคับด้วย composite foreign key ที่ระดับฐานข้อมูล
สิ่งที่คุณต้องทำ: ตรวจ event.livemode ให้ตรงกับ environment ของ handler เสมอ เจอไม่ตรงให้ทิ้งแล้ว alert · handler ที่ไม่ตรวจจะเครดิตเงินจริงจาก event ทดสอบในวันที่มีคนตั้ง endpoint ผิดช่อง
if (event.livemode !== (process.env.NODE_ENV === 'production')) {
logger.error({ eventId: event.id }, 'livemode ไม่ตรงกับ environment');
return res.writeHead(200).end(); // ตอบ 200 เพื่อไม่ให้ retry ถล่ม แล้วไป alert
}ลำดับที่แนะนำ
- ต่อแบบอ่านอย่างเดียวก่อน —
GET /v1/pay/banksและGET /v1/pay/quoteเพื่อพิสูจน์ว่าการเซ็น request ผ่าน ก่อน แตะเรื่องเงิน ใช้ test vector เทียบ - สร้างรายการฝากด้วยยอด 100.00 ให้เดินจนจบ (Quickstart)
- ไล่ยอดในตารางข้างล่างทีละเส้นทาง
- ยิง
replay-out-of-orderแล้วตรวจว่ายอดยังถูก - ค่อยขอคีย์ live
Magic amount
ยอดเงินคือสวิตช์ที่ใช้บังคับเส้นทางใน sandbox — สร้างรายการฝากด้วยยอดใดยอดหนึ่งข้างล่าง แล้วระบบจะเดินเส้นทางนั้นให้เอง
| ยอด (บาท) | amount_minor | เส้นทางที่ได้ |
|---|---|---|
| 100.00 | 10000 | จับคู่ได้ → ตรวจสลิปผ่าน → settled ภายใน ~5 วินาที |
| 101.00 | 10100 | → under_review ['SENDER_NAME_MISMATCH'] (ค้างจนกดอนุมัติ/ปฏิเสธ) |
| 102.00 | 10200 | → expired → late-slip อัตโนมัติที่ +60 วินาที → under_review ['LATE_SLIP'] → settled |
| 103.00 | 10300 | settled → reversed ที่ +60 วินาที |
| 104.00 | 10400 | external_gateway 2 legs (TTL ~4 นาที) |
| 105.00 | 10500 | ค้าง awaiting_liquidity เต็ม 25 วินาที แล้วจึงจับคู่ได้ด้วย liquidity_source: "external_gateway" |
| 106.00 | 10600 | → under_review ['BANK_DISABLED'] |
| 107.00 | 10700 | รายการถอนฝั่งตรงข้ามถูกซอยเป็น 3 fill |
| 108.00 | 10800 | โอนซ้ำ — settled แล้วมี deposit.overpaid ที่ +45 วินาที |
| 109.00 | 10900 | กด cancel แล้วเผลอโอน — canceled ที่ is_terminal:false → late-slip → under_review ['CANCELED_ORDER_SLIP'] |
| 110.00 | 11000 | ALLOCATION_CONFLICT — late-slip ที่ส่วนนั้นถูกจับคู่ให้คนอื่นไปแล้ว + withdrawal.disputed ฝั่งตรงข้าม |
| 111.00 | 11100 | clean short-pay อัตโนมัติ — โอนขาดโดย non-amount 5/5 ผ่าน → settled ที่ A=V<X, partial:true, settled_via:'auto' · ไม่เรียก force-complete และไม่เข้า review |
| 112.00 | 11200 | หมดหน้าต่าง late-slip → อัปสลิปได้ 410 late_slip_window_closed + claim_url |
| 113.00 | 11300 | 402 insufficient_balance ตอนสร้างรายการถอน (ยอดยังอยู่ใน pending_clearing) |
| 114.00 | 11400 | หมุนกุญแจ — webhook.key.rotating แล้วสลับ kid ใน 60 วินาที |
| 115.00 | 11500 | idempotency conflict — key เดิม body ต่าง |
| 116.00 | 11600 | same-slip overpay — ธนาคารยืนยัน V=12100 ขณะที่ X=11600; อีก 5 มิติผ่าน → settled ที่ A=X, flag E=500 แบบ open/contact support, ไม่มี auto-refund |
ยอดต้องเป็นเลขกลม
ระบบนี้ไม่ใช้เศษสตางค์ในการจับคู่ · ยอดที่มีเศษจะได้ 422 amount_not_supported · ยอดที่ไม่ได้อยู่ในตารางนี้ไม่มีเส้นทางที่กำหนดไว้ล่วงหน้า — เมื่อต้องการผลลัพธ์ที่แน่นอน ให้ใช้ยอดในตาราง
จำลองเคสที่พังบ่อยที่สุด
สลิปไม่ตรงกับที่แจ้งไว้ — ยอด 101.00
# amount_minor = 10100
mapfile -t H < <(./pg-sign.sh POST /v1/pay/deposits '' body-101.json "qs-101-$(date +%s)")
curl -sS -X POST "$PG_API/v1/pay/deposits" \
-H "Authorization: Bearer $PG_API_KEY" -H "Content-Type: application/json" \
"${H[@]}" --data-binary @body-101.jsonwebhook ที่จะได้
{
"id": "evt_01K3M9F2XN8VQKR4T0BDHY7WZE",
"object": "event",
"type": "deposit.under_review",
"api_version": "2026-08-01",
"livemode": false,
"created_at": "2026-08-21T04:19:08.774Z",
"sequence": 88231,
"account": { "merchant_id": "mch_01K2G4H9TVBN0PQ", "white_label_id": "wl_01K1QZ2MRT7XY45" },
"data": {
"object": {
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 6,
"status": "under_review",
"is_terminal": false,
"merchant_reference": "ORD-SBX-0101",
"amount_minor": 10100,
"currency": "THB",
"review": {
"opened_at": "2026-08-21T04:19:08.601Z",
"escalated": false,
"four_eyes_required": false,
"estimated_resolution_bucket": "under_1h",
"reasons": [
{
"code": "SENDER_NAME_MISMATCH",
"display_th": "ชื่อบัญชีผู้โอนในสลิปไม่ตรงกับชื่อที่แจ้งไว้ตอนสร้างรายการ เงินของคุณไม่หาย ไม่ต้องโอนซ้ำ",
"display_en": "The sender name on the slip does not match the name registered for this order.",
"actionable_by": "end_user",
"detail": { "registered_name_masked": "ส*** ใ***", "matched": false }
}
]
},
"slip": { "received_at": "2026-08-21T04:19:02.180Z", "is_late": false },
"verification": {
"amount_exact": true,
"within_time_window": true,
"sender_bank_match": true,
"sender_account_match": true,
"sender_name_match": false,
"receiver_match": true
},
"metadata": { "order_id": "ORD-SBX-0101" }
},
"previous_attributes": { "version": 5, "status": "slip_processing", "review": null, "verification": null }
}
}สิ่งที่ UI ของคุณต้องทำได้ตอนเห็น payload นี้
- แสดง
review.reasons[0].display_thตรง ๆ — ข้อความเราเขียนมาให้ครบ 3 ประโยคแล้ว (ได้รับสลิปแล้ว / ติดเพราะอะไร / เงินไม่หาย ไม่ต้องโอนซ้ำ) - สีเหลืองหรือฟ้า ไม่ใช่สีแดง และ ไม่มีปุ่ม "โอนใหม่"
- ถ้าจะบอกเวลาให้ใช้
review.estimated_resolution_bucket(under_15m/under_1h/under_4h/extended) 🔴 ห้าม hardcode "ไม่เกิน 15 นาที" verification{}เป็น boolean ล้วน 6 ตัว ใช้ทำ debug ได้ แต่ห้ามเอาไปโชว์ผู้ใช้ตรง ๆ
RECEIVER_MISMATCH ไม่มี detail เลย และนั่นตั้งใจ
บัญชีปลายทางเป็นของผู้ถอน การบอกว่า "ควรเป็น xxx-x-x1234" คือการยืนยันบัญชีของอีกฝ่ายให้คนที่โอนผิดรู้ · โค้ดที่ทำ reason.detail.expected โดยไม่เช็ค null จะพังที่เคสนี้
ธนาคารปิดรับตรวจอัตโนมัติ — ยอด 106.00
ได้ under_review ['BANK_DISABLED'] โดย detail.bank_code เป็นรหัสธนาคาร ของผู้โอนเอง เท่านั้น
BANK_DISABLED ไม่ใช่การปฏิเสธเงิน
is_terminal ยังเป็น false · เจ้าหน้าที่จะตรวจด้วยมือแล้วจบที่ settled ตามปกติ · ห้ามขึ้นข้อความว่ารายการล้มเหลว และห้าม auto-cancel
อีกด้านหนึ่งของเรื่องเดียวกัน: ธนาคารที่ accept_new = false จะทำให้ การสร้างรายการใหม่ ได้ 409 sender_bank_unavailable พร้อม details.alternative_banks[] — 🔴 แต่ สลิปของรายการเดิมยังส่งได้เสมอ อย่าปิดปุ่มอัปสลิปเพราะเห็นธนาคารถูกปิด
หมดเวลาแล้วผู้ใช้เพิ่งมาอัปสลิป — ยอด 102.00
เส้นทางที่จะเห็น:
deposit.expired is_terminal:false · late_slip_accepted_until = expires_at + 24 ชม.
deposit.slip_received is_late:true
deposit.under_review reasons=['LATE_SLIP']
deposit.settledเคสนี้คือเหตุผลที่ห้ามปิดออร์เดอร์ทันทีที่เห็น expired
โค้ดที่เขียน case 'deposit.expired': cancelOrder() จะยกเลิกรายการที่กำลังจะ settled — ผู้ใช้เสียเงินจริง
🔴 เช็ค is_terminal ไม่ใช่ชื่อ status · expired และ canceled ที่มี late_slip_accepted_until ต้องเหลือปุ่มอัปสลิปไว้
กด cancel แล้วเผลอโอน — ยอด 109.00
deposit.canceled มาพร้อม is_terminal: false และมี late_slip_accepted_until ⇒ UI ต้องเหมือนเคส expired ทุกประการ · เคสนี้เกิดจริงสัปดาห์ละหลายครั้งบน production
หมดหน้าต่าง late-slip ไปแล้ว — ยอด 112.00
อัปสลิปจะได้
HTTP/1.1 410 Gone{
"error": {
"code": "late_slip_window_closed",
"type": "invalid_request_error",
"message": "The late-slip window for this deposit has closed.",
"message_th": "หมดหน้าต่างรับสลิปย้อนหลังของรายการนี้แล้ว",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01K3PB6QW2ZR9YT5N4KDXCVH8M",
"details": { "claim_url": "https://pay.example-wl.com/claims/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B" }
}
}🔴 410 ตรงนี้ ไม่ได้แปลว่าเงินหาย — เปิดเรื่องต่อผ่าน POST /v1/bo/claims โดยส่ง trans_ref หรือรูปสลิปแนบไปด้วย ผลลัพธ์ที่เป็นไปได้คือ credited / refund_issued / rejected แจ้งผลผ่าน webhook claim.updated
กลับรายการหลังเครดิตไปแล้ว — ยอด 103.00
settled แล้ว 60 วินาทีต่อมาได้
deposit.reversed ← ปิดไม่ได้
balance.debited ← ปิดไม่ได้ทดสอบเคสนี้ให้ผ่านก่อน go-live ไม่ใช่หลัง
deposit.reversed, withdrawal.fill_reversed และ balance.debited คือ การเอาเงินออกจากกระเป๋าคุณ ระบบบังคับให้ subscribe ปิดไม่ได้ · merchant ที่ไม่มี handler จะปล่อยของไปแล้วมารู้ตอนสิ้นเดือน
สังเกตใน balance.debited: object เป็น balance_transaction ซึ่ง immutable ⇒ ไม่มี version ไม่มี previous_attributes ⇒ dedupe ด้วย id (btxn_...) และ สะสมยอด ไม่ใช่เขียนทับด้วย balance_after
ยอดถอนถูกซอยหลายก้อน — ยอด 107.00
รายการถอนฝั่งตรงข้ามจะได้ withdrawal.filled 3 ครั้ง แล้วจึงได้ withdrawal.settled · ใช้ทดสอบว่า UI ของคุณเป็น timeline หลายบรรทัดจริง และปิดรายการเมื่อ status === 'settled' และ remaining_minor === 0 เท่านั้น
เงินยังถอนไม่ได้เพราะอยู่ใน pending_clearing — ยอด 113.00
สร้างรายการถอนแล้วได้
{
"error": {
"code": "insufficient_balance",
"type": "invalid_request_error",
"message": "Available balance is lower than amount plus fee.",
"message_th": "ยอดเงินที่ถอนได้ไม่พอสำหรับยอดถอนบวกค่าธรรมเนียม",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01K3PC8RX3ZS0AU6P5LEYDWJ9N",
"details": {
"available_minor": 1002000,
"required_minor": 1003000,
"pending_clearing_minor": 500000,
"pending_clearing_available_at": "2026-08-24T14:26:44.000Z"
}
}
}🔴 ไม่มีรายการถอนถูกสร้างและไม่มีการล็อกเงิน ⇒ ต้องปลดล็อกยอดในบัญชีภายในของผู้ใช้คืน ทันที แล้วอธิบายด้วย pending_clearing_available_at
หมุนกุญแจ webhook — ยอด 114.00
ได้ webhook.key.rotating ที่มี next_kid + next_public_key + activates_at แล้ว 60 วินาทีต่อมา event จะถูกเซ็นด้วย kid ใหม่ · ใช้ทดสอบว่าโค้ดคุณ refetch keyset เมื่อเจอ kid ที่ไม่รู้จัก (throttle 1 ครั้งต่อนาที) แทนที่จะตอบ 401 ทิ้งทั้งหมด
key เดิม body ต่าง — ยอด 115.00
ได้ 409 idempotency_key_reused พร้อม details.original_request_id — ดูวิธี handle ที่ Idempotency
โอนเกินในสลิปเดียว — ยอด 116.00
Sandbox จำลอง X=11600, ธนาคารยืนยัน V=12100, amount_exact:false แต่อีก 5 มิติเป็น true ⇒ ส่ง deposit.settled ใบเดียว ที่ A=11600, partial:false และ
"overpayment": {
"claim_id": "clm_01K3PB6QW1M8HN0DZ2XKQ7RV4T",
"cause": "same_transfer_excess",
"amount_minor": 500,
"status": "open",
"action_required": "contact_support"
}เครดิตผู้ใช้เพียง A=X ครั้งเดียว แสดง claim_id ให้ติดต่อ support และห้ามคืน/เครดิต E อัตโนมัติ · เคสนี้ ไม่มี deposit.overpaid; event นั้นสงวนไว้สำหรับ transfer เพิ่มหลัง settle
จำลอง event สลับลำดับ
นี่คือเคสที่ทำให้ merchant เสียเงินจริงมากที่สุด และเป็นเคสที่ทดสอบด้วยมือไม่ได้
รูปแบบของ replay.json — ระบุรายการใน sandbox ที่จะเล่นซ้ำ และสถานการณ์ที่ต้องการ
{
"target": "deposit",
"object_id": "dep_01K3N2P8QT4V6X8Z0B2D4F6H8J",
"scenario": "expired_after_settled",
"delivery_gap_ms": 0
}| field | ค่าที่รับ | ความหมาย |
|---|---|---|
target | deposit | withdrawal | ชนิดของ object ที่จะเล่นซ้ำ |
object_id | id ของเราใน sandbox | ต้องเป็นรายการที่เดินจนถึงสถานะปลายทางแล้ว |
scenario | expired_after_settled (เคส A) | settled_after_reversed (เคส B) | ลำดับที่จะยิงกลับไป |
delivery_gap_ms | 0–60000 (ไม่บังคับ ค่าเริ่มต้น 0) | หน่วงระหว่างสองใบ — ตั้ง 0 เพื่อบีบให้มาพร้อมกันจริง ๆ |
ตอบ 202 พร้อม replay_id และรายการ event ที่จะถูกยิง (type + version + scheduled_at) · ทั้งสองใบเซ็นด้วยคีย์จริงของ sandbox และมี attempt > 1 เหมือน retry ของจริง
mapfile -t H < <(./pg-sign.sh POST /v1/test/webhooks/replay-out-of-order '' replay.json "replay-$(date +%s)")
curl -sS -X POST "$PG_API/v1/test/webhooks/replay-out-of-order" \
-H "Authorization: Bearer $PG_API_KEY" -H "Content-Type: application/json" \
"${H[@]}" --data-binary @replay.jsonเกณฑ์ผ่าน — หลังยิงแล้ว สถานะและยอดในระบบคุณต้องยังถูก ทั้งสองเคส
| เคส | เกิดอะไร | ระบบคุณต้อง |
|---|---|---|
| A | deposit.expired v6 (retry รอบที่ 4) มาถึง หลัง deposit.settled v9 | คงสถานะ settled ไว้ · ตอบ 200 · ไม่ยกเลิกออร์เดอร์ |
| B | deposit.settled v9 (retry รอบที่ 3) มาถึง หลัง deposit.reversed v12 | คงสถานะ reversed ไว้ · ไม่เครดิตซ้ำ ไม่ปล่อยของ |
สิ่งที่ทำให้ผ่าน — version guard ที่เป็น UPDATE คำสั่งเดียว ไม่ใช่ SELECT แล้ว if แล้ว UPDATE
UPDATE deposits
SET status = $2, version = $3, payload = $4, updated_at = now()
WHERE deposit_id = $1
AND version < $3;
-- rowCount = 0 ⇒ event เก่ากว่าที่มีอยู่ ⇒ ตอบ 200 แล้วไม่ทำอะไรต่อทำไมถึงปลอดภัย: ระบบรับประกันว่า ไม่มีสอง event ของ object เดียวกันที่ version เท่ากัน ⇒ ผูก side effect ไว้กับ rowCount ได้เลย
ห้าเคสที่ระบบตรวจอัตโนมัติก่อนออกคีย์ live
ไม่ใช่เช็กลิสต์ให้ติ๊กเอง — ระบบตรวจให้
| เคส | ยอด |
|---|---|
| กลับรายการหลังเครดิต | 103.00 |
| โอนซ้ำ | 108.00 |
| cancel แล้วเผลอโอน | 109.00 |
same-slip overpay: settle A=X + open/contact-support flag | 116.00 |
| event สลับลำดับ | POST /v1/test/webhooks/replay-out-of-order |
ทั้งห้าเคสนี้คือจุดที่ merchant เสียเงินจริงบ่อยที่สุด จึงต้องพิสูจน์ว่าโค้ดคุณรอด ก่อน จะได้คีย์ live
เช็กลิสต์ก่อนขอคีย์ live
- [ ]
GET /v1/pay/banksผ่านลายเซ็นตั้งแต่ครั้งแรก - [ ] test vector ผ่านครบ 3 ชุด · เปลี่ยน
Idempotency-Keyแล้วลายเซ็น fail - [ ] verify Ed25519 บน raw body · แก้ 1 ไบต์ในสลิป payload ต้องได้
401 - [ ] transactional inbox มี
UNIQUE(event_id)เก็บ ≥ 31 วัน · ส่ง event ซ้ำแล้วตอบ200 - [ ] ตรวจ
livemodeตรงกับ environment - [ ]
202ทั้งสองจุด (สร้างรายการฝาก / อัปสลิป) ไม่ถูกตีความเป็น error - [ ] UI ครบ 9 สถานะของรายการฝากและ 7 ของรายการถอน
- [ ] ยอด 102.00 และ 109.00 แล้วปุ่มอัปสลิปยังอยู่
- [ ] ยอด 103.00 แล้วยอดผู้ใช้ถูกหักคืน
- [ ] ยอด 107.00 แล้ว UI แสดง progress หลายบรรทัด
- [ ] ยอด 113.00 แล้วยอดที่ล็อกไว้ถูกปลดคืนทันที
- [ ] ยอด 115.00 แล้ว
409ถูก alert ไม่ใช่ retry - [ ] ยอด 116.00 settle ครั้งเดียวที่
A=X, เก็บV/A/Eถูก, แสดง open/contact-support claim และไม่มี auto-refund - [ ]
replay-out-of-orderผ่านทั้งเคส A และ B - [ ] มี reconcile job รายวันที่รันจริงติดกัน 3 วัน
ขั้นต่อไป
- ยิงรายการแรกบน sandbox → Quickstart
- ลายเซ็นไม่ผ่าน → การยืนยันตัวตนของ request
409ตอนสร้างรายการ → Idempotency- เช็กลิสต์เต็มก่อนขึ้น production → ก่อน go-live
