เริ่มที่นี่

หน้านี้เป็นไฟล์ HTML ไฟล์เดียว ไม่ต้องติดตั้งอะไร ไม่ต้องต่อเน็ต เปิดจากเครื่องได้เลย — ใช้จำลอง webhook ที่ระบบ LOCALPAYY จะยิงเข้า endpoint ของคุณ เพื่อให้เขียนโค้ดฝั่งรับได้ครบทุกเคสก่อนต่อของจริง

4 ขั้นตอน

1เปิดแท็บ “เครื่องมือยิง Webhook” กรอก URL ปลายทางของคุณ และ HMAC secret (ถ้าใช้)
2เลือก event จากรายการ · ตัวอย่าง payload จะขึ้นมาให้ แก้ไขได้ตามใจ
3กด “ยิง” ระบบจะคำนวณ x-signature ให้เหมือนของจริง แล้วส่ง POST พร้อมโชว์ทุก header + response ที่ได้กลับ
4ยิงเป็นชุด เพื่อจำลองทั้ง lifecycle ตามลำดับจริง เช่น จ่ายทีละขา → ปิดจ๊อบ
ใช้ AI agent เขียนโค้ดอยู่? โยนไฟล์นี้ให้มันอ่านได้เลย → localpayy.md เป็นสเปกฉบับเต็มที่เขียนให้ agent อ่านโดยเฉพาะ: สัญญาการเชื่อมต่อทั้งหมด, กฎ MUST/NEVER, test vector สำหรับตรวจลายเซ็นเองโดยไม่ต้องมีเซิร์ฟเวอร์, โค้ดตัวรับครบ 4 แบบ, เกณฑ์ตรวจรับ 12 ข้อ และ checklist ปิดงาน — อ่านจบแล้วลงมือได้ทันทีโดยไม่ต้องถามกลับ
ยังไม่มี endpoint? ใช้ webhook.site ได้เลย — สร้าง URL ฟรี ดู payload ที่ส่งไปได้ทันที และเปิด CORS ไว้ จึงยิงตรงจากหน้านี้ได้โดยไม่ติดอะไร

สิ่งที่ต้องเตรียม

app_callback_urlURL ที่รับ webhook (ต้องเป็น HTTPS บน production)
app_hmac_signaturesecret สำหรับตรวจลายเซ็น — แนะนำอย่างยิ่งให้เปิด
api_keyสำหรับเรียก API ฝั่งขาออก

ทั้งสามค่าตั้งจากฝั่งเรา แจ้งทีมงานเพื่อขอ/แก้ไข

กฎ 3 ข้อที่พลาดบ่อยที่สุด

ตอบ HTTP 200 ให้เร็ว (<10 วินาที) แล้วค่อยไปประมวลผลต่อ — ช้ากว่านั้นนับเป็น timeout
อย่าตัดสินผลจาก ชื่อ event อย่างเดียว — อ่าน data.outcome เสมอ
ต้อง กันซ้ำเอง ด้วย X-Idempotency-Key

ข้อเท็จจริงที่ต้องรู้

ถ้า endpoint คุณล่มหรือตอบ 5xx ระบบจะไม่ยิงซ้ำอัตโนมัติ — จะบันทึกไว้ว่าส่งไม่สำเร็จ แล้วต้องสั่งส่งใหม่ผ่าน retryWebhook หรือ poll เอาสถานะจาก API แทน

รายละเอียดอยู่ในหัวข้อ “รูปแบบการส่ง”

ภาพรวม: ใครยิงอะไร ตอนไหน

ร้านค้า (คุณ) LOCALPAYY ผู้ฝาก / ธนาคาร POST /createRequest/fiat WITHDRAWAL_VERIFY * แนบสลิป WITHDRAWAL_PARTIALLY_FUNDED ยิงทุกครั้งที่ยอดขยับ (ไม่ใช่ตัวจบ) แนบสลิปขาถัดไป… 1 ใน 3 ตัวจบ COMPLETED / COMPLETED_PARTIALLY / REJECT * WITHDRAWAL_VERIFY ยิงเฉพาะร้านที่เปิด second-verify และเป็น ตัวเดียวที่ต้องตอบ — ตอบไม่ 200 = รายการถูกปฏิเสธทันที

