# LocalPayy — Merchant Webhook Integration Spec **Audience:** a coding agent (or developer) implementing the *merchant side* of LocalPayy webhooks. **Status:** normative. **MUST** / **NEVER** rules are load-bearing — breaking one moves customer money incorrectly. **Scope:** receiving webhooks. Calling the LocalPayy API is summarised in §11 only. Names like `LOCALPAYY_WEBHOOK_SECRET`, `/localpayy/webhook` and `localpayy_shop` are placeholders — substitute the merchant's own. Every id, amount and account number in this document is synthetic. ## 0. What you are building One HTTP endpoint that accepts `POST` from LocalPayy, proves the request is genuine, and applies the result to the merchant's own ledger exactly once. Ordered pipeline — the order is part of the contract: 1. Read the **raw request body** (bytes, unparsed). 2. Reject if `x-timestamp` is older than 300s. 3. Reject if `x-signature` does not verify. 4. Claim `x-idempotency-key`; if already claimed, return 200 and stop. 5. If `event == "WITHDRAWAL_VERIFY"`, decide **synchronously** and return 200/4xx (see §9). 6. Return **200 immediately**. 7. Do the real work asynchronously. > Steps 1–4 are ordered by cost and by safety: the timestamp check is cheaper than an HMAC, and the idempotency claim must precede any side effect, never follow it. ## 1. Wire contract Transport: `POST` to the merchant's `app_callback_url`, `Content-Type: application/json`. The body always has exactly these four fields: ```jsonc { "event": "WITHDRAWAL_COMPLETED", // see §6 "type": "FIAT", // "FIAT" | "CRYPTO" "request_id": "req_1754300000123_k3f9x", // identifies THIS delivery attempt, not the order "data": { } // shape depends on event } ``` **NEVER read `body.order_id`, `body.agent_id` or `body.system_id` — they do not exist at the top level.** The merchant's own reference lives inside the entity object: | Need | Path | |---|---| | merchant order reference (withdrawal) | `data.withdrawal.order_id` | | merchant order reference (deposit) | `data.payment.order_id` | | LocalPayy's own id | `data.withdrawal.id` / `data.payment.id` | ## 2. Headers | Header | Example | Meaning | |---|---|---| | `Trust-X-Event` | `WITHDRAWAL_COMPLETED` | event name, duplicated from the body so you can route before parsing | | `Trust-X-Request-ID` | `req_1754300000123_k3f9x` | quote this when reporting a problem | | `X-Idempotency-Key` | `:[:]` | **stable across every re-delivery of the same logical event** | | `x-timestamp` | `1754300000` | Unix seconds at signing time | | `x-signature` | `sha256=` | present only when the merchant has an HMAC secret configured | ## 3. Signature ```text signing_payload = x-timestamp + "." + compact_json(data) x-signature = "sha256=" + hex(HMAC_SHA256(signing_payload, secret)) ``` **MUST** sign only `data` — not the whole body. This is the single most common integration failure. Serialization requirements (all four matter): | Requirement | Wrong | Right | |---|---|---| | no whitespace | `{ "a": 1 }` | `{"a":1}` | | no unicode escaping | `\u0e2a` | `ส` | | no slash escaping | `https:\/\/a` | `https://a` | | preserve key order | re-sorted keys | order as received | Per language: JS `JSON.stringify` · Python `json.dumps(x, separators=(",",":"), ensure_ascii=False)` · PHP `json_encode($x, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)` (needs `serialize_precision=-1`, the default since PHP 7.1) · Go `json.RawMessage` (keeps the original bytes — preferred). **MUST** compare in constant time (`timingSafeEqual` / `hmac.compare_digest` / `hash_equals`). **MUST** reject when `|now - x-timestamp| > 300`. ### 3.1 Self-check vector Compute this before wiring anything up. If your output differs, your serializer is wrong — fix that first, because no other test will pass. ```text secret = whsec_test_key timestamp = 1700000000 data = {"order_id":"SHOP-WD-90311","amount":900,"currency":"THB","receiver_name":"สมชาย ใจดี","callback":"https://shop.example.com/a/b"} expected = sha256=3cf9f1227dbdb6f3375a49dd643a2fb77c7c28cb200a32697ec826739f0b6a4b ``` ## 4. Idempotency LocalPayy suppresses duplicates for 24h on `{system_id}:{event}`, **but an operator-triggered resend deliberately bypasses that guard.** Deduplicate on your side too. **MUST** claim `X-Idempotency-Key` atomically before any side effect. Redis `SET key 1 EX 86400 NX`, or a `UNIQUE` column. **NEVER** an in-process map — it fails the moment there are two instances. `WITHDRAWAL_PARTIALLY_FUNDED` legitimately fires several times per withdrawal; LocalPayy appends a `:{scope}` suffix so each carries a distinct key. Deduplicating on the full key is therefore always safe. ## 5. Delivery semantics | Property | Value | |---|---| | Guarantee | **at-most-once** | | Automatic retry on your 5xx / timeout | **none** — the attempt is recorded and the job ends | | Response timeout | 10s | | Accepted response | any 2xx | | Ordering guaranteed | the terminal event always arrives after its own last `WITHDRAWAL_PARTIALLY_FUNDED` | | Ordering not guaranteed | between different withdrawals; between concurrent `PARTIALLY_FUNDED` events | Because there is no auto-retry, **MUST** build a reconciliation path: after downtime, replay state from `GET /v1/withdrawal/detail/:id`, which returns the same blocks as the terminal webhook. An operator can also re-fire via `POST /v1/withdrawal/retryWebhook/:id` or `POST /v1/payment/retryWebhook/:id`. ## 6. Events | Event | Kind | `type` | Fires when | |---|---|---|---| | `WITHDRAWAL_PARTIALLY_FUNDED` | progress | FIAT | ทุกครั้งที่ “ยอดที่จ่ายไปแล้ว” ของรายการถอนเปลี่ยน — ขาหนึ่งจ่ายเงินสำเร็จ, ขาที่จองไว้หมดอายุ, ขาที่แพ้ข้อพิพาท หรือส่วนที่เหลือถูกโยนไปให้ธนาคารทำแทน | | `WITHDRAWAL_COMPLETED` | terminal | FIAT | รายการถอนจบแบบลูกค้าได้เงิน**ครบเต็มจำนวน** ไม่ว่าจะมาจากทาง P2P ทางธนาคาร หรือผสมกัน | | `WITHDRAWAL_COMPLETED_PARTIALLY` | terminal | FIAT | รายการจบแบบ**ลูกค้าได้เงินไปแล้วบางส่วน** ส่วนที่เหลือคืนเข้ากระเป๋าร้าน | | `WITHDRAWAL_REJECT` | terminal (failure) | FIAT | รายการจบแบบ**ไม่มีเงินออกไปเลย** คืนเข้ากระเป๋าร้านเต็มจำนวนรวมค่าธรรมเนียม | | `WITHDRAWAL_APPROVED` | progress | CRYPTO | รายการถอน**คริปโต**ถูกอนุมัติและส่ง transaction ขึ้น chain แล้ว | | `WITHDRAWAL_FAILED` | terminal (failure) | CRYPTO | รายการถอน**คริปโต**ล้มเหลว | | `WITHDRAWAL_EXPIRED` | terminal (failure) | CRYPTO | รายการถอน**คริปโต**หมดอายุก่อนถูกอนุมัติ | | `WITHDRAWAL_VERIFY` | reply-required | FIAT | **ก่อน**สร้างรายการถอน — เฉพาะร้านที่เปิด second-verify ไว้ เพื่อยืนยันว่าคำสั่งถอนนี้มาจากคุณจริง | | `PAYMENT_PAID` | terminal | FIAT | ลูกค้าโอนเงินเข้ามาและระบบยืนยันแล้ว — **ตรงนี้คือจังหวะที่ปล่อยของ / เติมเครดิตได้** | | `PAYMENT_CANCELED` | terminal (failure) | FIAT | รายการรับเงินถูกยกเลิก — คุณสั่งเอง หรือระบบยกเลิกใบเก่าตอนสร้างใบใหม่ | | `PAYMENT_FAILED` | terminal (failure) | FIAT | รายการรับเงินล้มเหลว เช่น เจอสลิป/hash ซ้ำ | | `PAYMENT_CONVERTED` | progress | CRYPTO | แปลงสกุลอัตโนมัติหลังรับเงินเสร็จแล้ว (เฉพาะรายการที่ตั้ง `auto_convert = true`) | | `SLIP_PAYMENT_VERIFIED` | progress | FIAT | ตรวจสลิปผ่านแล้ว (สำหรับร้านที่ใช้ flow ตรวจสลิปแยก) | | `SLIP_PAYMENT_VERIFY_ERROR` | terminal (failure) | FIAT | ตรวจสลิปไม่ผ่าน (อ่าน QR ไม่ได้ / API ผู้ให้บริการล่ม) | | `SLIP_PAYMENT_INVALID_RECEIVER` | terminal (failure) | FIAT | สลิปถูกต้องแต่**โอนผิดบัญชีปลายทาง** | ### 6.1 Exactly one terminal event per withdrawal Chosen from the money actually delivered — never from a lifecycle status: | Event | Meaning | Customer keeps money? | |---|---|---| | `WITHDRAWAL_COMPLETED` | paid in full | yes, all of it | | `WITHDRAWAL_COMPLETED_PARTIALLY` | part paid, remainder returned to the merchant | **yes, partially — irreversible** | | `WITHDRAWAL_REJECT` | nothing paid | no | Silence is not failure: while a withdrawal is stuck or awaiting the bank, **no terminal event is sent at all**. Poll `detail` rather than assuming an outcome. `WITHDRAWAL_FAILED` and `WITHDRAWAL_EXPIRED` exist only on the CRYPTO side. ### 6.2.1 `WITHDRAWAL_PARTIALLY_FUNDED` ทุกครั้งที่ “ยอดที่จ่ายไปแล้ว” ของรายการถอนเปลี่ยน — ขาหนึ่งจ่ายเงินสำเร็จ, ขาที่จองไว้หมดอายุ, ขาที่แพ้ข้อพิพาท หรือส่วนที่เหลือถูกโยนไปให้ธนาคารทำแทน - **ไม่ใช่ event จบงาน** — ห้ามปิดออเดอร์ตอนได้ตัวนี้ ต่อให้ `settled_amount` จะเท่ากับยอดเต็มแล้วก็ตาม - ยิงได้หลายใบต่อ 1 รายการ และ **ยอดลดลงได้** เมื่อ `reason` เป็น `PLEDGE_EXPIRED` หรือ `PLEDGE_REJECTED` — อย่ายึดค่าสูงสุดที่เคยเห็น - ให้ใช้ `progress.settled_amount` เป็นยอดรวม ณ ขณะนั้นตรงๆ **อย่าบวกเอง**จาก `leg.match_amount` เพราะลำดับการมาถึงไม่การันตี - `unmatched_amount: 0` **ไม่ได้แปลว่าจ่ายครบ** — แปลว่าทุกบาทมีคนรับปากแล้ว แต่คำรับปากยังหมดอายุได้ ให้ดู `funding_status` แทน - ไม่มีบล็อก `summary` โดยตั้งใจ — การกระทบยอดเป็นหน้าที่ของ event ตัวจบ | Field | Type | Meaning | |---|---|---| | `data.withdrawal` | object | ข้อมูลรายการถอน (โครงเดียวกับทุก event ฝั่งถอน) | | `data.leg` | object | ขาที่ทำให้เกิด event นี้ — รูปแบบเดียวกับสมาชิกใน `matches[]` ของ event ตัวจบ | | `data.progress.withdrawal_amount` | number | ยอดถอนที่ขอไว้ทั้งหมด | | `data.progress.settled_amount` | number | **ได้เงินจริงแล้วเท่านี้** (ขาที่จ่ายสำเร็จ + ขาที่ชนะข้อพิพาท) | | `data.progress.reserved_amount` | number | จองไว้แล้วแต่**ยังไม่จ่าย** — เป็นแค่คำสัญญา ยังล้มได้ | | `data.progress.unmatched_amount` | number | ยังไม่มีใครรับ — ไม่เคยถูกจอง หรือจองแล้วหมดอายุ | | `data.progress.leg_count` | number | จำนวนขาทั้งหมดในกลุ่มนี้ | | `data.progress.settled_leg_count` | number | จำนวนขาที่จ่ายเงินสำเร็จแล้ว | | `data.progress.funding_status` | enum | `FULLY_SETTLED` ได้ครบแล้วจริง · `AWAITING_PLEDGES` ครบบนกระดาษ ยังรอคนโอน · `SHORT` คนจ่ายไม่พอ ที่เหลือจะไปทางธนาคาร | | `data.reason` | enum | `LEG_SETTLED` · `PLEDGE_EXPIRED` · `PLEDGE_REJECTED` · `SHORTFALL_TO_BANK` |
Example — ขาที่ 1 จ่ายแล้ว 200 / 900 ```json { "event": "WITHDRAWAL_PARTIALLY_FUNDED", "type": "FIAT", "request_id": "req_1754300000123_k3f9x", "data": { "withdrawal": { "id": "7e56f2fd-719c-4a46-a9f5-45523d3defaf", "seq_num": 448120, "source_id": "localpayy_shop", "order_id": "SHOP-WD-90311", "order_user_reference": "user_88213", "request_platform": "API", "withdrawal_mode": "FIAT", "processing_channel": "P2P", "receiver_bank": "KBANK", "receiver_name": "สมชาย ใจดี", "promptpay_id_type": null, "currency": "THB", "address": "1234567890", "amount": "900", "chain": "offchain", "asset_type": null, "network": "mainnet", "fee_model_type": "PERCENTAGE", "fee": 1.5, "fee_amount": 13.5, "extra_fee_network": 0, "realized_amount": 913.5, "operator_id": null, "operator_type": "NO_OPERATOR", "approved_at": null, "lifetime": 300, "rejected_reason": null, "p2p_matched_amount": null, "p2p_classic_amount": null, "p2p_fee_prepaid": true, "p2p_split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "p2p_window_closed_at": null, "bank_tx_id": null, "bank_transfer_id": null, "bank_final_status": null, "bank_error_msg": null, "settlement_status": null, "settled_amount": null, "outstanding_amount": null, "withdrawal_status": "PENDING", "last_updated_at": "2026-08-04T07:19:31.442Z", "created_at": "2026-08-04T07:12:40.117Z", "sequence_time": "2026-08-04T07:12:40.117Z", "agent_id": "c41d9f02-5b7e-4a11-9d3c-2f8e6b0a7415", "organization_id": "8c2f1b45-90d7-4e33-a6b1-77e4c0f2a918" }, "leg": { "match_id": "11b4e8c2-7d3f-4a90-b5e1-9c6a2f10d418", "match_amount": 200, "slip_amount": 200, "over_deposit_excess": 0, "slip_amount_override": false, "transfer_code": "TX19F4K", "slip_ref": "011483759210", "split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "split_index": 1, "split_total": 3, "match_status": "COMPLETED", "dispute_resolution": null, "depositor_agent_id": "d19c7e14-2a6b-4f80-9e35-c1478b12ad63", "completed_at": "2026-08-04T07:14:20.000Z" }, "progress": { "withdrawal_amount": 900, "settled_amount": 200, "reserved_amount": 400, "unmatched_amount": 300, "leg_count": 3, "settled_leg_count": 1, "funding_status": "SHORT" }, "reason": "LEG_SETTLED", "idempotency_key": "7e56f2fd-719c-4a46-a9f5-45523d3defaf:WITHDRAWAL_PARTIALLY_FUNDED:11b4e8c2-7d3f-4a90-b5e1-9c6a2f10d418" } } ```
### 6.2.2 `WITHDRAWAL_COMPLETED` รายการถอนจบแบบลูกค้าได้เงิน**ครบเต็มจำนวน** ไม่ว่าจะมาจากทาง P2P ทางธนาคาร หรือผสมกัน - ยิง**ครั้งเดียว**ต่อ 1 รายการ — ปลอดภัยที่จะปิดออเดอร์ตอนได้ตัวนี้ - `summary` ตอบว่า “ในซองนี้มีอะไร” · `outcome` ตอบว่า “ลูกค้าได้เงินจริงเท่าไร” · `fulfillment` ตอบว่า “เดินทางมายังไง รวมขาที่ล้มระหว่างทาง” - ค่าธรรมเนียมเป็นแบบ **agent_pays**: ลูกค้าได้เต็ม `withdrawal.amount` ส่วนร้านถูกหัก `realized_amount = amount + fee` | Field | Type | Meaning | |---|---|---| | `data.withdrawal.id` | uuid | id ของรายการฝั่งเรา (= `system_id`) | | `data.withdrawal.order_id` | string | เลขที่คุณส่งมาตอนสร้าง | | `data.withdrawal.amount` | decimal-string | ยอดที่ลูกค้าจะได้รับ — **เป็น string** ให้ parse ด้วย decimal ห้ามใช้ float | | `data.withdrawal.realized_amount` | number | ยอดที่หักจากกระเป๋าร้าน = amount + fee_amount + extra_fee_network | | `data.withdrawal.withdrawal_status` | enum | **สถานะขั้นตอน** ไม่ใช่ผลลัพธ์เรื่องเงิน — อย่าใช้ตัดสินใจ | | `data.withdrawal.settlement_status` | enum | **ผลลัพธ์เรื่องเงิน** · `PAID_IN_FULL` · `PAID_IN_PART` · `NOT_PAID` · `STUCK` · `CLAIMED_UNVERIFIED` · `LEGACY_ASSUMED_PAID` | | `data.withdrawal.settled_amount` | decimal-string | จ่ายออกไปจริงเท่าไร | | `data.withdrawal.outstanding_amount` | decimal-string | ค้างเท่าไร | | `data.withdrawal.processing_channel` | enum | `P2P` หรือ `CLASSIC` ณ ตอนสร้าง | | `data.match` | object? | มีเมื่อจับคู่ P2P แบบ 1:1 | | `data.matches[]` | array? | มีเมื่อเป็น split หลายขา | | `data.classic` | object? | มีเมื่อมีขาธนาคารเข้ามาเกี่ยว | | `data.summary` | object | สรุปยอดสองมุม — ดูตาราง “บล็อกที่ใช้ร่วมกัน” | | `data.outcome` | object | **บล็อกที่ควรตัดสินใจจากมัน** | | `data.fulfillment` | object | ประวัติการจ่ายจริง รวมความพยายามที่ล้มเหลว |
Example — P2P split 3 ขา ครบ 900 ```json { "event": "WITHDRAWAL_COMPLETED", "type": "FIAT", "request_id": "req_1754300000123_k3f9x", "data": { "withdrawal": { "id": "7e56f2fd-719c-4a46-a9f5-45523d3defaf", "seq_num": 448120, "source_id": "localpayy_shop", "order_id": "SHOP-WD-90311", "order_user_reference": "user_88213", "request_platform": "API", "withdrawal_mode": "FIAT", "processing_channel": "P2P", "receiver_bank": "KBANK", "receiver_name": "สมชาย ใจดี", "promptpay_id_type": null, "currency": "THB", "address": "1234567890", "amount": "900", "chain": "offchain", "asset_type": null, "network": "mainnet", "fee_model_type": "PERCENTAGE", "fee": 1.5, "fee_amount": 13.5, "extra_fee_network": 0, "realized_amount": 913.5, "operator_id": null, "operator_type": "NO_OPERATOR", "approved_at": "2026-08-04T07:12:44.180Z", "lifetime": 300, "rejected_reason": null, "p2p_matched_amount": "900", "p2p_classic_amount": "0", "p2p_fee_prepaid": true, "p2p_split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "p2p_window_closed_at": "2026-08-04T07:18:02.005Z", "bank_tx_id": null, "bank_transfer_id": null, "bank_final_status": null, "bank_error_msg": null, "settlement_status": "PAID_IN_FULL", "settled_amount": "900", "outstanding_amount": "0", "withdrawal_status": "COMPLETED", "last_updated_at": "2026-08-04T07:19:31.442Z", "created_at": "2026-08-04T07:12:40.117Z", "sequence_time": "2026-08-04T07:12:40.117Z", "agent_id": "c41d9f02-5b7e-4a11-9d3c-2f8e6b0a7415", "organization_id": "8c2f1b45-90d7-4e33-a6b1-77e4c0f2a918" }, "matches": [ { "match_id": "11b4e8c2-7d3f-4a90-b5e1-9c6a2f10d418", "match_amount": 200, "slip_amount": 200, "over_deposit_excess": 0, "slip_amount_override": false, "transfer_code": "TX19F4K", "slip_ref": "011483759210", "split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "split_index": 1, "split_total": 3, "match_status": "COMPLETED", "dispute_resolution": null, "depositor_agent_id": "d19c7e14-2a6b-4f80-9e35-c1478b12ad63", "completed_at": "2026-08-04T07:14:20.000Z" }, { "match_id": "12b4e8c2-7d3f-4a90-b5e1-9c6a2f20d418", "match_amount": 400, "slip_amount": 400, "over_deposit_excess": 0, "slip_amount_override": false, "transfer_code": "TX29F4K", "slip_ref": "012483759220", "split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "split_index": 2, "split_total": 3, "match_status": "COMPLETED", "dispute_resolution": null, "depositor_agent_id": "d29c7e14-2a6b-4f80-9e35-c1478b22ad63", "completed_at": "2026-08-04T07:15:20.000Z" }, { "match_id": "13b4e8c2-7d3f-4a90-b5e1-9c6a2f30d418", "match_amount": 300, "slip_amount": 300, "over_deposit_excess": 0, "slip_amount_override": false, "transfer_code": "TX39F4K", "slip_ref": "013483759230", "split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "split_index": 3, "split_total": 3, "match_status": "COMPLETED", "dispute_resolution": null, "depositor_agent_id": "d39c7e14-2a6b-4f80-9e35-c1478b32ad63", "completed_at": "2026-08-04T07:16:20.000Z" } ], "summary": { "channel": "P2P_PURE", "withdrawer_received": 900, "p2p_matched_amount": 900, "p2p_match_count": 3, "p2p_excess_total": 0, "classic_amount": 0, "agent_charged": 913.5, "platform_fee": 13.5, "platform_fee_breakdown": { "fee_amount": 13.5, "extra_fee_network": 0 }, "currency": "THB" }, "outcome": { "result": "FULLY_SETTLED", "attribution": "ATTRIBUTED", "requested_amount": 900, "delivered_amount": 900, "attributed_amount": 900, "refunded_amount": 0, "in_flight_amount": 0, "unaccounted_amount": 0, "customer_kept_money": true, "currency": "THB" }, "fulfillment": { "origin_channel": "P2P", "settled_channel": "P2P_PURE", "fell_back": false, "attempt_count": 3, "failed_attempt_count": 0, "attempts": [ { "match_id": "11b4e8c2-7d3f-4a90-b5e1-9c6a2f10d418", "status": "COMPLETED", "settled": true, "amount": 200, "split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "split_index": 1, "split_total": 3, "depositor_agent_id": "d19c7e14-2a6b-4f80-9e35-c1478b12ad63", "matched_at": "2026-08-04T07:13:05.000Z", "terminal_at": "2026-08-04T07:14:20.000Z" }, { "match_id": "12b4e8c2-7d3f-4a90-b5e1-9c6a2f20d418", "status": "COMPLETED", "settled": true, "amount": 400, "split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "split_index": 2, "split_total": 3, "depositor_agent_id": "d29c7e14-2a6b-4f80-9e35-c1478b22ad63", "matched_at": "2026-08-04T07:14:05.000Z", "terminal_at": "2026-08-04T07:15:20.000Z" }, { "match_id": "13b4e8c2-7d3f-4a90-b5e1-9c6a2f30d418", "status": "COMPLETED", "settled": true, "amount": 300, "split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "split_index": 3, "split_total": 3, "depositor_agent_id": "d39c7e14-2a6b-4f80-9e35-c1478b32ad63", "matched_at": "2026-08-04T07:15:05.000Z", "terminal_at": "2026-08-04T07:16:20.000Z" } ], "rails": { "p2p_settled_amount": 900, "classic_settled_amount": 0, "classic_unsent_amount": 0, "requested_amount": 900 } }, "idempotency_key": "7e56f2fd-719c-4a46-a9f5-45523d3defaf:WITHDRAWAL_COMPLETED" } } ```
### 6.2.3 `WITHDRAWAL_COMPLETED_PARTIALLY` รายการจบแบบ**ลูกค้าได้เงินไปแล้วบางส่วน** ส่วนที่เหลือคืนเข้ากระเป๋าร้าน - **event ที่อันตรายที่สุดถ้าเขียนโค้ดผิด** — เงินที่จ่ายไปแล้วคือการโอนจริงจากคนจริง ดึงกลับไม่ได้ ถ้าคุณคืนเงินลูกค้าเต็มจำนวน = ร้านขาดทุนส่วนต่างทันที - เดิม event นี้เคยถูกส่งออกไปในชื่อ `WITHDRAWAL_REJECT` ซึ่งอ่านแล้วเข้าใจผิดว่า “ไม่สำเร็จเลย” จึงแยกออกมาเป็นชื่อของตัวเอง - **ยอดที่ต้องคืนลูกค้าคือ `summary.withdrawer_pending_refund`** (= `requested_amount − delivered_amount`) — **ไม่ใช่ `outcome.refunded_amount`** ซึ่งเป็นยอดที่คืนเข้ากระเป๋าร้านและรวมค่าธรรมเนียมคืนตามสัดส่วนไว้ด้วย ตัวอย่างจริง: ถอน 900 ได้ 600 → คืนลูกค้า **300** แต่ `refunded_amount` = **304.50** ใช้ผิดตัวคือแจกค่าฟีให้ลูกค้าฟรีทุกใบ - ค่าธรรมเนียมคืนตามสัดส่วน — ดู `summary.platform_fee_refunded` และ `platform_fee_kept` | Field | Type | Meaning | |---|---|---| | `data.outcome.result` | enum | จะเป็น `PARTIALLY_SETTLED` | | `data.outcome.delivered_amount` | number | **ลูกค้าได้ไปแล้วเท่านี้ — ห้ามคืนซ้ำ** | | `data.outcome.refunded_amount` | number | คืนเข้ากระเป๋า**ร้าน**แล้วเท่านี้ (รวมค่าฟีคืน) — **ห้ามเอาไปคืนลูกค้า** | | `data.summary.withdrawer_pending_refund` | number | **ยอดที่ต้องคืนลูกค้า** — ตัวนี้คือตัวที่ใช้ | | `data.outcome.customer_kept_money` | boolean | จะเป็น `true` เสมอใน event นี้ | | `data.refund_reason` | enum | `SPLIT_PARTIAL_UNCOVERED` · `MATCH_EXPIRED_NO_FALLBACK` · `DISPUTE_LOST` · `CLASSIC_FALLBACK_FAILED` · `ADMIN_EXPLICIT` · `AML_HOLD_EXPIRED` · `INVALID_BANK_ACCOUNT` · `UNKNOWN` ฯลฯ | | `data.summary.withdrawer_received` | number | ยอดที่ลูกค้าได้ | | `data.summary.withdrawer_pending_refund` | number | ยอดที่ต้องคืน | | `data.summary.agent_refunded` | number | คืนเข้ากระเป๋าร้านจริง (รวมค่าธรรมเนียมตามสัดส่วน) | | `data.summary.agent_net_charged` | number | สุดท้ายร้านโดนหักสุทธิเท่านี้ |
Example — จ่ายไป 600 คืนร้าน 304.50 (รวมค่าฟีตามสัดส่วน) ```json { "event": "WITHDRAWAL_COMPLETED_PARTIALLY", "type": "FIAT", "request_id": "req_1754300000123_k3f9x", "data": { "withdrawal": { "id": "7e56f2fd-719c-4a46-a9f5-45523d3defaf", "seq_num": 448120, "source_id": "localpayy_shop", "order_id": "SHOP-WD-90311", "order_user_reference": "user_88213", "request_platform": "API", "withdrawal_mode": "FIAT", "processing_channel": "P2P", "receiver_bank": "KBANK", "receiver_name": "สมชาย ใจดี", "promptpay_id_type": null, "currency": "THB", "address": "1234567890", "amount": "900", "chain": "offchain", "asset_type": null, "network": "mainnet", "fee_model_type": "PERCENTAGE", "fee": 1.5, "fee_amount": 13.5, "extra_fee_network": 0, "realized_amount": 913.5, "operator_id": null, "operator_type": "NO_OPERATOR", "approved_at": "2026-08-04T07:12:44.180Z", "lifetime": 300, "rejected_reason": null, "p2p_matched_amount": "600", "p2p_classic_amount": "300", "p2p_fee_prepaid": true, "p2p_split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "p2p_window_closed_at": "2026-08-04T07:18:02.005Z", "bank_tx_id": null, "bank_transfer_id": null, "bank_final_status": null, "bank_error_msg": null, "settlement_status": "PAID_IN_PART", "settled_amount": "600", "outstanding_amount": "300", "withdrawal_status": "FAILED", "last_updated_at": "2026-08-04T07:19:31.442Z", "created_at": "2026-08-04T07:12:40.117Z", "sequence_time": "2026-08-04T07:12:40.117Z", "agent_id": "c41d9f02-5b7e-4a11-9d3c-2f8e6b0a7415", "organization_id": "8c2f1b45-90d7-4e33-a6b1-77e4c0f2a918" }, "matches": [ { "match_id": "11b4e8c2-7d3f-4a90-b5e1-9c6a2f10d418", "match_amount": 200, "slip_amount": 200, "over_deposit_excess": 0, "slip_amount_override": false, "transfer_code": "TX19F4K", "slip_ref": "011483759210", "split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "split_index": 1, "split_total": 3, "match_status": "COMPLETED", "dispute_resolution": null, "depositor_agent_id": "d19c7e14-2a6b-4f80-9e35-c1478b12ad63", "completed_at": "2026-08-04T07:14:20.000Z" }, { "match_id": "12b4e8c2-7d3f-4a90-b5e1-9c6a2f20d418", "match_amount": 400, "slip_amount": 400, "over_deposit_excess": 0, "slip_amount_override": false, "transfer_code": "TX29F4K", "slip_ref": "012483759220", "split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "split_index": 2, "split_total": 3, "match_status": "COMPLETED", "dispute_resolution": null, "depositor_agent_id": "d29c7e14-2a6b-4f80-9e35-c1478b22ad63", "completed_at": "2026-08-04T07:15:20.000Z" } ], "summary": { "channel": "P2P_PURE", "withdrawer_received": 600, "withdrawer_pending_refund": 300, "p2p_matched_amount": 600, "p2p_match_count": 2, "p2p_excess_total": 0, "classic_amount": 0, "classic_settled": false, "agent_charged_initially": 913.5, "agent_refunded": 304.5, "agent_net_charged": 609, "platform_fee": 13.5, "platform_fee_kept": 9, "platform_fee_refunded": 4.5, "platform_fee_breakdown": { "fee_amount": 13.5, "extra_fee_network": 0 }, "currency": "THB" }, "outcome": { "result": "PARTIALLY_SETTLED", "attribution": "ATTRIBUTED", "requested_amount": 900, "delivered_amount": 600, "attributed_amount": 600, "refunded_amount": 304.5, "in_flight_amount": 0, "unaccounted_amount": 0, "customer_kept_money": true, "currency": "THB" }, "fulfillment": { "origin_channel": "P2P", "settled_channel": "P2P_PURE", "fell_back": false, "attempt_count": 3, "failed_attempt_count": 1, "attempts": [ { "match_id": "11b4e8c2-7d3f-4a90-b5e1-9c6a2f10d418", "status": "COMPLETED", "settled": true, "amount": 200, "split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "split_index": 1, "split_total": 3, "depositor_agent_id": "d19c7e14-2a6b-4f80-9e35-c1478b12ad63", "matched_at": "2026-08-04T07:13:05.000Z", "terminal_at": "2026-08-04T07:14:20.000Z" }, { "match_id": "12b4e8c2-7d3f-4a90-b5e1-9c6a2f20d418", "status": "COMPLETED", "settled": true, "amount": 400, "split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "split_index": 2, "split_total": 3, "depositor_agent_id": "d29c7e14-2a6b-4f80-9e35-c1478b22ad63", "matched_at": "2026-08-04T07:14:05.000Z", "terminal_at": "2026-08-04T07:15:20.000Z" }, { "match_id": "13b4e8c2-7d3f-4a90-b5e1-9c6a2f30d418", "status": "EXPIRED", "settled": false, "amount": 300, "split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "split_index": 3, "split_total": 3, "depositor_agent_id": "d39c7e14-2a6b-4f80-9e35-c1478b32ad63", "matched_at": "2026-08-04T07:15:05.000Z", "terminal_at": "2026-08-04T07:16:20.000Z" } ], "rails": { "p2p_settled_amount": 600, "classic_settled_amount": 0, "classic_unsent_amount": 300, "requested_amount": 900 } }, "refund_reason": "SPLIT_PARTIAL_UNCOVERED", "idempotency_key": "7e56f2fd-719c-4a46-a9f5-45523d3defaf:WITHDRAWAL_COMPLETED_PARTIALLY" } } ```
### 6.2.4 `WITHDRAWAL_REJECT` รายการจบแบบ**ไม่มีเงินออกไปเลย** คืนเข้ากระเป๋าร้านเต็มจำนวนรวมค่าธรรมเนียม - ปลอดภัยที่จะคืนเงินลูกค้าเต็ม — แต่**ยังควรเช็ค `outcome.customer_kept_money === false` ก่อนอยู่ดี** เป็นการ์ดชั้นสุดท้าย - ถ้ามีเงินออกไปบางส่วน ระบบจะไม่ส่ง event นี้ แต่จะส่ง `WITHDRAWAL_COMPLETED_PARTIALLY` แทน | Field | Type | Meaning | |---|---|---| | `data.outcome.result` | enum | จะเป็น `NOT_SETTLED` | | `data.outcome.customer_kept_money` | boolean | `false` | | `data.refund_reason` | enum | เหตุผลที่ปฏิเสธ | | `data.withdrawal.rejected_reason` | enum | `TIMEOUT` · `INVALID_DATA` · `NETWORK_ERROR` · `SYSTEM_FAILED` ฯลฯ |
Example — ไม่มีคนรับเลย คืนเต็ม 913.50 ```json { "event": "WITHDRAWAL_REJECT", "type": "FIAT", "request_id": "req_1754300000123_k3f9x", "data": { "withdrawal": { "id": "7e56f2fd-719c-4a46-a9f5-45523d3defaf", "seq_num": 448120, "source_id": "localpayy_shop", "order_id": "SHOP-WD-90311", "order_user_reference": "user_88213", "request_platform": "API", "withdrawal_mode": "FIAT", "processing_channel": "P2P", "receiver_bank": "KBANK", "receiver_name": "สมชาย ใจดี", "promptpay_id_type": null, "currency": "THB", "address": "1234567890", "amount": "900", "chain": "offchain", "asset_type": null, "network": "mainnet", "fee_model_type": "PERCENTAGE", "fee": 1.5, "fee_amount": 13.5, "extra_fee_network": 0, "realized_amount": 913.5, "operator_id": null, "operator_type": "NO_OPERATOR", "approved_at": null, "lifetime": 300, "rejected_reason": "TIMEOUT", "p2p_matched_amount": "0", "p2p_classic_amount": "900", "p2p_fee_prepaid": true, "p2p_split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "p2p_window_closed_at": "2026-08-04T07:18:02.005Z", "bank_tx_id": null, "bank_transfer_id": null, "bank_final_status": null, "bank_error_msg": null, "settlement_status": "NOT_PAID", "settled_amount": "0", "outstanding_amount": "900", "withdrawal_status": "REJECTED", "last_updated_at": "2026-08-04T07:19:31.442Z", "created_at": "2026-08-04T07:12:40.117Z", "sequence_time": "2026-08-04T07:12:40.117Z", "agent_id": "c41d9f02-5b7e-4a11-9d3c-2f8e6b0a7415", "organization_id": "8c2f1b45-90d7-4e33-a6b1-77e4c0f2a918" }, "summary": { "channel": "CLASSIC_PURE", "withdrawer_received": 0, "withdrawer_pending_refund": 900, "p2p_matched_amount": 0, "p2p_match_count": 0, "p2p_excess_total": 0, "classic_amount": 0, "classic_settled": false, "agent_charged_initially": 913.5, "agent_refunded": 913.5, "agent_net_charged": 0, "platform_fee": 13.5, "platform_fee_kept": 0, "platform_fee_refunded": 13.5, "platform_fee_breakdown": { "fee_amount": 13.5, "extra_fee_network": 0 }, "currency": "THB" }, "outcome": { "result": "NOT_SETTLED", "attribution": "ATTRIBUTED", "requested_amount": 900, "delivered_amount": 0, "attributed_amount": 0, "refunded_amount": 913.5, "in_flight_amount": 0, "unaccounted_amount": 0, "customer_kept_money": false, "currency": "THB" }, "fulfillment": { "origin_channel": "P2P", "settled_channel": "NONE", "fell_back": false, "attempt_count": 1, "failed_attempt_count": 1, "attempts": [ { "match_id": "11b4e8c2-7d3f-4a90-b5e1-9c6a2f10d418", "status": "EXPIRED", "settled": false, "amount": 900, "split_group_id": "3ab77c19-8e42-4d6f-b0a3-51c9d7e28f60", "split_index": 1, "split_total": 3, "depositor_agent_id": "d19c7e14-2a6b-4f80-9e35-c1478b12ad63", "matched_at": "2026-08-04T07:13:05.000Z", "terminal_at": "2026-08-04T07:14:20.000Z" } ], "rails": { "p2p_settled_amount": 0, "classic_settled_amount": 0, "classic_unsent_amount": 0, "requested_amount": 900 } }, "refund_reason": "MATCH_EXPIRED_NO_FALLBACK", "idempotency_key": "7e56f2fd-719c-4a46-a9f5-45523d3defaf:WITHDRAWAL_REJECT" } } ```
### 6.2.5 `WITHDRAWAL_APPROVED` รายการถอน**คริปโต**ถูกอนุมัติและส่ง transaction ขึ้น chain แล้ว - ยังไม่ใช่ตัวจบ — ต้องรอ `WITHDRAWAL_COMPLETED` อีกที - ฝั่งเงินบาทไม่มี event นี้ | Field | Type | Meaning | |---|---|---| | `data.withdrawal` | object | รายการถอนที่อนุมัติแล้ว (ยังไม่มีบล็อก summary/outcome) |
Example — อนุมัติถอน USDT ```json { "event": "WITHDRAWAL_APPROVED", "type": "CRYPTO", "request_id": "req_1754300000123_k3f9x", "data": { "withdrawal": { "id": "7e56f2fd-719c-4a46-a9f5-45523d3defaf", "seq_num": 448120, "source_id": "localpayy_shop", "order_id": "SHOP-WD-90311", "order_user_reference": "user_88213", "request_platform": "API", "withdrawal_mode": "CRYPTO", "processing_channel": null, "receiver_bank": null, "receiver_name": null, "promptpay_id_type": null, "currency": "USDT", "address": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE", "amount": "250", "chain": "tron", "asset_type": "USDT", "network": "mainnet", "fee_model_type": "PERCENTAGE", "fee": 1.5, "fee_amount": 1, "extra_fee_network": 0, "realized_amount": 251, "operator_id": null, "operator_type": "NO_OPERATOR", "approved_at": "2026-08-04T07:12:44.180Z", "lifetime": 300, "rejected_reason": null, "p2p_matched_amount": null, "p2p_classic_amount": null, "p2p_fee_prepaid": null, "p2p_split_group_id": null, "p2p_window_closed_at": null, "bank_tx_id": null, "bank_transfer_id": null, "bank_final_status": null, "bank_error_msg": null, "settlement_status": null, "settled_amount": null, "outstanding_amount": null, "withdrawal_status": "APPROVED", "last_updated_at": "2026-08-04T07:19:31.442Z", "created_at": "2026-08-04T07:12:40.117Z", "sequence_time": "2026-08-04T07:12:40.117Z", "agent_id": "c41d9f02-5b7e-4a11-9d3c-2f8e6b0a7415", "organization_id": "8c2f1b45-90d7-4e33-a6b1-77e4c0f2a918" }, "idempotency_key": "7e56f2fd-719c-4a46-a9f5-45523d3defaf:WITHDRAWAL_APPROVED" } } ```
### 6.2.6 `WITHDRAWAL_FAILED` รายการถอน**คริปโต**ล้มเหลว - **ฝั่งเงินบาทจะไม่ได้ event นี้แล้ว** — ถูกแทนด้วย `WITHDRAWAL_REJECT` หรือ `WITHDRAWAL_COMPLETED_PARTIALLY` ที่บอกยอดจริงได้ | Field | Type | Meaning | |---|---|---| | `data.refund_reason` | enum | เหตุผล | | `data.outcome` | object | ผลลัพธ์เรื่องเงิน |
Example — ถอนคริปโตล้มเหลว คืนเต็ม ```json { "event": "WITHDRAWAL_FAILED", "type": "CRYPTO", "request_id": "req_1754300000123_k3f9x", "data": { "withdrawal": { "id": "7e56f2fd-719c-4a46-a9f5-45523d3defaf", "seq_num": 448120, "source_id": "localpayy_shop", "order_id": "SHOP-WD-90311", "order_user_reference": "user_88213", "request_platform": "API", "withdrawal_mode": "CRYPTO", "processing_channel": null, "receiver_bank": null, "receiver_name": null, "promptpay_id_type": null, "currency": "USDT", "address": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE", "amount": "250", "chain": "tron", "asset_type": "USDT", "network": "mainnet", "fee_model_type": "PERCENTAGE", "fee": 1.5, "fee_amount": 1, "extra_fee_network": 0, "realized_amount": 251, "operator_id": null, "operator_type": "NO_OPERATOR", "approved_at": "2026-08-04T07:12:44.180Z", "lifetime": 300, "rejected_reason": null, "p2p_matched_amount": null, "p2p_classic_amount": null, "p2p_fee_prepaid": null, "p2p_split_group_id": null, "p2p_window_closed_at": null, "bank_tx_id": null, "bank_transfer_id": null, "bank_final_status": null, "bank_error_msg": null, "settlement_status": "NOT_PAID", "settled_amount": "0", "outstanding_amount": "250", "withdrawal_status": "FAILED", "last_updated_at": "2026-08-04T07:19:31.442Z", "created_at": "2026-08-04T07:12:40.117Z", "sequence_time": "2026-08-04T07:12:40.117Z", "agent_id": "c41d9f02-5b7e-4a11-9d3c-2f8e6b0a7415", "organization_id": "8c2f1b45-90d7-4e33-a6b1-77e4c0f2a918" }, "summary": { "channel": "CLASSIC_PURE", "withdrawer_received": 0, "withdrawer_pending_refund": 250, "p2p_matched_amount": 0, "p2p_match_count": 0, "p2p_excess_total": 0, "classic_amount": 0, "classic_settled": false, "agent_charged_initially": 251, "agent_refunded": 251, "agent_net_charged": 0, "platform_fee": 1, "platform_fee_kept": 0, "platform_fee_refunded": 1, "platform_fee_breakdown": { "fee_amount": 1, "extra_fee_network": 0 }, "currency": "USDT" }, "outcome": { "result": "NOT_SETTLED", "attribution": "ATTRIBUTED", "requested_amount": 250, "delivered_amount": 0, "attributed_amount": 0, "refunded_amount": 251, "in_flight_amount": 0, "unaccounted_amount": 0, "customer_kept_money": false, "currency": "USDT" }, "refund_reason": "UNKNOWN", "idempotency_key": "7e56f2fd-719c-4a46-a9f5-45523d3defaf:WITHDRAWAL_FAILED" } } ```
### 6.2.7 `WITHDRAWAL_EXPIRED` รายการถอน**คริปโต**หมดอายุก่อนถูกอนุมัติ - ยิงจาก worker ฝั่งคริปโตเท่านั้น — ฝั่งเงินบาทไม่มี | Field | Type | Meaning | |---|---|---| | `data.withdrawal.withdrawal_status` | enum | `REJECTED` |
Example — หมดอายุ ```json { "event": "WITHDRAWAL_EXPIRED", "type": "CRYPTO", "request_id": "req_1754300000123_k3f9x", "data": { "withdrawal": { "id": "7e56f2fd-719c-4a46-a9f5-45523d3defaf", "seq_num": 448120, "source_id": "localpayy_shop", "order_id": "SHOP-WD-90311", "order_user_reference": "user_88213", "request_platform": "API", "withdrawal_mode": "CRYPTO", "processing_channel": null, "receiver_bank": null, "receiver_name": null, "promptpay_id_type": null, "currency": "USDT", "address": "1234567890", "amount": "250", "chain": "tron", "asset_type": "USDT", "network": "mainnet", "fee_model_type": "PERCENTAGE", "fee": 1.5, "fee_amount": 13.5, "extra_fee_network": 0, "realized_amount": 913.5, "operator_id": null, "operator_type": "NO_OPERATOR", "approved_at": null, "lifetime": 300, "rejected_reason": "TIMEOUT", "p2p_matched_amount": null, "p2p_classic_amount": null, "p2p_fee_prepaid": null, "p2p_split_group_id": null, "p2p_window_closed_at": null, "bank_tx_id": null, "bank_transfer_id": null, "bank_final_status": null, "bank_error_msg": null, "settlement_status": "NOT_PAID", "settled_amount": "0", "outstanding_amount": "250", "withdrawal_status": "REJECTED", "last_updated_at": "2026-08-04T07:19:31.442Z", "created_at": "2026-08-04T07:12:40.117Z", "sequence_time": "2026-08-04T07:12:40.117Z", "agent_id": "c41d9f02-5b7e-4a11-9d3c-2f8e6b0a7415", "organization_id": "8c2f1b45-90d7-4e33-a6b1-77e4c0f2a918" }, "idempotency_key": "7e56f2fd-719c-4a46-a9f5-45523d3defaf:WITHDRAWAL_EXPIRED" } } ```
### 6.2.8 `WITHDRAWAL_VERIFY` **ก่อน**สร้างรายการถอน — เฉพาะร้านที่เปิด second-verify ไว้ เพื่อยืนยันว่าคำสั่งถอนนี้มาจากคุณจริง - **ตัวเดียวในระบบที่คำตอบของคุณมีผล** — ตอบ 2xx = อนุญาต · อย่างอื่นทั้งหมด (รวม timeout 10 วิ) = ปฏิเสธรายการทันที (error 10060) - ออกแบบเป็น fail-closed โดยตั้งใจ: ถ้า endpoint คุณล่ม รายการถอนจะไม่เกิดขึ้น ปลอดภัยกว่าปล่อยผ่าน - โครง body **ต่างจาก event อื่น** — เป็นฟิลด์แบนๆ ไม่มี `withdrawal` ห่อ เพราะรายการยังไม่ถูกสร้าง - อ้างอิงด้วย `order_id` ของคุณเท่านั้น (ยังไม่มี id ฝั่งเรา) — ให้เช็คว่ามีออเดอร์นี้รออยู่จริงและยอดตรง - มี `x-signature` เหมือน webhook ปกติ ตรวจด้วยโค้ดชุดเดียวกันได้ | Field | Type | Meaning | |---|---|---| | `data.order_id` | string | เลขที่คุณส่งมา — ใช้ค้นในระบบคุณ | | `data.order_user_reference` | string\|null | อ้างอิงผู้ใช้ฝั่งคุณ | | `data.withdrawal_mode` | enum | `FIAT` หรือ `CRYPTO` | | `data.amount` | number | ยอด — **ควรเทียบกับที่บันทึกไว้ฝั่งคุณ** | | `data.currency` | string | สกุลเงิน | | `data.withdrawal_address` | string | เลขบัญชี หรือ address ปลายทาง | | `data.receiver_bank` | string\|null | ธนาคาร (เฉพาะ FIAT) | | `data.receiver_name` | string\|null | ชื่อผู้รับ (เฉพาะ FIAT) | | `data.chain` | string\|null | chain (เฉพาะ CRYPTO) | | `data.asset_type` | string\|null | เหรียญ (เฉพาะ CRYPTO) | | `data.agent_id` | uuid | id ร้านของคุณ |
Example — ขออนุมัติถอน 900 บาท ```json { "event": "WITHDRAWAL_VERIFY", "type": "FIAT", "request_id": "req_1754300000123_k3f9x", "data": { "order_id": "SHOP-WD-90311", "order_user_reference": "user_88213", "withdrawal_mode": "FIAT", "amount": 900, "currency": "THB", "withdrawal_address": "1234567890", "receiver_bank": "KBANK", "receiver_name": "สมชาย ใจดี", "chain": null, "asset_type": null, "agent_id": "c41d9f02-5b7e-4a11-9d3c-2f8e6b0a7415" } } ```
### 6.2.9 `PAYMENT_PAID` ลูกค้าโอนเงินเข้ามาและระบบยืนยันแล้ว — **ตรงนี้คือจังหวะที่ปล่อยของ / เติมเครดิตได้** - ยิงครั้งเดียวต่อ 1 รายการรับเงิน (กันซ้ำด้วย `{payment_id}:PAYMENT_PAID`) - `deposit_type` บอกว่าเงินมาทางไหน: `CLASSIC` โอนเข้าบัญชีเรา · `P2P` จับคู่กับคนที่กำลังถอน — **ผลลัพธ์ต่อร้านเหมือนกัน** ไม่ต้องแยกโค้ด - ให้ยึด `payment.payment_amount` (ยอดที่โอนมาจริง) ไม่ใช่ `payment.amount` (ยอดที่ตั้งไว้) — สองค่านี้ต่างกันได้เมื่อมีการแปรผันทศนิยมหรือโอนเกิน - ฟิลด์ลับถูกตัดออกก่อนส่งเสมอ: `slip_payload`, `slip_hash`, `slip_raw_signature`, `signature_mode_used` · และฟิลด์ฝั่งคริปโตจะไม่ติดมากับ webhook FIAT (และกลับกัน) | Field | Type | Meaning | |---|---|---| | `data.deposit_type` | enum | `CLASSIC` · `P2P` | | `data.payment.id` | uuid | id รายการรับเงิน (= `system_id`) | | `data.payment.order_id` | string | เลขออเดอร์ของคุณ | | `data.payment.amount` | string | ยอดที่ตั้งไว้ตอนสร้าง | | `data.payment.payment_amount` | number | **ยอดที่จ่ายมาจริง** — ใช้ตัวนี้ | | `data.payment.merchant_amount` | number | ยอดสุทธิที่เข้ากระเป๋าร้าน (หักค่าธรรมเนียมแล้ว) | | `data.payment.payment_status` | enum | `PAYMENT_PAID` · `PAYMENT_PAID_OVER` · `PAYMENT_PAID_UNDER` · `PAYMENT_PAID_MANUALLY` | | `data.payment.payer_bank_provider` | string | ธนาคารต้นทาง | | `data.payment.payer_bank_account_number` | string | เลขบัญชีต้นทาง (ปิดบังบางส่วน) | | `data.payment.payer_bank_account_name` | string | ชื่อผู้โอน | | `data.payment.slip_ref` | string | เลขอ้างอิงสลิป — ใช้กระทบยอดกับ statement ธนาคาร | | `data.payment.order_tracking_id` | string(8) | รหัสติดตาม 8 ตัว ใช้แจ้งทีมงานได้เลย | | `data.transaction` | object? | รายการฝั่งธนาคาร (มีเมื่อเป็น FIAT) |
Example — รับเงินบาท ทาง CLASSIC ```json { "event": "PAYMENT_PAID", "type": "FIAT", "request_id": "req_1754300000123_k3f9x", "data": { "deposit_type": "CLASSIC", "payment": { "id": "9f1c8e2a-4b60-47d5-8e91-3ca705d6f284", "seq_num": 812345, "source_id": "localpayy_shop", "slip_ref": "014483759210", "order_id": "SHOP-ORD-100245", "order_user_reference": "user_88213", "order_auto_cancel_previous": true, "order_display_mode": "FIAT", "request_platform": "API", "payment_method_type": "FIAT", "payment_provider": null, "invoice_type": "FIAT", "from_currency": "THB", "to_currency": "THB", "chain": "offchain", "network": "mainnet", "address": "1234567890", "from_address": null, "amount": "500", "payment_amount": 500.25, "payer_paid_currency": "THB", "payer_paid_amount": 500.25, "payer_bank_provider": "KBANK", "payer_bank_account_number": "xxx-x-x1234-x", "payer_bank_account_name": "สมชาย ใจดี", "merchant_promptpay_id": "0812345678", "merchant_amount": 492.75, "merchant_number": "1234567890", "merchant_name": "LOCALPAYY SHOP CO LTD", "merchant_provider": "KBANK", "ebank_account_id": "5d81f7a3-2c94-4e60-b8d7-16a3f0c92e57", "is_multiple_order": false, "lifetime": 300, "expired_at": "2026-08-04T06:35:00.000Z", "fee_subtract": 0, "discount_percent": 0, "discount_amount": 0, "accuracy_percent": 0, "order_tracking_id": "A7K2M9QX", "payment_url": "https://pay.example.com/9f1c8e2a", "payment_qr": "00020101021229370016A0000006770101110213…", "payment_domain": "pay.example.com", "url_return": "https://your-shop.example.com/return", "url_success": "https://your-shop.example.com/ok", "url_failed": "https://your-shop.example.com/fail", "exchange_rate": 1, "tx_value": 500.25, "payment_status": "PAYMENT_PAID", "payment_match_type": "EXACTLY", "is_completed": true, "status": "PAID", "failed_reason": null, "sequence_time": "2026-08-04T06:30:12.400Z", "created_at": "2026-08-04T06:30:12.400Z", "updated_at": "2026-08-04T06:31:48.900Z", "agent_id": "c41d9f02-5b7e-4a11-9d3c-2f8e6b0a7415", "organization_id": "8c2f1b45-90d7-4e33-a6b1-77e4c0f2a918" }, "transaction": { "id": "ab3d7e91-5f02-4c68-9a14-7be25d0c8f36", "seq_num": 331902, "source_id": "localpayy_shop", "unique_hash": "a91f7c…", "order_id": "SHOP-ORD-100245", "order_user_reference": "user_88213", "type": "RECEIVE", "status": "SUCCESS", "settlement_channel": "CLASSIC", "reference": "014483759210", "currency": "THB", "from_bank": "KBANK", "from_address": "xxx-x-x1234-x", "from_name": "สมชาย ใจดี", "amount": 500.25, "tx_date": "2026-08-04 13:31:40", "tx_unix_time": 1754300500, "fee": 1.5, "fee_amount": 7.5, "realized_amount": 492.75, "created_at": "2026-08-04T06:31:45.220Z" }, "idempotency_key": "9f1c8e2a-4b60-47d5-8e91-3ca705d6f284:PAYMENT_PAID" } } ```
### 6.2.10 `PAYMENT_CANCELED` รายการรับเงินถูกยกเลิก — คุณสั่งเอง หรือระบบยกเลิกใบเก่าตอนสร้างใบใหม่ - เกิดอัตโนมัติได้เมื่อ `order_auto_cancel_previous = true` แล้วลูกค้าคนเดิมสร้างรายการใหม่ | Field | Type | Meaning | |---|---|---| | `data.payment.payment_status` | enum | `PAYMENT_CANCELED` | | `data.payment.failed_reason` | string\|null | เหตุผล |
Example — ยกเลิกรายการ ```json { "event": "PAYMENT_CANCELED", "type": "FIAT", "request_id": "req_1754300000123_k3f9x", "data": { "payment": { "id": "9f1c8e2a-4b60-47d5-8e91-3ca705d6f284", "seq_num": 812345, "source_id": "localpayy_shop", "slip_ref": null, "order_id": "SHOP-ORD-100245", "order_user_reference": "user_88213", "order_auto_cancel_previous": true, "order_display_mode": "FIAT", "request_platform": "API", "payment_method_type": "FIAT", "payment_provider": null, "invoice_type": "FIAT", "from_currency": "THB", "to_currency": "THB", "chain": "offchain", "network": "mainnet", "address": "1234567890", "from_address": null, "amount": "500", "payment_amount": null, "payer_paid_currency": "THB", "payer_paid_amount": 500.25, "payer_bank_provider": "KBANK", "payer_bank_account_number": "xxx-x-x1234-x", "payer_bank_account_name": "สมชาย ใจดี", "merchant_promptpay_id": "0812345678", "merchant_amount": null, "merchant_number": "1234567890", "merchant_name": "LOCALPAYY SHOP CO LTD", "merchant_provider": "KBANK", "ebank_account_id": "5d81f7a3-2c94-4e60-b8d7-16a3f0c92e57", "is_multiple_order": false, "lifetime": 300, "expired_at": "2026-08-04T06:35:00.000Z", "fee_subtract": 0, "discount_percent": 0, "discount_amount": 0, "accuracy_percent": 0, "order_tracking_id": "A7K2M9QX", "payment_url": "https://pay.example.com/9f1c8e2a", "payment_qr": "00020101021229370016A0000006770101110213…", "payment_domain": "pay.example.com", "url_return": "https://your-shop.example.com/return", "url_success": "https://your-shop.example.com/ok", "url_failed": "https://your-shop.example.com/fail", "exchange_rate": 1, "tx_value": 500.25, "payment_status": "PAYMENT_CANCELED", "payment_match_type": "EXACTLY", "is_completed": false, "status": "CANCELED", "failed_reason": "CANCELED_BY_MERCHANT", "sequence_time": "2026-08-04T06:30:12.400Z", "created_at": "2026-08-04T06:30:12.400Z", "updated_at": "2026-08-04T06:31:48.900Z", "agent_id": "c41d9f02-5b7e-4a11-9d3c-2f8e6b0a7415", "organization_id": "8c2f1b45-90d7-4e33-a6b1-77e4c0f2a918" }, "idempotency_key": "9f1c8e2a-4b60-47d5-8e91-3ca705d6f284:PAYMENT_CANCELED" } } ```
### 6.2.11 `PAYMENT_FAILED` รายการรับเงินล้มเหลว เช่น เจอสลิป/hash ซ้ำ - อย่าปล่อยของเมื่อได้ event นี้ | Field | Type | Meaning | |---|---|---| | `data.payment.payment_status` | enum | `PAYMENT_FAILED` | | `data.payment.failed_reason` | string | สาเหตุ |
Example — สลิปซ้ำ ```json { "event": "PAYMENT_FAILED", "type": "FIAT", "request_id": "req_1754300000123_k3f9x", "data": { "payment": { "id": "9f1c8e2a-4b60-47d5-8e91-3ca705d6f284", "seq_num": 812345, "source_id": "localpayy_shop", "slip_ref": "014483759210", "order_id": "SHOP-ORD-100245", "order_user_reference": "user_88213", "order_auto_cancel_previous": true, "order_display_mode": "FIAT", "request_platform": "API", "payment_method_type": "FIAT", "payment_provider": null, "invoice_type": "FIAT", "from_currency": "THB", "to_currency": "THB", "chain": "offchain", "network": "mainnet", "address": "1234567890", "from_address": null, "amount": "500", "payment_amount": 500.25, "payer_paid_currency": "THB", "payer_paid_amount": 500.25, "payer_bank_provider": "KBANK", "payer_bank_account_number": "xxx-x-x1234-x", "payer_bank_account_name": "สมชาย ใจดี", "merchant_promptpay_id": "0812345678", "merchant_amount": 492.75, "merchant_number": "1234567890", "merchant_name": "LOCALPAYY SHOP CO LTD", "merchant_provider": "KBANK", "ebank_account_id": "5d81f7a3-2c94-4e60-b8d7-16a3f0c92e57", "is_multiple_order": false, "lifetime": 300, "expired_at": "2026-08-04T06:35:00.000Z", "fee_subtract": 0, "discount_percent": 0, "discount_amount": 0, "accuracy_percent": 0, "order_tracking_id": "A7K2M9QX", "payment_url": "https://pay.example.com/9f1c8e2a", "payment_qr": "00020101021229370016A0000006770101110213…", "payment_domain": "pay.example.com", "url_return": "https://your-shop.example.com/return", "url_success": "https://your-shop.example.com/ok", "url_failed": "https://your-shop.example.com/fail", "exchange_rate": 1, "tx_value": 500.25, "payment_status": "PAYMENT_FAILED", "payment_match_type": "EXACTLY", "is_completed": false, "status": "FAILED", "failed_reason": "DUPLICATE_HASH", "sequence_time": "2026-08-04T06:30:12.400Z", "created_at": "2026-08-04T06:30:12.400Z", "updated_at": "2026-08-04T06:31:48.900Z", "agent_id": "c41d9f02-5b7e-4a11-9d3c-2f8e6b0a7415", "organization_id": "8c2f1b45-90d7-4e33-a6b1-77e4c0f2a918" }, "idempotency_key": "9f1c8e2a-4b60-47d5-8e91-3ca705d6f284:PAYMENT_FAILED" } } ```
### 6.2.12 `PAYMENT_CONVERTED` แปลงสกุลอัตโนมัติหลังรับเงินเสร็จแล้ว (เฉพาะรายการที่ตั้ง `auto_convert = true`) - ยิง**ตามหลัง** `PAYMENT_PAID` เสมอ — ไม่ใช่แทนที่ - `system_id` ใช้รูปแบบ `convert:{convert_id}` เพื่อไม่ให้ชนกับ dedup ของ `PAYMENT_PAID` | Field | Type | Meaning | |---|---|---| | `data.payment` | object | รายการรับเงินที่อัปเดตแล้ว | | `data.convert` | object | รายละเอียดคำสั่งแปลงสกุล |
Example — แปลง USDT → THB ```json { "event": "PAYMENT_CONVERTED", "type": "CRYPTO", "request_id": "req_1754300000123_k3f9x", "data": { "payment": { "id": "9f1c8e2a-4b60-47d5-8e91-3ca705d6f284", "seq_num": 812345, "source_id": "localpayy_shop", "slip_ref": null, "order_id": "SHOP-ORD-100245", "order_user_reference": "user_88213", "order_auto_cancel_previous": true, "order_display_mode": "FIAT", "request_platform": "API", "payment_method_type": "CRYPTO", "payment_provider": null, "invoice_type": "CRYPTO", "from_currency": "USD", "to_currency": "USDT", "chain": "tron", "network": "mainnet", "address": "1234567890", "from_address": null, "amount": "500", "payment_amount": 25, "payer_paid_currency": "THB", "payer_paid_amount": 500.25, "payer_bank_provider": "KBANK", "payer_bank_account_number": "xxx-x-x1234-x", "payer_bank_account_name": "สมชาย ใจดี", "merchant_promptpay_id": "0812345678", "merchant_amount": 24.6, "merchant_number": "1234567890", "merchant_name": "LOCALPAYY SHOP CO LTD", "merchant_provider": "KBANK", "ebank_account_id": "5d81f7a3-2c94-4e60-b8d7-16a3f0c92e57", "is_multiple_order": false, "lifetime": 300, "expired_at": "2026-08-04T06:35:00.000Z", "fee_subtract": 0, "discount_percent": 0, "discount_amount": 0, "accuracy_percent": 0, "order_tracking_id": "A7K2M9QX", "payment_url": "https://pay.example.com/9f1c8e2a", "payment_qr": "00020101021229370016A0000006770101110213…", "payment_domain": "pay.example.com", "url_return": "https://your-shop.example.com/return", "url_success": "https://your-shop.example.com/ok", "url_failed": "https://your-shop.example.com/fail", "exchange_rate": 1, "tx_value": 500.25, "payment_status": "PAYMENT_PAID", "payment_match_type": "EXACTLY", "is_completed": true, "status": "PAID", "failed_reason": null, "sequence_time": "2026-08-04T06:30:12.400Z", "created_at": "2026-08-04T06:30:12.400Z", "updated_at": "2026-08-04T06:31:48.900Z", "agent_id": "c41d9f02-5b7e-4a11-9d3c-2f8e6b0a7415", "organization_id": "8c2f1b45-90d7-4e33-a6b1-77e4c0f2a918", "auto_convert": true, "convert_to": "THB" }, "convert": { "id": "cv_88f10a3e", "from_currency": "USDT", "to_currency": "THB", "from_amount": 24.6, "to_amount": 842.83, "rate": 34.26, "status": "COMPLETED", "created_at": "2026-08-04T06:33:10.000Z" }, "idempotency_key": "convert:cv_88f10a3e:PAYMENT_CONVERTED" } } ```
### 6.2.13 `SLIP_PAYMENT_VERIFIED` ตรวจสลิปผ่านแล้ว (สำหรับร้านที่ใช้ flow ตรวจสลิปแยก) - เป็น event สายตรวจสลิป ไม่ใช่สายเงินเข้า — การเติมเครดิตให้ยึด `PAYMENT_PAID` | Field | Type | Meaning | |---|---|---| | `data` | object | ผลการตรวจสลิป |
Example — สลิปผ่าน ```json { "event": "SLIP_PAYMENT_VERIFIED", "type": "FIAT", "request_id": "req_1754300000123_k3f9x", "data": { "slip_ref": "014483759210", "amount": 500.25, "sender_name": "สมชาย ใจดี", "sender_bank": "KBANK", "receiver_name": "LOCALPAYY SHOP CO LTD", "receiver_bank": "KBANK", "transfer_at": "2026-08-04T06:31:40.000Z", "verified": true } } ```
### 6.2.14 `SLIP_PAYMENT_VERIFY_ERROR` ตรวจสลิปไม่ผ่าน (อ่าน QR ไม่ได้ / API ผู้ให้บริการล่ม) | Field | Type | Meaning | |---|---|---| | `data.error` | string | สาเหตุ |
Example — อ่าน QR ไม่ได้ ```json { "event": "SLIP_PAYMENT_VERIFY_ERROR", "type": "FIAT", "request_id": "req_1754300000123_k3f9x", "data": { "slip_ref": null, "error": "QR_NOT_FOUND", "verified": false } } ```
### 6.2.15 `SLIP_PAYMENT_INVALID_RECEIVER` สลิปถูกต้องแต่**โอนผิดบัญชีปลายทาง** - ควรแจ้งลูกค้าให้ติดต่อทีมงาน — เงินไม่ได้เข้าบัญชีที่ถูกต้อง | Field | Type | Meaning | |---|---|---| | `data.receiver_account` | string | บัญชีที่สลิปบอก | | `data.expected_account` | string | บัญชีที่ควรเป็น |
Example — โอนผิดบัญชี ```json { "event": "SLIP_PAYMENT_INVALID_RECEIVER", "type": "FIAT", "request_id": "req_1754300000123_k3f9x", "data": { "slip_ref": "014483759210", "receiver_account": "9876543210", "expected_account": "1234567890", "verified": false } } ```
## 7. Shared payload blocks The withdrawal events reuse these. Write one parser. ### `outcome` **บล็อกที่ควรใช้ตัดสินใจ** — คำนวณจากหลักฐานการจ่ายจริงเท่านั้น (ขาที่ COMPLETED + ขาที่ชนะข้อพิพาท + การโอนธนาคารที่มี `bank_tx_id` จริง) | Field | Type | Meaning | |---|---|---| | `result` | enum | `FULLY_SETTLED` · `PARTIALLY_SETTLED` · `NOT_SETTLED` | | `attribution` | enum | `ATTRIBUTED` ตามรอยได้ทุกบาท · `PLATFORM_DECLARED` ระบบยืนยันว่าจ่ายแล้วแต่ตามรอยไม่ครบ (เช่นแอดมินโอนนอกระบบ) ให้เชื่อ `result` · `INCOMPLETE` โหลดประวัติไม่ครบ ให้ถือเป็นค่าต่ำสุดแล้วไป reconcile ที่ API | | `requested_amount` | number | ยอดที่ขอถอน | | `delivered_amount` | number | **ถึงมือลูกค้าจริงเท่านี้** | | `attributed_amount` | number | ส่วนที่ตามรอยได้จริง (ต่างจาก delivered เมื่อ attribution = PLATFORM_DECLARED) | | `refunded_amount` | number | คืนเข้ากระเป๋า**ร้าน**แล้วเท่านี้ — รวมค่าธรรมเนียมคืนตามสัดส่วน จึง**มากกว่า**ยอดที่ต้องคืนลูกค้า ยอดสำหรับลูกค้าอยู่ที่ `summary.withdrawer_pending_refund` หรือคิดเองจาก `requested_amount − delivered_amount` | | `in_flight_amount` | number | ขาที่จองแล้วแต่ยังไม่ยืนยันสลิป — ยังไม่จ่าย และยังไม่คืน | | `unaccounted_amount` | number | **ถ้ามากกว่า 0 บนรายการที่ปิดแล้ว = มีเงินหาย** ให้แจ้งทีมงาน อย่าเพิ่งบอกลูกค้า | | `customer_kept_money` | boolean | **บูลีนตัวเดียวที่ควรเช็คก่อนยกเลิกออเดอร์** — true = มีเงินถึงลูกค้าแล้ว ดึงกลับไม่ได้ | | `currency` | string | สกุลเงิน | ### `summary` สรุปยอดสองมุม: มุมลูกค้า (ได้เท่าไร) และมุมร้าน (โดนหักเท่าไร) · ฟิลด์กลุ่มคืนเงินจะโผล่เฉพาะ event ที่มีการคืน | Field | Type | Meaning | |---|---|---| | `channel` | enum | `P2P_SINGLE` · `P2P_PURE` · `HYBRID` · `CLASSIC_PURE` | | `withdrawer_received` | number | ลูกค้าได้รับ | | `p2p_matched_amount` | number | ยอดรวมจากขา P2P | | `p2p_match_count` | number | จำนวนขา P2P | | `p2p_excess_total` | number | ยอดที่ผู้ฝากโอนเกินรวม | | `classic_amount` | number | ยอดฝั่งธนาคาร | | `agent_charged` | number | หักจากกระเป๋าร้าน = amount + fee (เฉพาะเคสสำเร็จ) | | `platform_fee` | number | ค่าธรรมเนียมรวม | | `platform_fee_breakdown` | object | `{ fee_amount, extra_fee_network }` | | `withdrawer_pending_refund` | number | _เคสคืนเงิน_ ยอดที่ต้องคืน | | `agent_refunded` | number | _เคสคืนเงิน_ คืนเข้ากระเป๋าร้านจริง (รวมค่าฟีตามสัดส่วน) | | `agent_net_charged` | number | _เคสคืนเงิน_ ร้านโดนหักสุทธิ | | `platform_fee_kept` | number | _เคสคืนเงิน_ ค่าฟีที่เก็บไว้ (ตามสัดส่วนที่จ่ายสำเร็จ) | | `platform_fee_refunded` | number | _เคสคืนเงิน_ ค่าฟีที่คืนให้ | ### `fulfillment` ประวัติว่าเงินเดินทางมาอย่างไร **รวมความพยายามที่ล้มเหลว** — ใช้ตอบคำถาม “ทำไมรายการนี้ช้า” และแยกให้ออกว่า P2P สำเร็จเอง หรือ P2P ล้มแล้วธนาคารมารับช่วง | Field | Type | Meaning | |---|---|---| | `origin_channel` | enum | `P2P` หรือ `CLASSIC` — ตอนเริ่มเสนอเข้าช่องทางไหน | | `settled_channel` | enum | `P2P_SINGLE` · `P2P_PURE` · `HYBRID` · `P2P_FALLBACK_CLASSIC` · `CLASSIC_PURE` · `NONE` | | `fell_back` | boolean | true เมื่อเริ่มที่ P2P แล้วธนาคาร**โอนจริง** (ยอดที่ถูกคืนไม่นับเป็น fallback) | | `attempt_count` | number | จำนวนขาทั้งหมดที่เคยถูกสร้าง | | `failed_attempt_count` | number | จำนวนขาที่ไม่สำเร็จ | | `attempts[]` | array | แต่ละขา: `match_id, status, settled, amount, split_index, split_total, depositor_agent_id, matched_at, terminal_at` | | `rails.p2p_settled_amount` | number | ยอดที่ P2P จ่ายได้จริง | | `rails.classic_settled_amount` | number | ยอดที่ธนาคารโอนจริง | | `rails.classic_unsent_amount` | number | ยอดที่กันไว้ให้ธนาคารแต่**ไม่ได้โอน** (ถูกคืนแทน) — ห้ามนับเป็นจ่ายแล้ว | | `rails.requested_amount` | number | ยอดที่ขอ | | `degraded` | boolean? | โผล่เมื่อโหลดประวัติไม่ครบ — ให้ไป reconcile ที่ `GET /v1/withdrawal/detail/:id` | ### `matches[] / match / leg` ขาการจ่ายเงินหนึ่งขา — โครงเดียวกันทั้งใน `matches[]` ของ event ตัวจบ และ `leg` ของ `PARTIALLY_FUNDED` เขียน parser ตัวเดียวพอ | Field | Type | Meaning | |---|---|---| | `match_id` | uuid | id ของขานี้ | | `match_amount` | number | ยอดที่ขานี้รับผิดชอบ | | `slip_amount` | number\|null | ยอดบนสลิปจริง — **อาจไม่เท่า match_amount** ถ้าโอนเกิน | | `over_deposit_excess` | number\|null | ส่วนที่โอนเกิน | | `slip_amount_override` | boolean | แอดมินปรับยอดตามสลิปหรือไม่ | | `transfer_code` | string\|null | รหัสอ้างอิงการโอน | | `slip_ref` | string\|null | เลขอ้างอิงสลิป ใช้กระทบยอดกับ statement | | `split_group_id` | uuid\|null | กลุ่มของ split — ขาที่อยู่กลุ่มเดียวกันคือรายการถอนใบเดียวกัน | | `split_index / split_total` | number\|null | ขาที่เท่าไร จากทั้งหมดกี่ขา | | `match_status` | enum | `WAITING_DEPOSIT` · `MATCHED` · `COMPLETED` · `EXPIRED` · `VOIDED` · `CANCELLED` · `DISPUTED` · `DISPUTE_RESOLVED` ฯลฯ | | `dispute_resolution` | enum\|null | `APPROVE_DEPOSITOR` = ผู้ฝากชนะ **ถือว่าจ่ายแล้ว** · `REJECT_DEPOSITOR` = แพ้ | | `depositor_agent_id` | uuid\|null | ร้านต้นทางของผู้ฝาก (ไว้ใช้ด้าน compliance) | | `completed_at` | iso8601\|null | เวลาที่ขานี้จบ | ### `classic` ขาธนาคาร — **ตัวชี้ขาดคือ `bank_tx_id`** ไม่ใช่การมีอยู่ของบล็อกนี้ บล็อกนี้ถูกแนบตั้งแต่ตอนกันยอดไว้ให้ธนาคาร ซึ่งเกิดก่อนที่จะรู้ว่าจะโอนจริงหรือคืนเงิน | Field | Type | Meaning | |---|---|---| | `amount` | number | ยอดฝั่งธนาคาร | | `bank_tx_id` | string\|null | **null = ไม่ได้โอนจริง** | | `bank_transfer_id` | string\|null | เลขอ้างอิงของธนาคาร | | `bank_final_status` | string\|null | `SUCCESS` · `FAILED` · `RECHECK` ฯลฯ | | `bank_error_msg` | string\|null | ข้อความ error จากธนาคาร | | `transferred_at` | iso8601\|null | เวลาที่โอนสำเร็จ | | `ebank_account_id` | uuid\|null | บัญชีที่ใช้โอนออก | | `operator_type` | enum\|null | `AUTO_APPROVER` · `MANUAL` · `NO_OPERATOR` | ## 8. Decision rules Branch on `data.outcome`, **never** on the event name. The name tells you the shape of the ending; `outcome` tells you where the money went. ```text if outcome is absent -> close using the event name (crypto-only events) if outcome.attribution == INCOMPLETE -> figures are a LOWER BOUND; reconcile via GET /v1/withdrawal/detail/:id if outcome.unaccounted_amount > 0 -> money is unaccounted for; alert a human, tell the customer nothing if outcome.result == FULLY_SETTLED -> mark paid for outcome.delivered_amount if outcome.result == PARTIALLY_SETTLED -> mark partly paid; refund the customer (outcome.requested_amount - outcome.delivered_amount) if outcome.result == NOT_SETTLED -> refund the customer outcome.requested_amount ``` ### 8.1 The refund figure — the expensive mistake Two refund numbers appear in the payload and they are **not** interchangeable: | Field | Whose money | Includes fee refund | |---|---|---| | `summary.withdrawer_pending_refund` | **the customer** — use this | no | | `outcome.refunded_amount` = `summary.agent_refunded` | the merchant's own wallet | yes, pro-rata | Worked example — 900 THB withdrawal, 1.5% fee, 600 delivered: | Quantity | Value | |---|---| | `outcome.requested_amount` | 900.00 | | `outcome.delivered_amount` | 600.00 | | **refund to customer** | **300.00** | | `outcome.refunded_amount` (merchant wallet) | 304.50 | **NEVER** refund `outcome.refunded_amount` to a customer — the extra 4.50 is the merchant's own fee coming back, and paying it out gives the fee away on every partially-settled withdrawal. ### 8.2 Progress events - **NEVER** close an order on `WITHDRAWAL_PARTIALLY_FUNDED`, even when `funding_status == "FULLY_SETTLED"`. Pledges can still expire. - **MUST** treat `progress.settled_amount` as the running total. **NEVER** accumulate it yourself — arrival order is not guaranteed. - **MUST** allow the figure to decrease (`reason: PLEDGE_EXPIRED` / `PLEDGE_REJECTED`). Clamping to a high-water mark reports money that was never received. - `unmatched_amount == 0` does **not** mean paid. It means every unit is pledged; read `funding_status`. ### 8.3 Money types `withdrawal.amount`, `settled_amount` and `outstanding_amount` arrive as **strings** (`Decimal(18,8)` in storage). The `outcome` / `summary` blocks are numbers. **MUST NOT** do float arithmetic on money. Convert to minor units (integer) or use a decimal type: JS `Math.round(Number(v) * 100)` or `decimal.js` · Python `Decimal(str(v))` · PHP `bcmul((string) $v, '100', 0)`. ## 9. `WITHDRAWAL_VERIFY` — the only event whose reply matters Sent **before** a withdrawal is created, to merchants who enabled second-verify. It asks: *did you really order this?* | Your response | Effect | |---|---| | any 2xx | withdrawal proceeds | | anything else, including timeout | withdrawal is **rejected** (`error.code 10060`) | Fail-closed by design: if your endpoint is down, no withdrawal is created. **MUST** decide synchronously — never queue it. **MUST** return non-2xx when the `order_id` is unknown or the amount disagrees; that check is what stops a leaked API key from draining the account. The body is flat — there is no `withdrawal` wrapper, because the record does not exist yet: ```json { "order_id": "SHOP-WD-90311", "order_user_reference": "user_88213", "withdrawal_mode": "FIAT", "amount": 900, "currency": "THB", "withdrawal_address": "1234567890", "receiver_bank": "KBANK", "receiver_name": "สมชาย ใจดี", "chain": null, "asset_type": null, "agent_id": "c41d9f02-5b7e-4a11-9d3c-2f8e6b0a7415" } ``` ## 10. Reference implementations Complete and runnable. Substitute the placeholder names, then implement `claimOnce` / `approveWithdrawal` / `store.*` — all three MUST be idempotent. ### localpayy-webhook.js _Node 18+ · Express 4 · npm i express_ ```javascript /* ═══════════════════════════════════════════════════════════════ localpayy-webhook.js — ตัวรับ webhook ฉบับเต็ม Node 18+ · Express 4 · npm i express รัน: node localpayy-webhook.js ═══════════════════════════════════════════════════════════════ */ import express from 'express' import crypto from 'node:crypto' const SECRET = process.env.LOCALPAYY_WEBHOOK_SECRET // = app_hmac_signature const API = process.env.LOCALPAYY_API // เช่น https://api.example.com const MAX_SKEW = 300 // วินาที const app = express() /* express.raw ต้องครอบ route นี้โดยเฉพาะ และห้ามให้ express.json() มากินก่อน — ถ้า body ถูก parse แล้ว serialize ใหม่ ลายเซ็นจะไม่มีวันตรง */ app.post('/localpayy/webhook', express.raw({ type: '*/*' }), async (req, res) => { const raw = req.body.toString('utf8') // ── 1) timestamp ก่อน — ถูกกว่าคำนวณ HMAC ─────────────────── const ts = req.get('x-timestamp') if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > MAX_SKEW) return res.sendStatus(401) // ── 2) ลายเซ็น — ไม่ผ่าน = ไม่ใช่ของเรา ห้ามแตะข้อมูลต่อ ───── let body try { body = JSON.parse(raw) } catch { return res.sendStatus(400) } if (!verifySignature(ts, body.data, req.get('x-signature'))) return res.sendStatus(401) // ── 3) กันซ้ำ — ต้องมาก่อนทำงาน ไม่ใช่หลัง ────────────────── const key = req.get('x-idempotency-key') if (key && !(await claimOnce(key))) return res.sendStatus(200) // เคยรับไปแล้ว ตอบ 200 เงียบๆ // ── 4) WITHDRAWAL_VERIFY — ตัวเดียวที่คำตอบเรามีผล ────────── // ต้องตัดสินใจตรงนี้ ห้ามโยนเข้า queue if (body.event === 'WITHDRAWAL_VERIFY') { const ok = await approveWithdrawal(body.data) return res.sendStatus(ok ? 200 : 403) // ไม่ 2xx = รายการถอนถูกยกเลิก } // ── 5) ตอบก่อน แล้วค่อยทำงาน (เรามีเวลา 10 วินาที) ────────── res.sendStatus(200) setImmediate(() => handle(body).catch(e => console.error('[localpayy]', body.event, e))) }) /* ─── ลายเซ็น ──────────────────────────────────────────────────── เซ็นเฉพาะ data · JSON แบบไม่มีช่องว่าง · เทียบแบบ constant-time */ function verifySignature(ts, data, received) { if (!SECRET) return true // ยังไม่ได้เปิด HMAC if (!received) return false const expect = 'sha256=' + crypto .createHmac('sha256', SECRET) .update(ts + '.' + JSON.stringify(data)) .digest('hex') const a = Buffer.from(received), b = Buffer.from(expect) return a.length === b.length && crypto.timingSafeEqual(a, b) } /* ─── แยก event ──────────────────────────────────────────────── */ async function handle(body) { const d = body.data switch (body.event) { case 'PAYMENT_PAID': return onDepositPaid(d) case 'PAYMENT_CANCELED': case 'PAYMENT_FAILED': return onDepositDead(d) case 'PAYMENT_CONVERTED': return onConverted(d) case 'WITHDRAWAL_PARTIALLY_FUNDED': return onWithdrawalProgress(d) case 'WITHDRAWAL_COMPLETED': case 'WITHDRAWAL_COMPLETED_PARTIALLY': case 'WITHDRAWAL_REJECT': case 'WITHDRAWAL_FAILED': case 'WITHDRAWAL_EXPIRED': return onWithdrawalFinal(body.event, d) case 'WITHDRAWAL_APPROVED': return // แค่แจ้ง ยังไม่จบ default: console.warn('[localpayy] event ที่ยังไม่รองรับ:', body.event) // อย่า throw } } /* ─── ฝั่งรับเงิน ─────────────────────────────────────────────── */ async function onDepositPaid(d) { const p = d.payment // payment_amount = ยอดที่โอนมาจริง (อาจไม่เท่า amount ที่ตั้งไว้) await store.creditCustomer(p.order_id, satang(p.payment_amount), { depositType: d.deposit_type, // 'CLASSIC' | 'P2P' — ผลต่อร้านเหมือนกัน slipRef: p.slip_ref, tracking: p.order_tracking_id, paidOver: p.payment_status === 'PAYMENT_PAID_OVER', }) } async function onDepositDead(d) { await store.closeDepositUnpaid(d.payment.order_id, d.payment.failed_reason) } async function onConverted(d) { await store.recordConversion(d.payment.order_id, d.convert) } /* ─── ฝั่งถอน: ระหว่างทาง (ยังไม่จบ) ─────────────────────────── */ async function onWithdrawalProgress(d) { // settled_amount เป็นยอดรวม ณ ขณะนั้นอยู่แล้ว // อย่าบวกเอง และต้องยอมให้ค่ามัน "ลดลง" ได้ (PLEDGE_EXPIRED) await store.updateWithdrawalProgress(d.withdrawal.order_id, { settled: satang(d.progress.settled_amount), reserved: satang(d.progress.reserved_amount), unmatched: satang(d.progress.unmatched_amount), status: d.progress.funding_status, // FULLY_SETTLED ก็ยังไม่จบ reason: d.reason, }) // ห้ามปิดออเดอร์ตรงนี้ ไม่ว่ายอดจะครบแล้วหรือไม่ } /* ─── ฝั่งถอน: ตัวจบ — จุดที่เงินเคลื่อนจริง ──────────────────── */ async function onWithdrawalFinal(event, d) { const orderId = d.withdrawal?.order_id const o = d.outcome if (!o) return store.closeWithdrawal(orderId, event) // crypto บาง event ไม่มีบล็อกนี้ // ประวัติโหลดไม่ครบ → ตัวเลขเป็นแค่ค่าต่ำสุด ห้ามตัดสินใจจากมัน if (o.attribution === 'INCOMPLETE') return reconcile(d.withdrawal.id) // มีเงินที่ยังไม่รู้ว่าอยู่ไหน → ให้คนดู อย่าเพิ่งบอกลูกค้า if (o.unaccounted_amount > 0) return alertOps('localpayy: unaccounted', { orderId, outcome: o }) const requested = satang(o.requested_amount) const delivered = satang(o.delivered_amount) // ⚠ ยอดที่ต้องคืนลูกค้า = requested − delivered // ห้ามใช้ o.refunded_amount — นั่นคือยอดที่คืนเข้ากระเป๋าร้าน // ซึ่งรวมค่าธรรมเนียมคืนตามสัดส่วนไว้ด้วย (มากกว่าเสมอ) const owedToCustomer = requested - delivered if (o.result === 'FULLY_SETTLED') return store.closeWithdrawalPaid(orderId, delivered) if (o.result === 'PARTIALLY_SETTLED') // ลูกค้าได้เงินไปแล้วบางส่วน ดึงกลับไม่ได้ — คืนเฉพาะส่วนต่าง return store.closeWithdrawalPartial(orderId, delivered, owedToCustomer) // NOT_SETTLED — ไม่มีเงินออกไปเลย คืนได้เต็ม return store.refundCustomerFull(orderId, requested) } /* ─── กู้คืนตอนระบบเราล่ม หรือข้อมูลไม่ครบ ────────────────────── detail ให้บล็อกชุดเดียวกับ webhook เป๊ะ จึงส่งเข้า handler เดิมได้ */ async function reconcile(withdrawalId) { const r = await fetch(API + '/v1/withdrawal/detail/' + withdrawalId, { headers: { 'x-api-key': process.env.LOCALPAYY_API_KEY }, }) const { data } = await r.json() return onWithdrawalFinal('RECONCILE', data) } /* เก็บเงินเป็นสตางค์ (integer) เสมอ — อย่าบวกลบด้วย float */ const satang = v => Math.round(Number(v ?? 0) * 100) /* ═══ 3 อย่างที่คุณต้องเขียนเอง ═══════════════════════════════ */ /** ต้องคืน true เฉพาะครั้งแรกของแต่ละคีย์ · เก็บอย่างน้อย 24 ชม. * อย่าใช้ตัวแปรใน memory ถ้ามีเซิร์ฟเวอร์หลายเครื่อง */ async function claimOnce(key) { // Redis: return (await redis.set('localpayy:' + key, 1, 'EX', 86400, 'NX')) === 'OK' throw new Error('ยังไม่ได้เขียน claimOnce') } /** ตอบว่าคำสั่งถอนนี้เราสั่งเองจริงไหม — ไม่รู้จัก = false เสมอ * นี่คือด่านที่กัน API key รั่วไม่ให้ถอนเงินออกไปได้ */ async function approveWithdrawal(d) { const order = await store.findPendingWithdrawal(d.order_id) return !!order && satang(order.amount) === satang(d.amount) } /** store.* = ระบบของคุณเอง · ทุกตัวต้อง idempotent * เพราะแอดมินสั่ง resend ได้ตลอด */ app.listen(3000, () => console.log('localpayy webhook → :3000')) ``` ### localpayy_webhook.py _Python 3.10+ · pip install fastapi uvicorn httpx_ ```python # ═══════════════════════════════════════════════════════════════ # localpayy_webhook.py — ตัวรับ webhook ฉบับเต็ม # Python 3.10+ · pip install fastapi uvicorn httpx # รัน: uvicorn localpayy_webhook:app --port 3000 # ═══════════════════════════════════════════════════════════════ import hashlib import hmac import json import logging import os import time from decimal import Decimal import httpx from fastapi import BackgroundTasks, FastAPI, Request, Response SECRET = os.environ.get("LOCALPAYY_WEBHOOK_SECRET", "") # = app_hmac_signature API = os.environ.get("LOCALPAYY_API", "") MAX_SKEW = 300 # วินาที app = FastAPI() log = logging.getLogger("localpayy") @app.post("/localpayy/webhook") async def localpayy_webhook(request: Request, bg: BackgroundTasks): raw = await request.body() # ต้องเป็นไบต์ดิบ ห้ามให้ framework parse ก่อน # ── 1) timestamp ก่อน — ถูกกว่าคำนวณ HMAC ─────────────────── ts = request.headers.get("x-timestamp") if not ts or abs(time.time() - int(ts)) > MAX_SKEW: return Response(status_code=401) # ── 2) ลายเซ็น — ไม่ผ่าน = ไม่ใช่ของเรา ห้ามแตะข้อมูลต่อ ──── try: body = json.loads(raw) except ValueError: return Response(status_code=400) if not verify_signature(ts, body["data"], request.headers.get("x-signature", "")): return Response(status_code=401) # ── 3) กันซ้ำ — ต้องมาก่อนทำงาน ไม่ใช่หลัง ───────────────── key = request.headers.get("x-idempotency-key") if key and not claim_once(key): return Response(status_code=200) # เคยรับไปแล้ว ตอบ 200 เงียบๆ # ── 4) WITHDRAWAL_VERIFY — ตัวเดียวที่คำตอบเรามีผล ───────── # ต้องตัดสินใจตรงนี้ ห้ามโยนเข้า background if body["event"] == "WITHDRAWAL_VERIFY": ok = approve_withdrawal(body["data"]) return Response(status_code=200 if ok else 403) # ── 5) ตอบก่อน แล้วค่อยทำงาน (เรามีเวลา 10 วินาที) ───────── bg.add_task(handle, body) return Response(status_code=200) def verify_signature(ts: str, data: dict, received: str) -> bool: """เซ็นเฉพาะ data separators=(",", ":") → ไม่มีช่องว่าง ให้ตรงกับ JSON.stringify ensure_ascii=False → ไม่ escape ภาษาไทย """ if not SECRET: return True # ยังไม่ได้เปิด HMAC payload = json.dumps(data, separators=(",", ":"), ensure_ascii=False) expect = "sha256=" + hmac.new( SECRET.encode(), f"{ts}.{payload}".encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expect, received) # ─── แยก event ────────────────────────────────────────────────── async def handle(body: dict) -> None: d, event = body["data"], body["event"] try: if event == "PAYMENT_PAID": await on_deposit_paid(d) elif event in ("PAYMENT_CANCELED", "PAYMENT_FAILED"): await on_deposit_dead(d) elif event == "PAYMENT_CONVERTED": await on_converted(d) elif event == "WITHDRAWAL_PARTIALLY_FUNDED": await on_withdrawal_progress(d) elif event in ("WITHDRAWAL_COMPLETED", "WITHDRAWAL_COMPLETED_PARTIALLY", "WITHDRAWAL_REJECT", "WITHDRAWAL_FAILED", "WITHDRAWAL_EXPIRED"): await on_withdrawal_final(event, d) elif event == "WITHDRAWAL_APPROVED": pass # แค่แจ้ง ยังไม่จบ else: log.warning("event ที่ยังไม่รองรับ: %s", event) # อย่า raise except Exception: log.exception("handle %s ล้มเหลว", event) # ─── ฝั่งรับเงิน ──────────────────────────────────────────────── async def on_deposit_paid(d: dict) -> None: p = d["payment"] # payment_amount = ยอดที่โอนมาจริง (อาจไม่เท่า amount ที่ตั้งไว้) await store.credit_customer( p["order_id"], satang(p["payment_amount"]), deposit_type=d["deposit_type"], # 'CLASSIC' | 'P2P' — ผลต่อร้านเหมือนกัน slip_ref=p.get("slip_ref"), tracking=p.get("order_tracking_id"), paid_over=p["payment_status"] == "PAYMENT_PAID_OVER", ) async def on_deposit_dead(d: dict) -> None: await store.close_deposit_unpaid(d["payment"]["order_id"], d["payment"].get("failed_reason")) async def on_converted(d: dict) -> None: await store.record_conversion(d["payment"]["order_id"], d["convert"]) # ─── ฝั่งถอน: ระหว่างทาง (ยังไม่จบ) ───────────────────────────── async def on_withdrawal_progress(d: dict) -> None: p = d["progress"] # settled_amount เป็นยอดรวม ณ ขณะนั้นอยู่แล้ว # อย่าบวกเอง และต้องยอมให้ค่ามัน "ลดลง" ได้ (PLEDGE_EXPIRED) await store.update_withdrawal_progress( d["withdrawal"]["order_id"], settled=satang(p["settled_amount"]), reserved=satang(p["reserved_amount"]), unmatched=satang(p["unmatched_amount"]), status=p["funding_status"], # FULLY_SETTLED ก็ยังไม่จบ reason=d.get("reason"), ) # ห้ามปิดออเดอร์ตรงนี้ ไม่ว่ายอดจะครบแล้วหรือไม่ # ─── ฝั่งถอน: ตัวจบ — จุดที่เงินเคลื่อนจริง ──────────────────── async def on_withdrawal_final(event: str, d: dict) -> None: order_id = (d.get("withdrawal") or {}).get("order_id") o = d.get("outcome") if not o: # crypto บาง event ไม่มีบล็อกนี้ return await store.close_withdrawal(order_id, event) # ประวัติโหลดไม่ครบ → ตัวเลขเป็นแค่ค่าต่ำสุด ห้ามตัดสินใจจากมัน if o["attribution"] == "INCOMPLETE": return await reconcile(d["withdrawal"]["id"]) # มีเงินที่ยังไม่รู้ว่าอยู่ไหน → ให้คนดู อย่าเพิ่งบอกลูกค้า if o["unaccounted_amount"] > 0: return await alert_ops("localpayy: unaccounted", order_id, o) requested = satang(o["requested_amount"]) delivered = satang(o["delivered_amount"]) # ⚠ ยอดที่ต้องคืนลูกค้า = requested − delivered # ห้ามใช้ o["refunded_amount"] — นั่นคือยอดที่คืนเข้ากระเป๋าร้าน # ซึ่งรวมค่าธรรมเนียมคืนตามสัดส่วนไว้ด้วย (มากกว่าเสมอ) owed_to_customer = requested - delivered if o["result"] == "FULLY_SETTLED": return await store.close_withdrawal_paid(order_id, delivered) if o["result"] == "PARTIALLY_SETTLED": # ลูกค้าได้เงินไปแล้วบางส่วน ดึงกลับไม่ได้ — คืนเฉพาะส่วนต่าง return await store.close_withdrawal_partial(order_id, delivered, owed_to_customer) # NOT_SETTLED — ไม่มีเงินออกไปเลย คืนได้เต็ม return await store.refund_customer_full(order_id, requested) # ─── กู้คืนตอนระบบเราล่ม หรือข้อมูลไม่ครบ ────────────────────── # detail ให้บล็อกชุดเดียวกับ webhook เป๊ะ จึงส่งเข้า handler เดิมได้ async def reconcile(withdrawal_id: str) -> None: async with httpx.AsyncClient() as c: r = await c.get(f"{API}/v1/withdrawal/detail/{withdrawal_id}", headers={"x-api-key": os.environ["LOCALPAYY_API_KEY"]}) await on_withdrawal_final("RECONCILE", r.json()["data"]) def satang(v) -> int: """เก็บเงินเป็นสตางค์ (integer) เสมอ · Decimal(str(...)) กัน float ปัดเพี้ยน""" return int((Decimal(str(v or 0)) * 100).quantize(Decimal("1"))) # ═══ 3 อย่างที่คุณต้องเขียนเอง ═════════════════════════════════ def claim_once(key: str) -> bool: """ต้องคืน True เฉพาะครั้งแรกของแต่ละคีย์ · เก็บอย่างน้อย 24 ชม. อย่าใช้ dict ใน memory ถ้ามีเซิร์ฟเวอร์หลายเครื่อง""" # Redis: return bool(redis.set("localpayy:" + key, 1, ex=86400, nx=True)) raise NotImplementedError("ยังไม่ได้เขียน claim_once") def approve_withdrawal(d: dict) -> bool: """ตอบว่าคำสั่งถอนนี้เราสั่งเองจริงไหม — ไม่รู้จัก = False เสมอ นี่คือด่านที่กัน API key รั่วไม่ให้ถอนเงินออกไปได้""" order = store.find_pending_withdrawal(d["order_id"]) return bool(order) and satang(order.amount) == satang(d["amount"]) # store.* = ระบบของคุณเอง · ทุกตัวต้อง idempotent # เพราะแอดมินสั่ง resend ได้ตลอด ``` ### LocalPayyWebhookController.php _Laravel 10/11 · เพิ่ม route + ปิด CSRF ให้ path นี้_ ```php header('x-timestamp'); if (!$ts || abs(time() - (int) $ts) > self::MAX_SKEW) { return response('', 401); } // ── 2) ลายเซ็น — ต้องใช้ raw body เท่านั้น ───────────── $raw = $request->getContent(); $body = json_decode($raw, true); if (!is_array($body) || !isset($body['event'], $body['data'])) { return response('', 400); } if (!$this->verifySignature($secret, $ts, $body['data'], $request->header('x-signature'))) { return response('', 401); } // ── 3) กันซ้ำ — ต้องมาก่อนทำงาน ไม่ใช่หลัง ──────────── $key = $request->header('x-idempotency-key'); if ($key && !Cache::add('localpayy:' . $key, 1, now()->addDay())) { return response('', 200); // เคยรับไปแล้ว ตอบ 200 เงียบๆ } // ── 4) WITHDRAWAL_VERIFY — ตัวเดียวที่คำตอบเรามีผล ──── // ต้องตัดสินใจตรงนี้ ห้าม dispatch job if ($body['event'] === 'WITHDRAWAL_VERIFY') { return response('', $this->approveWithdrawal($body['data']) ? 200 : 403); } // ── 5) ตอบก่อน แล้วค่อยทำงาน (เรามีเวลา 10 วินาที) ──── ProcessLocalPayyWebhook::dispatch($body); // job แยก อย่าทำ inline return response('', 200); } /** เซ็นเฉพาะ data * JSON_UNESCAPED_UNICODE → ไม่ escape ภาษาไทย * JSON_UNESCAPED_SLASHES → ไม่ทำ / เป็น \/ * (ตรวจด้วยว่า php.ini serialize_precision = -1 ซึ่งเป็นค่า default ตั้งแต่ PHP 7.1) */ private function verifySignature(?string $secret, string $ts, array $data, ?string $received): bool { if (!$secret) { return true; // ยังไม่ได้เปิด HMAC } $payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $expect = 'sha256=' . hash_hmac('sha256', $ts . '.' . $payload, $secret); return hash_equals($expect, $received ?? ''); } /** ตอบว่าคำสั่งถอนนี้เราสั่งเองจริงไหม — ไม่รู้จัก = false เสมอ * นี่คือด่านที่กัน API key รั่วไม่ให้ถอนเงินออกไปได้ */ private function approveWithdrawal(array $d): bool { $order = Withdrawal::where('order_id', $d['order_id'])->where('status', 'PENDING')->first(); return $order && satang($order->amount) === satang($d['amount']); } } /* ═══════════════════════════════════════════════════════════════ app/Jobs/ProcessLocalPayyWebhook.php — ทำงานจริงหลังตอบ 200 ไปแล้ว ═══════════════════════════════════════════════════════════════ */ class ProcessLocalPayyWebhook implements ShouldQueue { public function __construct(private array $body) {} public function handle(): void { $d = $this->body['data']; match ($this->body['event']) { 'PAYMENT_PAID' => $this->onDepositPaid($d), 'PAYMENT_CANCELED', 'PAYMENT_FAILED' => $this->onDepositDead($d), 'PAYMENT_CONVERTED' => $this->onConverted($d), 'WITHDRAWAL_PARTIALLY_FUNDED' => $this->onProgress($d), 'WITHDRAWAL_COMPLETED', 'WITHDRAWAL_COMPLETED_PARTIALLY', 'WITHDRAWAL_REJECT', 'WITHDRAWAL_FAILED', 'WITHDRAWAL_EXPIRED' => $this->onFinal($this->body['event'], $d), 'WITHDRAWAL_APPROVED' => null, // แค่แจ้ง ยังไม่จบ default => Log::warning('[localpayy] event ที่ยังไม่รองรับ', ['event' => $this->body['event']]), }; } /* ─── ฝั่งรับเงิน ─────────────────────────────────────────── */ private function onDepositPaid(array $d): void { $p = $d['payment']; // payment_amount = ยอดที่โอนมาจริง (อาจไม่เท่า amount ที่ตั้งไว้) Store::creditCustomer($p['order_id'], satang($p['payment_amount']), [ 'deposit_type' => $d['deposit_type'], // 'CLASSIC' | 'P2P' — ผลเหมือนกัน 'slip_ref' => $p['slip_ref'] ?? null, 'tracking' => $p['order_tracking_id'] ?? null, 'paid_over' => $p['payment_status'] === 'PAYMENT_PAID_OVER', ]); } private function onDepositDead(array $d): void { Store::closeDepositUnpaid($d['payment']['order_id'], $d['payment']['failed_reason'] ?? null); } private function onConverted(array $d): void { Store::recordConversion($d['payment']['order_id'], $d['convert']); } /* ─── ฝั่งถอน: ระหว่างทาง (ยังไม่จบ) ──────────────────────── */ private function onProgress(array $d): void { $p = $d['progress']; // settled_amount เป็นยอดรวม ณ ขณะนั้นอยู่แล้ว // อย่าบวกเอง และต้องยอมให้ค่ามัน "ลดลง" ได้ (PLEDGE_EXPIRED) Store::updateWithdrawalProgress($d['withdrawal']['order_id'], [ 'settled' => satang($p['settled_amount']), 'reserved' => satang($p['reserved_amount']), 'unmatched' => satang($p['unmatched_amount']), 'status' => $p['funding_status'], // FULLY_SETTLED ก็ยังไม่จบ 'reason' => $d['reason'] ?? null, ]); // ห้ามปิดออเดอร์ตรงนี้ ไม่ว่ายอดจะครบแล้วหรือไม่ } /* ─── ฝั่งถอน: ตัวจบ — จุดที่เงินเคลื่อนจริง ─────────────── */ private function onFinal(string $event, array $d): void { $orderId = $d['withdrawal']['order_id'] ?? null; $o = $d['outcome'] ?? null; if (!$o) { // crypto บาง event ไม่มีบล็อกนี้ Store::closeWithdrawal($orderId, $event); return; } // ประวัติโหลดไม่ครบ → ตัวเลขเป็นแค่ค่าต่ำสุด ห้ามตัดสินใจจากมัน if ($o['attribution'] === 'INCOMPLETE') { $this->reconcile($d['withdrawal']['id']); return; } // มีเงินที่ยังไม่รู้ว่าอยู่ไหน → ให้คนดู อย่าเพิ่งบอกลูกค้า if ($o['unaccounted_amount'] > 0) { Store::alertOps('localpayy: unaccounted', $orderId, $o); return; } $requested = satang($o['requested_amount']); $delivered = satang($o['delivered_amount']); // ⚠ ยอดที่ต้องคืนลูกค้า = requested − delivered // ห้ามใช้ $o['refunded_amount'] — นั่นคือยอดที่คืนเข้ากระเป๋าร้าน // ซึ่งรวมค่าธรรมเนียมคืนตามสัดส่วนไว้ด้วย (มากกว่าเสมอ) $owedToCustomer = $requested - $delivered; match ($o['result']) { // ลูกค้าได้เงินไปแล้วบางส่วน ดึงกลับไม่ได้ — คืนเฉพาะส่วนต่าง 'PARTIALLY_SETTLED' => Store::closeWithdrawalPartial($orderId, $delivered, $owedToCustomer), 'FULLY_SETTLED' => Store::closeWithdrawalPaid($orderId, $delivered), // NOT_SETTLED — ไม่มีเงินออกไปเลย คืนได้เต็ม default => Store::refundCustomerFull($orderId, $requested), }; } /* detail ให้บล็อกชุดเดียวกับ webhook เป๊ะ จึงส่งเข้า handler เดิมได้ */ private function reconcile(string $withdrawalId): void { $res = Http::withHeaders(['x-api-key' => config('services.localpayy.api_key')]) ->get(config('services.localpayy.api') . '/v1/withdrawal/detail/' . $withdrawalId); $this->onFinal('RECONCILE', $res->json('data')); } } /** เก็บเงินเป็นสตางค์ (integer) เสมอ · ใช้ string กัน float ปัดเพี้ยน */ function satang($v): int { return (int) bcmul((string) ($v ?? 0), '100', 0); } ``` ### localpayy-webhook.php _PHP 8.0+ · ไม่ต้องใช้ framework · วางไว้หลัง nginx/apache ได้เลย_ ```php MAX_SKEW) { http_response_code(401); exit; } /* ── 2) ลายเซ็น — ต้องอ่านจาก php://input เท่านั้น ──────────── */ $raw = file_get_contents('php://input'); $body = json_decode($raw, true); if (!is_array($body) || !isset($body['event'], $body['data'])) { http_response_code(400); exit; } if ($SECRET !== '') { // JSON_UNESCAPED_UNICODE → ไม่ escape ภาษาไทย // JSON_UNESCAPED_SLASHES → ไม่ทำ / เป็น \/ $payload = json_encode($body['data'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $expect = 'sha256=' . hash_hmac('sha256', $ts . '.' . $payload, $SECRET); if (!hash_equals($expect, $_SERVER['HTTP_X_SIGNATURE'] ?? '')) { http_response_code(401); exit; } } /* ── 3) กันซ้ำ — ต้องมาก่อนทำงาน ไม่ใช่หลัง ─────────────────── */ $key = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? ''; if ($key !== '' && !claim_once($key)) { http_response_code(200); // เคยรับไปแล้ว ตอบ 200 เงียบๆ exit; } /* ── 4) WITHDRAWAL_VERIFY — ตัวเดียวที่คำตอบเรามีผล ─────────── */ if ($body['event'] === 'WITHDRAWAL_VERIFY') { http_response_code(approve_withdrawal($body['data']) ? 200 : 403); exit; } /* ── 5) ตอบ 200 ก่อน แล้วค่อยทำงาน ──────────────────────────── PHP ไม่มี background thread — ปิด connection ก่อนแล้วทำต่อ (ถ้ามี queue อยู่แล้ว ให้ push เข้า queue ตรงนี้แทน ดีกว่า) */ http_response_code(200); header('Content-Length: 0'); header('Connection: close'); ignore_user_abort(true); if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } else { ob_end_flush(); flush(); } handle($body); /* ═══════════════════════════════════════════════════════════════ */ function handle(array $body): void { $d = $body['data']; try { switch ($body['event']) { case 'PAYMENT_PAID': on_deposit_paid($d); break; case 'PAYMENT_CANCELED': case 'PAYMENT_FAILED': Store::closeDepositUnpaid($d['payment']['order_id'], $d['payment']['failed_reason'] ?? null); break; case 'PAYMENT_CONVERTED': Store::recordConversion($d['payment']['order_id'], $d['convert']); break; case 'WITHDRAWAL_PARTIALLY_FUNDED': on_progress($d); break; case 'WITHDRAWAL_COMPLETED': case 'WITHDRAWAL_COMPLETED_PARTIALLY': case 'WITHDRAWAL_REJECT': case 'WITHDRAWAL_FAILED': case 'WITHDRAWAL_EXPIRED': on_final($body['event'], $d); break; case 'WITHDRAWAL_APPROVED': break; // แค่แจ้ง ยังไม่จบ default: error_log('[localpayy] event ที่ยังไม่รองรับ: ' . $body['event']); } } catch (Throwable $e) { error_log('[localpayy] handle ล้มเหลว: ' . $e->getMessage()); } } function on_deposit_paid(array $d): void { $p = $d['payment']; // payment_amount = ยอดที่โอนมาจริง (อาจไม่เท่า amount ที่ตั้งไว้) Store::creditCustomer($p['order_id'], satang($p['payment_amount']), [ 'deposit_type' => $d['deposit_type'], // 'CLASSIC' | 'P2P' — ผลเหมือนกัน 'slip_ref' => $p['slip_ref'] ?? null, 'tracking' => $p['order_tracking_id'] ?? null, 'paid_over' => $p['payment_status'] === 'PAYMENT_PAID_OVER', ]); } function on_progress(array $d): void { $p = $d['progress']; // settled_amount เป็นยอดรวม ณ ขณะนั้นอยู่แล้ว // อย่าบวกเอง และต้องยอมให้ค่ามัน "ลดลง" ได้ (PLEDGE_EXPIRED) Store::updateWithdrawalProgress($d['withdrawal']['order_id'], [ 'settled' => satang($p['settled_amount']), 'reserved' => satang($p['reserved_amount']), 'unmatched' => satang($p['unmatched_amount']), 'status' => $p['funding_status'], // FULLY_SETTLED ก็ยังไม่จบ 'reason' => $d['reason'] ?? null, ]); // ห้ามปิดออเดอร์ตรงนี้ ไม่ว่ายอดจะครบแล้วหรือไม่ } function on_final(string $event, array $d): void { $orderId = $d['withdrawal']['order_id'] ?? null; $o = $d['outcome'] ?? null; if (!$o) { // crypto บาง event ไม่มีบล็อกนี้ Store::closeWithdrawal($orderId, $event); return; } // ประวัติโหลดไม่ครบ → ตัวเลขเป็นแค่ค่าต่ำสุด ห้ามตัดสินใจจากมัน if ($o['attribution'] === 'INCOMPLETE') { reconcile($d['withdrawal']['id']); return; } // มีเงินที่ยังไม่รู้ว่าอยู่ไหน → ให้คนดู อย่าเพิ่งบอกลูกค้า if ($o['unaccounted_amount'] > 0) { Store::alertOps('localpayy: unaccounted', $orderId, $o); return; } $requested = satang($o['requested_amount']); $delivered = satang($o['delivered_amount']); // ⚠ ยอดที่ต้องคืนลูกค้า = requested − delivered // ห้ามใช้ $o['refunded_amount'] — นั่นคือยอดที่คืนเข้ากระเป๋าร้าน // ซึ่งรวมค่าธรรมเนียมคืนตามสัดส่วนไว้ด้วย (มากกว่าเสมอ) $owedToCustomer = $requested - $delivered; if ($o['result'] === 'FULLY_SETTLED') { Store::closeWithdrawalPaid($orderId, $delivered); } elseif ($o['result'] === 'PARTIALLY_SETTLED') { // ลูกค้าได้เงินไปแล้วบางส่วน ดึงกลับไม่ได้ — คืนเฉพาะส่วนต่าง Store::closeWithdrawalPartial($orderId, $delivered, $owedToCustomer); } else { // NOT_SETTLED — ไม่มีเงินออกไปเลย คืนได้เต็ม Store::refundCustomerFull($orderId, $requested); } } /* detail ให้บล็อกชุดเดียวกับ webhook เป๊ะ จึงส่งเข้า handler เดิมได้ */ function reconcile(string $withdrawalId): void { $ch = curl_init(getenv('LOCALPAYY_API') . '/v1/withdrawal/detail/' . $withdrawalId); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['x-api-key: ' . getenv('LOCALPAYY_API_KEY')], CURLOPT_TIMEOUT => 15, ]); $res = json_decode((string) curl_exec($ch), true); curl_close($ch); on_final('RECONCILE', $res['data']); } /** เก็บเงินเป็นสตางค์ (integer) เสมอ · ใช้ string กัน float ปัดเพี้ยน */ function satang($v): int { return (int) bcmul((string) ($v ?? 0), '100', 0); } /* ═══ 2 อย่างที่คุณต้องเขียนเอง ═══════════════════════════════ */ /** ต้องคืน true เฉพาะครั้งแรกของแต่ละคีย์ · เก็บอย่างน้อย 24 ชม. * ตัวอย่างนี้ใช้ตาราง MySQL ที่มี UNIQUE KEY ที่คอลัมน์ idem_key */ function claim_once(string $key): bool { $pdo = db(); $st = $pdo->prepare('INSERT IGNORE INTO localpayy_seen (idem_key, seen_at) VALUES (?, NOW())'); $st->execute([$key]); return $st->rowCount() === 1; // 1 = เพิ่งเห็นครั้งแรก } /** ตอบว่าคำสั่งถอนนี้เราสั่งเองจริงไหม — ไม่รู้จัก = false เสมอ * นี่คือด่านที่กัน API key รั่วไม่ให้ถอนเงินออกไปได้ */ function approve_withdrawal(array $d): bool { $st = db()->prepare('SELECT amount FROM withdrawals WHERE order_id = ? AND status = "PENDING"'); $st->execute([$d['order_id']]); $row = $st->fetch(); return $row && satang($row['amount']) === satang($d['amount']); } ``` ## 11. Errors returned by the LocalPayy API Applies to calls **you** make, not to webhooks. Shape: ```json { "success": false, "error": { "code": 10011, "message": "Insufficient balance.", "detail": "Available: 47.66 THB, Withdrawal: 860 THB" }, "data": {} } ``` **MUST** branch on `error.code`. **NEVER** match on `error.message` — it is translated per `Accept-Language`, its wording changes, and 27 codes have no message defined at all and arrive as the number in string form (e.g. `"message": "35011"`). Retired codes are never reused. HTTP status is 400 unless listed otherwise; 429 responses carry `retry_after` (seconds) and `retry_at`. Full table of all 532 codes: `docs/api/WEBHOOK_TESTER.html` §7 (searchable). The ones that surface during integration: | Code | HTTP | Name | Cause | |---|---|---|---| | `1008` | 400 | `UNAUTHORIZED` | Unauthorized access. | | `2003` | 403 | `IP_NOT_FOUND_WHITELIST` | Access denied. Your IP address is not authorized. | | `10005` | 400 | `WITHDRAWAL_ORDER_ID_EXISTS` | Withdrawal order ID already exists. | | `10011` | 400 | `WITHDRAWAL_INSUFFICIENT_BALANCE` | Insufficient balance. | | `10046` | 400 | `WITHDRAWAL_DUPLICATE_ORDER_ID` | Withdrawal order with this order ID already exists. | | `10060` | 400 | `WITHDRAWAL_VERIFY_REJECTED` | Withdrawal could not be verified with the merchant system. Please try again. | | `10061` | 400 | `WITHDRAWAL_VERIFY_MISCONFIGURED` | Withdrawal verification is misconfigured (missing callback URL or secret). | | `10062` | 400 | `WITHDRAWAL_ALREADY_REFUNDED` | This withdrawal was already refunded. Acting on it again would move the money twice. | | `10063` | 400 | `WITHDRAWAL_PARTIALLY_SETTLED` | Part of this withdrawal was already delivered to the receiver. A full refund would pay it twice — resolve the delivered portion first. | | `6026` | 400 | `WITHDRAWAL_VERIFY_COOLDOWN_ACTIVE` | The withdrawal verification settings were recently changed. Withdrawals are temporarily restricted. | | `38003` | 400 | `P2P_NO_MATCH_AVAILABLE` | No matching withdrawal available for this amount. | | `38005` | 409 | `P2P_DUPLICATE_SLIP` | This slip has already been used in another transaction. | | `38045` | 409 | `P2P_DUPLICATE_ORDER_ID` | Duplicate order_id: a session with this order already exists. | ## 12. Acceptance tests Drive these from `docs/api/WEBHOOK_TESTER.html` — open it in a browser, enter your URL and secret, fire. Each row is a behaviour the implementation MUST show. | # | Stimulus | Required behaviour | |---|---|---| | 1 | `WITHDRAWAL_COMPLETED` | order closed as paid, for `delivered_amount` | | 2 | `WITHDRAWAL_COMPLETED_PARTIALLY` (900 requested, 600 delivered) | order closed as partly paid; customer refunded **300.00**, not 304.50 | | 3 | `WITHDRAWAL_REJECT` | customer refunded in full | | 4 | lifecycle burst: three `PARTIALLY_FUNDED` then a terminal event | progress updated three times; order closed **once**, at the terminal event | | 5 | `PARTIALLY_FUNDED` with `reason: PLEDGE_EXPIRED` | stored figure allowed to stay or decrease; no high-water clamp | | 6 | same event re-fired with the same `X-Idempotency-Key` | 200 returned, **no** second side effect | | 7 | tampered or absent `x-signature` | 401, nothing processed | | 8 | `x-timestamp` older than 300s | 401 | | 9 | endpoint offline for an hour, then restored | state recovered via `GET /v1/withdrawal/detail/:id` | | 10 | `WITHDRAWAL_VERIFY` for an unknown `order_id` | non-2xx | | 11 | `PAYMENT_PAID` | customer credited `payment.payment_amount` (not `payment.amount`) | | 12 | unknown future event name | 200, logged, no crash | ## 13. Definition of done - [ ] ตอบ HTTP 200 ภายใน 10 วินาที และประมวลผลจริงแบบ async - [ ] ตรวจ x-signature ทุกใบ และปฏิเสธใบที่ลายเซ็นไม่ตรง - [ ] ตรวจ x-timestamp ว่าห่างไม่เกิน 5 นาที - [ ] กันซ้ำด้วย X-Idempotency-Key และทดสอบยิงซ้ำแล้วไม่เกิดผลสองรอบ - [ ] อ่าน order_id จาก data.withdrawal.order_id / data.payment.order_id (ไม่ใช่ระดับบนสุด) - [ ] ตัดสินใจจาก data.outcome ไม่ใช่จากชื่อ event - [ ] รองรับ WITHDRAWAL_COMPLETED_PARTIALLY — คืนลูกค้าเท่ากับ requested_amount − delivered_amount (ไม่ใช่ refunded_amount) - [ ] ไม่ปิดออเดอร์ตอนได้ WITHDRAWAL_PARTIALLY_FUNDED - [ ] ยอมให้ progress.settled_amount ลดลงได้ (PLEDGE_EXPIRED) - [ ] parse ยอดเงินด้วย decimal ไม่ใช่ float (amount มาเป็น string) - [ ] มี job reconcile ด้วย GET /v1/withdrawal/detail/:id ไว้กู้ตอนระบบล่ม - [ ] แจ้ง IP ขาออกของ production (รวม IP สำรอง) ให้ทีมงานใส่ whitelist - [ ] endpoint เป็น HTTPS และใบรับรองใช้ได้จริง - [ ] ถ้าเปิด second-verify: ตอบ 200 เฉพาะออเดอร์ที่มีอยู่จริงและยอดตรง - [ ] เก็บ log ทุก webhook พร้อม Trust-X-Request-ID ไว้อย่างน้อย 30 วัน ## 14. Failure modes seen in real integrations | Symptom | Cause | |---|---| | every signature mismatches | signed the whole body instead of `data` | | signatures verify in tests, fail in production | the framework parsed and re-serialized the body; capture raw bytes | | Thai names break the signature | serializer escaped non-ASCII (`ensure_ascii=True`) | | URLs break the signature | serializer escaped `/` (missing `JSON_UNESCAPED_SLASHES`) | | `order_id` is undefined | read from the top level instead of `data.withdrawal.order_id` | | customers refunded slightly too much | used `outcome.refunded_amount` instead of requested − delivered | | orders closed early, then reopened | closed on `WITHDRAWAL_PARTIALLY_FUNDED` | | duplicate credits after an operator resend | idempotency claimed after the side effect, or held in process memory | | webhooks silently lost | assumed automatic retry; there is none | --- Companion material: `docs/api/WEBHOOK_TESTER.html` (interactive tester, 532 error codes) · `docs/p2p/P2P_MERCHANT_GUIDE_SLIDES.html` (business-level walkthrough) · `/v1/docs` (OpenAPI).