เริ่มที่นี่
หน้านี้เป็นไฟล์ HTML ไฟล์เดียว ไม่ต้องติดตั้งอะไร ไม่ต้องต่อเน็ต เปิดจากเครื่องได้เลย — ใช้จำลอง webhook ที่ระบบ LOCALPAYY จะยิงเข้า endpoint ของคุณ เพื่อให้เขียนโค้ดฝั่งรับได้ครบทุกเคสก่อนต่อของจริง
4 ขั้นตอน
| 1 | เปิดแท็บ “เครื่องมือยิง Webhook” กรอก URL ปลายทางของคุณ และ HMAC secret (ถ้าใช้) |
| 2 | เลือก event จากรายการ · ตัวอย่าง payload จะขึ้นมาให้ แก้ไขได้ตามใจ |
| 3 | กด “ยิง” ระบบจะคำนวณ x-signature ให้เหมือนของจริง แล้วส่ง POST พร้อมโชว์ทุก header + response ที่ได้กลับ |
| 4 | ยิงเป็นชุด เพื่อจำลองทั้ง lifecycle ตามลำดับจริง เช่น จ่ายทีละขา → ปิดจ๊อบ |
สิ่งที่ต้องเตรียม
| app_callback_url | URL ที่รับ webhook (ต้องเป็น HTTPS บน production) |
| app_hmac_signature | secret สำหรับตรวจลายเซ็น — แนะนำอย่างยิ่งให้เปิด |
| api_key | สำหรับเรียก API ฝั่งขาออก |
ทั้งสามค่าตั้งจากฝั่งเรา แจ้งทีมงานเพื่อขอ/แก้ไข
กฎ 3 ข้อที่พลาดบ่อยที่สุด
| ① | ตอบ HTTP 200 ให้เร็ว (<10 วินาที) แล้วค่อยไปประมวลผลต่อ — ช้ากว่านั้นนับเป็น timeout |
| ② | อย่าตัดสินผลจาก ชื่อ event อย่างเดียว — อ่าน data.outcome เสมอ |
| ③ | ต้อง กันซ้ำเอง ด้วย X-Idempotency-Key |
ข้อเท็จจริงที่ต้องรู้
ถ้า endpoint คุณล่มหรือตอบ 5xx ระบบจะไม่ยิงซ้ำอัตโนมัติ — จะบันทึกไว้ว่าส่งไม่สำเร็จ แล้วต้องสั่งส่งใหม่ผ่าน retryWebhook หรือ poll เอาสถานะจาก API แทน
รายละเอียดอยู่ในหัวข้อ “รูปแบบการส่ง”
ภาพรวม: ใครยิงอะไร ตอนไหน
เครื่องมือยิง Webhook
ยิงจริงจากเบราว์เซอร์ พร้อมลายเซ็นที่คำนวณเหมือน production ทุกประการ · ค่าที่กรอกเก็บไว้ในเครื่องคุณเท่านั้น
ยิงแล้วขึ้น “Failed to fetch” ทำยังไง?
เบราว์เซอร์บล็อกด้วย CORS — ไม่ใช่ endpoint คุณพัง คำขอไม่ได้ถูกส่งออกไปด้วยซ้ำ เลือกทางใดทางหนึ่ง:
| ง่ายสุด | กด “คัดลอกเป็น curl” แล้ววางรันใน terminal — ลายเซ็นถูกคำนวณไว้แล้ว (ใช้ได้ภายใน 5 นาที) |
| dev | ใส่ header Access-Control-Allow-Origin: * และตอบ OPTIONS ด้วย 204 เฉพาะบนเครื่อง dev |
| ไม่แตะโค้ด | ใช้ webhook.site ดู payload ก่อน แล้วค่อยย้ายมา endpoint จริงด้วย curl |
หรือรัน relay สั้นๆ นี้ไว้ที่ http://localhost:8787 แล้วกรอก URL นั้นแทน:
// relay.mjs — node relay.mjs (ใช้เฉพาะตอน dev)
import http from 'node:http'
const TARGET = 'http://localhost:3000/localpayy/webhook' // endpoint จริงของคุณ
http.createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', '*')
if (req.method === 'OPTIONS') return res.writeHead(204).end()
const chunks = []; for await (const c of req) chunks.push(c)
const body = Buffer.concat(chunks)
const r = await fetch(TARGET, { method: 'POST', headers: req.headers, body })
res.writeHead(r.status, { 'content-type': 'application/json' })
res.end(await r.text())
}).listen(8787, () => console.log('relay → 8787'))
เลือก Event
ผลการยิง
รูปแบบการส่ง
ทุก event ใช้ซองเดียวกันหมด เขียน handler ตัวเดียวรับได้ทั้งระบบ
ซองบนสาย (wire envelope)
HTTP POST ไปที่ app_callback_url ของคุณ · body เป็น JSON 4 ฟิลด์นี้เสมอ
{
"event": "WITHDRAWAL_COMPLETED", // ชื่อ event — ดูรายการทั้งหมดในหัวข้อ 5
"type": "FIAT", // "FIAT" | "CRYPTO"
"request_id": "req_1754300000123_k3f9x", // id ของ "การยิงครั้งนี้" ไม่ใช่ของรายการ
"data": { ... } // เนื้อจริง — โครงต่างกันตาม event
}
agent_id, order_id, system_id ไม่ได้อยู่ระดับบนสุด ของ body — มันอยู่ข้างใน object ของรายการ เช่น data.withdrawal.order_id หรือ data.payment.order_id อย่าไปอ่าน body.order_id เพราะจะได้ undefined
Header ที่ส่งมาด้วย
| Header | ตัวอย่าง | ใช้ทำอะไร |
|---|---|---|
| Content-Type | application/json | เสมอ |
| Trust-X-Event | WITHDRAWAL_COMPLETED | ชื่อ event ซ้ำกับใน body — route ได้โดยไม่ต้อง parse |
| Trust-X-Request-ID | req_1754300000123_k3f9x | ไว้อ้างอิงเวลาแจ้งปัญหากับเรา |
| X-Idempotency-Key | 7e56f2fd…:WITHDRAWAL_COMPLETED | คีย์กันซ้ำ — รูปแบบ {system_id}:{event}[:{scope}] ค่าเดิมทุกครั้งที่ยิงเรื่องเดียวกัน |
| x-timestamp | 1754300000 | Unix seconds ตอนเซ็น — ใช้กันการยิงซ้ำย้อนหลัง |
| x-signature | sha256=9c1f… | ลายเซ็น HMAC — ส่งมาเฉพาะร้านที่ตั้ง app_hmac_signature ไว้ |
สิ่งที่เราคาดหวังจากคุณ
| สถานะ | 2xx = รับแล้ว · อย่างอื่นทั้งหมดนับว่าไม่สำเร็จ |
| เวลา | ตอบภายใน 10 วินาที — เกินนั้นตัดเป็น timeout |
| เนื้อตอบ | อะไรก็ได้ เราเก็บไว้เป็น log เฉยๆ ไม่ตีความ (ยกเว้น WITHDRAWAL_VERIFY) |
ยิงพลาดแล้วเกิดอะไรขึ้น
webhook_request ไม่ว่าจะสำเร็จหรือไม่ แล้วจบงานนั้น การที่คุณตอบ 500 หรือ timeout ไม่ทำให้เกิด retry
ทางกู้คืนมี 2 ทาง:
| สั่งส่งใหม่ | POST /v1/withdrawal/retryWebhook/:idPOST /v1/payment/retryWebhook/:id |
| ดึงเอง | GET /v1/withdrawal/detail/:id — ให้ผลตรงกับ payload ของ event ตัวจบเสมอ |
ถ้าระบบคุณล่มไปช่วงหนึ่ง วิธีที่ถูกคือ reconcile ด้วย detail ไม่ใช่รอ webhook มาเอง
กันซ้ำ (idempotency)
ฝั่งเรากันซ้ำด้วย {system_id}:{event} อายุ 24 ชั่วโมง แต่ คุณต้องกันฝั่งคุณเองด้วย เพราะการสั่ง resend ของแอดมินจะข้ามการ์ดตัวนี้โดยตั้งใจ
// เก็บคีย์ก่อน ทำงานทีหลัง const key = req.headers['x-idempotency-key'] if (key && !(await claimOnce(key))) return res.sendStatus(200) // เคยทำแล้ว จบเลย await handle(req.body) return res.sendStatus(200)
WITHDRAWAL_PARTIALLY_FUNDED ต้องยิงได้หลายรอบต่อ 1 รายการ (ขาจ่ายแล้ว / ขาหมดอายุ / โยนเข้าธนาคาร) ระบบจึงเติม :{scope} ต่อท้ายคีย์ให้แต่ละรอบต่างกัน คุณจึงกันซ้ำด้วยคีย์เต็มได้อย่างปลอดภัย ไม่ต้องแยกเคส
ลำดับการมาถึง
| รับประกัน | event ตัวจบ จะมาหลัง WITHDRAWAL_PARTIALLY_FUNDED ตัวสุดท้ายที่คู่กันเสมอ (ผูกกันด้วย follow-up chain ไม่ใช่แค่ลำดับคิว) |
| ไม่รับประกัน | ลำดับระหว่างรายการคนละใบ · ลำดับของ PARTIALLY_FUNDED หลายตัวเมื่อขาจ่ายพร้อมกัน |
PARTIALLY_FUNDED — ให้อ่าน data.progress.settled_amount ซึ่งเป็นยอดรวม ณ ขณะนั้นอยู่แล้ว บวกเองแล้วสลับลำดับมาจะเพี้ยนทันทีตรวจลายเซ็น HMAC
กันคนอื่นยิง webhook ปลอมเข้า endpoint คุณ — ถ้าไม่ตรวจ ใครก็ยิง “จ่ายเงินแล้ว” เข้ามาได้
สูตร
signing_payload = "{x-timestamp}" + "." + JSON.stringify(data)
x-signature = "sha256=" + HMAC_SHA256(signing_payload, app_hmac_signature).hex()
data ไม่ใช่ทั้ง body — นี่คือจุดที่พลาดกันบ่อยที่สุด อย่าเอา {event, data, type, request_id} ทั้งก้อนไปเซ็น
| ไม่มีช่องว่าง | ต้อง serialize แบบ compact — {"a":1} ไม่ใช่ { "a": 1 } |
| ไม่ escape ยูนิโค้ด | ชื่อไทยต้องเป็นตัวอักษรจริง ไม่ใช่ ส |
| ไม่ escape / | URL ในข้อมูลต้องคง / ไว้ ไม่ใช่ \/ |
| ลำดับคีย์ | ต้องรักษาลำดับเดิมตามที่ได้รับมา ห้ามเรียงใหม่ |
| อายุ | ปฏิเสธถ้า x-timestamp ห่างจากเวลาปัจจุบันเกิน ~5 นาที |
| เทียบค่า | ใช้ฟังก์ชันเทียบแบบ constant-time เสมอ (timingSafeEqual) |
ลองคำนวณดู
โค้ดตรวจลายเซ็น
โค้ดตัวรับ webhook แบบเต็ม
ไฟล์เดียวจบ คัดลอกไปวางแล้วแก้แค่ 3 ฟังก์ชันที่ต่อกับระบบคุณ · ครบทั้งตรวจลายเซ็น กันซ้ำ ตอบ 200 เร็ว แยก event และตัดสินใจเรื่องเงิน
LOCALPAYY ในโค้ดเป็นแค่ตัวอย่าง — ก่อนใช้จริงให้แทนที่ทีเดียวจบ:
LOCALPAYY_WEBHOOK_SECRET · LOCALPAYY_API · LOCALPAYY_API_KEY ·
path /localpayy/webhook · คีย์กันซ้ำ localpayy: · ตาราง localpayy_seen ·
ชื่อคลาส LocalPayyWebhookController / ProcessLocalPayyWebhook
ข้อมูลตัวอย่างก็เป็นของสมมติทั้งหมดเช่นกัน (
localpayy_shop, LOCALPAYY SHOP CO LTD, SHOP-WD-90311) ไม่ได้มาจากระบบจริง
summary.withdrawer_pending_refund (เท่ากับ requested_amount − delivered_amount)
ไม่ใช่ outcome.refunded_amount ซึ่งเป็นยอดที่คืนเข้ากระเป๋าร้านและรวมค่าธรรมเนียมคืนตามสัดส่วนไว้ด้วย
· เคสจริง: ถอน 900 ได้ 600 → คืนลูกค้า 300.00 · refunded_amount = 304.50 (ส่วนต่าง 4.50 คือค่าฟีที่คืนให้ร้าน)
· โค้ดข้างล่างใช้ตัวที่ถูกให้แล้ว
โครงเดียวกันทั้ง 3 ภาษา
| # | ขั้นตอน | ทำไมต้องเรียงแบบนี้ |
|---|---|---|
| 1 | อ่าน raw body | ต้องเซ็นจากไบต์ที่ได้รับจริง ถ้าให้ framework parse เป็น object ก่อนแล้วค่อย serialize ใหม่ ลายเซ็นจะไม่มีวันตรง |
| 2 | ตรวจ timestamp | ตัดของเก่าทิ้งก่อน ถูกกว่าคำนวณ HMAC |
| 3 | ตรวจ ลายเซ็น | ไม่ผ่าน = ไม่ใช่ของเรา ห้ามแตะข้อมูลใดๆ ต่อ |
| 4 | กันซ้ำ | ต้องมาก่อนทำงาน ไม่ใช่หลัง — ไม่งั้นยิงซ้ำจะทำงานสองรอบ |
| 5 | แยก WITHDRAWAL_VERIFY | ตัวเดียวที่คำตอบมีผล ต้องตัดสินใจตรงนั้น ห้ามโยนเข้า queue |
| 6 | ตอบ 200 | มีเวลา 10 วินาที ตอบก่อนแล้วค่อยทำงาน |
| 7 | ทำงานแบบ async | error ตอนนี้ต้องไม่ทำให้ตอบกลับเปลี่ยน |
เลือกภาษา
ตัวอย่าง JavaScript และ Python ผ่านการรันจริงแล้ว — ให้ลายเซ็นตรงกับที่ระบบใช้ทุกไบต์บน payload เดียวกัน (ทดสอบด้วยข้อมูลที่มีทั้งภาษาไทยและ / ซึ่งเป็นจุดที่ serializer มักไม่ตรงกัน) · ตัวอย่าง PHP ทั้งสองแบบยังไม่ได้รันตรวจ เขียนตามสูตรเดียวกัน ถ้าลายเซ็นไม่ตรงให้เช็ค serialize_precision ใน php.ini ต้องเป็น -1 (ค่า default ตั้งแต่ PHP 7.1)
3 ฟังก์ชันที่คุณต้องเขียนเอง
| ฟังก์ชัน | ต้องทำอะไร |
|---|---|
| claimOnce(key) | คืน true ครั้งแรก ที่เห็นคีย์นี้ · คืน false ครั้งต่อไป · เก็บอย่างน้อย 24 ชม. — ใช้ Redis SET key 1 EX 86400 NX หรือคอลัมน์ที่เป็น UNIQUE ในฐานข้อมูล อย่าใช้ตัวแปรใน memory ถ้ามีเซิร์ฟเวอร์หลายเครื่อง |
| approveWithdrawal(d) | ตอบว่าออเดอร์ d.order_id มีอยู่จริง ยังรออนุมัติ และ d.amount ตรงกับที่คุณบันทึกไว้หรือไม่ · ไม่รู้จัก = ตอบ false (นี่คือสิ่งที่กัน API key รั่ว) |
| store.* | เขียนผลลงระบบคุณ — ปิดออเดอร์ / คืนเงิน / อัปเดตยอดระหว่างทาง |
เรื่องทศนิยม — จุดที่เงียบแต่ผิดสะสม
withdrawal.amount กับ settled_amount ส่งมาเป็น string เพราะฐานข้อมูลเก็บเป็น Decimal(18,8) · ส่วนบล็อก outcome / summary เป็น number
| ภาษา | ใช้อะไร |
|---|---|
| JavaScript | เก็บเป็นสตางค์ด้วย integer (Math.round(x * 100)) หรือใช้ decimal.js — อย่าบวกลบด้วย float ตรงๆ |
| Python | Decimal(str(x)) — ห้าม Decimal(0.1) |
| PHP | bcadd / bcsub / bccomp โดยส่งเป็น string |
ตัวอย่างข้างบนแปลงเป็นสตางค์ (integer) ให้แล้วทุกภาษา
ทดสอบตัวรับที่เขียนเสร็จ
| 1 | รันตัวรับของคุณขึ้นมา |
| 2 | ไปแท็บ “เครื่องมือยิง Webhook” กรอก URL + secret เดียวกัน |
| 3 | เลือก WITHDRAWAL_COMPLETED_PARTIALLY แล้วกด “ยิงเป็นชุดตาม lifecycle” |
| 4 | ต้องได้ 200 ทั้ง 4 ใบ · ยอดขยับ 200 → 600 → 600 · ปิดออเดอร์ครั้งเดียวที่ใบสุดท้าย · คืนลูกค้า 300.00 |
| 5 | กด “ยิง 1 ครั้ง” ซ้ำที่ใบสุดท้าย — ต้องได้ 200 แต่ไม่เกิดอะไรเพิ่ม (กันซ้ำทำงาน) |
| 6 | ลบ secret ในช่องแล้วยิงใหม่ — ต้องได้ 401 |
Event & Payload ทั้งหมด
ทุก event ที่ระบบยิงออกไปหาร้านค้า พร้อมความหมายของทุกฟิลด์
อ่านตารางนี้ก่อน — event ไหนคือ “จบงาน”
| ประเภท | Event | แปลว่า |
|---|---|---|
| ตัวจบ | WITHDRAWAL_COMPLETED | ลูกค้าได้เงินครบ |
| ตัวจบ | WITHDRAWAL_COMPLETED_PARTIALLY | ลูกค้าได้เงินบางส่วน ส่วนที่เหลือคืนร้าน — เงินที่จ่ายไปแล้วเรียกคืนไม่ได้ |
| ตัวจบ | WITHDRAWAL_REJECT | ไม่ได้จ่ายเลย เงินคืนร้านเต็มจำนวน |
| ระหว่างทาง | WITHDRAWAL_PARTIALLY_FUNDED | ยอดที่จ่ายแล้วขยับ — ยังไม่จบ อย่าเพิ่งปิดออเดอร์ |
event อย่างเดียว ให้เช็ค data.outcome.customer_kept_money ก่อนยกเลิกออเดอร์เสมอ — ถ้าเป็น true แปลว่ามีเงินถึงมือลูกค้าจริงแล้ว ไม่ว่าชื่อ event จะเขียนว่าอะไร
บล็อกข้อมูลที่ใช้ร่วมกัน
event ฝั่งถอนใช้บล็อกชุดเดียวกัน เขียน parser ครั้งเดียวใช้ได้หมด
Error Codes
รหัสทั้งหมดที่ API ตอบกลับ ดึงตรงจากซอร์สโค้ด · ค้นได้ทั้งเลข ชื่อ และข้อความ
รูปแบบ error response
{
"success": false,
"error": {
"code": 10011, // <-- ตัดสินใจจากตัวนี้ ไม่ใช่จากข้อความ
"message": "Insufficient balance.", // แปลตาม Accept-Language
"detail": "Available: 47.66 THB, Withdrawal: 860 THB" // มีบ้างไม่มีบ้าง
},
"data": { } // รายละเอียดเพิ่มเติม (บาง error)
}
message เปลี่ยนตามภาษาที่ส่งใน Accept-Language และแก้ถ้อยคำได้ตลอด แต่ code เป็นสัญญา ตัวเลขที่เลิกใช้แล้วเราจะไม่เอากลับมาใช้ซ้ำmessage จะกลายเป็นตัวเลขในรูปสตริง เช่น "message": "35011" — ในตารางข้างล่างจะติดป้าย ยังไม่มีข้อความ ไว้ให้ ให้ยึด code อย่างเดียวเสมอ| Code | HTTP | ชื่อ | ข้อความ |
|---|
รหัสที่เจอบ่อยตอนต่อระบบ
| Code | สาเหตุ & ทางแก้ |
|---|---|
| 1008 | Unauthorized — API key ผิด/หมดอายุ หรือส่งมาผิด header |
| 2003 | IP ไม่อยู่ใน whitelist — แจ้ง IP ขาออกของ production ให้เราเพิ่ม (อย่าลืม IP สำรอง) |
| 10005 / 10046 | order_id ซ้ำ — order_id เป็น unique ตลอดกาล ยิงซ้ำด้วยเลขเดิมไม่ได้แม้รายการเดิมจะล้มไปแล้ว |
| 10011 | ยอดไม่พอ — ดูตัวเลขจริงใน detail · จำไว้ว่าระบบหักจริงคือ amount + fee |
| 10060 | second-verify ถูกปฏิเสธ — endpoint คุณตอบไม่ใช่ 2xx หรือช้าเกิน 10 วิ (fail-closed โดยตั้งใจ) |
| 10061 | second-verify ตั้งค่าไม่ครบ — เปิดใช้แล้วแต่ยังไม่มี app_callback_url หรือ app_hmac_signature |
| 10062 / 10063 | คืนเงินไปแล้ว / จ่ายไปบางส่วนแล้ว — การ์ดกันเงินออกซ้ำ ต้องให้แอดมินตัดสินเท่านั้น |
| 6026 | withdrawal_verify cooldown — ถูกบล็อก 24 ชม. หลังพลาดหลายครั้ง ติดต่อทีมงานเพื่อปลด |
| 38003 | ไม่มีคู่ P2P — ปกติจะตกไปช่องทางธนาคารเอง ยกเว้นร้านที่ปิด fallback ไว้ |
| 38005 / 38045 | สลิปซ้ำ / order_id P2P ซ้ำ |
API ที่ร้านค้าเรียก
ฝั่งขาออก — เอกสารเต็มพร้อมลองยิงได้ที่ /v1/docs และ /v1/swagger
ถอนเงิน (Withdrawal)
| Method | Path | ใช้ทำอะไร |
|---|---|---|
| POST | /v1/withdrawal/createRequest/fiat | สร้างรายการถอนเงินบาท — จุดเริ่มของทุก flow ฝั่งถอน |
| POST | /v1/withdrawal/createRequest/crypto | สร้างรายการถอนคริปโต |
| GET | /v1/withdrawal/detail/:id | แหล่งความจริงสำหรับ reconcile — ให้บล็อกเดียวกับ payload ของ event ตัวจบ |
| GET | /v1/withdrawal/list | ดูรายการทั้งหมด กรองได้ |
| POST | /v1/withdrawal/retryWebhook/:id | สั่งยิง webhook ใหม่ (ข้ามการ์ดกันซ้ำฝั่งเรา โดยตั้งใจ) |
| GET | /v1/withdrawal/summary/fiat | สรุปยอด |
รับเงิน (Payment / Deposit)
| Method | Path | ใช้ทำอะไร |
|---|---|---|
| POST | /v1/payment/createInvoicePayment/fiat | สร้างรายการรับเงินบาท (ได้ QR / เลขบัญชีกลับมา) |
| POST | /v1/payment/createInvoicePayment/crypto | สร้างรายการรับคริปโต |
| GET | /v1/payment/info | ดูสถานะรายการ |
| POST | /v1/payment/cancel | ยกเลิกรายการที่ยังไม่จ่าย |
| POST | /v1/payment/retryWebhook/:id | สั่งยิง webhook ใหม่ |
| GET | /v1/payment/paymentMethods | ช่องทางที่เปิดใช้อยู่ |
คำแนะนำเรื่อง order_id
| เป็นของคุณ | ร้านค้าเป็นคนออกเลขนี้ ระบบเราไม่แตะ และส่งกลับให้ทุก webhook (อยู่ใน data.withdrawal.order_id / data.payment.order_id) |
| unique ตลอดกาล | ใช้ซ้ำไม่ได้แม้รายการเดิมจะ FAILED — ถ้าต้องยิงใหม่ ให้ต่อท้าย เช่น -r2 |
| อย่าใส่ข้อมูลลับ | มันโผล่ใน log และหน้าจอแอดมิน |
Checklist ก่อนขึ้น production
ทุกข้อผ่านแล้วค่อยเปิดจริง — ติ๊กเก็บไว้ในเครื่องได้
เคสที่ควรทดสอบให้ครบ (ยิงจากแท็บที่ 2 ได้เลย)
| # | สถานการณ์ | สิ่งที่ระบบคุณต้องทำให้ถูก |
|---|---|---|
| 1 | ถอนสำเร็จเต็มจำนวน | ปิดออเดอร์ · ตัดยอดลูกค้า |
| 2 | ถอนสำเร็จบางส่วน | ห้ามคืนเงินลูกค้าเต็มจำนวน — คืนเท่ากับ summary.withdrawer_pending_refund (= requested − delivered) ไม่ใช่ refunded_amount ซึ่งเป็นยอดฝั่งร้าน |
| 3 | ถูกปฏิเสธเต็มจำนวน | คืนยอดให้ลูกค้าได้เต็ม |
| 4 | ได้ PARTIALLY_FUNDED หลายใบเรียงกัน | อัปเดตยอดตาม progress.settled_amount โดยไม่ปิดออเดอร์ |
| 5 | ได้ PARTIALLY_FUNDED ที่ยอดลดลง (reason: PLEDGE_EXPIRED) | ยอมให้ยอดลดได้ — ห้ามยึดค่าสูงสุดที่เคยเห็น |
| 6 | ได้ webhook ใบเดิมซ้ำ (idempotency key เดิม) | เมินเงียบๆ แล้วตอบ 200 |
| 7 | ลายเซ็นผิด / ไม่มี | ตอบ 401 และไม่ประมวลผล |
| 8 | x-timestamp เก่ากว่า 5 นาที | ปฏิเสธ (กัน replay) |
| 9 | ระบบคุณล่มไป 1 ชั่วโมง | กลับมาแล้ว reconcile ด้วย GET /withdrawal/detail/:id ไม่ใช่รอ webhook |
| 10 | WITHDRAWAL_VERIFY เข้ามา | ตอบ 200 ถ้ารู้จักออเดอร์นี้ · ตอบ 4xx ถ้าไม่รู้จัก (กัน API key รั่ว) |
สรุปสิ่งที่พังบ่อยที่สุด 5 อันดับ
| 1 | เอา body ทั้งก้อนไปเซ็นแทนที่จะเซ็นเฉพาะ data |
| 2 | อ่าน body.order_id (ไม่มี) แทน body.data.withdrawal.order_id |
| 3 | เห็นชื่อ event เป็น REJECT แล้วคืนเงินลูกค้าเต็ม ทั้งที่ outcome.customer_kept_money = true |
| 4 | ปิดออเดอร์ตั้งแต่ PARTIALLY_FUNDED ใบแรก |
| 5 | ประมวลผลหนักก่อนตอบ 200 จนหลุด 10 วินาที แล้วนึกว่าระบบจะยิงซ้ำให้ |
เอกสารอ้างอิงเพิ่มเติม: docs/api/WEBHOOK.md · docs/api/WEBHOOK_FIRE_AND_FORGET.md · docs/p2p/P2P_MERCHANT_GUIDE_SLIDES.html · /v1/docs