Skip to content

Error code ทั้งหมด ​

ทุก endpoint ใช้รูปแบบ error เดียวกัน ไม่มีข้อยกเว้น

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_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
retryabletrue = ยิงซ้ำแล้วมีโอกาสสำเร็จ · 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 มากกว่าเจ้าอื่น

โค้ดที่เขียนแบบนี้จะพังทั้งเส้นฝากและเส้นอัปสลิป

js
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 ที่ยังยืนยันไม่ครบตอบ retryable 503

เงื่อนไขที่ถูกคือ 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 เรียงตามตัวอักษร ​

codeHTTPretry ได้ไหมไปหมวด
alloc_state_conflict409✅ชนสถานะ
amount_not_supported422❌รูปแบบ request
api_key_invalid401❌ยืนยันตัวตน
api_key_revoked401❌ยืนยันตัวตน
beneficiary_not_verified409✅ฝั่งถอน
cursor_filter_mismatch400❌รูปแบบ request
deposit_not_cancelable409❌ฝั่งฝาก
duplicate_open_deposit409❌ฝั่งฝาก
idempotency_in_flight409✅Idempotency
idempotency_key_required400❌รูปแบบ request
idempotency_key_reused409❌Idempotency
insufficient_balance402✅ ตามเวลายอดเงินไม่พอ
internal_error500✅ระบบตึง
late_slip_window_closed410❌หน้าต่างปิดแล้ว
malformed_json400❌รูปแบบ request
nonce_replayed401❌ยืนยันตัวตน
rate_limited429✅ระบบตึง
resource_not_found404❌หาไม่เจอ
review_note_required422❌รูปแบบ request
route_unavailable503✅ระบบตึง
scope_denied403❌ยืนยันตัวตน
sender_bank_unavailable409✅ฝั่งฝาก
sender_required422❌รูปแบบ request
service_unavailable503✅ระบบตึง
signature_invalid401❌ยืนยันตัวตน
tenant_suspended403❌ยืนยันตัวตน
timestamp_out_of_window401✅ หลัง sync นาฬิกายืนยันตัวตน
too_many_open_deposits409❌ฝั่งฝาก
unsupported_media_type400❌รูปแบบ request
validation_error422❌รูปแบบ request
withdrawal_has_pending_fill409✅ฝั่งถอน
withdrawal_not_cancelable409❌ฝั่งถอน
withdrawal_state_conflict409✅ชนสถานะ

ยืนยันตัวตนและสิทธิ์ (401 · 403) ​

codeHTTPความหมาย (ไทย)Meaning (EN)retryต้องทำอะไรต่อ
signature_invalid401canonical string ประกอบผิด หรือลายเซ็นไม่ตรงCanonical string or signature does not match.❌เทียบกับ test vector ทั้ง 3 ชุด ก่อนแก้อย่างอื่น · จุดที่พลาดบ่อยที่สุดคือลืมใส่ Idempotency-Key และ PG-Version ลงใน canonical
timestamp_out_of_window401PG-Timestamp ห่างจากเวลาจริงเกิน ±300 วินาทีRequest timestamp is outside the ±300s window.✅sync นาฬิกาเครื่องด้วย NTP แล้วยิงใหม่ · 🔴 อย่าแก้ด้วยการขยับ timestamp เอง
nonce_replayed401PG-Nonce ซ้ำภายใน 10 นาทีThe nonce was already used within 10 minutes.❌สร้าง nonce ใหม่ทุก attempt · 🔴 แต่ Idempotency-Key ต้องคงเดิม สองค่านี้คนละหน้าที่กัน
api_key_invalid401key ไม่มีอยู่จริงAPI key does not exist.❌ตรวจว่าใช้ key ของ environment ถูก (sk_live_* กับ sk_test_* ข้ามกันไม่ได้)
api_key_revoked401key ถูกเพิกถอน (ตรวจทั้งสายบัญชีทุก merchant-HMAC request)API key has been revoked.❌ติดต่อผู้ให้บริการแบรนด์ของคุณเพื่อออก key ใหม่ · ออร์เดอร์ที่เปิดค้างอยู่ ไม่ขยับ จากเหตุนี้
tenant_suspended403บัญชีชั้นใดชั้นหนึ่งในสายถูกระงับAn account in the chain is suspended.❌ติดต่อผู้ให้บริการแบรนด์ของคุณ · หยุดสร้างรายการใหม่ แต่ยังรับ webhook ของรายการเดิมได้
scope_denied403key นี้ไม่มีสิทธิ์เรียก 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) ​

