Error code ทั้งหมด
ทุก endpoint ใช้รูปแบบ error เดียวกัน ไม่มีข้อยกเว้น
{
"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_01JQA5T8V0X2Z4B6D8F0H2J4L6",
"details": {
"available_minor": 1002000,
"required_minor": 1003000,
"pending_clearing_minor": 500000,
"pending_clearing_available_at": "2026-08-24T14:26:44.000Z"
}
}
}| ฟิลด์ | ใช้ยังไง |
|---|---|
code | 🔴 แยกเคสด้วยตัวนี้เท่านั้น ห้าม parse message และห้ามแยกด้วย HTTP status อย่างเดียว |
message_th | แสดงให้ทีมงานภายในดูได้ · 🔴 ไม่ได้เขียนมาเพื่อผู้ใช้ปลายทาง — ข้อความสำหรับผู้ใช้อยู่ใน details.public_message_th หรือ review.reasons[].display_th |
retryable | true = ยิงซ้ำแล้วมีโอกาสสำเร็จ · false = ยิงซ้ำอีกกี่ครั้งก็ได้ผลเดิม ต้องแก้ที่ต้นทาง |
retry_after_seconds | มีค่าเมื่อรู้เวลาแน่นอน (429, 503, 409 idempotency_in_flight, 409 withdrawal_has_pending_fill) |
request_id | 🔴 log ทุกครั้ง — เวลาเปิด ticket เราค้นด้วยค่านี้ตัวเดียว |
details | โครงต่างกันตาม code ดูคอลัมน์ "details มีอะไร" ในตารางแต่ละหมวด |
2xx ไม่ใช่ error — และเราใช้ 202 มากกว่าเจ้าอื่น
โค้ดที่เขียนแบบนี้จะพังทั้งเส้นฝากและเส้นอัปสลิป
if (res.status !== 200) throw new Error('failed'); // ❌ พังPOST /v1/pay/depositsตอบ201เมื่อจับคู่ปลายทางได้ทันที และ202เมื่อยังจัดหาปลายทางไม่ได้ (ออร์เดอร์ถูกสร้างแล้วเสมอ)POST /v1/pay/deposits/{id}/slipตอบ202เมื่อ capability auth/validation และ durable storage+queue สำเร็จ; intake ที่ยังยืนยันไม่ครบตอบ retryable503
เงื่อนไขที่ถูกคือ if (!res.ok) { ... } แล้วจึงแยกเคสด้วย body.error.code
อัปสลิปไม่มี 409 duplicate_slip แบบทันที — ต่างจากเจ้าอื่นในตลาด
ผู้ให้บริการส่วนใหญ่ตรวจสลิปซ้ำแบบ synchronous แล้วตอบ 409 กลับมาในคำสั่งเดียวกัน ของเราไม่ทำแบบนั้น
เราตอบ 202 หลังเขียน object storage และยืนยัน queue สำเร็จ การตรวจซ้ำ ตรวจยอด ตรวจชื่อ เกิดขึ้นหลังจากนั้น ⇒ ผลลัพธ์มาทาง webhook หรือ GET /v1/pay/deposits/{id} เท่านั้น ถ้ายังยืนยัน intake ไม่ครบให้ retry logical evidence/key เดิมจาก 503 slip_intake_unavailable
- สลิปซ้ำ ⇒
deposit.under_reviewที่มีreview.reasons[0].code = "DUPLICATE_SLIP" - ทำไมถึงออกแบบแบบนี้: การอัปสลิปคือจุดที่ผู้ใช้โอนเงินไปแล้วจริง ระบบต้องรับหลักฐานไว้ให้ได้แม้ฐานข้อมูลจะช้าหรือล่ม ถ้าตอบ error ตรงนี้ ผู้ใช้จะกดโอนซ้ำ
- สิ่งที่ merchant ต้องทำเพิ่ม: UI หลังอัปสลิปต้องเป็น "กำลังตรวจสอบ" ไม่ใช่ "สำเร็จ" และต้องมี handler ของ
deposit.under_reviewตั้งแต่วันแรก
รายละเอียดที่ อัปโหลดสลิป — durable intake สำเร็จตอบ 202
ดัชนีเร็ว — ทุก code เรียงตามตัวอักษร
code | HTTP | retry ได้ไหม | ไปหมวด |
|---|---|---|---|
alloc_state_conflict | 409 | ✅ | ชนสถานะ |
amount_not_supported | 422 | ❌ | รูปแบบ request |
api_key_invalid | 401 | ❌ | ยืนยันตัวตน |
api_key_revoked | 401 | ❌ | ยืนยันตัวตน |
beneficiary_not_verified | 409 | ✅ | ฝั่งถอน |
cursor_filter_mismatch | 400 | ❌ | รูปแบบ request |
deposit_not_cancelable | 409 | ❌ | ฝั่งฝาก |
duplicate_open_deposit | 409 | ❌ | ฝั่งฝาก |
idempotency_in_flight | 409 | ✅ | Idempotency |
idempotency_key_required | 400 | ❌ | รูปแบบ request |
idempotency_key_reused | 409 | ❌ | Idempotency |
insufficient_balance | 402 | ✅ ตามเวลา | ยอดเงินไม่พอ |
internal_error | 500 | ✅ | ระบบตึง |
late_slip_window_closed | 410 | ❌ | หน้าต่างปิดแล้ว |
malformed_json | 400 | ❌ | รูปแบบ request |
nonce_replayed | 401 | ❌ | ยืนยันตัวตน |
rate_limited | 429 | ✅ | ระบบตึง |
resource_not_found | 404 | ❌ | หาไม่เจอ |
review_note_required | 422 | ❌ | รูปแบบ request |
route_unavailable | 503 | ✅ | ระบบตึง |
scope_denied | 403 | ❌ | ยืนยันตัวตน |
sender_bank_unavailable | 409 | ✅ | ฝั่งฝาก |
sender_required | 422 | ❌ | รูปแบบ request |
service_unavailable | 503 | ✅ | ระบบตึง |
signature_invalid | 401 | ❌ | ยืนยันตัวตน |
tenant_suspended | 403 | ❌ | ยืนยันตัวตน |
timestamp_out_of_window | 401 | ✅ หลัง sync นาฬิกา | ยืนยันตัวตน |
too_many_open_deposits | 409 | ❌ | ฝั่งฝาก |
unsupported_media_type | 400 | ❌ | รูปแบบ request |
validation_error | 422 | ❌ | รูปแบบ request |
withdrawal_has_pending_fill | 409 | ✅ | ฝั่งถอน |
withdrawal_not_cancelable | 409 | ❌ | ฝั่งถอน |
withdrawal_state_conflict | 409 | ✅ | ชนสถานะ |
ยืนยันตัวตนและสิทธิ์ (401 · 403)
code | HTTP | ความหมาย (ไทย) | Meaning (EN) | retry | ต้องทำอะไรต่อ |
|---|---|---|---|---|---|
signature_invalid | 401 | canonical string ประกอบผิด หรือลายเซ็นไม่ตรง | Canonical string or signature does not match. | ❌ | เทียบกับ test vector ทั้ง 3 ชุด ก่อนแก้อย่างอื่น · จุดที่พลาดบ่อยที่สุดคือลืมใส่ Idempotency-Key และ PG-Version ลงใน canonical |
timestamp_out_of_window | 401 | PG-Timestamp ห่างจากเวลาจริงเกิน ±300 วินาที | Request timestamp is outside the ±300s window. | ✅ | sync นาฬิกาเครื่องด้วย NTP แล้วยิงใหม่ · 🔴 อย่าแก้ด้วยการขยับ timestamp เอง |
nonce_replayed | 401 | PG-Nonce ซ้ำภายใน 10 นาที | The nonce was already used within 10 minutes. | ❌ | สร้าง nonce ใหม่ทุก attempt · 🔴 แต่ Idempotency-Key ต้องคงเดิม สองค่านี้คนละหน้าที่กัน |
api_key_invalid | 401 | key ไม่มีอยู่จริง | API key does not exist. | ❌ | ตรวจว่าใช้ key ของ environment ถูก (sk_live_* กับ sk_test_* ข้ามกันไม่ได้) |
api_key_revoked | 401 | key ถูกเพิกถอน (ตรวจทั้งสายบัญชีทุก merchant-HMAC request) | API key has been revoked. | ❌ | ติดต่อผู้ให้บริการแบรนด์ของคุณเพื่อออก key ใหม่ · ออร์เดอร์ที่เปิดค้างอยู่ ไม่ขยับ จากเหตุนี้ |
tenant_suspended | 403 | บัญชีชั้นใดชั้นหนึ่งในสายถูกระงับ | An account in the chain is suspended. | ❌ | ติดต่อผู้ให้บริการแบรนด์ของคุณ · หยุดสร้างรายการใหม่ แต่ยังรับ webhook ของรายการเดิมได้ |
scope_denied | 403 | key นี้ไม่มีสิทธิ์เรียก endpoint นั้น | The API key lacks the required scope. | ❌ | ขอ scope เพิ่ม · 🔴 /v1/bo/* เป็น endpoint ของ back office ไม่ใช่ของการต่อระบบ |
details ที่ได้: signature_invalid ไม่มี details (ตั้งใจ — ไม่บอกว่าผิดตรงไหน เพื่อไม่ให้เป็นเครื่องมือเดา) · 🔴 อย่าเขียนโค้ดที่พึ่ง details ของ error กลุ่ม 401 — ให้เทียบกับ test vector ทั้ง 3 ชุดแทน และ sync นาฬิกาด้วย NTP
กลไกขาเข้ากับขาออกคนละอย่างกันสิ้นเชิง
merchant request ปกติที่ คุณยิงมาหาเรา เซ็นด้วย HMAC-SHA256 ใน header PG-Signature; ข้อยกเว้น POST slip_upload.upload_url ใช้ order capability และไม่รับ PG-* headers webhook ที่ เรายิงไปหาคุณ เซ็นด้วย Ed25519 ใน header X-PG-Signature
ใช้โค้ดตัวเดียวกันไม่ได้ · การยืนยันตัวตนของ request · ตรวจลายเซ็น Ed25519
รูปแบบ request ผิด (400 · 422)
code | HTTP | ความหมาย (ไทย) | Meaning (EN) | retry | ต้องทำอะไรต่อ |
|---|---|---|---|---|---|
idempotency_key_required | 400 | ไม่ส่ง header Idempotency-Key บน POST | Missing Idempotency-Key header on a POST. | ❌ | เพิ่ม header · 🔴 สร้าง key แล้วเขียนลง DB ก่อนยิง ไม่ใช่ generate ตอนยิง |
malformed_json | 400 | body ไม่ใช่ JSON ที่ parse ได้ | Request body is not valid JSON. | ❌ | แก้โค้ด |
unsupported_media_type | 400 | Content-Type ไม่ตรงกับที่ endpoint รับ | Unsupported Content-Type. | ❌ | JSON ใช้ application/json · อัปสลิปแบบไฟล์ใช้ multipart/form-data |
cursor_filter_mismatch | 400 | เปลี่ยน filter กลางคันขณะไล่หน้า | Pagination filters changed mid-cursor. | ❌ | เปลี่ยน limit ระหว่างไล่หน้าได้ · เปลี่ยน from/to/type/status/order ไม่ได้ ต้องเริ่มไล่ใหม่ |
validation_error | 422 | field ผิดรูปแบบ | One or more fields failed validation. | ❌ | อ่าน details.fields[] ซึ่งบอกชื่อ field กับเหตุผลรายตัว |
sender_required | 422 | ไม่ส่งทั้ง sender{} และ sender_id ตอนสร้างรายการฝาก | Neither sender nor sender_id was provided. | ❌ | 🔴 ต้องเพิ่มหน้าจอเลือกบัญชีผู้โอน ดูด้านล่าง |
amount_not_supported | 422 | ยอดต่ำหรือสูงกว่าที่นโยบายอนุญาต หรือมีเศษสตางค์ | Amount is outside policy limits or not a round amount. | ❌ | ใช้ details.min_minor / details.max_minor · 🔴 ยอดต้องกลม ไม่มีเศษสตางค์ |
review_note_required | 422 | โน้ตประกอบการตัดสินของเจ้าหน้าที่สั้นกว่า 20 ตัวอักษร (endpoint ฝั่ง back office) | The reviewer note is shorter than 20 characters. | ❌ | เขียนโน้ตให้ครบ 20 ตัวอักษรขึ้นไป |
422 sender_required — เรื่องนี้ต่างจากตลาด และแก้ผิดวิธีได้ง่ายมาก
เงินของเราเข้า บัญชีบุคคลของผู้ถอนโดยตรง ไม่มีบัญชีกลางในเส้นทางปกติ ⇒ ระบบต้องรู้ตั้งแต่ก่อนโอนว่า "สลิปใบไหนเป็นของรายการไหน" การจับคู่ทำด้วย ตัวตนผู้โอน ไม่ใช่ด้วยเศษสตางค์
ทางแก้ที่ผิดและเจอบ่อยที่สุด: ยัดชื่อเจ้าของบัญชีจากฐานข้อมูลตัวเองลงไปให้ผ่าน 422 ⇒ ชื่อไม่ตรงกับสลิปจริง ⇒ ทุกรายการเข้า under_review และคิวเจ้าหน้าที่ล้นตั้งแต่วันแรก
ทางแก้ที่ถูก มี 2 ทาง
GET /v1/pay/senders?end_user_ref=u_88213แล้วให้ผู้ใช้ เลือก บัญชีที่เคยยืนยันแล้ว → ส่งsender_id· การันตีว่าไม่เข้าคิวเพราะชื่อหรือบัญชีไม่ตรง- ผู้ใช้ใหม่ที่ยังไม่มีบัญชีในระบบ → ให้กรอก
sender{bank_code, account_number, account_name_th}· 🔴 ห้ามมีคำนำหน้า (นาย/นาง/น.ส./คุณ) ในชื่อ
รายละเอียดและตัวอย่างหน้าจอที่ บัญชีผู้โอน (บังคับ)
ตัวอย่าง 422 validation_error เต็มใบ
{
"error": {
"code": "validation_error",
"type": "invalid_request_error",
"message": "One or more fields failed validation.",
"message_th": "ข้อมูลบางฟิลด์ไม่ผ่านการตรวจสอบ",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01K3M7Y8ZC4N0PQR2STU6VWX8Y",
"details": {
"fields": [
{ "path": "sender.account_name_th", "reason": "must_not_contain_title", "hint": "ตัดคำนำหน้า เช่น นาย นาง น.ส. ออกก่อนส่ง" },
{ "path": "amount_minor", "reason": "must_be_integer_minor_units" }
]
}
}
}ยอดเงินไม่พอ (402)
code | HTTP | ความหมาย (ไทย) | Meaning (EN) | retry | ต้องทำอะไรต่อ |
|---|---|---|---|---|---|
insufficient_balance | 402 | ยอดที่ถอนได้ต่ำกว่ายอดถอนบวกค่าธรรมเนียม | Available balance is lower than amount plus fee. | ✅ หลัง details.pending_clearing_available_at | ปลดล็อกยอดฝั่งผู้ใช้คืนทันที แล้วอธิบายด้วยเวลาที่ยอดจะพร้อม |
สองเรื่องที่ทำให้ 402 งงที่สุด
- ค่าธรรมเนียมฝั่งถอนของเราบวกเพิ่ม ไม่ใช่หักจากยอดถอน ⇒ ต้องมี
available_minor ≥ amount_minor + fee_total_minor· เช็คล่วงหน้าด้วยGET /v1/pay/quote?channel=withdrawal&amount_minor=1000000 - ยอดที่เพิ่งเข้าจากรายการฝากยังถอนไม่ได้ทันที — อยู่ใน
pending_clearingก่อน 72 ชั่วโมง แล้วจึงย้ายไปavailable(คุณจะได้ eventbalance.clearedตอนนั้น) ⇒ dashboard เห็นเงิน แต่สร้างรายการถอนได้402เป็นเรื่องปกติ
🔴 เมื่อได้ 402 จะไม่มีออร์เดอร์ถูกสร้าง และไม่มีการล็อกเงินใด ๆ ⇒ ถ้าโค้ดคุณล็อกยอดผู้ใช้ไว้ก่อนยิง ต้องปลดล็อกคืนในกิ่งนี้เสมอ ไม่งั้นยอดผู้ใช้ค้างล็อกถาวร
หาไม่เจอ (404)
code | HTTP | ความหมาย (ไทย) | Meaning (EN) | retry | ต้องทำอะไรต่อ |
|---|---|---|---|---|---|
resource_not_found | 404 | id ไม่มีอยู่ หรืออยู่นอกขอบเขตบัญชีของคุณ | The id does not exist or is out of your account scope. | ❌ | ตรวจ id · 🔴 เราไม่แยกสองเคสนี้ออกจากกันโดยตั้งใจ (ไม่งั้นจะกลายเป็นเครื่องมือไล่เดา id) |
Idempotency (409)
code | HTTP | ความหมาย (ไทย) | Meaning (EN) | retry | ต้องทำอะไรต่อ |
|---|---|---|---|---|---|
idempotency_key_reused | 409 | key เดิม แต่ body ต่างจากครั้งแรก | Same key, different body. | ❌ | เปลี่ยน body = ต้องเปลี่ยน key · details.original_request_id ชี้ไป request แรก |
idempotency_in_flight | 409 | key เดิม และ request แรกยังทำงานไม่จบ | The first request with this key is still in flight. | ✅ (retry_after_seconds: 1) | รอตามที่บอกแล้วยิงซ้ำ ด้วย key เดิม · 🔴 เราไม่ block ไม่รอให้ (double-click จะได้คำตอบทันทีแทนที่จะค้าง) |
POST slip_upload.upload_url เป็นข้อยกเว้น: idempotency_key_reused เทียบ logical evidence_fingerprint ไม่เทียบ raw multipart/JSON bytes; boundary, key order และ base64 formatting ต่างแต่ decoded evidence เดิมถือเป็น replay
key ซ้ำ + body เดิม ไม่ใช่ error
ถ้ายิงซ้ำด้วย key เดิมและ body เหมือนเดิม เราคืน response เดิมทั้งดุ้น รวมถึง HTTP status เดิม พร้อม header Idempotent-Replay: true
⇒ ถ้าครั้งแรกได้ 202 การ replay ก็จะได้ 202 แม้ว่าตอนนี้ออร์เดอร์จะเดินไปถึง awaiting_payment แล้ว · สถานะจริงต้องอ่านจาก GET /v1/pay/deposits/{id} หรือจาก webhook ไม่ใช่จาก body ที่ replay กลับมา
TTL ของเราคือ 7 วัน ⇒ TTL ฝั่งคุณต้องไม่สั้นกว่านี้ · 429 ไม่ยึด key ⇒ ยิงซ้ำด้วย key เดิมได้ทันทีหลัง Retry-After · รายละเอียด Idempotency
ฝั่งฝาก (409)
code | HTTP | ความหมาย (ไทย) | Meaning (EN) | retry | details มีอะไร | ต้องทำอะไรต่อ |
|---|---|---|---|---|---|---|
sender_bank_unavailable | 409 | ธนาคารของผู้โอนปิดรับรายการใหม่ชั่วคราว | The sender's bank is not accepting new orders. | ✅ | alternative_banks[] · public_message_th · retry_after_hint_seconds | เสนอธนาคารอื่นจาก alternative_banks[] · 🔴 สลิปของรายการที่เปิดค้างอยู่แล้วยังส่งได้เสมอ ห้ามปิดปุ่มอัปสลิป |
duplicate_open_deposit | 409 | มีรายการ awaiting_payment ยอดเท่ากันและผู้โอนคนเดียวกันค้างอยู่ | An identical open deposit already exists. | ❌ | open_deposit_ids[] | พาผู้ใช้กลับไปที่รายการเดิม (open_deposit_ids[0]) · นี่คือการกัน AMBIGUOUS_MATCH ตั้งแต่ต้นทาง |
too_many_open_deposits | 409 | เกินจำนวนรายการที่ยังไม่จ่ายที่เปิดพร้อมกันได้ | Too many unpaid deposits are open. | ❌ | open_deposit_ids[] | ให้ผู้ใช้ปิดหรือยกเลิกรายการเดิมก่อน · 🔴 ถ้าไม่ส่ง client{} เพดานจะเหลือ 1 |
deposit_not_cancelable | 409 | ยกเลิกไม่ได้ เพราะมีสลิปแล้วหรืออยู่ในสถานะที่ยกเลิกไม่ได้ | The deposit can no longer be canceled. | ❌ | status ปัจจุบัน | ปล่อยให้เดินต่อ แล้วรอผลทาง webhook · นี่คือกลไกกันการยึดเงินผู้ใช้ที่เพิ่งกดโอน |
client{} ไม่ใช่ optional จริง ๆ
POST /v1/pay/deposits มีฟิลด์ client{device_fingerprint, ip, user_agent} ที่เขียนว่า optional แต่ถ้าไม่ส่ง
- เพดานรายการเปิดพร้อมกันลดจาก 2 เหลือ 1 ต่อผู้ใช้
- rate limit ตกไป bucket หยาบที่ แชร์กับผู้ใช้ทุกคนของคุณ ⇒ merchant ขนาดกลางชน
429ตั้งแต่นาทีแรก
⇒ ในทางปฏิบัติต้องส่งทุกครั้ง
ฝั่งถอน (409)
code | HTTP | ความหมาย (ไทย) | Meaning (EN) | retry | ต้องทำอะไรต่อ |
|---|---|---|---|---|---|
withdrawal_has_pending_fill | 409 | ยกเลิกไม่ได้ตอนนี้ เพราะมีก้อนที่กำลังจับคู่ค้างอยู่ (reserved_minor > 0) | A fill is in progress; cancel is blocked. | ✅ (retry_after_seconds) | รอแล้วลองใหม่ · 🔴 กลไกนี้มีไว้กันการยึดเงินคนที่กำลังโอนอยู่ ไม่ใช่บั๊ก |
withdrawal_not_cancelable | 409 | ใบนี้ settled / expired / canceled ไปแล้ว | The withdrawal is already in a closed state. | ❌ | อ่านสถานะล่าสุดจาก GET /v1/pay/withdrawals/{id} |
beneficiary_not_verified | 409 | บัญชีผู้รับเงินยังไม่ผ่านการยืนยันชื่อกับธนาคาร | The beneficiary account is not verified yet. | ✅ หลังยืนยันผ่าน | รอ webhook beneficiary.verified (ปกติ < 30 วินาที) แล้วค่อยสร้างใบถอน · 🔴 ไม่มีออร์เดอร์ถูกสร้าง ไม่มีการล็อกเงิน |
ชนสถานะระหว่างทาง (409)
code | HTTP | ความหมาย (ไทย) | Meaning (EN) | retry | ต้องทำอะไรต่อ |
|---|---|---|---|---|---|
alloc_state_conflict | 409 | ก้อนย่อยขยับสถานะไปแล้วระหว่างที่ request วิ่ง | The underlying fill changed state mid-request. | ✅ | อ่านสถานะล่าสุดด้วย GET แล้วตัดสินใจใหม่ |
withdrawal_state_conflict | 409 | ใบถอนขยับสถานะไปแล้วระหว่างที่ request วิ่ง | The withdrawal changed state mid-request. | ✅ | เหมือนบน |
ทำไมสองตัวนี้เป็น 409 ไม่ใช่ 500
เพราะมันไม่ใช่ความผิดพลาด — เป็นการแข่งกันตามปกติของระบบที่มีคนโอนพร้อมกันหลายคน · เราตั้งใจ map ให้เป็น 409 ที่ retryable: true เพื่อให้โค้ดคุณ "อ่านใหม่แล้วลองใหม่" แทนที่จะปลุกคนขึ้นมากลางดึก
หน้าต่างปิดแล้ว (410)
code | HTTP | ความหมาย (ไทย) | Meaning (EN) | retry | ต้องทำอะไรต่อ |
|---|---|---|---|---|---|
late_slip_window_closed | 410 | อัปสลิปหลังหมดหน้าต่างอัปสลิปย้อนหลัง 24 ชั่วโมง | The 24-hour late-slip window has closed. | ❌ | เปิดเรื่องผ่าน details.claim_url → POST /v1/bo/claims · 🔴 ห้ามจบเรื่องแค่ขึ้น error เพราะเงินผู้ใช้ออกจากบัญชีไปแล้วจริง |
{
"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": {
"deposit_id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"late_slip_accepted_until": "2026-08-22T04:30:47.902Z",
"claim_url": "https://pay.example-wl.com/claims/new?deposit_id=dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B"
}
}
}ระบบตึงและของเราพัง (429 · 503 · 500)
code | HTTP | ความหมาย (ไทย) | Meaning (EN) | retry | ต้องทำอะไรต่อ |
|---|---|---|---|---|---|
rate_limited | 429 | คุณ ยิงเกินโควตาของตัวเอง | You exceeded your own rate limit bucket. | ✅ | เคารพ Retry-After + jitter ±20% · 🔴 ห้าม retry เป็นลูปแน่น มีบทลงโทษทวีคูณ · ดู details.bucket และ details.scope ว่าชน bucket ไหน |
route_unavailable | 503 | ตอนนี้ไม่มีช่องทางเปิดรายการฝากใหม่ได้ | No route is currently available to open a new deposit. | ✅ | แสดง "ระบบหนาแน่น ลองใหม่อีกครั้ง" + ใช้ Retry-After · 🔴 ไม่ใช่บั๊ก ไม่ใช่ 500 |
service_unavailable | 503 | ระบบ กำลังลดโหลดตัวเอง | The system is shedding load. | ✅ | เหมือนบน |
internal_error | 500 | ของเราพังจริง | Something on our side failed. | ✅ | ส่ง request_id ให้ทีมสนับสนุน |
429 กับ 503 คนละสาเหตุ คนละวิธีแก้
429 = คุณ ยิงเกินโควตา — แก้ที่ฝั่งคุณ (ลดความถี่ poll, ใช้ webhook เป็นหลัก, ส่ง client{} เพื่อไม่ให้ตกไป bucket รวม) 503 = ระบบ กำลังลดโหลด — แก้ไม่ได้จากฝั่งคุณ รอแล้วลองใหม่
การ retry 429 แบบลูปแน่นทำให้ทั้ง API key ของคุณโดน backoff ⇒ ผู้ใช้คนอื่นสร้างรายการไม่ได้ทั้งระบบ นี่คือเคสที่ merchant ทำตัวเองล่มบ่อยที่สุด
ลำดับการลดโหลดเมื่อระบบตึง — เรียงจากที่ปกป้องที่สุดไปหาที่ยอมสละก่อน
| ลำดับ | งาน | ถูกลดโหลดไหม |
|---|---|---|
| P0 | อัปโหลดสลิป | 🔴 ไม่เคยถูกลดโหลด — แม้ฐานข้อมูลล่มก็ยังตอบ 202 |
| P1 | เครดิตและลงบัญชี | ไม่ |
| P2 | อ่านสถานะ (GET) | เกือบไม่ |
| P3 | สร้างรายการฝากใหม่ | ✅ โดนก่อนเพื่อน — ผู้ใช้ยังไม่ได้โอน จึงไม่มีใครเสียหาย |
ทุก response (ไม่ใช่แค่ตอน 429) มี header เหล่านี้ให้อ่านล่วงหน้า
RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 41
RateLimit-Policy: 600;w=60
X-RateLimit-Scope: api_key
Retry-After: 41โค้ดจัดการ error ที่ใช้ได้จริง
curl — ดู error body เต็ม ๆ
curl -sS -i -X POST 'https://api.example-wl.com/v1/pay/withdrawals' \
-H 'Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx' \
-H 'Content-Type: application/json' \
-H 'PG-Timestamp: 1755749553' \
-H 'PG-Nonce: 01JQA5T8V0X2Z4B6D8F0H2J4L6' \
-H 'PG-Version: 2026-08-01' \
-H 'Idempotency-Key: wdr-ORD-9981-attempt-1' \
-H 'PG-Signature: v1=6f1b9c0d…' \
-d '{
"amount_minor": 1000000,
"currency": "THB",
"merchant_reference": "WDR-4471",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2G5R8XT4M0QWD7HZP3NC6VB",
"client": { "device_fingerprint": "df_9a1c7e", "ip": "203.0.113.44", "user_agent": "Mozilla/5.0" }
}'Node (fetch) — แยกเคสด้วย code และเคารพ retry_after_seconds
const RETRY_BUDGET = 4;
async function callApi(path, init, attempt = 1) {
const res = await fetch(`https://api.example-wl.com${path}`, init);
const text = await res.text();
const body = text ? JSON.parse(text) : {};
if (res.ok) return { status: res.status, body }; // 200 · 201 · 202 ถือว่าสำเร็จหมด
const err = body.error ?? { code: 'internal_error', retryable: true };
// เวลารอ: ใช้ retry_after_seconds ถ้ามี ไม่งั้นใช้ Retry-After ไม่งั้น exponential + jitter
const headerWait = Number(res.headers.get('retry-after'));
const waitMs = (err.retry_after_seconds ?? (Number.isFinite(headerWait) ? headerWait : 2 ** attempt))
* 1000 * (0.8 + Math.random() * 0.4); // jitter ±20%
switch (err.code) {
// ยิงซ้ำได้ทันทีด้วย Idempotency-Key เดิม (nonce ต้องใหม่)
case 'idempotency_in_flight':
case 'alloc_state_conflict':
case 'withdrawal_state_conflict':
case 'withdrawal_has_pending_fill':
case 'rate_limited':
case 'route_unavailable':
case 'service_unavailable':
case 'internal_error':
if (attempt > RETRY_BUDGET) throw new PermanentError(err);
await new Promise((r) => setTimeout(r, waitMs));
return callApi(path, withFreshNonce(init), attempt + 1); // 🔴 nonce ใหม่ · key เดิม
// ต้องปลดล็อกยอดผู้ใช้คืนก่อน แล้วค่อยบอกเวลาที่จะทำได้
case 'insufficient_balance':
await releaseUserHold(init.body);
throw new BalanceError(err.details.pending_clearing_available_at, err);
// ต้องแก้ที่ต้นทาง ยิงซ้ำไม่ช่วย
case 'sender_required':
case 'validation_error':
case 'amount_not_supported':
case 'duplicate_open_deposit':
case 'too_many_open_deposits':
case 'idempotency_key_reused':
case 'late_slip_window_closed':
throw new PermanentError(err);
// 🔴 code ใหม่เพิ่มได้โดยไม่นับเป็น breaking change — ต้องมี default เสมอ
default:
if (err.retryable && attempt <= RETRY_BUDGET) {
await new Promise((r) => setTimeout(r, waitMs));
return callApi(path, withFreshNonce(init), attempt + 1);
}
throw new PermanentError(err);
}
}PHP — รูปแบบเดียวกัน
<?php
function callApi(string $path, array $opts, int $attempt = 1): array
{
$ch = curl_init("https://api.example-wl.com{$path}");
curl_setopt_array($ch, $opts + [CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => false]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$body = $raw === '' ? [] : json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if ($status >= 200 && $status < 300) {
return ['status' => $status, 'body' => $body]; // 201 และ 202 คือสำเร็จ
}
$err = $body['error'] ?? ['code' => 'internal_error', 'retryable' => true];
$retryable = [
'idempotency_in_flight', 'alloc_state_conflict', 'withdrawal_state_conflict',
'withdrawal_has_pending_fill', 'rate_limited', 'route_unavailable',
'service_unavailable', 'internal_error',
];
// 🔴 เคารพ retryable ที่ server ส่งมาด้วย เผื่อ code ใหม่ที่ยังไม่อยู่ในลิสต์
$canRetry = in_array($err['code'], $retryable, true) || ($err['retryable'] ?? false);
if ($canRetry && $attempt <= 4) {
$wait = ($err['retry_after_seconds'] ?? 2 ** $attempt) * (0.8 + mt_rand(0, 400) / 1000);
usleep((int) ($wait * 1_000_000));
return callApi($path, withFreshNonce($opts), $attempt + 1); // key เดิม nonce ใหม่
}
error_log(sprintf('[pg] %s request_id=%s', $err['code'], $err['request_id'] ?? '-'));
throw new RuntimeException($err['message_th'] ?? $err['code']);
}สิ่งที่ต้องทำทุกครั้งที่ retry
เปลี่ยน PG-Nonce ทุก attempt (ไม่งั้นได้ 401 nonce_replayed) แต่ Idempotency-Key ต้องคงเดิม ตลอด logical operation เดียวกัน (ไม่งั้นได้ออร์เดอร์ซ้ำ ล็อกเงินซ้ำ)
PG-Timestamp ก็ต้องใหม่ และเมื่อ nonce กับ timestamp เปลี่ยน ต้องเซ็น PG-Signature ใหม่ด้วย
error ฝั่งตรงข้าม — endpoint ของคุณตอบอะไรกลับมา
เวลาเรายิง webhook ไปหาคุณ เราตีความ HTTP status ที่คุณตอบแบบนี้
| คุณตอบ | เราตีความว่า | ผลที่ตามมา |
|---|---|---|
200 201 202 204 | สำเร็จ | หยุด retry |
3xx | ล้มเหลว | 🔴 เรา ไม่ตาม redirect เด็ดขาด (กัน SSRF และกันลายเซ็นไปโผล่ที่อื่น) |
410 Gone | ล้มเหลวถาวร | 🔴 หยุด retry ทันที และ flag endpoint · อย่าตอบ 410 ถ้าคุณแค่อยากให้เราหยุดชั่วคราว |
429 และ 4xx/5xx อื่น | ล้มเหลว | retry ตามตาราง 11 ครั้ง ~92 ชั่วโมง 36 นาที (เราไม่อ่าน Retry-After ของคุณ — ตารางคงที่เพื่อให้คาดเดาได้) |
| ไม่ตอบภายใน 15 วินาที | ล้มเหลว | retry · แนะนำให้ตอบภายใน 3 วินาที แล้วทำงานหนักแบบ async |
event ซ้ำต้องตอบ 200 ไม่ใช่ 409
เจอ event.id ที่เคยรับแล้ว ให้ตอบ 200 แล้วไม่ทำอะไร — ตอบ non-2xx = เรา retry ต่อ = คุณโดนถล่มเอง
รายละเอียดที่ การส่งซ้ำและ endpoint ล่ม
ขั้นต่อไป
| ถ้าคุณกำลัง… | ไปที่ |
|---|---|
แก้ 401 signature_invalid | การยืนยันตัวตนของ request |
เจอ 409 idempotency_* | Idempotency |
เจอ 422 sender_required | บัญชีผู้โอน (บังคับ) |
เจอ 402 insufficient_balance | กระเป๋าเงินและยอดคงเหลือ |
เจอ 410 late_slip_window_closed | เรื่องร้องเรียน (claims) |
อยากรู้ว่าฟิลด์ใน details แปลว่าอะไร | พจนานุกรม field |
| อยากลองให้เกิด error แต่ละตัวใน sandbox | Sandbox |
