Skip to content

Sandbox ​

สถานะการรองรับ

หน้านี้อธิบายเป้าหมาย sandbox รวม advanced slip scenarios ดู Sandbox payment API ที่รองรับ สำหรับขอบเขต simulator ที่มี implementation แล้ว และส่วนที่ยังไม่เชื่อม เช่น signed callbacks

sandbox คือระบบเดียวกับ live ทุกบรรทัด ต่างกันแค่ไม่มีเงินจริงและไม่มีธนาคารจริง — state machine, ลายเซ็น, error code, รูปแบบ payload เหมือนกันเป๊ะ สิ่งที่คุณทดสอบผ่านที่นี่จะทำงานเหมือนกันบน live

ต่างจาก live อย่างไร ​

เรื่องsandboxlive
livemode ในทุก eventfalsetrue
คีย์คีย์ sandboxsk_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 ผิดช่อง

js
if (event.livemode !== (process.env.NODE_ENV === 'production')) {
  logger.error({ eventId: event.id }, 'livemode ไม่ตรงกับ environment');
  return res.writeHead(200).end();       // ตอบ 200 เพื่อไม่ให้ retry ถล่ม แล้วไป alert
}

ลำดับที่แนะนำ ​

  1. ต่อแบบอ่านอย่างเดียวก่อน — GET /v1/pay/banks และ GET /v1/pay/quote เพื่อพิสูจน์ว่าการเซ็น request ผ่าน ก่อน แตะเรื่องเงิน ใช้ test vector เทียบ
  2. สร้างรายการฝากด้วยยอด 100.00 ให้เดินจนจบ (Quickstart)
  3. ไล่ยอดในตารางข้างล่างทีละเส้นทาง
  4. ยิง replay-out-of-order แล้วตรวจว่ายอดยังถูก
  5. ค่อยขอคีย์ live

Magic amount ​

ยอดเงินคือสวิตช์ที่ใช้บังคับเส้นทางใน sandbox — สร้างรายการฝากด้วยยอดใดยอดหนึ่งข้างล่าง แล้วระบบจะเดินเส้นทางนั้นให้เอง

ยอด (บาท)amount_minorเส้นทางที่ได้
100.0010000จับคู่ได้ → ตรวจสลิปผ่าน → settled ภายใน ~5 วินาที
101.0010100→ under_review ['SENDER_NAME_MISMATCH'] (ค้างจนกดอนุมัติ/ปฏิเสธ)
102.0010200→ expired → late-slip อัตโนมัติที่ +60 วินาที → under_review ['LATE_SLIP'] → settled
103.0010300settled → reversed ที่ +60 วินาที
104.0010400external_gateway 2 legs (TTL ~4 นาที)
105.0010500ค้าง awaiting_liquidity เต็ม 25 วินาที แล้วจึงจับคู่ได้ด้วย liquidity_source: "external_gateway"
106.0010600→ under_review ['BANK_DISABLED']
107.0010700รายการถอนฝั่งตรงข้ามถูกซอยเป็น 3 fill
108.0010800โอนซ้ำ — settled แล้วมี deposit.overpaid ที่ +45 วินาที
109.0010900กด cancel แล้วเผลอโอน — canceled ที่ is_terminal:false → late-slip → under_review ['CANCELED_ORDER_SLIP']
110.0011000ALLOCATION_CONFLICT — late-slip ที่ส่วนนั้นถูกจับคู่ให้คนอื่นไปแล้ว + withdrawal.disputed ฝั่งตรงข้าม
111.0011100clean short-pay อัตโนมัติ — โอนขาดโดย non-amount 5/5 ผ่าน → settled ที่ A=V<X, partial:true, settled_via:'auto' · ไม่เรียก force-complete และไม่เข้า review
112.0011200หมดหน้าต่าง late-slip → อัปสลิปได้ 410 late_slip_window_closed + claim_url
113.0011300402 insufficient_balance ตอนสร้างรายการถอน (ยอดยังอยู่ใน pending_clearing)
114.0011400หมุนกุญแจ — webhook.key.rotating แล้วสลับ kid ใน 60 วินาที
115.0011500idempotency conflict — key เดิม body ต่าง
116.0011600same-slip overpay — ธนาคารยืนยัน V=12100 ขณะที่ X=11600; อีก 5 มิติผ่าน → settled ที่ A=X, flag E=500 แบบ open/contact support, ไม่มี auto-refund

ยอดต้องเป็นเลขกลม

ระบบนี้ไม่ใช้เศษสตางค์ในการจับคู่ · ยอดที่มีเศษจะได้ 422 amount_not_supported · ยอดที่ไม่ได้อยู่ในตารางนี้ไม่มีเส้นทางที่กำหนดไว้ล่วงหน้า — เมื่อต้องการผลลัพธ์ที่แน่นอน ให้ใช้ยอดในตาราง


จำลองเคสที่พังบ่อยที่สุด ​

สลิปไม่ตรงกับที่แจ้งไว้ — ยอด 101.00 ​

bash
# 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.json

webhook ที่จะได้

json
{
  "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
HTTP/1.1 410 Gone
json
{
  "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 ​

สร้างรายการถอนแล้วได้

json
{
  "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 และ

json
"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 ที่จะเล่นซ้ำ และสถานการณ์ที่ต้องการ

json
{
  "target": "deposit",
  "object_id": "dep_01K3N2P8QT4V6X8Z0B2D4F6H8J",
  "scenario": "expired_after_settled",
  "delivery_gap_ms": 0
}
fieldค่าที่รับความหมาย
targetdeposit | withdrawalชนิดของ object ที่จะเล่นซ้ำ
object_idid ของเราใน sandboxต้องเป็นรายการที่เดินจนถึงสถานะปลายทางแล้ว
scenarioexpired_after_settled (เคส A) | settled_after_reversed (เคส B)ลำดับที่จะยิงกลับไป
delivery_gap_ms0–60000 (ไม่บังคับ ค่าเริ่มต้น 0)หน่วงระหว่างสองใบ — ตั้ง 0 เพื่อบีบให้มาพร้อมกันจริง ๆ

ตอบ 202 พร้อม replay_id และรายการ event ที่จะถูกยิง (type + version + scheduled_at) · ทั้งสองใบเซ็นด้วยคีย์จริงของ sandbox และมี attempt > 1 เหมือน retry ของจริง

bash
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

เกณฑ์ผ่าน — หลังยิงแล้ว สถานะและยอดในระบบคุณต้องยังถูก ทั้งสองเคส

เคสเกิดอะไรระบบคุณต้อง
Adeposit.expired v6 (retry รอบที่ 4) มาถึง หลัง deposit.settled v9คงสถานะ settled ไว้ · ตอบ 200 · ไม่ยกเลิกออร์เดอร์
Bdeposit.settled v9 (retry รอบที่ 3) มาถึง หลัง deposit.reversed v12คงสถานะ reversed ไว้ · ไม่เครดิตซ้ำ ไม่ปล่อยของ

สิ่งที่ทำให้ผ่าน — version guard ที่เป็น UPDATE คำสั่งเดียว ไม่ใช่ SELECT แล้ว if แล้ว UPDATE

sql
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 flag116.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 วัน

ขั้นต่อไป ​

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01