เครื่องมือยิง Webhook

ยิงจริงจากเบราว์เซอร์ พร้อมลายเซ็นที่คำนวณเหมือน production ทุกประการ · ค่าที่กรอกเก็บไว้ในเครื่องคุณเท่านั้น

เก็บใน localStorage ของเบราว์เซอร์นี้เท่านั้น ไม่ถูกส่งไปที่ไหน
ยิงแล้วขึ้น “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-Typeapplication/jsonเสมอ
Trust-X-EventWITHDRAWAL_COMPLETEDชื่อ event ซ้ำกับใน body — route ได้โดยไม่ต้อง parse
Trust-X-Request-IDreq_1754300000123_k3f9xไว้อ้างอิงเวลาแจ้งปัญหากับเรา
X-Idempotency-Key7e56f2fd…:WITHDRAWAL_COMPLETEDคีย์กันซ้ำ — รูปแบบ {system_id}:{event}[:{scope}] ค่าเดิมทุกครั้งที่ยิงเรื่องเดียวกัน
x-timestamp1754300000Unix seconds ตอนเซ็น — ใช้กันการยิงซ้ำย้อนหลัง
x-signaturesha256=9c1f…ลายเซ็น HMAC — ส่งมาเฉพาะร้านที่ตั้ง app_hmac_signature ไว้

สิ่งที่เราคาดหวังจากคุณ

สถานะ2xx = รับแล้ว · อย่างอื่นทั้งหมดนับว่าไม่สำเร็จ
เวลาตอบภายใน 10 วินาที — เกินนั้นตัดเป็น timeout
เนื้อตอบอะไรก็ได้ เราเก็บไว้เป็น log เฉยๆ ไม่ตีความ (ยกเว้น WITHDRAWAL_VERIFY)
รับแล้วให้ตอบ 200 ทันที แล้วโยนเข้า queue ของคุณไปทำต่อ — อย่ารอ DB/API ตัวอื่นในจังหวะเดียวกัน

ยิงพลาดแล้วเกิดอะไรขึ้น

ไม่มีการยิงซ้ำอัตโนมัติ — ระบบส่งแบบ fire-and-forget: ยิงแล้วบันทึกผลลง webhook_request ไม่ว่าจะสำเร็จหรือไม่ แล้วจบงานนั้น การที่คุณตอบ 500 หรือ timeout ไม่ทำให้เกิด retry

ทางกู้คืนมี 2 ทาง:

สั่งส่งใหม่POST /v1/withdrawal/retryWebhook/:id
POST /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)
ทำไมบาง event ยิงหลายครั้งได้ทั้งที่ system_id เดียวกัน: 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ทำงานแบบ asyncerror ตอนนี้ต้องไม่ทำให้ตอบกลับเปลี่ยน

เลือกภาษา

ตัวอย่าง 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.*เขียนผลลงระบบคุณ — ปิดออเดอร์ / คืนเงิน / อัปเดตยอดระหว่างทาง
ทั้งสามตัวต้อง idempotent — เรียกซ้ำด้วยข้อมูลเดิมต้องไม่เกิดผลสองรอบ เพราะแอดมินสั่ง resend ได้ตลอด

เรื่องทศนิยม — จุดที่เงียบแต่ผิดสะสม

withdrawal.amount กับ settled_amount ส่งมาเป็น string เพราะฐานข้อมูลเก็บเป็น Decimal(18,8) · ส่วนบล็อก outcome / summary เป็น number

