Skip to content

Upload a slip ​

POST /v1/pay/deposits/{id}/slip is the step where your user has already transferred the money and is now sending proof.

Every payload that passes auth and validation and is durably admitted answers 202

No 200. No immediate 409 duplicate_slip. No verification result inside the response to this request. Auth/validation failures, idempotency conflicts, a closed late-slip window, or storage/queue failures that prevent confirmed durable intake can still return non-2xx.

202 means "the evidence is durably stored" and returns only a receipt. It does not mean the order has moved to slip_processing. Receipt claiming and verification results arrive later through webhooks or the status endpoint.


Why successful durable intake is 202 ​

How others do it — most gateways verify the slip synchronously inside the same request and answer with the outcome: 200 verified, 409 duplicate_slip, 422 invalid_slip. So merchant code is written as "call it and read the result right there".

How we do it — we create a durable intake receipt by conditional-creating the evidence, an encrypted intake envelope, and a manifest bound to every object hash/etag. We read back the winning objects, then await Queue before returning 202. The Queue message is only the pointer {receipt_id, manifest_key, manifest_etag}; it carries no tenant, deposit, claims, or amount. A Queue failure returns 503 slip_intake_unavailable, and retry/reconciliation reuses the same winning manifest. The consumer pins manifest_etag, decrypts the envelope, and re-verifies the capability, URL, claims, fingerprint, hashes/etags, and live order/reservation before any transition. Queue and any non-pointer fields are not sources of truth.

Why — this is the one point in the system where the user's money has already left their account. If we verified inside the request and a verification provider were slow, or the database were under pressure, or the platform were shedding load, the user would see an error after paying. And then they would do the worst possible thing: transfer again.

Slip intake has the highest priority in the system and is never load-shed, but it returns 202 only after both the blob write and queue confirmation succeed. If object storage or the queue cannot confirm intake, it returns a retryable 503 instead of claiming that evidence was accepted when the work could be lost.

The side effect you have to handle

You do not learn the outcome immediately ⇒ your UI must not say "top-up successful" after the upload completes, and your webhook handler has to be working before you go live, not added later.


Auth — use the order capability, not merchant HMAC ​

