สถานะรายการฝาก
กฎ 3 ข้อที่ต้องรู้ก่อน
status = "settled"เท่านั้นที่แปลว่าเครดิตได้ — ทุกค่าอื่นห้ามปล่อยของ ไม่มีข้อยกเว้นexpiredและcanceledไม่ใช่จุดจบเสมอไป — ดูที่is_terminalไม่ใช่ที่ชื่อสถานะ- webhook เป็นหลัก ·
GETเป็นตัวสำรอง — ไม่ใช่ทางกลับกัน
ทั้ง 9 สถานะ
status | ความหมาย | จบแล้ว | 💰 เงินอยู่ไหน | คุณต้องทำอะไร |
|---|---|---|---|---|
awaiting_liquidity | สร้างรายการแล้ว แต่ยังไม่มีปลายทางให้โอน ไม่มี QR ไม่มีเลขบัญชี | ไม่ | ยังไม่โอน | หน้าจอ "กำลังจัดหาช่องทางโอน" + เสนอ liquidity.suggested_amounts_minor[] · 🔴 ห้ามแสดง QR ห้ามบอกให้โอน |
awaiting_payment | จับคู่แล้ว มี legs[] และ expires_at | ไม่ | ยังไม่โอน (อยู่ที่ผู้ใช้) | แสดง QR/เลขบัญชี + countdown จาก expires_at + กล่อง "โอนจากบัญชีนี้เท่านั้น" |
slip_processing | รับสลิปแล้ว กำลังตรวจ | ไม่ | โอนแล้ว รอตรวจ | "กำลังตรวจสอบสลิป" · disable ปุ่มอัปสลิปซ้ำ · 🔴 ห้ามปล่อยของ |
under_review | มิติ non-amount ไม่ผ่าน / ติด policy / ธนาคารปิดตรวจอัตโนมัติ / สลิปซ้ำ / กำกวม; amount mismatch อย่างเดียวไม่เข้า state นี้ | ไม่ | โอนแล้ว รอเจ้าหน้าที่ | แสดง review.reasons[0].display_th ตรง ๆ · สีเหลือง/ฟ้า ไม่ใช่แดง · 🔴 ห้ามปล่อยของ ห้ามมีปุ่ม "โอนใหม่" |
settled | เครดิตแล้ว; clean short-pay มี partial:true, settled_via:'auto'; อาจมี overpayment{} flag | เกือบ (ออกได้ทางเดียวคือ reversed) | รับเข้าเฉพาะ amount_received_minor; ส่วนเกินอยู่ใน claim | เครดิตผู้ใช้ด้วย amount_received_minor; short-pay แสดงยอดจริงที่รับ; ถ้ามี flag ให้แสดงยอดเกิน/claim และปุ่มติดต่อ support |
expired | หมด expires_at โดยไม่มีสลิป · 🔴 ไม่ใช่การปฏิเสธเงิน | ไม่ จนพ้นหน้าต่างย้อนหลัง | ปกติยังไม่โอน · ถ้าอัปสลิปย้อนหลัง = โอนแล้ว | ต้องเหลือปุ่ม "ฉันโอนไปแล้วแต่หมดเวลา" จนถึง late_slip_accepted_until |
canceled | ปิดก่อนโอน — คุณกด / ผู้ใช้กด / ระบบปิดเพราะหาปลายทางไม่ได้ | ขึ้นกับ late_slip_accepted_until | ปกติยังไม่โอน | ถ้ามี late_slip_accepted_until ต้องเหลือปุ่มอัปสลิป เหมือน expired |
rejected | เจ้าหน้าที่ตัดสินไม่รับสลิปใบนี้ | ✅ | ไม่เครดิต | แสดง rejection.display_th + แสดง deposit_id ให้ copy ได้ |
reversed | เคย settled แล้วถูกกลับรายการ | ✅ | หักคืนแล้ว | 🔴 ต้องหักยอดผู้ใช้คืน และแจ้งผู้ใช้เสมอ · handler ของ deposit.reversed + balance.debited ปิดไม่ได้ |
rejected ไม่เท่ากับคืนเงิน
rejected แปลว่า "สลิปใบนี้ไม่ใช่หลักฐานของรายการนี้" ถ้าเงินจริงเข้าไปแล้วแต่เป็นของรายการอื่น เรื่องนั้นเดินต่อในเส้น claim ไม่ใช่ที่สถานะนี้ · อย่าเขียนโค้ดที่ auto-refund เมื่อเห็น rejected
is_terminal ไม่ใช่ชื่อสถานะ
// ❌ ผิด — ปิดออร์เดอร์ทั้งที่ผู้ใช้อาจโอนไปแล้วและกำลังจะอัปสลิป
if (['expired', 'canceled', 'rejected', 'reversed'].includes(d.status)) closeOrder()
// ✅ ถูก
if (d.is_terminal) closeOrder()expired และ canceled ที่มี late_slip_accepted_until จะมี is_terminal: false · เมื่อหน้าต่างปิดจริง เราจะยิง deposit.expired / deposit.canceled ซ้ำอีกครั้ง ด้วย is_terminal: true และ version ใหม่ — นั่นคือจุดที่ปิดออร์เดอร์ได้
อ่านสถานะรายการเดียว
curl https://api.example-wl.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755749900" \
-H "PG-Nonce: 01JQA5TB4N6Q8S0U2W4Y6A8C0E" \
-H "PG-Version: 2026-08-01" \
-H "PG-Signature: v1=8e0b...cd72"ตัวอย่าง response ของรายการที่เครดิตแล้ว
{
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 7,
"status": "settled",
"is_terminal": false,
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"ordered_amount_minor": 500000,
"currency": "THB",
"created_at": "2026-08-21T04:10:47.902Z",
"settled_at": "2026-08-21T04:19:33.204Z",
"settled_via": "auto",
"partial": false,
"liquidity_source": "internal",
"transferred_amount_minor": 500000,
"amount_received_minor": 500000,
"fee_total_minor": 7500,
"fee_breakdown": [
{ "component": "processing_fee", "amount_minor": 5000, "rate_bps": 100 },
{ "component": "fixed_fee", "amount_minor": 2000 },
{ "component": "vat", "amount_minor": 500 }
],
"credited_amount_minor": 492500,
"overpayment": null,
"balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"clearing_available_at": "2026-08-24T04:19:33.204Z",
"sender": {
"bank_code": "004",
"account_number_masked": "xxx-x-x7890",
"account_name_th_masked": "ส*** ใ***"
},
"metadata": { "campaign": "aug-cashback", "order_id": "ORD-9981" }
}ยอดโอนจริงกับยอดรับเข้าคนละความหมาย
| field | ใช้ทำอะไร |
|---|---|
transferred_amount_minor | ยอด settlement evidence ที่ธนาคารยืนยัน; same-slip overpay อาจสูงกว่า A · transfer เพิ่มหลัง settle ไม่บวกสะสมใน field นี้ |
amount_received_minor | เครดิตให้ผู้ใช้ปลายทาง — ยอดที่ระบบรับเข้า order นี้ |
fee_total_minor | ค่าธรรมเนียมที่เก็บจากคุณ |
credited_amount_minor | ยอดที่เข้ากระเป๋าของคุณ = amount_received_minor − fee_total_minor |
ใช้ credited_amount_minor ไปเครดิตผู้ใช้ = ผู้ใช้ได้เงินขาดเท่ากับค่าธรรมเนียมทุกรายการ · ใช้ amount_received_minor ไปกระทบยอดกระเป๋า = ยอดในระบบคุณสูงกว่าจริงเท่ากับค่าธรรมเนียมทั้งหมด
เก็บทุกตัวแยกคอลัมน์ แล้ว assert amount_received_minor = min(transferred_amount_minor, ordered_amount_minor), 0 ≤ fee_total_minor ≤ amount_received_minor และ amount_received_minor − fee_total_minor = credited_amount_minor
ถ้า same-slip transferred_amount_minor > amount_received_minor รายการยัง settled; object จะมี overpayment{cause:'same_transfer_excess',claim_id,amount_minor,action_required:'contact_support'}. เครดิตเฉพาะ amount_received_minor และให้ผู้ใช้ติดต่อ support พร้อม claim id · ถ้ามี transfer เพิ่มหลัง settle จะได้ cause:'additional_transfer' โดย transferred_amount_minor ยังคงเป็น evidence เดิม ไม่ใช่ยอดสะสม
credited_amount_minor ยังถอนไม่ได้ทันที
ยอดที่เพิ่งเข้าจะอยู่ในกลุ่ม pending_clearing ก่อน แล้วย้ายไป available ที่เวลา clearing_available_at (T+72 ชม.) · สร้างรายการถอนด้วยยอดที่ยังไม่ clear จะได้ 402 insufficient_balance ทั้งที่ dashboard เห็นเงิน
อ่านหลายรายการทีเดียว
curl -G https://api.example-wl.com/v1/pay/deposits \
--data-urlencode "status=settled" \
--data-urlencode "updated_after=2026-08-21T00:00:00.000Z" \
--data-urlencode "limit=100" \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Version: 2026-08-01" \
-H "PG-Signature: v1=1d47...9f30"{
"data": [
{
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B", "object": "deposit_order", "version": 7,
"status": "settled", "is_terminal": false, "merchant_reference": "ORD-9981",
"amount_minor": 500000, "ordered_amount_minor": 500000, "currency": "THB",
"created_at": "2026-08-21T04:10:47.902Z", "transferred_amount_minor": 500000,
"amount_received_minor": 500000, "fee_total_minor": 7500,
"fee_breakdown": [{ "component": "processing_fee", "amount_minor": 7500 }],
"credited_amount_minor": 492500, "partial": false, "settled_via": "auto",
"liquidity_source": "internal", "overpayment": null,
"balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"settled_at": "2026-08-21T04:19:33.204Z",
"clearing_available_at": "2026-08-24T04:19:33.204Z"
},
{
"id": "dep_01K3MB2K8T4WQZ6XR0NHD7YV5A", "object": "deposit_order", "version": 5,
"status": "settled", "is_terminal": false, "merchant_reference": "ORD-9984",
"amount_minor": 120000, "ordered_amount_minor": 120000, "currency": "THB",
"created_at": "2026-08-21T04:35:10.128Z", "transferred_amount_minor": 120000,
"amount_received_minor": 120000, "fee_total_minor": 3200,
"fee_breakdown": [{ "component": "processing_fee", "amount_minor": 3200 }],
"credited_amount_minor": 116800, "partial": false, "settled_via": "auto",
"liquidity_source": "internal", "overpayment": null,
"balance_transaction_id": "btxn_01K3MB2K9V5XR0Q7NHD8YWC6FA",
"settled_at": "2026-08-21T04:41:02.771Z",
"clearing_available_at": "2026-08-24T04:41:02.771Z"
}
],
"has_more": true,
"next_cursor": "cur_01K3MB9Q0R2T4V6X8Z0B2D4F6H"
}| ข้อ | กติกา |
|---|---|
limit | 1..200 — เปลี่ยนกลางคันได้ |
cursor | ค่า opaque จาก next_cursor |
| เปลี่ยน filter กลางคัน | 🔴 เปลี่ยน status / ช่วงเวลาแล้วใช้ cursor เดิม → 400 cursor_filter_mismatch ต้องเริ่มหน้าใหม่ |
อย่า poll ถี่ — ตัวเลขจริง
จำนวนที่ชน rate limit
GET /v1/pay/* ทั้งหมดใช้ bucket เดียวกันคือ 1,200 ครั้งต่อ 60 วินาที ต่อ API key (burst 2 เท่า)
| วิธี poll | รายการที่เปิดพร้อมกัน | request/นาที | ผล |
|---|---|---|---|
| ทุก 1 วินาที รายตัว | 20 | 1,200 | 🔴 ชนพอดี — รายการที่ 21 ทำให้ทั้ง key ติด 429 |
| ทุก 1 วินาที รายตัว | 100 | 6,000 | 🔴 ชนตั้งแต่วินาทีที่ 12 ของทุกนาที |
| ทุก 5 วินาที รายตัว | 100 | 1,200 | 🔴 ชนพอดี ไม่มีที่ว่างให้ GET อื่นเลย |
backoff 3 → 5 → 10 → 30 วินาที | 100 | ~200–400 | ✅ ปลอดภัย |
GET /v1/pay/deposits?status= ทุก 15 วินาที (200 ใบ/ครั้ง) | 1,000+ | 4 | ✅ ปลอดภัยมาก |
🔴 429 ทำให้ GET ทุกเส้นของ key นั้นติดไปด้วย ไม่ใช่เฉพาะรายการที่คุณ poll · และ retry เป็นลูปมี penalty ทวีคูณ
กติกาที่ปลอดภัย
- webhook เป็นช่องแจ้งเตือนหลัก ไม่ใช่ source of truth — สถานะ/ledger ที่อ่านผ่าน
GET /v1/pay/*และงาน reconcile เป็นตัวตัดสินเมื่อข้อมูลไม่ตรงกัน - poll รายตัวใช้ backoff 3 → 5 → 10 → 30 วินาที แล้วหยุดเมื่อ
is_terminalเป็นtrueหรือstatus = "settled" - รายการที่
under_reviewห้าม poll ต่อ — ใช้เวลาที่เจ้าหน้าที่ตรวจ ให้รอ webhook แทน ถ้าจะเช็คก็ใช้ batch วันละไม่กี่ครั้ง - reconcile รายวันใช้
GET /v1/pay/events?since_sequence=+GET /v1/pay/deposits?updated_after=ไม่ใช่ไล่GETรายตัวทั้งวัน - เคารพ header ที่เราคืนมาทุก response ไม่ใช่เฉพาะตอน
429
RateLimit-Limit: 1200
RateLimit-Remaining: 1187
RateLimit-Reset: 41
RateLimit-Policy: 1200;w=60// Node — poll แบบ backoff ที่หยุดถูกจุด
const BACKOFF_MS = [3000, 5000, 10000, 30000]
async function waitForDeposit(depositId, { maxMs = 15 * 60 * 1000 } = {}) {
const deadline = Date.now() + maxMs
let step = 0
while (Date.now() < deadline) {
const d = await getDeposit(depositId)
if (d.status === 'settled') return d
if (d.is_terminal) return d
// รอเจ้าหน้าที่ตรวจ — ไม่มีประโยชน์ที่จะถามซ้ำถี่ ๆ ให้ webhook เป็นคนบอก
if (d.status === 'under_review') return d
await sleep(BACKOFF_MS[Math.min(step++, BACKOFF_MS.length - 1)])
}
return getDeposit(depositId)
}<?php
// PHP — batch แทนการไล่ทีละใบ
function syncRecentDeposits(string $since, array $headers): int
{
$cursor = null;
$count = 0;
do {
$query = http_build_query(array_filter([
'updated_after' => $since,
'limit' => 200,
'cursor' => $cursor,
]));
$res = httpGet("https://api.example-wl.com/v1/pay/deposits?{$query}", $headers);
foreach ($res['data'] as $d) {
// 🔴 เขียนทับเฉพาะเมื่อ version สูงกว่าที่มีอยู่ ไม่ใช่ทับทุกครั้ง
applyDepositIfNewer($d['id'], $d['version'], $d);
$count++;
}
$cursor = $res['has_more'] ? $res['next_cursor'] : null;
} while ($cursor !== null);
return $count;
}GET แล้วได้ค่าเก่ากว่า webhook ได้ไหม
ได้ ถ้า webhook ของ version ใหม่มาถึงคุณก่อนที่คุณจะยิง GET แล้ว GET ไปเจอ replica ที่ตามหลังอยู่ · ทางแก้เหมือนกันทั้งสองทาง: เขียนทับสถานะเฉพาะเมื่อ version ที่ได้มาสูงกว่าที่เก็บไว้ ไม่ว่าค่านั้นจะมาจาก webhook หรือจาก GET
UPDATE deposits
SET status = $2, version = $3, payload = $4, updated_at = now()
WHERE deposit_id = $1
AND version < $3; -- rowCount = 0 แปลว่าเรามีของใหม่กว่าอยู่แล้ว ให้ข้ามไปเลยรายละเอียดเต็มอยู่ที่ ลำดับ webhook ไม่การันตี
ขั้นต่อไป
- รายการที่ยังค้างอยู่ — หารายการที่เปิดค้างของผู้ใช้คนหนึ่ง
- ลำดับ webhook ไม่การันตี — version guard ที่ทำให้
GETกับ webhook อยู่ร่วมกันได้ - ตรวจลายเซ็น Ed25519 — ทำให้ webhook เชื่อถือได้ก่อนเลิกพึ่ง poll
- ยกเลิกรายการฝาก — เมื่อรายการค้างต้องถูกปิด
- State machine ทั้งหมด — เส้นทางระหว่างสถานะทุกเส้น
