เพิ่งเริ่มเชื่อมต่อ? เปิด Starter Guide · ทดสอบฝาก–ถอนด้วย Local Sandbox →
QUEST · PRODUCTION · API v1
Developer documentation
เอกสารทั้งหมด

Quest Merchant API — ฝากและถอน

คู่มือสำหรับโปรแกรมเมอร์ร้านค้า • API v1 • ตรวจทาน 24 กันยายน 2026

1. เริ่มเชื่อมต่อ

Production Base URL: https://quest-top-up.com

เอกสารนี้ใช้กับ Quest production การสร้างรายการฝากและถอนบนระบบนี้เกี่ยวข้องกับเงินจริง ไม่มี sandbox flag ให้เปิดในคำขอ Production หากต้องการทดสอบจำลอง ให้ขอบัญชีและ URL staging จาก Quest แยกต่างหาก

ร้านทำหน้าเว็บฝาก–ถอนของตัวเองและ backend สำหรับเรียก API พร้อม endpoint รับ Webhook ส่วน Quest เตรียมบัญชีร้าน, API key, Webhook secret, เรท และลงทะเบียน Webhook URL ให้ ร้านต้องได้รับการเปิดสิทธิ์ถอนใน production เพิ่มเติมก่อนเรียกขาถอน การสร้างร้านลูกไม่ได้เปิดสิทธิ์ถอนให้อัตโนมัติ

ข้อมูลที่ต้องเตรียม:

Username/password หลังบ้านใช้เข้าเว็บเท่านั้น ไม่ใช้แทน API key ร้านแต่ละระดับใช้ key ของร้านตัวเอง การเป็นร้านแม่ไม่ได้ทำให้เรียกดูรายการของร้านลูกด้วย key ร้านแม่ได้

2. Authentication และ endpoints

