# 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 เพิ่มเติมก่อนเรียกขาถอน การสร้างร้านลูกไม่ได้เปิดสิทธิ์ถอนให้อัตโนมัติ

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

- API key ของร้านจาก Quest (`gw_live_...`) เก็บเฉพาะฝั่ง backend
- Webhook secret สำหรับตรวจลายเซ็น เป็นคนละค่ากับ API key
- HTTPS Webhook URL ที่เข้าถึงได้ ใช้ URL เดียวรับผลทั้งฝากและถอนได้
- ยอดคงเหลือและเรทฝาก/ถอนที่ตกลงกับ Quest
- ฝั่งรับ Webhook ต้องตอบโดยไม่ redirect และรับ JSON ได้

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 หน้าเว็บ

| Method | Endpoint | ใช้ทำอะไร |
|---|---|---|
| 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` ในสเปกนี้

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

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

```json
{"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 เดิม:

```bash
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 ออเดอร์เดิม

| ฟิลด์ | ชนิด | เงื่อนไข |
|---|---|---|
| amount | number | บังคับ; Quest ปัจจุบัน 20–100,000 บาท ทศนิยมไม่เกิน 2 ตำแหน่ง |
| merchant_ref | string | บังคับ; 1–64 ตัวอักษร เลขอ้างอิงจากร้าน |
| description | string | ไม่บังคับ; ไม่เกิน 255 ตัวอักษร |
| return_url | string | ไม่บังคับ; http(s) URL ไม่เกิน 500 ตัวอักษร สำหรับพาลูกค้ากลับเว็บร้าน |

```bash
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 และวันเวลาเป็นข้อมูลตัวอย่าง):

```json
{
  "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. ฝากเงิน — ตรวจสถานะและยกเลิก

```bash
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 | ยกเลิกแล้ว; ไม่เครดิตจากสถานะนี้ |
| expired | QR หมดอายุ; อย่าอนุมานว่าลูกค้าไม่โอน หากมีหลักฐานให้แจ้งตรวจสอบ |

รายการ 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

| ฟิลด์ | ชนิด | เงื่อนไข |
|---|---|---|
| amount | number | 300–30,000 บาท ทศนิยมไม่เกิน 2 ตำแหน่ง |
| merchant_ref | string | 1–64 ตัวอักษร; ไม่มี control characters; ไม่ซ้ำในร้าน |
| recipient | object | ข้อมูลผู้รับตามตารางด้านล่าง |

| recipient field | เงื่อนไข |
|---|---|
| type | `account` หรือ `promptpay` |
| bank_code | บังคับสำหรับ account: รหัสธนาคารตัวเลข 3 หลัก เช่น `004`; ห้ามส่งสำหรับ promptpay |
| account_no | account: 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`

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

```bash
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` หมายถึงรับคำขอแล้วและกันยอดไว้ ไม่ได้หมายความว่าโอนสำเร็จ:

```json
{
  "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:

```bash
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 เดิม:

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

- บันทึก Idempotency-Key, merchant_ref, body และ ID ที่ตอบกลับในฐานข้อมูลร้านก่อนเริ่ม retry
- Timeout/5xx: อย่าสร้างเลขอ้างอิงใหม่ ส่งซ้ำด้วย key/ref/body เดิม หรือ GET รายการเดิมก่อน
- ฝาก: คำขอซ้ำ key เดิมคืน HTTP 200 และ payment เดิม; เปลี่ยนยอดด้วย key เดิมคืน 409 การเปลี่ยน merchant_ref/description/return_url ด้วย key เดิมไม่ได้สร้างรายการใหม่ ต้องเก็บ body เดิมเอง และห้ามเปลี่ยน key เพื่อ retry เพราะอาจเกิดรายการใหม่
- ถอน: key และ ref ผูกกับเนื้อหาคำขอทั้งหมด ส่งซ้ำเหมือนเดิมคืน 200; ถ้ารายการเดิม rejected จะคืน 422 พร้อมรายการเดิม; เปลี่ยนข้อมูลหรือใช้ ref เดิมกับ key ใหม่คืน 409
- ถอน rejected ทันทีเพราะยอดร้านไม่พอหรือบัญชีถูกบล็อกมี withdrawal_id และไม่กันเงิน กรณีนี้ไม่มี Webhook เพิ่ม ต้องจัดการจาก response 422
- หลังแก้สาเหตุ rejected แล้วจึงตั้งใจสร้างคำขอใหม่ด้วย key/ref ใหม่ ห้ามทำเช่นนี้เมื่อสถานะ unknown หรือ timeout ที่ยังไม่ทราบผล

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

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

| Header | ความหมาย |
|---|---|
| Content-Type | `application/json` |
| X-Gateway-Event | ชื่อเหตุการณ์ |
| X-Gateway-Delivery | เลขการส่ง ใช้กันการประมวลผลซ้ำ |
| X-Gateway-Signature | `sha256=<hex>` ของ HMAC-SHA256 บน raw body |

| Event | การจัดการ |
|---|---|
| payment.paid | ยืนยันฝากสำเร็จ; เทียบ payment_id, merchant_ref, ยอดและสกุลเงิน |
| payment.cancelled | แจ้งยกเลิกฝาก |
| withdrawal.paid | ถอนสำเร็จ |
| withdrawal.rejected | ถอนถูกปฏิเสธหลังรับคำขอ |
| withdrawal.unknown | ผลยังไม่แน่นอน; ติดตามต่อ ไม่คืนเงิน/ส่งจ่ายใหม่ |

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

```json
{"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}
```

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

```json
{"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 สำเร็จรูป):

```javascript
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 และการแก้ไข

```json
{"error":"withdrawals are not enabled for this merchant and environment","code":"withdrawals_disabled"}
```

| HTTP / code | ความหมาย / การจัดการ |
|---|---|
| 400 | JSON ผิดรูปแบบ หรือ upstream ปฏิเสธ; อ่าน error |
| 401 invalid_api_key | key ไม่ถูกต้อง/ไม่ใช่ของ environment นี้ หรือร้านถูกปิดใช้งาน |
| 403 merchant_suspended / merchant_unavailable | ร้านถูกระงับหรือไม่พร้อมใช้งาน |
| 403 withdrawals_disabled | ร้านยังไม่ได้เปิดสิทธิ์ถอนใน environment นี้ |
| 403 ip_not_allowed | IP ต้นทางไม่อยู่ในรายการที่ร้านอนุญาตไว้; ตรวจ IP ขาออกจริงของเซิร์ฟเวอร์แล้วให้ร้านเพิ่มในหลังบ้าน |
| 404 not_found | ไม่มีรายการในขอบเขต key ร้านนี้ |
| 409 idempotency_conflict | key/ref ซ้ำกับข้อมูลที่ต่างกัน; อย่าสุ่มเปลี่ยน key เพื่อแก้ |
| 409 already_paid | ยกเลิกฝากที่จ่ายสำเร็จแล้วไม่ได้ |
| 413 | body ใหญ่เกินกำหนด |
| 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. ดาวน์โหลดและขอบเขตเอกสาร

- [OpenAPI สำหรับฝากและถอน](/docs/quest/openapi.yaml)
- [คู่มือ Markdown](/docs/quest/guide.md)
- [หน้าคู่มือบนเว็บ](/docs/quest)

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