Skip to content

อัปโหลดสลิป ​

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_payment response จะมี 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 และหมดอายุที่ reservation hold_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 ต้องตรงกับ claim htu
  • ก่อนส่งให้ client ตรวจ decoded bytes กับ max_bytes และ media type กับ content_types; ingress จะตรวจขนาดและ magic bytes ซ้ำ

เส้น awaiting_liquidity ขอ token หลัง matched และก่อนแสดง payment instructions คำขอนี้ยิงเข้า money service จึงเซ็น merchant HMAC ตาม หน้า authentication:

http
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 (แนะนำ) ​

bash
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 ต่อ

bash
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

multipartbase64
ขนาดที่ส่งจริงเท่าไฟล์+33%
หน่วยความจำฝั่งคุณstream ได้ต้องโหลดทั้งไฟล์เข้า memory
authcapability token + Idempotency-Key · ไม่ใช้ HMACเหมือนกัน
เหมาะกับทุกกรณีระบบเดิมที่เก็บรูปเป็น base64 อยู่แล้ว

ใช้ multipart ถ้าไม่มีเหตุผลเฉพาะ

ก่อนส่ง JSON ต้องมี application/json อยู่ใน content_types; max_bytes วัด decoded evidence bytes ไม่ใช่ขนาด base64 บนสาย

แบบ 3 — ไม่มีรูป มีแต่เลขอ้างอิง ​

ผู้ใช้ลบรูปไปแล้ว แต่เปิดแอปธนาคารดูรายการย้อนหลังได้

bash
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 แล้วส่ง
js
// 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 เท่านั้น ​

json
{
  "receipt_id": "rcpt_01K3M9DR6T2VXN8QY0KZH4WB7F",
  "status": "accepted",
  "received_at": "2026-08-21T04:19:02.180Z"
}
fieldความหมาย
receipt_idreceipt ของ 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 ​

js
// 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 นาที')   // ❌ ไม่ใช่ "เติมเงินสำเร็จ"
}
js
// ❌ ผิด — พังทั้งเส้น เพราะ 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 วินาที ไม่ใช่ทุกวินาที


ผลตรวจมายังไง ​

ผลตรวจwebhookstatus ปลายทางต้องทำอะไร
ยอดตรงและผ่านครบ 6 มิติdeposit.settledsettledเครดิตผู้ใช้ด้วย amount_received_minor · ปล่อยของ
ยอดเกินและ non-amount อีก 5 มิติผ่านdeposit.settled พร้อม overpayment{}settledเครดิตเฉพาะยอดที่สร้าง · ปล่อยของ · แสดง claim และปุ่มติดต่อ supportเรื่องส่วนเกิน
short-pay และ non-amount อีก 5 มิติผ่านdeposit.settledsettledเครดิตยอดจริง A=V<X · ต้องได้ partial:true, settled_via:'auto' · ปล่อยของตามยอดจริง
มีมิติ non-amount ไม่ผ่าน / ติด policy / ธนาคารปิดตรวจ / สลิปซ้ำ / กำกวมdeposit.under_reviewunder_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 นี้ ​

HTTPcodeเกิดเมื่อต้องทำอะไร
410late_slip_window_closedอัปสลิปหลังหน้าต่างย้อนหลังปิดแล้วพาผู้ใช้ไปที่ details.claim_url เพื่อเปิดเรื่อง
404resource_not_founddeposit_id ไม่มี หรือไม่ใช่ของ merchant คุณตรวจ id
400unsupported_media_typeชนิดไฟล์ไม่รองรับ / Content-Type ผิดตรวจ magic bytes ฝั่งคุณก่อนส่ง
422validation_errorไฟล์ใหญ่เกิน / field ผิดรูปดู details.fields[]
429rate_limitedชน slip_upload ตาม signed rate_subject+IP หรือ bucket ต่อผู้ใช้ (20 ครั้ง/นาที)เคารพ Retry-After; ingress ไม่มี API key จึงไม่ rate-limit ด้วย api_key
409idempotency_in_flightผู้ใช้กดอัปสองครั้งติดรอ 1 วินาทีแล้วยิงซ้ำด้วย key เดิม
409idempotency_key_reusedkey เดิมแต่ versioned logical evidence_fingerprint ต่างหลักฐานคนละชุดต้องใช้ key ใหม่; boundary/ลำดับ JSON key/รูปแบบ base64 ต่างแต่ decoded evidence + canonical fields เดิมถือเป็น replay ไม่ใช่ conflict
503slip_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 เดิม


ดูรูปสลิปย้อนหลัง ​

bash
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 ของคุณเอง


ขั้นต่อไป ​

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01