codeHTTPความหมาย (ไทย)Meaning (EN)retryต้องทำอะไรต่อ
idempotency_key_required400ไม่ส่ง header Idempotency-Key บน POSTMissing Idempotency-Key header on a POST.❌เพิ่ม header · 🔴 สร้าง key แล้วเขียนลง DB ก่อนยิง ไม่ใช่ generate ตอนยิง
malformed_json400body ไม่ใช่ JSON ที่ parse ได้Request body is not valid JSON.❌แก้โค้ด
unsupported_media_type400Content-Type ไม่ตรงกับที่ endpoint รับUnsupported Content-Type.❌JSON ใช้ application/json · อัปสลิปแบบไฟล์ใช้ multipart/form-data
cursor_filter_mismatch400เปลี่ยน filter กลางคันขณะไล่หน้าPagination filters changed mid-cursor.❌เปลี่ยน limit ระหว่างไล่หน้าได้ · เปลี่ยน from/to/type/status/order ไม่ได้ ต้องเริ่มไล่ใหม่
validation_error422field ผิดรูปแบบOne or more fields failed validation.❌อ่าน details.fields[] ซึ่งบอกชื่อ field กับเหตุผลรายตัว
sender_required422ไม่ส่งทั้ง sender{} และ sender_id ตอนสร้างรายการฝากNeither sender nor sender_id was provided.❌🔴 ต้องเพิ่มหน้าจอเลือกบัญชีผู้โอน ดูด้านล่าง
amount_not_supported422ยอดต่ำหรือสูงกว่าที่นโยบายอนุญาต หรือมีเศษสตางค์Amount is outside policy limits or not a round amount.❌ใช้ details.min_minor / details.max_minor · 🔴 ยอดต้องกลม ไม่มีเศษสตางค์
review_note_required422โน้ตประกอบการตัดสินของเจ้าหน้าที่สั้นกว่า 20 ตัวอักษร (endpoint ฝั่ง back office)The reviewer note is shorter than 20 characters.❌เขียนโน้ตให้ครบ 20 ตัวอักษรขึ้นไป

422 sender_required — เรื่องนี้ต่างจากตลาด และแก้ผิดวิธีได้ง่ายมาก

เงินของเราเข้า บัญชีบุคคลของผู้ถอนโดยตรง ไม่มีบัญชีกลางในเส้นทางปกติ ⇒ ระบบต้องรู้ตั้งแต่ก่อนโอนว่า "สลิปใบไหนเป็นของรายการไหน" การจับคู่ทำด้วย ตัวตนผู้โอน ไม่ใช่ด้วยเศษสตางค์

ทางแก้ที่ผิดและเจอบ่อยที่สุด: ยัดชื่อเจ้าของบัญชีจากฐานข้อมูลตัวเองลงไปให้ผ่าน 422 ⇒ ชื่อไม่ตรงกับสลิปจริง ⇒ ทุกรายการเข้า under_review และคิวเจ้าหน้าที่ล้นตั้งแต่วันแรก

ทางแก้ที่ถูก มี 2 ทาง

  1. GET /v1/pay/senders?end_user_ref=u_88213 แล้วให้ผู้ใช้ เลือก บัญชีที่เคยยืนยันแล้ว → ส่ง sender_id · การันตีว่าไม่เข้าคิวเพราะชื่อหรือบัญชีไม่ตรง
  2. ผู้ใช้ใหม่ที่ยังไม่มีบัญชีในระบบ → ให้กรอก sender{bank_code, account_number, account_name_th} · 🔴 ห้ามมีคำนำหน้า (นาย/นาง/น.ส./คุณ) ในชื่อ