ส่ง X-Api-Key: <MERCHANT_API_KEY> ทุกคำขอ /v1/* และส่ง Content-Type: application/json เมื่อมี JSON body ไม่ส่ง X-Internal-Auth และไม่ส่ง API key ใน URL หรือ JavaScript หน้าเว็บ

MethodEndpointใช้ทำอะไร
GET/v1/balanceอ่านยอดรวมของร้าน และยอดแยกกระเป๋าเมื่อเปิดใช้สองกระเป๋า
GET/v1/balance/depositอ่านยอดกระเป๋าฝาก
GET/v1/balance/withdrawอ่านยอดกระเป๋าถอนและสถานะเปิดถอน
POST/v1/paymentsสร้างรายการฝาก/รับชำระและ QR
GET/v1/payments/{payment_id}ตรวจสถานะฝาก
POST/v1/payments/{payment_id}/cancelยกเลิกรายการฝากที่ยังยกเลิกได้
POST/v1/withdrawalsขอถอนเข้าบัญชีลูกค้า
GET/v1/withdrawals/{withdrawal_id}ตรวจสถานะถอน
GET/v1/withdrawals?merchant_ref=...ค้นรายการถอนด้วยเลขอ้างอิงร้าน
GET/v1/withdrawals/{withdrawal_id}/inquiryสอบถามผลล่าสุดจากผู้ให้บริการด้วย ID
GET/v1/withdrawals/inquiry?merchant_ref=...สอบถามผลล่าสุดจากผู้ให้บริการด้วยเลขอ้างอิงร้าน

ขาฝากเรียก payments ส่วนขาถอนเรียก withdrawals เพราะเป็นรายการคนละประเภท ไม่มี endpoint /v1/deposit หรือ /v1/withdraw ในสเปกนี้

ตัวอย่างอ่านยอด:

export QUEST_BASE_URL='https://quest-top-up.com'
# ตั้ง QUEST_API_KEY ผ่าน secret manager หรือ environment ฝั่ง backend
curl --fail-with-body "$QUEST_BASE_URL/v1/balance" \
  -H "X-Api-Key: $QUEST_API_KEY"
{"balance":10000,"currency":"THB","wallets":{"incoming":7000,"outgoing":3000,"outgoing_pending":0}}

สำหรับร้านที่เปิดสองกระเป๋า /v1/balance รวมยอด wallets.incoming และ wallets.outgoing; wallets.outgoing_pending คือยอดที่กำลังจัดสรรไปกระเป๋าถอนซึ่งยังไม่เข้าเงินจริง ร้านที่ยังใช้กระเป๋าเดียวจะไม่มีฟิลด์ wallets ยอดที่กันไว้ให้รายการถอนถูกหักแล้ว แต่ยอดรวมอาจยังรวมเงินสำรองคงที่ที่ถอนไม่ได้ จึงไม่ใช่ยอดที่รับประกันว่าถอนได้ทั้งหมด และไม่ใช่ยอด wallet ผู้ให้บริการขาออก

อ่านยอดแยกกระเป๋าด้วย API key เดิม:

curl --fail-with-body "$QUEST_BASE_URL/v1/balance/deposit" \
  -H "X-Api-Key: $QUEST_API_KEY"
curl --fail-with-body "$QUEST_BASE_URL/v1/balance/withdraw" \
  -H "X-Api-Key: $QUEST_API_KEY"

/v1/balance/deposit คืน {"balance":7000,"currency":"THB"} สำหรับยอดกระเป๋าฝาก ส่วน /v1/balance/withdraw คืน {"balance":3000,"currency":"THB","withdrawal_enabled":true} สำหรับยอดที่ใช้ถอนและสถานะรับคำขอถอนของร้าน ถ้ายังใช้กระเป๋าเดียว ยอดถอนคือยอดฝากหลังหักเงินสำรองคงที่โดยไม่ต่ำกว่า 0; withdrawal_enabled ไม่ได้ยืนยันว่ามียอดพอทำรายการ

ยอด balance และ wallets.* ใน API v1 เป็นตัวเลข JSON ซึ่งแสดงค่าได้ถูกต้องไม่เกิน 15 หลัก (รวมทศนิยม 2 ตำแหน่ง) หากยอดรวมเกินกว่านั้น endpoint ยอดคงเหลือทั้งสามจะตอบ HTTP 500 พร้อม {"error":"...","code":"balance_not_representable"} แทนการส่งตัวเลขที่ถูกปัดเศษ ให้ตรวจยอดจริงในหน้าหลังบ้าน

3. ฝากเงิน — สร้างรายการ

POST /v1/payments พร้อม Idempotency-Key ความยาว 1–128 ตัวอักษร ใช้ค่าใหม่สำหรับออเดอร์ใหม่ และใช้ค่าเดิมเมื่อ retry ออเดอร์เดิม

ฟิลด์ชนิดเงื่อนไข
amountnumberบังคับ; Quest ปัจจุบัน 20–100,000 บาท ทศนิยมไม่เกิน 2 ตำแหน่ง
merchant_refstringบังคับ; 1–64 ตัวอักษร เลขอ้างอิงจากร้าน
descriptionstringไม่บังคับ; ไม่เกิน 255 ตัวอักษร
return_urlstringไม่บังคับ; http(s) URL ไม่เกิน 500 ตัวอักษร สำหรับพาลูกค้ากลับเว็บร้าน
curl --fail-with-body "$QUEST_BASE_URL/v1/payments" \
  -H "X-Api-Key: $QUEST_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: deposit-order-0001' \
  -d '{"amount":1000,"merchant_ref":"deposit-order-0001","description":"เติมเงิน","return_url":"https://merchant.example/payment/result"}'

ตัวอย่าง response ใหม่ HTTP 201 (เลข ID และวันเวลาเป็นข้อมูลตัวอย่าง):

{
  "payment_id":"pay_EXAMPLE_0001",
  "status":"pending",
  "amount":1000,
  "currency":"THB",
  "merchant_ref":"deposit-order-0001",
  "checkout_url":"https://quest-top-up.com/p/pay_EXAMPLE_0001",
  "qr_data_url":null,
  "fee_amount":null,
  "net_amount":null,
  "paid_at":null,
  "expires_in_sec":1800,
  "created_at":"2026-09-19T10:00:00.000Z"
}

เปิด checkout_url ให้ลูกค้าดู QR หรือแสดงรูปจาก qr_data_url เมื่อมีค่า data:image/png;base64,... หาก QR ยังเป็น null ให้ใช้หน้าชำระเงินที่ระบบส่งกลับ ไม่ควรสร้างออเดอร์ใหม่เพียงเพราะรูปยังไม่พร้อม ค่า fee/net/paid_at อาจยังเป็น null ก่อนจ่ายสำเร็จ

expires_in_sec เป็นอายุ QR สำหรับสแกน (30 นาที) ไม่ใช่จำนวนวินาทีคงเหลือที่นับถอยหลังทุกครั้งที่เรียก GET; หลัง QR หมดอายุ Gateway คงรายการเป็น pending เพื่อรอผลชำระที่มาช้า และเปลี่ยนเป็น expired เมื่อเกิน 24 ชั่วโมงแล้วยังไม่พบการจ่าย หาก Waylar แจ้งว่ามีเงินค้างยืนยัน ระบบจะรอตรวจต่อ ให้ยึดสถานะล่าสุด ห้ามใช้การกลับ return_url เป็นหลักฐานว่าจ่ายแล้ว

4. ฝากเงิน — ตรวจสถานะและยกเลิก

curl --fail-with-body "$QUEST_BASE_URL/v1/payments/pay_EXAMPLE_0001" \
  -H "X-Api-Key: $QUEST_API_KEY"

curl --fail-with-body -X POST "$QUEST_BASE_URL/v1/payments/pay_EXAMPLE_0001/cancel" \
  -H "X-Api-Key: $QUEST_API_KEY"

GET และ cancel ที่สำเร็จคืน HTTP 200 พร้อม Payment object รูปแบบเดียวกับข้อ 3 หากจ่ายแล้ว cancel คืน 409 code already_paid

statusความหมาย / การจัดการ
createdระบบบันทึกรายการแล้ว อยู่ระหว่างเตรียมรับชำระ
pendingรอชำระ; ยังไม่เครดิตลูกค้า
paidยืนยันชำระแล้ว; ตรวจยอดและอ้างอิงก่อนเครดิตเพียงครั้งเดียว
cancelledยกเลิกแล้ว; ไม่เครดิตจากสถานะนี้
expiredQR หมดอายุ; อย่าอนุมานว่าลูกค้าไม่โอน หากมีหลักฐานให้แจ้งตรวจสอบ

รายการ expired อาจได้รับการยืนยันล่าช้าเป็น paid พร้อม Webhook payment.paid ที่มี late: true ได้ ต้องรับเหตุการณ์นี้อย่างไม่เครดิตซ้ำ

เรทตัวอย่าง 1.6%: ฝาก 1,000 บาท ค่าธรรมเนียม 16 บาท ร้านได้รับสุทธิ 984 บาท ยึด fee_amount และ net_amount ที่ API/Webhook ส่งจริง ไม่ hardcode เรทให้ทุกร้าน

5. ถอนเงิน — สร้างรายการ

POST /v1/withdrawals พร้อม Idempotency-Key ยาว 1–128 ตัวอักษร ASCII ที่มองเห็นได้ ไม่มีช่องว่าง

จำนวนเงิน amount คือยอดที่ลูกค้าได้รับ ค่าถอนบวกเพิ่มเป็นยอดหักจากร้าน เรทตัวอย่าง 0.2%: ถอน 300 บาท ค่าถอน 0.60 บาท ยอดหัก/กันเงินร้าน 300.60 บาท ค่าถอนปัดขึ้นถึงหน่วยสตางค์ เรทถอน API ตั้งแยกจากค่าธรรมเนียม Settlement

ฟิลด์ชนิดเงื่อนไข
amountnumber300–30,000 บาท ทศนิยมไม่เกิน 2 ตำแหน่ง
merchant_refstring1–64 ตัวอักษร; ไม่มี control characters; ไม่ซ้ำในร้าน
recipientobjectข้อมูลผู้รับตามตารางด้านล่าง
recipient fieldเงื่อนไข
typeaccount หรือ promptpay
bank_codeบังคับสำหรับ account: รหัสธนาคารตัวเลข 3 หลัก เช่น 004; ห้ามส่งสำหรับ promptpay
account_noaccount: 10–15 หลัก; promptpay: เบอร์ไทย 10 หลักเริ่ม 0 หรือเลขประจำตัว 13 หลัก
account_nameบังคับ; ชื่อบัญชี ไม่เกิน 120 ตัวอักษร
given_nameบังคับ; ชื่อผู้รับ ไม่เกิน 100 ตัวอักษร
surnameบังคับ; นามสกุลผู้รับ ไม่เกิน 100 ตัวอักษร
emailบังคับ อีเมลผู้รับที่ถูกต้อง ไม่เกิน 254 ตัวอักษรหลังตัดช่องว่าง ห้ามละฟิลด์ ส่งค่าว่าง หรือ null

ไม่ต้องส่ง tax_id หรือ address_country, address_city, address_street ระบบเติมเลขผู้เสียภาษีและที่อยู่บริษัทของ Quest ให้อัตโนมัติตาม API key ของร้าน คำขอเก่าที่ส่งฟิลด์เหล่านี้ยังตรวจรูปแบบตามเดิม แต่ใช้ค่าบริษัทจากเซิร์ฟเวอร์แทน ระบบบันทึกค่ากับรายการเพื่อให้ retry ใช้ข้อมูลเดิม

ส่ง account_no เป็น string เพื่อรักษาเลขศูนย์นำหน้า รับเฉพาะตัวเลข ASCII ช่องว่างและขีด โดยระบบตัดช่องว่าง/ขีดออก ข้อความชื่อต้องไม่ว่างและไม่มี control characters ไม่รับ currency, callback_url หรือฟิลด์อื่นนอกสเปกในคำขอถอน Production ห้ามส่ง test_scenario

ข้อมูลผู้รับด้านล่างเป็นตัวอย่าง ต้องแทนที่ด้วยข้อมูลผู้รับจริงที่ได้รับอนุญาตก่อนใช้งาน:

curl --fail-with-body "$QUEST_BASE_URL/v1/withdrawals" \
  -H "X-Api-Key: $QUEST_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: withdraw-order-0001' \
  -d '{
    "amount":300,
    "merchant_ref":"withdraw-order-0001",
    "recipient":{
      "type":"account","bank_code":"004","account_no":"0012345678",
      "account_name":"Example Recipient","given_name":"Example","surname":"Recipient",
      "email":"recipient@example.com"
    }
  }'

สำหรับ PromptPay เปลี่ยนเป็น "type":"promptpay","account_no":"0812345678" และเอา bank_code ออก โดยยังต้องส่งชื่อและที่อยู่ครบ การตรวจรูปแบบข้อมูลไม่ได้ยืนยันความเป็นเจ้าของหรือการมีอยู่ของบัญชีธนาคาร

6. ถอนเงิน — ผลตอบกลับและสถานะ

HTTP 202 หมายถึงรับคำขอแล้วและกันยอดไว้ ไม่ได้หมายความว่าโอนสำเร็จ:

{
  "withdrawal_id":"wd_EXAMPLE_0001",
  "merchant_ref":"withdraw-order-0001",
  "status":"queued",
  "reason":null,
  "estimated_payout_at":null,
  "amount":300,
  "fee_amount":0.6,
  "debit_amount":300.6,
  "currency":"THB",
  "reserved_amount":300.6,
  "charged_amount":0,
  "recipient":{"type":"account","bank_code":"004","account_no":"0012345678","account_name":"Example Recipient"},
  "simulated":false,
  "created_at":"2026-09-19T10:00:00.000Z",
  "updated_at":"2026-09-19T10:00:00.000Z"
}
statusความหมาย / การจัดการ
queuedรอ worker ประมวลผล
creatingอยู่ระหว่างสร้างคำสั่งกับผู้ให้บริการ
readyมีคำสั่งแล้ว รอดำเนินการจ่าย
waiting_fundsรอเงินฝั่งผู้ให้บริการขาออก; อาจมี reason provider_balance_insufficient
dispatchingอยู่ระหว่างส่งคำสั่งจ่าย
processingผู้ให้บริการกำลังประมวลผล
cancellingรอผลยืนยันยกเลิกจากผู้ให้บริการ ยังห้ามคืนเงินเอง
unknownยังไม่ทราบผลแน่นอน ให้ติดตามรายการเดิมและแจ้ง Quest ห้ามถือว่าล้มเหลวหรือสร้างรายการจ่ายซ้ำ
paidจ่ายสำเร็จ; reserved_amount = 0, charged_amount = debit_amount
rejectedปฏิเสธแน่นอน; reserved_amount = 0, charged_amount = 0

estimated_payout_at เป็นเวลาที่ผู้ให้บริการคาดว่าจะโอนออก (ISO 8601 UTC) แสดงเฉพาะสถานะ processing; หากไม่มีข้อมูลหรือรายการจบแล้วเป็น null เวลาอาจคลาดเคลื่อน ต้องตรวจสถานะและ Webhook ต่อ ห้ามถือว่าจ่ายสำเร็จหรือส่งถอนซ้ำเมื่อถึงเวลานี้

pending ไม่ใช่ค่า status ขาถอนในสเปก Quest หากหน้าเว็บร้านแสดง PENDING ให้เก็บ response จาก Quest เพื่อดูสถานะจริง ไม่ใช้ป้ายบนเว็บร้านเพียงอย่างเดียวตัดสินว่าระบบค้าง

fee_amount/debit_amount เป็นยอดที่บันทึกตอนสร้าง แม้ถูก rejected ก็อาจยังแสดงค่าเดิม ยอดที่เรียกเก็บจริงให้ดู charged_amount การปฏิเสธหลังกันเงินแล้วจะคืนยอดที่กันไว้ใน ledger เพียงครั้งเดียว

ตรวจสถานะด้วย ID หรือ merchant_ref:

curl --fail-with-body "$QUEST_BASE_URL/v1/withdrawals/wd_EXAMPLE_0001" \
  -H "X-Api-Key: $QUEST_API_KEY"
curl --fail-with-body --get "$QUEST_BASE_URL/v1/withdrawals" \
  -H "X-Api-Key: $QUEST_API_KEY" \
  --data-urlencode 'merchant_ref=withdraw-order-0001'

ทั้งสองแบบคืน HTTP 200 และ Withdrawal object เดิมที่มีสถานะล่าสุด GET อ่านสถานะที่ Quest บันทึก ไม่ได้บังคับยิง inquiry ผู้ให้บริการทันที แนะนำสำรองด้วย polling ทุก 30–60 วินาทีร่วมกับ Webhook และหยุดเมื่อ paid/rejected; ความถี่นี้เป็นแนวทาง integration ไม่ใช่ SLA ไม่มี merchant endpoint สำหรับ cancel รายการถอนในสเปกนี้

หากต้องการสอบถามสถานะล่าสุดจาก Waylar โดยตรง ใช้ API key และ IP allowlist เดิม:

curl "$QUEST_BASE_URL/v1/withdrawals/wd_example_001/inquiry" \
  --header 'X-Api-Key: <MERCHANT_API_KEY>'
curl --get "$QUEST_BASE_URL/v1/withdrawals/inquiry" \
  --data-urlencode 'merchant_ref=payout-order-001' \
  --header 'X-Api-Key: <MERCHANT_API_KEY>'

เห็นเฉพาะรายการของร้านเอง HTTP 200 คืน Withdrawal object เดิมพร้อม inquiry ที่มี result (checked หรือ no_provider_order), source (waylar หรือ mock), provider_order_code, provider_status (pending, processing, verified, rejected, unknown), checked_at และ consistent สถานะบนสุดยังเป็นสถานะที่ Quest บันทึก ส่วน provider_status เป็นผลจากผู้ให้บริการ ณ เวลาตรวจ

consistent เทียบ verified กับ paid, rejected กับ rejected, processing กับ processing, unknown กับ unknown, pending กับ ready/waiting_funds สถานะอาจคลาดกันระหว่าง worker ประมวลผล เมื่อไม่มีเลขออเดอร์ result เป็น no_provider_order และฟิลด์อื่นใน inquiry เป็น null ซึ่งไม่ยืนยันว่าไม่มีการจ่าย โดยเฉพาะรายการ creating/unknown ต้องให้ทีมตรวจสอบ ห้ามสร้างรายการใหม่เพื่อทดแทน

Inquiry อ่านอย่างเดียว ไม่สั่งจ่าย ไม่แก้สถานะ ไม่คืนเงิน ไม่ปล่อยยอดกันไว้ และไม่ส่ง callback แม้ตรวจพบ verified ระบบ worker เดิมยังเป็นผู้ปรับสถานะ โหมดจำลองไม่เร่งสถานะและไม่ติดต่อ Waylar แนะนำเว้น 30–60 วินาทีระหว่างการตรวจ

HTTP 401 คีย์ไม่ถูกต้อง, 403 ร้านถูกระงับหรือ IP ไม่ได้รับอนุญาต, 404 ไม่พบในร้านนี้, 422 เลขอ้างอิงไม่ถูกต้อง, 502 ตรวจผู้ให้บริการไม่ได้, 503 บัญชีผู้ให้บริการที่ผูกกับรายการไม่พร้อม กรณีตรวจไม่ได้ไม่ใช่ผลว่าจ่ายล้มเหลว ให้ตรวจรายการเดิมภายหลัง

7. Retry / Idempotency

8. Webhook — รับผลฝากและถอน

Quest ส่ง HTTP POST JSON ไปยัง URL ที่ลงทะเบียนกับร้าน ไม่ส่ง API key หรือ Webhook secret ไปใน callback ส่งลายเซ็นที่สร้างจาก secret แทน

Headerความหมาย
Content-Typeapplication/json
X-Gateway-Eventชื่อเหตุการณ์
X-Gateway-Deliveryเลขการส่ง ใช้กันการประมวลผลซ้ำ
X-Gateway-Signaturesha256=<hex> ของ HMAC-SHA256 บน raw body
Eventการจัดการ
payment.paidยืนยันฝากสำเร็จ; เทียบ payment_id, merchant_ref, ยอดและสกุลเงิน
payment.cancelledแจ้งยกเลิกฝาก
withdrawal.paidถอนสำเร็จ
withdrawal.rejectedถอนถูกปฏิเสธหลังรับคำขอ
withdrawal.unknownผลยังไม่แน่นอน; ติดตามต่อ ไม่คืนเงิน/ส่งจ่ายใหม่

ตัวอย่างฝากสำเร็จที่เรท 1.6%:

{"event":"payment.paid","payment_id":"pay_EXAMPLE_0001","merchant_ref":"deposit-order-0001","amount":1000,"paid_amount":1000,"fee_amount":16,"net_amount":984,"currency":"THB","paid_at":"2026-09-19T10:01:00.000Z","late":false}

ตัวอย่างยกเลิกฝาก:

{"event":"payment.cancelled","payment_id":"pay_EXAMPLE_0001","merchant_ref":"deposit-order-0001","amount":1000,"status":"cancelled"}

Webhook ถอนมี Withdrawal object ตามข้อ 6 เพิ่มฟิลด์ event เช่น withdrawal.paid โดย status เป็น paid และปรับ reserved_amount/charged_amount ตามตาราง ไม่ควรคาดหวังว่าฝากและถอนมี payload เหมือนกัน

ตรวจลายเซ็น Node.js (ตัวอย่างฟังก์ชันตรวจสอบ ไม่ใช่ receiver สำเร็จรูป):

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyQuestWebhook(rawBody, signature, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof signature !== 'string' ||
      !/^sha256=[0-9a-f]{64}$/.test(signature) || !secret) return false;
  const expected = Buffer.from('sha256=' + createHmac('sha256', secret)
    .update(rawBody).digest('hex'));
  const actual = Buffer.from(signature);
  return actual.length === expected.length && timingSafeEqual(actual, expected);
}

เก็บ request body เป็น bytes ก่อน JSON parser (เช่น express.raw({type:'application/json'}) สำหรับ route นี้ ก่อน express.json()) ห้าม parse แล้ว stringify กลับเพื่อตรวจลายเซ็น เมื่อผ่านแล้วค่อย parse JSON และอ่าน event/ID จาก body ที่ตรวจแล้ว พร้อมเทียบ X-Gateway-Event ให้ตรงกัน ลายเซ็นครอบ body เท่านั้น ไม่ครอบ delivery header และไม่มี signed timestamp ในสเปกนี้

ใช้ transaction ในฐานข้อมูลร้านเพื่อบันทึก event และเครดิต/อัปเดตออเดอร์อย่าง atomic: กันซ้ำทั้ง delivery ID และผลทางธุรกิจ เช่น payment_id ที่เครดิตแล้ว หรือ withdrawal_id ที่ปิดรายการแล้ว ตอบ 2xx หลังบันทึกลงฐานข้อมูลหรือ durable queue สำเร็จเท่านั้น การส่งซ้ำของเหตุการณ์เดิมให้ตอบ 2xx โดยไม่ลงยอดเพิ่ม

Webhook อาจซ้ำหรือสลับลำดับได้ ถ้าสถานะขัดกันให้ GET ตรวจล่าสุด ไม่ย้อน paid กลับ unknown จาก callback เก่า ตอบ 2xx เป็นเพียงยืนยันรับข้อความ ไม่ใช่คำสั่งให้ Quest ยืนยันเงิน

Timeout ส่ง Webhook 10 วินาที ถ้าปลายทางไม่ตอบ 2xx จะ retry โดยเว้น 30 วินาที, 2 นาที, 10 นาที และ 1 ชั่วโมง (รวมสูงสุด 5 ครั้ง) เวลาจริงขึ้นกับรอบ worker; หลังครบให้ใช้ GET ตรวจสอบและประสาน Quest ไม่ควรพึ่ง Webhook อย่างเดียว

9. HTTP errors และการแก้ไข

{"error":"withdrawals are not enabled for this merchant and environment","code":"withdrawals_disabled"}
HTTP / codeความหมาย / การจัดการ
400JSON ผิดรูปแบบ หรือ upstream ปฏิเสธ; อ่าน error
401 invalid_api_keykey ไม่ถูกต้อง/ไม่ใช่ของ environment นี้ หรือร้านถูกปิดใช้งาน
403 merchant_suspended / merchant_unavailableร้านถูกระงับหรือไม่พร้อมใช้งาน
403 withdrawals_disabledร้านยังไม่ได้เปิดสิทธิ์ถอนใน environment นี้
403 ip_not_allowedIP ต้นทางไม่อยู่ในรายการที่ร้านอนุญาตไว้; ตรวจ IP ขาออกจริงของเซิร์ฟเวอร์แล้วให้ร้านเพิ่มในหลังบ้าน
404 not_foundไม่มีรายการในขอบเขต key ร้านนี้
409 idempotency_conflictkey/ref ซ้ำกับข้อมูลที่ต่างกัน; อย่าสุ่มเปลี่ยน key เพื่อแก้
409 already_paidยกเลิกฝากที่จ่ายสำเร็จแล้วไม่ได้
413body ใหญ่เกินกำหนด
422ข้อมูลไม่ผ่าน validation, key ขาด/ผิดรูปแบบ หรือถอนถูกปฏิเสธทันที
429ถ้าพบการจำกัดจากชั้นบริการ ให้ลดความถี่และใช้ retry แบบ backoff
500 / 502ระบบหรือ upstream ขัดข้อง; ตรวจรายการเดิมก่อน retry
503 withdrawals_disabledระบบขาถอนถูกปิดระดับบริการ

ร้านเปิด "IP ที่อนุญาต (API)" ได้เองในหลังบ้าน เมื่อเปิดบังคับใช้ ระบบจะรับเฉพาะ call ที่มาจาก IP หรือ CIDR ในรายการของร้านเท่านั้น call จาก IP อื่นจะได้ 403 ip_not_allowed ทันทีแม้ API key ถูกต้อง มีผลทั้ง /v1/payments และ /v1/withdrawals ก่อนเปิดให้ตรวจ IP ขาออกจริงของเซิร์ฟเวอร์ที่ยิง API (หน้าหลังบ้านแสดง IP ที่ระบบเห็นล่าสุด 30 วันให้กดเพิ่มได้) ถ้าร้านย้ายเครื่องหรือเปลี่ยนผู้ให้บริการคลาวด์ ต้องเพิ่ม IP ใหม่ก่อน ไม่เช่นนั้นรายการจะถูกปฏิเสธทั้งหมด

ข้อผิดพลาดทั่วไปมี error และอาจมี code; validation error อาจไม่มี code ขาถอน 422 แบบ rejected เป็น Withdrawal object ไม่ใช่ Error object โดย reason เช่น insufficient_balance หรือ recipient_blocked จึงต้องตรวจรูปแบบ response ก่อน

10. ลำดับทดสอบและส่งเคสให้ Quest

  1. ตรวจ key ด้วย GET balance และยืนยันว่าเป็น Quest environment ที่ถูกต้อง
  2. ฝาก: สร้างหนึ่งรายการ เปิด checkout_url ชำระตามแผนทดสอบที่อนุมัติ แล้วตรวจ GET + Webhook + เครดิตลูกค้าครั้งเดียว
  3. ทดสอบ retry key เดิม และ replay Webhook เพื่อยืนยันว่าไม่ลงยอดซ้ำ
  4. ถอน: ให้ Quest เปิดสิทธิ์และตรวจยอดร้าน/เงินพร้อมจ่ายก่อน ส่งคำขอหนึ่งรายการ เก็บ withdrawal_id และตามจน paid/rejected
  5. ทดสอบลายเซ็นผิด, timeout และ error cases บน staging หรือชุดทดสอบของร้าน อย่าจำลองการจ่ายจริงซ้ำใน production
  6. การโอน Settlement ให้ร้านเป็นคนละกระบวนการกับ API ถอนให้ลูกค้า ใช้รายงานรอบจ่ายเพื่อตรวจยอด ไม่ใช้การรับ Webhook แทนหลักฐาน Settlement

หากรายการค้าง ให้ส่ง Base URL, merchant code, payment_id หรือ withdrawal_id, merchant_ref, ยอด, เวลาพร้อม timezone, HTTP status และ response ที่ปิดข้อมูลส่วนบุคคลแล้ว ไม่ส่ง API key, Webhook secret หรือรหัสผ่าน

IP whitelist ไม่ใช่ header ที่ต้องส่งใน API contract นี้ หากร้านจำกัดเครือข่ายรับ Webhook ให้ขอข้อมูล IP ขาออกปัจจุบันจาก Quest ก่อนเปิดระบบ ไม่ใช้ IP เก่าจากเอกสารเป็น allowlist โดยไม่มีการยืนยัน

11. ดาวน์โหลดและขอบเขตเอกสาร

เอกสารนี้อธิบาย API contract และการตั้งค่า Quest ที่ตรวจ ณ วันที่ข้างต้น ไม่ได้ยืนยันว่าทุกร้านเปิดถอนแล้ว และไม่ใช่หลักฐานผ่านการทดสอบเงินจริงของแต่ละร้าน จำนวนเงินในตัวอย่างเป็นเพียงตัวอย่าง; ใช้เรทที่ตกลงและยอดที่ตอบกลับจริง