อัปโหลดสลิป
POST /v1/pay/deposits/{id}/slip คือขั้นที่ผู้ใช้โอนเงินไปแล้วและกำลังส่งหลักฐานเข้ามา
ทุก payload ที่ผ่าน auth/validation และรับเข้าแบบ durable สำเร็จตอบ 202
ไม่มี 200 · ไม่มี 409 duplicate_slip แบบทันที · ไม่มีการบอกผลตรวจในคำตอบของ request นี้ แต่ auth/validation, idempotency conflict, หน้าต่างรับสลิปปิด หรือ storage/queue ที่ยังยืนยัน durable intake ไม่ครบยังตอบ non-2xx ได้
202 แปลว่า "เก็บหลักฐานอย่าง durable แล้ว" และคืน receipt เท่านั้น ยังไม่แปลว่า order เปลี่ยนเป็น slip_processing ผลการ claim receipt และผลตรวจจะมาทีหลังผ่าน webhook หรือ status endpoint
ทำไม durable intake สำเร็จจึงเป็น 202
ที่อื่นทำแบบไหน — gateway ส่วนใหญ่ตรวจสลิปแบบ synchronous ในคำขอเดียวกัน แล้วตอบผลกลับมาเลย: 200 verified หรือ 409 duplicate_slip หรือ 422 invalid_slip โค้ดฝั่ง merchant จึงเขียนแบบ "ยิงแล้วอ่านผลตรงนั้น"
เราทำแบบไหน — เราสร้าง durable intake receipt โดยเขียนหลักฐานแบบ conditional-create พร้อม encrypted intake envelope และ manifest ที่ผูก hash/etag ของ object ทั้งหมด แล้ว read-back ยืนยัน winner ก่อนรอ Queue สำเร็จและตอบ 202 ข้อความใน Queue เป็น pointer เท่านั้น: {receipt_id, manifest_key, manifest_etag} ไม่มี tenant, deposit, claims หรือยอดเงิน ถ้าส่ง Queue ไม่สำเร็จ request จะตอบ 503 slip_intake_unavailable; retry/reconciliation ใช้ manifest winner เดิม Consumer pin manifest_etag, decrypt envelope และ re-verify capability, URL, claims, fingerprint, hash/etag รวมถึง order/reservation ในฐานข้อมูลก่อนทำ transition ใด ๆ — Queue และ field นอก pointer ไม่ใช่แหล่งความจริง
ทำไม — เพราะจุดนี้คือจุดเดียวในระบบที่เงินของผู้ใช้ออกจากบัญชีไปแล้ว ถ้าเราตรวจสลิปในคำขอเดียวกัน แล้วผู้ให้บริการตรวจสลิปช้า หรือฐานข้อมูลตึง หรือระบบกำลังลดโหลด ผู้ใช้จะได้ error ทั้งที่เงินออกไปแล้ว และเขาจะทำสิ่งที่แย่ที่สุดคือ โอนซ้ำ
ระบบรับสลิปจึงเป็นงานที่ถูกจัดลำดับความสำคัญสูงสุดและไม่ถูกลดโหลดแม้ระบบตึง แต่จะตอบ 202 ก็ต่อเมื่อเก็บ blob และยืนยัน queue สำเร็จครบแล้วเท่านั้น ถ้า object storage หรือ queue ยังยืนยันไม่ได้จะตอบ retryable 503 แทน เพื่อไม่อ้างว่ารับหลักฐานแล้วทั้งที่งานอาจสูญหาย
ผลข้างเคียงที่คุณต้องรับมือ
คุณจะ ไม่รู้ผลทันที ⇒ UI ห้ามขึ้นว่า "เติมเงินสำเร็จ" หลังอัปสลิปเสร็จ · และ handler ของ webhook ต้องพร้อมก่อนขึ้น production ไม่ใช่ค่อยมาทำทีหลัง
Auth — ใช้ capability token ของรายการ ไม่ใช้ merchant HMAC
POST /v1/pay/deposits/{id}/slip เป็นข้อยกเว้นของ /v1/pay/*: ส่งเพียง Authorization: Bearer <slip_upload.token> กับ Idempotency-Key และ body เท่านั้น ห้ามส่ง sk_live_*, PG-Timestamp, PG-Nonce, PG-Version หรือ PG-Signature มาที่ ingress นี้
- ถ้าสร้างรายการแล้วได้
201 awaiting_paymentresponse จะมีslip_upload: { token, expires_at, upload_url, max_bytes, content_types }อยู่แล้ว - ถ้าสร้างแล้วได้
202 awaiting_liquidityจะยังไม่มี token เพราะยังไม่มี destination/reservation หลังได้deposit.matchedให้ backend เรียกPOST /v1/pay/deposits/{id}/slip-tokenด้วย merchant API key + HMAC แบบปกติ แล้วจึงแสดง payment instructions - token เป็น Ed25519 capability สำหรับ deposit เดียว (
aud=pg-slip-ingest,scope=slip:write) ผูก tenant, livemode,deposit_id, URL เต็มในhtu,rate_subject, API version และหมดอายุที่ reservationhold_untilห้าม log, ใส่ URL หรือเก็บใน analytics - token ที่ออกแล้วตั้งใจให้ใช้อัปสลิปของ order ที่เปิดอยู่ต่อได้แม้ API key/WL ถูก freeze ภายหลัง เพราะผู้ใช้อาจโอนไปแล้ว; consumer จะตรวจ tenant/order/reservation/state ใน DB ซ้ำก่อน transition ทางธุรกิจ
- ส่ง request ไปที่
slip_upload.upload_urlที่คืนมา ทุกตัวอักษร ห้ามประกอบ host/path จากPG_APIหรือdeposit_idเอง เพราะ URL ต้องตรงกับ claimhtu - ก่อนส่งให้ client ตรวจ decoded bytes กับ
max_bytesและ media type กับcontent_types; ingress จะตรวจขนาดและ magic bytes ซ้ำ
เส้น awaiting_liquidity ขอ token หลัง matched และก่อนแสดง payment instructions คำขอนี้ยิงเข้า money service จึงเซ็น merchant HMAC ตาม หน้า authentication:
POST /v1/pay/deposits/{id}/slip-token
Authorization: Bearer sk_live_...
PG-Timestamp: ...
PG-Nonce: ...
PG-Version: 2026-08-01
PG-Signature: ...
Idempotency-Key: slip-token-{deposit_id}
HTTP/1.1 200 OK
Cache-Control: no-store
{
"token": "pgsut_eyJraWQiOiJvcmRlc...",
"expires_at": "2026-08-22T04:30:47.902Z",
"upload_url": "https://slip-api.example.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/slip",
"max_bytes": 4194304,
"content_types": ["image/jpeg", "image/png", "image/webp", "application/json"]
}Request
POST /v1/pay/deposits/{id}/slip รับ body ได้ 3 แบบ
แบบ 1 — multipart (แนะนำ)
curl -X POST "$SLIP_UPLOAD_URL" \
-H "Authorization: Bearer $SLIP_UPLOAD_TOKEN" \
-H "Idempotency-Key: slip-ORD-9981-01" \
-F "file=@/tmp/slip.jpg;type=image/jpeg" \
-F "payload=0038000600000101030040814"| field | บังคับ | หมายเหตุ |
|---|---|---|
file | ✅ | media type ต้องอยู่ใน slip_upload.content_types และ decoded bytes ต้องไม่เกิน slip_upload.max_bytes |
payload | — | สตริงที่ client decode ได้จาก mini-QR บนสลิป · ส่งมาด้วยช่วยให้ตรวจแม่นขึ้นมาก |
แบบ 2 — JSON base64
ใช้เมื่อ backend ของคุณรับไฟล์จาก client มาแล้วและไม่อยาก stream ต่อ
curl -X POST "$SLIP_UPLOAD_URL" \
-H "Authorization: Bearer $SLIP_UPLOAD_TOKEN" \
-H "Idempotency-Key: slip-ORD-9981-01" \
-H "Content-Type: application/json" \
-d '{
"image_base64": "/9j/4AAQSkZJRgABAQAAAQABAAD...",
"payload": "0038000600000101030040814"
}'multipart หรือ base64
| multipart | base64 | |
|---|---|---|
| ขนาดที่ส่งจริง | เท่าไฟล์ | +33% |
| หน่วยความจำฝั่งคุณ | stream ได้ | ต้องโหลดทั้งไฟล์เข้า memory |
| auth | capability token + Idempotency-Key · ไม่ใช้ HMAC | เหมือนกัน |
| เหมาะกับ | ทุกกรณี | ระบบเดิมที่เก็บรูปเป็น base64 อยู่แล้ว |
ใช้ multipart ถ้าไม่มีเหตุผลเฉพาะ
ก่อนส่ง JSON ต้องมี application/json อยู่ใน content_types; max_bytes วัด decoded evidence bytes ไม่ใช่ขนาด base64 บนสาย
แบบ 3 — ไม่มีรูป มีแต่เลขอ้างอิง
ผู้ใช้ลบรูปไปแล้ว แต่เปิดแอปธนาคารดูรายการย้อนหลังได้
curl -X POST "$SLIP_UPLOAD_URL" \
-H "Authorization: Bearer $SLIP_UPLOAD_TOKEN" \
-H "Idempotency-Key: slip-ORD-9981-02" \
-H "Content-Type: application/json" \
-d '{
"trans_ref": "015566778899001234",
"sending_bank_code": "004",
"occurred_at": "2026-08-21T04:18:55.000Z"
}'เส้นนี้เข้าคิวเจ้าหน้าที่เสมอ
ส่ง trans_ref อย่างเดียวจะถูกบังคับเข้า under_review ด้วย reasons = ['NO_SLIP_IMAGE'] ทุกครั้ง ไม่มีทางผ่านอัตโนมัติ · ให้ใช้เป็นทางเลือกสุดท้าย และบอกผู้ใช้ให้ชัดว่าจะใช้เวลานานกว่าปกติ
ข้อกำหนดของรูป
| ข้อ | ค่า |
|---|---|
| ชนิดไฟล์ | อ่านจาก slip_upload.content_types ของ capability ใบนั้น (ตัวอย่าง: image/jpeg · image/png · image/webp) |
| ขนาดสูงสุด | อ่านจาก slip_upload.max_bytes ของ capability ใบนั้น (ตัวอย่าง response: 4,194,304 bytes) |
| การตรวจชนิดไฟล์ | ตรวจจาก magic bytes ไม่ใช่จากนามสกุลหรือ Content-Type — เปลี่ยนนามสกุล .pdf เป็น .jpg ไม่ผ่าน |
อย่าบีบอัดจน QR ในสลิปพัง
สลิปของธนาคารไทยเกือบทุกใบมี mini-QR อยู่มุมล่าง ซึ่งเป็นหลักฐานที่แม่นที่สุดในการตรวจ · การย่อรูปแรง ๆ ฝั่ง client ทำให้ QR นั้นอ่านไม่ออก แล้วรายการที่ควรผ่านอัตโนมัติจะตกไปเข้าคิวเจ้าหน้าที่
กฎที่ปลอดภัย
- ถ้าไฟล์ต้นฉบับ ≤
max_bytesอยู่แล้ว ส่งต้นฉบับไปเลย อย่าแตะ - ถ้าต้องย่อจริง ๆ ให้คงด้านสั้นไว้ ไม่ต่ำกว่า 1,000 px และคุณภาพ JPEG ไม่ต่ำกว่า 85
- 🔴 ห้ามแปลงเป็นขาวดำ ห้ามครอปมุมล่างของสลิปทิ้ง ห้ามใส่ watermark ทับ
- ห้ามแปลงเป็น PDF แล้วส่ง
// Node — ย่อเฉพาะเมื่อจำเป็นจริง ๆ (ตัวอย่างใช้ sharp)
async function prepareSlip(buffer, mime, capability) {
if (!capability.content_types.includes(mime)) {
throw new Error(`ชนิดไฟล์ไม่อยู่ใน capability: ${mime}`)
}
if (buffer.length <= capability.max_bytes) return { buffer, mime } // ✅ ส่งต้นฉบับดีที่สุด
if (!capability.content_types.includes('image/jpeg')) {
throw new Error('capability นี้ไม่อนุญาต JPEG สำหรับผลย่อ')
}
const sharp = require('sharp')
const out = await sharp(buffer)
.resize({ width: 1600, withoutEnlargement: true }) // ด้านสั้นยังเกิน 1,000 px
.jpeg({ quality: 88 }) // 🔴 อย่าต่ำกว่า 85
.toBuffer()
if (out.length > capability.max_bytes) throw new Error('สลิปใหญ่เกิน capability.max_bytes ให้ผู้ใช้ถ่ายใหม่')
return { buffer: out, mime: 'image/jpeg' }
}Response 202 — durable receipt เท่านั้น
{
"receipt_id": "rcpt_01K3M9DR6T2VXN8QY0KZH4WB7F",
"status": "accepted",
"received_at": "2026-08-21T04:19:02.180Z"
}| field | ความหมาย |
|---|---|
receipt_id | receipt ของ blob ที่รับแบบ durable · เก็บไว้อ้างอิงกับ support และ retry ด้วย idempotency key เดิม |
status | เป็น accepted เสมอ · 🔴 ไม่ใช่สถานะของ deposit และห้ามใช้ปล่อยของ |
received_at | เวลาที่ระบบเก็บหลักฐานสำเร็จ (UTC) |
หลังจากตอบแล้ว consumer จะ claim receipt และทำ transition ไป slip_processing แบบ async จากนั้นจึงยิง deposit.slip_received การเรียก GET /v1/pay/deposits/{id} ทันทีหลัง 202 อาจยังเห็นสถานะเดิมได้ ซึ่งเป็นพฤติกรรมปกติ
สิ่งที่ต้องทำหลังได้ 202
// Node — ✅ ถูก
const res = await fetch(capability.upload_url, { method: 'POST', headers, body: form })
if (res.status === 202) {
const { receipt_id, status, received_at } = await res.json()
await db.recordSlipReceipt(depositId, { receipt_id, status, received_at })
return ui.show('กำลังตรวจสอบสลิปกับธนาคาร ใช้เวลาไม่เกิน 1 นาที') // ❌ ไม่ใช่ "เติมเงินสำเร็จ"
}// ❌ ผิด — พังทั้งเส้น เพราะ endpoint นี้ไม่เคยตอบ 200
if (res.status !== 200) throw new Error('อัปโหลดสลิปไม่สำเร็จ')
// ❌ ผิด — ปล่อยของก่อนตรวจจบ
if (res.ok) await creditUser(userId, order.amountMinor)UI ที่ต้องออกแบบใหม่ถ้าย้ายมาจากที่อื่น
| ขั้น | ❌ แบบเดิมที่เคยทำได้ | ✅ ที่นี่ต้องทำ |
|---|---|---|
| กดอัปโหลดเสร็จ | "เติมเงินสำเร็จ 5,000 บาท" | "กำลังตรวจสอบสลิปกับธนาคาร ใช้เวลาไม่เกิน 1 นาที" |
| ปุ่มอัปสลิป | เปิดค้างไว้ | disable ทันทีที่ได้ 202 เพื่อกันอัปซ้ำ |
| เครดิตยอดผู้ใช้ | ทำที่ขั้นนี้ | ทำที่ webhook deposit.settled เท่านั้น |
| สลิปซ้ำ | ขึ้น error ตรงนั้น | มาทีหลังเป็น deposit.under_review ['DUPLICATE_SLIP'] |
| รูปอ่านไม่ออก | ขึ้น error ตรงนั้น | มาทีหลังเป็น deposit.slip_rejected แล้วให้ผู้ใช้ส่งใหม่ |
หน้าจอที่ทำงานได้จริง
หลังได้ 202 ให้แสดงหน้ารอที่ฟัง webhook ผ่าน channel ของคุณเอง (websocket / SSE / long-poll ภายใน) แล้วอัปเดตหน้าจอเมื่อรายการเปลี่ยนสถานะ · ถ้ายังไม่มี realtime ให้ poll GET /v1/pay/deposits/{id} แบบ backoff 3 → 5 → 10 → 30 วินาที ไม่ใช่ทุกวินาที
ผลตรวจมายังไง
| ผลตรวจ | webhook | status ปลายทาง | ต้องทำอะไร |
|---|---|---|---|
| ยอดตรงและผ่านครบ 6 มิติ | deposit.settled | settled | เครดิตผู้ใช้ด้วย amount_received_minor · ปล่อยของ |
| ยอดเกินและ non-amount อีก 5 มิติผ่าน | deposit.settled พร้อม overpayment{} | settled | เครดิตเฉพาะยอดที่สร้าง · ปล่อยของ · แสดง claim และปุ่มติดต่อ supportเรื่องส่วนเกิน |
| short-pay และ non-amount อีก 5 มิติผ่าน | deposit.settled | settled | เครดิตยอดจริง A=V<X · ต้องได้ partial:true, settled_via:'auto' · ปล่อยของตามยอดจริง |
| มีมิติ non-amount ไม่ผ่าน / ติด policy / ธนาคารปิดตรวจ / สลิปซ้ำ / กำกวม | deposit.under_review | under_review | แสดง review.reasons[0].display_th · 🔴 ห้ามปล่อยของ ห้ามมีปุ่มโอนใหม่ |
| รูปเสีย decode ไม่ได้ และยังไม่หมดเวลา | deposit.slip_rejected (retryable: true) | กลับเป็น awaiting_payment | ขอรูปใหม่จากผู้ใช้ · ดู attempts_left |
| รูปเสีย และหมดเวลาแล้ว | deposit.slip_rejected (retryable: true) | expired | ยังอัปสลิปย้อนหลังได้จนถึง late_slip_accepted_until |
deposit.slip_rejected ไม่ใช่การปฏิเสธเงิน
ชื่อ event อ่านน่ากลัวกว่าความจริง — มันแปลว่า "รูปอ่านไม่ออก ขอใหม่" เท่านั้น ไม่ได้แปลว่าเงินไม่เข้า · ข้อความที่ควรขึ้นคือ "อ่านสลิปไม่ออก กรุณาถ่ายใหม่ให้เห็นทั้งใบ" ไม่ใช่ "รายการถูกปฏิเสธ"
อัปได้สูงสุด 3 ครั้ง ต่อรายการ (นับรวมทั้งกรณีที่ยังไม่หมดเวลาและหมดเวลาแล้ว)
สลิปซ้ำมาทีหลังเสมอ
นี่คือความต่างที่ทำให้โค้ดเดิมของคุณพังเงียบ ๆ
ที่อื่น: POST /slip → 409 duplicate_slip → โค้ดคุณ catch แล้วขึ้น error ให้ผู้ใช้
ที่นี่: POST /slip → 202 accepted + receipt_id
→ async claim → deposit.slip_received (slip_processing)
→ (ไม่กี่วินาทีต่อมา)
→ webhook deposit.under_review reasons=['DUPLICATE_SLIP']
→ เจ้าหน้าที่ตรวจ → settled หรือ rejected🔴 สลิปซ้ำไม่ทำให้รายการถูกปฏิเสธอัตโนมัติ — เพราะเคสที่พบบ่อยคือผู้ใช้เผลออัปสลิปใบเดิมสองรายการ ซึ่งอาจมีใบใดใบหนึ่งที่ถูกต้องจริง · เจ้าหน้าที่เป็นคนตัดสิน
สลิปย้อนหลัง
รายการที่ expired หรือ canceled ยังรับสลิปได้ จนถึงเวลาใน late_slip_accepted_until
| สถานการณ์ | ผล |
|---|---|
อัปสลิปตอน expired ภายในหน้าต่าง | 202 accepted → async claim เข้า pipeline ปกติ |
อัปสลิปตอน canceled ภายในหน้าต่าง | 202 accepted → async claim แล้ว 🔴 บังคับเข้า under_review (CANCELED_ORDER_SLIP) เสมอ |
| อัปสลิปหลังหน้าต่างปิด | 410 late_slip_window_closed + details.claim_url ⇒ เปิดเรื่องผ่าน claim แทน |
ปุ่มอัปสลิปห้ามหายไปตอน expired หรือ canceled
ตราบใดที่ is_terminal เป็น false และยังไม่ถึง late_slip_accepted_until ผู้ใช้ที่โอนไปแล้วต้องยังกดอัปสลิปได้ · การปิด order ทันทีที่เห็นคำว่า expired/canceled คือหนึ่งในบั๊กที่ทำให้ผู้ใช้เสียเงินจริงบ่อยที่สุด
Error ของ endpoint นี้
| HTTP | code | เกิดเมื่อ | ต้องทำอะไร |
|---|---|---|---|
410 | late_slip_window_closed | อัปสลิปหลังหน้าต่างย้อนหลังปิดแล้ว | พาผู้ใช้ไปที่ details.claim_url เพื่อเปิดเรื่อง |
404 | resource_not_found | deposit_id ไม่มี หรือไม่ใช่ของ merchant คุณ | ตรวจ id |
400 | unsupported_media_type | ชนิดไฟล์ไม่รองรับ / Content-Type ผิด | ตรวจ magic bytes ฝั่งคุณก่อนส่ง |
422 | validation_error | ไฟล์ใหญ่เกิน / field ผิดรูป | ดู details.fields[] |
429 | rate_limited | ชน slip_upload ตาม signed rate_subject+IP หรือ bucket ต่อผู้ใช้ (20 ครั้ง/นาที) | เคารพ Retry-After; ingress ไม่มี API key จึงไม่ rate-limit ด้วย api_key |
409 | idempotency_in_flight | ผู้ใช้กดอัปสองครั้งติด | รอ 1 วินาทีแล้วยิงซ้ำด้วย key เดิม |
409 | idempotency_key_reused | key เดิมแต่ versioned logical evidence_fingerprint ต่าง | หลักฐานคนละชุดต้องใช้ key ใหม่; boundary/ลำดับ JSON key/รูปแบบ base64 ต่างแต่ decoded evidence + canonical fields เดิมถือเป็น replay ไม่ใช่ conflict |
503 | slip_intake_unavailable | ยังยืนยัน durable intake ไม่ครบ เช่นเก็บ R2 แล้วแต่ queue.send() ล้มเหลว | ทำตาม Retry-After แล้วส่ง logical evidence เดิมด้วย Idempotency-Key เดิม ระบบจะ reuse blob/receipt เดิม |
🔴 ไม่มี 409 duplicate_slip ในตารางนี้ และจะไม่มี — ตรวจซ้ำเกิดหลังจากรับไฟล์แล้วเสมอ
อัปสลิปไม่ถูกลดโหลด แต่ infrastructure ยังล้มเหลวได้
ระบบไม่ปฏิเสธสลิปเพียงเพราะโหลดสูง แต่ 503 slip_intake_unavailable เป็นไปได้เมื่อ storage หรือ queue ยังยืนยันการรับเข้าไม่ครบ ห้ามเปลี่ยน key หรือให้ผู้ใช้โอนใหม่ — retry logical evidence เดิมด้วย Idempotency-Key เดิมเพื่อได้ receipt เดิม
ดูรูปสลิปย้อนหลัง
curl https://api.example-wl.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/slip \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Version: 2026-08-01" \
-H "PG-Signature: v1=2f9c...44d1" \
-o slip.jpgตอบเป็นไฟล์ภาพตรง ๆ ต้องมี auth และรายการต้องเป็นของคุณ · 🔴 ไม่มี presigned URL ให้ อย่าออกแบบ UI ที่ต้องมีลิงก์รูปแบบเปิดสาธารณะ ให้ proxy ผ่าน backend ของคุณเอง
ขั้นต่อไป
- สถานะรายการฝาก — อ่านผลตรวจที่ตามมาหลัง
202 - ลำดับ webhook ไม่การันตี — version guard ที่ต้องมีก่อนรับผลตรวจ
- ตรวจลายเซ็น Ed25519 — verify ก่อน parse เสมอ
- ยอดที่โอนไม่ครบ — clean short-pay สำเร็จอัตโนมัติ และวิธีใช้ compatibility endpoint เฉพาะเคสที่มีเหตุ review อื่น
- บัญชีผู้โอน — สาเหตุอันดับหนึ่งที่สลิปเข้าคิวเจ้าหน้าที่