รายละเอียดและตัวอย่างหน้าจอที่ บัญชีผู้โอน (บังคับ)

ตัวอย่าง 422 validation_error เต็มใบ

json
{
  "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) ​

codeHTTPความหมาย (ไทย)Meaning (EN)retryต้องทำอะไรต่อ
insufficient_balance402ยอดที่ถอนได้ต่ำกว่ายอดถอนบวกค่าธรรมเนียมAvailable balance is lower than amount plus fee.✅ หลัง details.pending_clearing_available_atปลดล็อกยอดฝั่งผู้ใช้คืนทันที แล้วอธิบายด้วยเวลาที่ยอดจะพร้อม

สองเรื่องที่ทำให้ 402 งงที่สุด

  1. ค่าธรรมเนียมฝั่งถอนของเราบวกเพิ่ม ไม่ใช่หักจากยอดถอน ⇒ ต้องมี available_minor ≥ amount_minor + fee_total_minor · เช็คล่วงหน้าด้วย GET /v1/pay/quote?channel=withdrawal&amount_minor=1000000
  2. ยอดที่เพิ่งเข้าจากรายการฝากยังถอนไม่ได้ทันที — อยู่ใน pending_clearing ก่อน 72 ชั่วโมง แล้วจึงย้ายไป available (คุณจะได้ event balance.cleared ตอนนั้น) ⇒ dashboard เห็นเงิน แต่สร้างรายการถอนได้ 402 เป็นเรื่องปกติ

🔴 เมื่อได้ 402 จะไม่มีออร์เดอร์ถูกสร้าง และไม่มีการล็อกเงินใด ๆ ⇒ ถ้าโค้ดคุณล็อกยอดผู้ใช้ไว้ก่อนยิง ต้องปลดล็อกคืนในกิ่งนี้เสมอ ไม่งั้นยอดผู้ใช้ค้างล็อกถาวร


หาไม่เจอ (404) ​

codeHTTPความหมาย (ไทย)Meaning (EN)retryต้องทำอะไรต่อ
resource_not_found404id ไม่มีอยู่ หรืออยู่นอกขอบเขตบัญชีของคุณThe id does not exist or is out of your account scope.❌ตรวจ id · 🔴 เราไม่แยกสองเคสนี้ออกจากกันโดยตั้งใจ (ไม่งั้นจะกลายเป็นเครื่องมือไล่เดา id)

Idempotency (409) ​

codeHTTPความหมาย (ไทย)Meaning (EN)retryต้องทำอะไรต่อ
idempotency_key_reused409key เดิม แต่ body ต่างจากครั้งแรกSame key, different body.❌เปลี่ยน body = ต้องเปลี่ยน key · details.original_request_id ชี้ไป request แรก
idempotency_in_flight409key เดิม และ 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) ​

codeHTTPความหมาย (ไทย)Meaning (EN)retrydetails มีอะไรต้องทำอะไรต่อ
sender_bank_unavailable409ธนาคารของผู้โอนปิดรับรายการใหม่ชั่วคราวThe sender's bank is not accepting new orders.✅alternative_banks[] · public_message_th · retry_after_hint_secondsเสนอธนาคารอื่นจาก alternative_banks[] · 🔴 สลิปของรายการที่เปิดค้างอยู่แล้วยังส่งได้เสมอ ห้ามปิดปุ่มอัปสลิป
duplicate_open_deposit409มีรายการ awaiting_payment ยอดเท่ากันและผู้โอนคนเดียวกันค้างอยู่An identical open deposit already exists.❌open_deposit_ids[]พาผู้ใช้กลับไปที่รายการเดิม (open_deposit_ids[0]) · นี่คือการกัน AMBIGUOUS_MATCH ตั้งแต่ต้นทาง
too_many_open_deposits409เกินจำนวนรายการที่ยังไม่จ่ายที่เปิดพร้อมกันได้Too many unpaid deposits are open.❌open_deposit_ids[]ให้ผู้ใช้ปิดหรือยกเลิกรายการเดิมก่อน · 🔴 ถ้าไม่ส่ง client{} เพดานจะเหลือ 1
deposit_not_cancelable409ยกเลิกไม่ได้ เพราะมีสลิปแล้วหรืออยู่ในสถานะที่ยกเลิกไม่ได้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) ​