ภาษาใช้อะไร
JavaScriptเก็บเป็นสตางค์ด้วย integer (Math.round(x * 100)) หรือใช้ decimal.jsอย่าบวกลบด้วย float ตรงๆ
PythonDecimal(str(x)) — ห้าม Decimal(0.1)
PHPbcadd / 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ยอดที่จ่ายแล้วขยับ — ยังไม่จบ อย่าเพิ่งปิดออเดอร์
หนึ่งรายการถอน = ตัวจบใบเดียวเท่านั้น เลือกจาก 3 ตัวข้างบนตามเงินที่ออกไปจริง ถ้าระหว่างทางค้างหรือรอธนาคารตอบ จะไม่ยิงตัวจบจนกว่าจะรู้ผลจริง — เงียบไม่ได้แปลว่าล้มเหลว
ตัดสินใจยังไงให้ปลอดภัยที่สุด: อย่าดู 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)
}
อย่า match ด้วยข้อความmessage เปลี่ยนตามภาษาที่ส่งใน Accept-Language และแก้ถ้อยคำได้ตลอด แต่ code เป็นสัญญา ตัวเลขที่เลิกใช้แล้วเราจะไม่เอากลับมาใช้ซ้ำ
อีกเหตุผลที่ห้าม match ด้วยข้อความ: รหัสบางตัวยังไม่มีคำแปลในระบบ เวลาเจอ message จะกลายเป็นตัวเลขในรูปสตริง เช่น "message": "35011" — ในตารางข้างล่างจะติดป้าย ยังไม่มีข้อความ ไว้ให้ ให้ยึด code อย่างเดียวเสมอ
429 → มี retry_after (วินาที) และ retry_at ให้ 400 คือค่า default
CodeHTTPชื่อข้อความ

รหัสที่เจอบ่อยตอนต่อระบบ

Codeสาเหตุ & ทางแก้
1008Unauthorized — API key ผิด/หมดอายุ หรือส่งมาผิด header
2003IP ไม่อยู่ใน whitelist — แจ้ง IP ขาออกของ production ให้เราเพิ่ม (อย่าลืม IP สำรอง)
10005 / 10046order_id ซ้ำorder_id เป็น unique ตลอดกาล ยิงซ้ำด้วยเลขเดิมไม่ได้แม้รายการเดิมจะล้มไปแล้ว
10011ยอดไม่พอ — ดูตัวเลขจริงใน detail · จำไว้ว่าระบบหักจริงคือ amount + fee
10060second-verify ถูกปฏิเสธ — endpoint คุณตอบไม่ใช่ 2xx หรือช้าเกิน 10 วิ (fail-closed โดยตั้งใจ)
10061second-verify ตั้งค่าไม่ครบ — เปิดใช้แล้วแต่ยังไม่มี app_callback_url หรือ app_hmac_signature
10062 / 10063คืนเงินไปแล้ว / จ่ายไปบางส่วนแล้ว — การ์ดกันเงินออกซ้ำ ต้องให้แอดมินตัดสินเท่านั้น
6026withdrawal_verify cooldown — ถูกบล็อก 24 ชม. หลังพลาดหลายครั้ง ติดต่อทีมงานเพื่อปลด
38003ไม่มีคู่ P2P — ปกติจะตกไปช่องทางธนาคารเอง ยกเว้นร้านที่ปิด fallback ไว้
38005 / 38045สลิปซ้ำ / order_id P2P ซ้ำ

API ที่ร้านค้าเรียก

ฝั่งขาออก — เอกสารเต็มพร้อมลองยิงได้ที่ /v1/docs และ /v1/swagger

ถอนเงิน (Withdrawal)

MethodPathใช้ทำอะไร
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)

MethodPathใช้ทำอะไร
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 และไม่ประมวลผล
8x-timestamp เก่ากว่า 5 นาทีปฏิเสธ (กัน replay)
9ระบบคุณล่มไป 1 ชั่วโมงกลับมาแล้ว reconcile ด้วย GET /withdrawal/detail/:id ไม่ใช่รอ webhook
10WITHDRAWAL_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