Skip to content

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 สำหรับเซ็น

bash
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 ใช้ตลอดทั้งหน้านี้

bash
#!/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 แบบอ่านอย่างเดียวก่อน ถ้าอันนี้ผ่าน แปลว่าการเซ็นของคุณถูกทั้งระบบ

bash
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

ผลลัพธ์ที่ควรเห็น

json
{
  "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_windowsync NTP
nonce_replayedPG-Nonce ต้องใหม่ทุก attempt (ต่างจาก Idempotency-Key ที่ต้องคงเดิม)
api_key_invalidตรวจว่าใช้คีย์ sandbox ไม่ใช่คีย์ live

เก็บ available: false ไว้ใช้เทาปุ่มธนาคารในฟอร์มผู้โอน ไม่งั้นผู้ใช้จะเจอ 409 ที่ขั้นสุดท้าย


ขั้นที่ 2 — สร้างรายการฝากใบแรก (1 นาที) ​

ยอด 100.00 บาท เป็นยอดพิเศษของ sandbox ที่เดินเส้นทาง happy path ให้อัตโนมัติ (ตารางยอดทั้งหมด)

bash
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

json
{
  "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 ไว้ใช้ต่อ:

bash
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"

ห้ากับดักในขั้นเดียวนี้

  1. ไม่ส่ง sender หรือ sender_id ⇒ 422 sender_required สร้างรายการไม่ได้เลย นี่คือความต่างที่ทำให้โค้ดเดิมของคุณพังเป็นอย่างแรก
  2. 202 ไม่ใช่ error — ถ้ายังไม่มีสภาพคล่อง คุณจะได้ 202 พร้อม status: "awaiting_liquidity", legs: [] และ liquidity.suggested_amounts_minor ⇒ แสดงหน้ารอ ห้ามยิง POST ซ้ำ รอ webhook deposit.matched
  3. legs.length > 1 เกิดขึ้นได้ ⇒ UI ต้องรองรับ "ขั้นที่ 1 จาก 2" · legs[0] ไม่ใช่ทั้งหมด
  4. qr_payload ห้าม log ห้ามเก็บลง DB ใช้แสดงผลแล้วทิ้ง · เก็บได้แค่ expires_at
  5. slip_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 วินาที) ​

bash
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)

bash
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

json
{
  "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 วินาที) ​

bash
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[@]}"

ผลลัพธ์ที่ควรเห็น เมื่อเดินจบแล้ว

json
{
  "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_minor250บันทึกเป็นค่าใช้จ่าย
credited_amount_minor9750ยอดที่เข้ากระเป๋า 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)

bash
curl -sS "$PG_API/v1/webhooks/signing-keys"
json
{
  "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

js
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

  1. ใช้ express.json() บน path นี้ ⇒ raw bytes หายไป verify ไม่มีวันผ่าน
  2. ไม่มี version guard ⇒ deposit.expired v6 ที่ retry มาถึงทีหลัง จะทับ settled v7 ที่เครดิตไปแล้ว — ลำดับ event ไม่การันตี หน้าต่าง retry ยาวถึง ~92 ชั่วโมง
  3. ตัดสินใจจาก 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-ordersandbox
☐reconcile job รายวันเช็กลิสต์ก่อน go-live

ขั้นต่อไป ​

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