codeHTTPความหมาย (ไทย)Meaning (EN)retryต้องทำอะไรต่อ
withdrawal_has_pending_fill409ยกเลิกไม่ได้ตอนนี้ เพราะมีก้อนที่กำลังจับคู่ค้างอยู่ (reserved_minor > 0)A fill is in progress; cancel is blocked.✅ (retry_after_seconds)รอแล้วลองใหม่ · 🔴 กลไกนี้มีไว้กันการยึดเงินคนที่กำลังโอนอยู่ ไม่ใช่บั๊ก
withdrawal_not_cancelable409ใบนี้ settled / expired / canceled ไปแล้วThe withdrawal is already in a closed state.❌อ่านสถานะล่าสุดจาก GET /v1/pay/withdrawals/{id}
beneficiary_not_verified409บัญชีผู้รับเงินยังไม่ผ่านการยืนยันชื่อกับธนาคารThe beneficiary account is not verified yet.✅ หลังยืนยันผ่านรอ webhook beneficiary.verified (ปกติ < 30 วินาที) แล้วค่อยสร้างใบถอน · 🔴 ไม่มีออร์เดอร์ถูกสร้าง ไม่มีการล็อกเงิน

ชนสถานะระหว่างทาง (409) ​

codeHTTPความหมาย (ไทย)Meaning (EN)retryต้องทำอะไรต่อ
alloc_state_conflict409ก้อนย่อยขยับสถานะไปแล้วระหว่างที่ request วิ่งThe underlying fill changed state mid-request.✅อ่านสถานะล่าสุดด้วย GET แล้วตัดสินใจใหม่
withdrawal_state_conflict409ใบถอนขยับสถานะไปแล้วระหว่างที่ request วิ่งThe withdrawal changed state mid-request.✅เหมือนบน

ทำไมสองตัวนี้เป็น 409 ไม่ใช่ 500

เพราะมันไม่ใช่ความผิดพลาด — เป็นการแข่งกันตามปกติของระบบที่มีคนโอนพร้อมกันหลายคน · เราตั้งใจ map ให้เป็น 409 ที่ retryable: true เพื่อให้โค้ดคุณ "อ่านใหม่แล้วลองใหม่" แทนที่จะปลุกคนขึ้นมากลางดึก


หน้าต่างปิดแล้ว (410) ​

codeHTTPความหมาย (ไทย)Meaning (EN)retryต้องทำอะไรต่อ
late_slip_window_closed410อัปสลิปหลังหมดหน้าต่างอัปสลิปย้อนหลัง 24 ชั่วโมงThe 24-hour late-slip window has closed.❌เปิดเรื่องผ่าน details.claim_url → POST /v1/bo/claims · 🔴 ห้ามจบเรื่องแค่ขึ้น error เพราะเงินผู้ใช้ออกจากบัญชีไปแล้วจริง
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": {
      "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) ​

codeHTTPความหมาย (ไทย)Meaning (EN)retryต้องทำอะไรต่อ
rate_limited429คุณ ยิงเกินโควตาของตัวเองYou exceeded your own rate limit bucket.✅เคารพ Retry-After + jitter ±20% · 🔴 ห้าม retry เป็นลูปแน่น มีบทลงโทษทวีคูณ · ดู details.bucket และ details.scope ว่าชน bucket ไหน
route_unavailable503ตอนนี้ไม่มีช่องทางเปิดรายการฝากใหม่ได้No route is currently available to open a new deposit.✅แสดง "ระบบหนาแน่น ลองใหม่อีกครั้ง" + ใช้ Retry-After · 🔴 ไม่ใช่บั๊ก ไม่ใช่ 500
service_unavailable503ระบบ กำลังลดโหลดตัวเองThe system is shedding load.✅เหมือนบน
internal_error500ของเราพังจริง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 เต็ม ๆ ​

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

js
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
<?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 แต่ละตัวใน sandboxSandbox

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