POST /v1/pay/deposits/{id}/slip is the exception under /v1/pay/*: send only Authorization: Bearer <slip_upload.token>, Idempotency-Key, and the body. Do not send sk_live_*, PG-Timestamp, PG-Nonce, PG-Version, or PG-Signature to this ingress.

  • A 201 awaiting_payment create response already includes slip_upload: { token, expires_at, upload_url, max_bytes, content_types }.
  • A 202 awaiting_liquidity response cannot include a token because no destination/reservation exists yet. After deposit.matched, your backend calls POST /v1/pay/deposits/{id}/slip-token with normal merchant API-key + HMAC authentication, then reveals the payment instructions.
  • The token is an Ed25519 capability for one deposit (aud=pg-slip-ingest, scope=slip:write), bound to tenant, livemode, deposit_id, the full URL in htu, rate_subject, API version, and the reservation's hold_until. Never log it, put it in a URL, or store it in analytics.
  • An issued token deliberately remains usable for that open order if the API key/WL is frozen later, because the user may already have paid. The consumer revalidates tenant, order, reservation, and state in the database before any business transition.
  • Send the request to the returned slip_upload.upload_url byte for byte. Never construct a host/path from PG_API or deposit_id; the URL must match the signed htu claim.
  • Before sending, check decoded bytes against max_bytes and the media type against content_types. Ingress rechecks size and magic bytes.

On the awaiting_liquidity path, recover the token after matching and before revealing payment instructions. This request reaches the money service, so sign it with merchant HMAC as described in 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 accepts three body shapes.

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"
fieldrequirednotes
file✅Its media type must be in slip_upload.content_types, and decoded bytes must not exceed slip_upload.max_bytes.
payload—The string your client decoded from the mini-QR printed on the slip. Sending it raises verification accuracy a lot.

Shape 2 — JSON base64 ​

Use this when your backend already holds the file and you do not want to stream it onward.

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 or base64

multipartbase64
Bytes on the wiresame as the file+33%
Memory on your sidestreamablewhole file must be in memory
Authcapability token + Idempotency-Key; no HMACsame
Good foreverythinglegacy systems that already store images as base64

Use multipart unless you have a specific reason not to.

Before sending JSON, require application/json in content_types. max_bytes measures decoded evidence bytes, not the expanded base64 size on the wire.

Shape 3 — no image, reference number only ​

The user deleted the image but can still see the transaction in their banking app.

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"
  }'

This path always goes to manual review

Sending only a trans_ref forces the order into under_review with reasons = ['NO_SLIP_IMAGE'] every time. It can never clear automatically. Treat it as a last resort and tell the user plainly that it takes longer than usual.


Image requirements ​

ItemValue
File typesRead slip_upload.content_types from this capability (example: image/jpeg · image/png · image/webp)
Maximum sizeRead slip_upload.max_bytes from this capability (example response: 4,194,304 bytes)
Type detectionFrom magic bytes, not from the extension or Content-Type — renaming a .pdf to .jpg will not pass

Do not compress the mini-QR out of existence

Almost every Thai bank slip carries a mini-QR in the lower corner, and it is the strongest evidence we have. Aggressive client-side downscaling makes that QR unreadable, and orders that should have cleared automatically fall into the manual review queue instead.

Safe rules:

  • If the original file is already ≤ max_bytes, send the original, untouched.
  • If you truly must resize, keep the short edge at 1,000 px or more and JPEG quality at 85 or above.
  • 🔴 Never convert to greyscale, never crop off the bottom of the slip, never stamp a watermark over it.
  • Never convert to PDF and send that.
js
// Node — resize only when there is no other option (example uses sharp)
async function prepareSlip(buffer, mime, capability) {
  if (!capability.content_types.includes(mime)) {
    throw new Error(`media type is not in the capability: ${mime}`)
  }
  if (buffer.length <= capability.max_bytes) return { buffer, mime } // ✅ original is best

  if (!capability.content_types.includes('image/jpeg')) {
    throw new Error('this capability does not permit a resized JPEG')
  }

  const sharp = require('sharp')
  const out = await sharp(buffer)
    .resize({ width: 1600, withoutEnlargement: true })    // short edge stays above 1,000 px
    .jpeg({ quality: 88 })                                // 🔴 never below 85
    .toBuffer()

  if (out.length > capability.max_bytes) throw new Error('slip exceeds capability.max_bytes; ask the user to retake it')
  return { buffer: out, mime: 'image/jpeg' }
}

Response 202 — durable receipt only ​

json
{
  "receipt_id": "rcpt_01K3M9DR6T2VXN8QY0KZH4WB7F",
  "status": "accepted",
  "received_at": "2026-08-21T04:19:02.180Z"
}
fieldMeaning
receipt_idReceipt for the durably stored blob; keep it for support and retry with the same idempotency key
statusAlways accepted · 🔴 not the deposit status and never permission to release anything
received_atWhen the evidence became durably stored (UTC)

After the response, a consumer claims the receipt and asynchronously transitions the order to slip_processing, then emits deposit.slip_received. An immediate GET /v1/pay/deposits/{id} may still return the previous state; that is expected.

What to do after a 202 ​

js
// Node — ✅ correct
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 })
  // "กำลังตรวจสอบสลิปกับธนาคาร ใช้เวลาไม่เกิน 1 นาที"
  //  = "checking your slip with the bank, this takes under a minute"
  return ui.show('กำลังตรวจสอบสลิปกับธนาคาร ใช้เวลาไม่เกิน 1 นาที')   // ❌ not "top-up successful"
}
js
// ❌ wrong — breaks on every call, because this endpoint never returns 200
if (res.status !== 200) throw new Error('อัปโหลดสลิปไม่สำเร็จ')

// ❌ wrong — releasing goods before verification finishes
if (res.ok) await creditUser(userId, order.amountMinor)

UI you must redesign if you are migrating ​

Step❌ What used to work✅ What is required here
Upload finished"เติมเงินสำเร็จ 5,000 บาท" (top-up successful, THB 5,000)"กำลังตรวจสอบสลิปกับธนาคาร ใช้เวลาไม่เกิน 1 นาที" (checking your slip with the bank, under a minute)
Upload buttonLeft enabledDisable it immediately on 202 to prevent duplicate uploads
Crediting the user's balanceAt this stepOnly on the deposit.settled webhook
Duplicate slipError shown right thereArrives later as deposit.under_review with ['DUPLICATE_SLIP']
Unreadable imageError shown right thereArrives later as deposit.slip_rejected, then ask for a new image

A screen that actually works

After the 202, show a waiting screen that listens for webhook-driven updates over your own channel (websocket / SSE / internal long-poll) and updates when the order changes state. If you have no realtime layer yet, poll GET /v1/pay/deposits/{id} with a 3 → 5 → 10 → 30 second backoff, not every second.


How the verification result arrives ​

OutcomewebhookFinal statusWhat to do
Exact amount and all six dimensions passdeposit.settledsettledCredit with amount_received_minor · release the goods
Overpayment and the other five dimensions passdeposit.settled with overpayment{}settledCredit only the ordered amount · release goods · show the claim/contact-support action for the excess
Short-pay and the other five dimensions passdeposit.settledsettledCredit actual value A=V<X · require partial:true, settled_via:'auto' · release goods for the accepted value
A non-amount check fails / policy hold / bank checking off / duplicate / ambiguousdeposit.under_reviewunder_reviewShow review.reasons[0].display_th · 🔴 do not release anything, no "transfer again" button
Image unreadable and not yet expireddeposit.slip_rejected (retryable: true)back to awaiting_paymentAsk the user for a new image · check attempts_left
Image unreadable and already expireddeposit.slip_rejected (retryable: true)expiredLate slips are still accepted until late_slip_accepted_until

deposit.slip_rejected is not a rejection of the money

The event name sounds worse than it is — it only means "the image is unreadable, send another one". It does not mean the money will not arrive. The right message is "อ่านสลิปไม่ออก กรุณาถ่ายใหม่ให้เห็นทั้งใบ" (we could not read the slip, please retake it showing the whole slip), not "your order was rejected".

A maximum of 3 uploads per order (counting both the not-yet-expired and already-expired cases).

Duplicate slips always arrive later ​

This is the difference that makes existing merchant code fail silently.

Elsewhere:  POST /slip  →  409 duplicate_slip  →  your code catches it and shows an error
Here:       POST /slip  →  202 accepted + receipt_id
                        →  async claim → deposit.slip_received (slip_processing)
                        →  (a few seconds later)
                        →  webhook deposit.under_review  reasons=['DUPLICATE_SLIP']
                        →  human review  →  settled or rejected

🔴 A duplicate slip never auto-rejects the order — the common case is a user who uploaded the same image to two orders, one of which may well be legitimate. A human decides.


Late slips ​

Orders in expired or canceled still accept slips until the timestamp in late_slip_accepted_until.

SituationResult
Slip uploaded while expired, inside the window202 accepted → async claim into the normal pipeline
Slip uploaded while canceled, inside the window202 accepted → async claim, then 🔴 forced into under_review (CANCELED_ORDER_SLIP) every time
Slip uploaded after the window closed410 late_slip_window_closed + details.claim_url ⇒ open a claim instead

The upload button must not disappear on expired or canceled

As long as is_terminal is false and late_slip_accepted_until has not passed, a user who already transferred must still be able to upload. Closing the order the moment you see the word expired or canceled is one of the most common bugs that costs users real money.


Errors on this endpoint ​

HTTPcodeWhenWhat to do
410late_slip_window_closedUpload after the late-slip window closedSend the user to details.claim_url to open a claim
404resource_not_foundThe deposit_id does not exist or is not yoursCheck the id
400unsupported_media_typeUnsupported file type / wrong Content-TypeCheck magic bytes on your side before sending
422validation_errorFile too large / malformed fieldRead details.fields[]
429rate_limitedHit slip_upload by signed rate_subject + IP, or the per-user bucket (20/min)Respect Retry-After; the ingress has no API key, so it is not rate-limited by api_key
409idempotency_in_flightThe user tapped upload twiceWait 1 second and retry with the same key
409idempotency_key_reusedSame key, different versioned logical evidence_fingerprintDifferent evidence needs a new key. Different multipart boundaries, JSON key order, or base64 formatting with the same decoded evidence + canonical fields are a replay, not a conflict.
503slip_intake_unavailableDurable intake is not fully confirmed, for example R2 succeeded but queue.send() failedRespect Retry-After, then retry the same logical evidence with the same Idempotency-Key; the existing blob/receipt is reused

🔴 There is no 409 duplicate_slip in this table, and there never will be — duplicate detection always happens after the file is accepted.

Slip upload is never load-shed, but infrastructure can still fail

The platform does not reject a slip merely because load is high. 503 slip_intake_unavailable is still possible when storage or the queue cannot confirm durable intake. Do not change the key or tell the user to transfer again — retry the same logical evidence with the same Idempotency-Key to recover the same receipt.


Fetching the slip image later ​

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

It returns the image bytes directly. Auth is required and the order must be yours. 🔴 There are no presigned URLs. Do not design a UI that needs a publicly reachable image link — proxy it through your own backend.


Next ​

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