ตั้งค่า
GET /v1/bo/settings
PATCH /v1/bo/settingsหน้าจอ Integration ที่เปิดใช้จริง
การอ่าน health แบบ redacted และการเปลี่ยน webhook endpoint revision ใช้ GET /v1/bo/integrations กับ PUT /v1/bo/webhooks/{webhook_id} ดูสัญญา transport, CAS, step-up และ URL policy ที่ Integration และ Webhook
หลักการเดียวที่ครอบทุกค่า — ตั้งเข้มขึ้นได้ ตั้งหลวมกว่าไม่ได้
ทุกค่าในหน้านี้ถูก clamp ด้วยนโยบายของชั้นบน ส่งค่าที่หลวมกว่าเพดานไปจะ ไม่ error แต่จะถูกลดลงมาให้อยู่ในเพดานเงียบ ๆ ⇒ 🔴 อ่านค่าที่ระบบคืนกลับมาเสมอ ห้ามสมมติว่าค่าที่ส่งไปคือค่าที่ได้
3 ระดับของการตั้งค่า
| ระดับ | ตั้งที่ไหน | ตัวอย่าง |
|---|---|---|
| ต่อ request | ใส่ในตัว request แต่ละใบ | อายุรายการ · ช่องทางที่ยอมรับ · metadata |
| ต่อบัญชี | PATCH /v1/bo/settings และหน้าตั้งค่า webhook | URL ปลายทาง webhook · เวอร์ชัน API · ผู้ติดต่อทางเทคนิค |
| เพดานของชั้นบน | ปรับไม่ได้ | รอบ retry · หน้าต่าง 72 ชั่วโมง · โควตา rate limit |
ระดับที่ 1 — ค่าที่ส่งไปกับ request ทีละใบ
ค่าเหล่านี้ ไม่ต้องตั้งล่วงหน้า ส่งไปตอนสร้างรายการเลย
{
"amount_minor": 500000,
"currency": "THB",
"merchant_reference": "ORD-9981",
"end_user_ref": "u_88213",
"sender_id": "snd_01K2R4M8N0PZ3XT5VKD9WY2C7Q",
"client": {
"device_fingerprint": "df_9a1c4e77b2",
"ip": "203.0.113.44",
"user_agent": "Mozilla/5.0"
},
"fill_preference": "any",
"min_ttl_seconds": 300,
"expires_in_seconds": 1200,
"return_url": "https://shop.example.com/topup/9981",
"metadata": { "campaign": "aug-cashback", "order_id": "ORD-9981" }
}| ฟิลด์ | ค่า | หมายเหตุ |
|---|---|---|
expires_in_seconds | วินาที | 🔴 ถูก clamp ด้วยนโยบายของชั้นบน · อ่าน expires_at ที่คืนมา |
min_ttl_seconds | 0–86,400 วินาที | ถ้าจับคู่ได้แต่เวลาเหลือน้อยกว่านี้ ให้ข้ามไปหาช่องทางอื่นแทน; ถ้าไม่พอดีกับอายุรายการจะได้ 422 |
fill_preference | any (ค่าเริ่มต้น) หรือ internal_only | internal_only = ไม่ใช้ช่องทางสำรอง ⇒ โอกาสอยู่ในสถานะรอสภาพคล่องนานขึ้น |
return_url | https:// | หน้าที่ผู้ใช้กลับมาหลังจ่ายบนหน้าชำระเงินสำเร็จรูป |
metadata | object | ไม่เกิน 20 key · แต่ละค่าไม่เกิน 500 ไบต์ |
client{} ไม่ใช่ optional ในทางปฏิบัติ
ในเอกสารเขียนว่า optional แต่ผลของการไม่ส่งคือ
ส่ง client{} | เพดานรายการค้างต่อผู้ใช้ | ถังของ rate limit |
|---|---|---|
| ครบ (device fingerprint + IP) | 2 ใบ ต่อผู้ใช้แต่ละคน | pay_create_per_user 10 ครั้ง/นาที ต่อผู้ใช้ |
| ไม่ส่ง | 🔴 เหลือ 1 ใบ | 🔴 ตกไปถังหยาบที่ แชร์กับผู้ใช้ทุกคนของคุณ ⇒ ชน 429 ตั้งแต่นาทีแรก |
เหตุผล: ถ้าไม่มี device fingerprint ค่า end_user_ref เป็นสิ่งที่คุณกรอกเองและปลอมได้ฟรี เราจึงต้องรัดเพดานลง
fee_breakdown[] แยกตาม component ไม่ใช่ตามผู้รับ
fee_breakdown[] ที่ติดมากับ deposit.settled มีรูปแบบ {component, amount_minor, rate_bps?} เช่น processing_fee · fixed_fee · vatcomponent เป็น open enum ⇒ switch ของคุณต้องมี default: ที่บันทึกไว้แล้ว alert
ระดับที่ 2 — ตั้งค่าต่อบัญชี
ปลายทาง webhook
| ค่า | บังคับ | ถ้าไม่ตั้ง |
|---|---|---|
deposit_url | ✅ | — |
withdrawal_url | ❌ | event ของรายการถอนไปที่ deposit_url |
account_url | ❌ | event balance.* และ webhook.* ไปที่ deposit_url |
ข้อกำหนดของ URL ที่ปฏิเสธไม่ได้
- ต้องเป็น
https://· TLS ตั้งแต่ 1.2 ขึ้นไป · ใบรับรองต้องใช้ได้จริง (self-signed = ไม่ผ่าน) - 🔴 เราไม่ตาม redirect — ตอบ
3xxนับเป็นล้มเหลว (กัน SSRF และกันลายเซ็นไปโผล่ที่อื่น) - เราไม่เติม query parameter ใด ๆ และไม่ส่งข้อมูลใน query string เลย
- ตอบ
2xxภายใน 3 วินาที (ค่าที่แนะนำ) · เพดานจริง 15 วินาที - 🔴 ห้ามพึ่ง IP allowlist — เราไม่มี IP ต้นทางคงที่
5 event ที่ยกเลิกการรับไม่ได้
deposit.reversed · withdrawal.fill_reversed · balance.debited · webhook.key.rotating · webhook.endpoint.disabled
สามตัวแรกคือ การเอาเงินออกจากกระเป๋าคุณ ถ้าปิดได้ คุณจะเห็นแต่ขาเข้าและกระทบยอดไม่ได้เลย บังคับที่ระดับฐานข้อมูล ไม่ใช่แค่เขียนไว้ในเอกสาร — endpoint ที่ subscribe ไม่ครบจะบันทึกไม่ได้
เวอร์ชัน API
ส่งเป็น header PG-Version: 2026-08-01 ทุก request และมันอยู่ใน canonical string ของลายเซ็นด้วย
| เรื่อง | นโยบาย |
|---|---|
| รูปแบบ | YYYY-MM-DD |
| อัปเกรด | opt-in — เราไม่ย้ายให้เอง |
| breaking change | ประกาศล่วงหน้า 90 วัน · รองรับเวอร์ชันเก่า 12 เดือน · มี header X-PG-Deprecation |
สิ่งเหล่านี้ ไม่นับ เป็น breaking change
- เพิ่ม field ใหม่ใน payload
- เพิ่มค่าใหม่ใน enum ที่ประกาศว่าเป็น open enum:
reasons[].code·rejection.code·balance_transaction.type·fee_breakdown[].component·EventType
⇒ 🔴 ทุก switch ที่แตะค่าเหล่านี้ ต้องมี default: ที่บันทึกไว้แล้ว alert ไม่ใช่ throw และไม่ใช่เงียบ โค้ดที่ throw ใน default: จะทำให้ endpoint ตอบ non-2xx ⇒ เข้ารอบ retry ⇒ ยอด delivery ล้มเหลวพุ่งจนระบบปิด endpoint ให้อัตโนมัติ
api_version ของ event ที่ส่งออก render ตามเวอร์ชันที่คุณ pin ไว้ ณ ตอนสร้าง event ⇒ retry ของ event เก่าใช้เวอร์ชันเดิมเสมอ
กุญแจตรวจลายเซ็น
GET https://api.example-wl.com/v1/webhooks/signing-keys- ไม่ต้อง auth (public key เป็นของสาธารณะอยู่แล้ว) · โควตา 60 ครั้ง/นาที/IP
- แคชได้ไม่เกิน 24 ชั่วโมง · เจอ
kidที่ไม่รู้จัก → ดึงใหม่ 1 ครั้ง (หน่วง 1 ครั้ง/นาที) ก่อนตัดสินว่าไม่ผ่าน - pin ลง ENV ได้ แต่ ต้อง subscribe
webhook.key.rotatingซึ่งประกาศkidใหม่ล่วงหน้า 7 วัน
เราเซ็นด้วยกุญแจที่ active ณ วินาทีที่ส่ง ไม่ใช่กุญแจตอนสร้าง event
เพราะ t ในลายเซ็นต้องใหม่ทุกครั้ง ⇒ merchant ที่ยังไม่รับกุญแจใหม่จะ verify ไม่ผ่าน ทันที ที่มีการสลับกุญแจ รวมถึง event เก่าที่ยัง retry อยู่ นี่คือเหตุผลที่ webhook.key.rotating ปิดไม่ได้
ผู้ติดต่อทางเทคนิค
ต้องเป็นอีเมลที่มีคนอ่านจริง — ใช้แจ้งกรณี endpoint ล่มยาวและกรณีระบบปิด endpoint ให้อัตโนมัติ ถ้าคุณมี endpoint เดียวและมันถูกปิด event ที่ปิดไม่ได้จะถูกส่งทางอีเมลไปที่นี่
Sandbox กับ live
- ทุก event มีฟิลด์
livemode·false= sandbox - 🔴 event ของ sandbox ไม่มีวันถึง endpoint ของ live และกลับกัน — บังคับที่ระดับฐานข้อมูล ไม่ใช่ตรรกะในโค้ด
- ตรวจ
livemodeให้ตรงกับ environment ของคุณเป็นเงื่อนไข go-live
ระดับที่ 3 — เพดานที่ชั้นบนกำหนด (ปรับไม่ได้)
| เรื่อง | ค่า | ผลถ้าชน |
|---|---|---|
| รายการฝากที่ยังไม่จ่าย ต่อผู้ใช้ | 2 ใบ (1 ใบถ้าไม่ส่ง client{}) | 409 too_many_open_deposits + details.open_deposit_ids[] |
| รายการยอดเดียวกัน + ผู้โอนเดียวกันที่ค้างอยู่ | ห้ามซ้ำ | 409 duplicate_open_deposit + details.open_deposit_ids[] |
| ยอดต่ำสุด/สูงสุด | ตามนโยบาย | 422 amount_not_supported + details.min_minor / details.max_minor |
| เศษสตางค์ในยอดรายการฝาก | 🔴 ไม่รับ ต้องเป็นยอดกลม | 422 amount_not_supported |
| จำนวนก้อนสูงสุดต่อ 1 รายการถอน | อ่านจาก max_splits ใน object ของใบนั้น | ระบบจัดการให้เอง |
| หน้าต่างรอพ้นข้อพิพาท | 72 ชั่วโมง | เงินอยู่ในช่อง pending_clearing |
| หน้าต่างรับสลิปย้อนหลัง | 24 ชั่วโมงหลัง expires_at | 410 late_slip_window_closed + claim_url |
| เวลารออนุมัติรายการถอน | 24 ชั่วโมง | ยกเลิกอัตโนมัติ cancel_reason_code: "approval_ttl_expired" |
| รอบ retry ของ webhook | 11 ครั้ง ~92 ชั่วโมง 36 นาที | เลิก retry แล้วเก็บ event ไว้ 30 วันให้ resend |
อายุของ Idempotency-Key | 7 วัน | 🔴 TTL ฝั่งคุณ ห้ามสั้นกว่านี้ |
| ไฟล์สลิป | ชนิด/ขนาดตาม slip_upload.content_types / slip_upload.max_bytes ของ capability ใบนั้น | 400 unsupported_media_type / 422 validation_error |
ย้อนหลังของ /v1/pay/events | 30 วัน | ดึงเก่ากว่านี้ไม่ได้ |
🔴 ยอดรายการฝากต้องเป็นยอดกลม ห้ามมีเศษสตางค์
ถ้าระบบเดิมของคุณใช้เทคนิค "บวกเศษสตางค์สุ่มเพื่อแยกรายการ" (amount + random_cents) ต้องลบทิ้ง เราจับคู่ด้วย ตัวตนของผู้โอน ไม่ใช่ด้วยเศษสตางค์ ⇒ ยอดที่มีเศษจะได้ 422 amount_not_supported และตัว matcher เดิมที่จับสลิปจากเศษสตางค์จะใช้ไม่ได้อีกเลย
โควตา rate limit
ทุก response มี header ชุดนี้ ไม่ใช่เฉพาะตอนโดน 429
RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 41
RateLimit-Policy: 600;w=60
X-RateLimit-Scope: api_key
Retry-After: 41ตัวอย่างด้านบนเป็น merchant API ปกติจึงได้ X-RateLimit-Scope: api_key; slip ingress ใช้ scope จาก signed rate_subject/jti + IP ใน capability และ /v1/bo/* ใช้ employee OIDC tenant + principal — ไม่มีสองเส้นนี้ที่ fallback ไป bucket API key
| ถัง | ขอบเขต | เพดาน | หน้าต่าง |
|---|---|---|---|
pay_write (สร้างรายการฝาก/ถอน) | API key | 600 | 60 วินาที |
pay_create_per_user | ผู้ใช้ + device + IP | 10 | 60 วินาที |
pay_read | API key | 1,200 | 60 วินาที |
slip_upload | signed rate_subject/jti + IP จาก capability | 1,200 | 60 วินาที |
slip_upload_per_user | signed rate_subject + IP จาก capability | 20 | 60 วินาที |
bo_read | employee OIDC tenant + principal | 300 | 60 วินาที |
bo_reports | employee OIDC tenant + principal | 60 | 60 วินาที |
webhook_resend (นับ คำสั่ง ไม่ใช่ delivery) | endpoint | 10 | 60 วินาที |
banks_quote | API key | 3,000 | 60 วินาที |
429 กับ 503 ไม่ใช่เรื่องเดียวกัน
429 rate_limited= คุณ ยิงเกินโควตาของตัวเอง ⇒ เคารพRetry-After+ jitter ±20% · 🔴 ห้าม retry เป็นลูป มีบทลงโทษทวีคูณ503 service_unavailable/503 route_unavailable= ระบบ กำลังลดโหลด ⇒ ไม่ใช่บั๊กของคุณ ไม่ใช่500
429 ไม่ยึด Idempotency-Key ⇒ ยิงซ้ำด้วย key เดิมได้ทันทีหลัง Retry-After
สิ่งที่เราไม่เปิดให้ตั้ง — และเหตุผล
| อยากตั้ง | ได้ไหม | เพราะ |
|---|---|---|
| ให้ webhook มาครั้งเดียวตอนรายการถอนจบ | ❌ | แต่ละก้อนมาจากผู้โอนคนละคน ⇒ statement ธนาคารของคุณมาเป็นรายบรรทัด ถ้ายิงก้อนเดียวคุณกระทบยอดไม่ได้ |
| ปิดหน้าต่าง 72 ชั่วโมงเพื่อถอนได้ทันที | ❌ | มันคือสิ่งที่ทำให้การกลับรายการตัดจาก pending_clearing แทนที่จะทำให้กระเป๋าคุณติดลบ |
| ให้ระบบอนุมัติรายการที่ค้างคิวตรวจสอบเองเมื่อเกินเวลา | ❌ | ไม่มี event ชื่อ deposit.auto_approved และจะไม่มี · เกินเวลาเราส่ง deposit.under_review ซ้ำ ด้วย review.escalated: true และ version ใหม่ |
| เลือกให้ระบบใช้สภาพคล่องแหล่งไหน | บางส่วน | ตั้งได้แค่ fill_preference ต่อ request · รายละเอียดที่ลึกกว่านั้นเป็นนโยบายภายใน |
| เห็นเวลาที่เจ้าหน้าที่ต้องตัดสินให้เสร็จ | ❌ | คิว support เป็นของผู้ให้บริการชั้นบน · สิ่งที่ส่งให้คุณคือ review.estimated_resolution_bucket (under_15m / under_1h / under_4h / extended) |
| ปรับข้อความที่แสดงเหตุผลการเข้าคิวตรวจสอบ | ❌ | เราส่ง display_th / display_en มาให้พร้อมใช้ ⇒ 🔴 อย่าเขียนข้อความเอง และ ห้าม hardcode "ไม่เกิน 15 นาที" |
เช็กลิสต์การตั้งค่า
- [ ] อ่านค่าที่ระบบคืนกลับมาหลัง
PATCHทุกครั้ง ไม่สมมติว่าได้ค่าที่ส่งไป - [ ]
deposit_urlตั้งแล้ว และ endpoint ตอบ2xxภายใน 3 วินาที - [ ] subscribe ครบ 5 event ที่ปิดไม่ได้
- [ ] pin
PG-Versionแล้วมีขั้นตอนอัปเกรดที่ทดสอบใน sandbox ก่อน - [ ]
default:ครบทุกswitchของ open enum - [ ] TTL ของ idempotency ฝั่งคุณไม่สั้นกว่า 7 วัน
- [ ]
sk_live_*อยู่ใน secret manager หมุนได้โดยไม่ต้อง deploy ใหม่ - [ ] ผู้ติดต่อทางเทคนิคเป็นอีเมลที่มีคนอ่านจริง
- [ ] ส่ง
client{}ครบทุกครั้ง
ขั้นต่อไป
- การส่งซ้ำและ endpoint ล่ม — สิ่งที่เกิดเมื่อ endpoint ตอบไม่ได้
- บันทึกและ audit log — ตรวจว่า endpoint ที่ตั้งไว้รับ event ได้จริง
- อนุมัติรายการถอน — เพดานที่ต้องมีผู้อนุมัติสองคน
- Error code ทั้งหมด
- เช็กลิสต์ก่อน go-live
