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_paymentcreate response already includesslip_upload: { token, expires_at, upload_url, max_bytes, content_types }. - A
202 awaiting_liquidityresponse cannot include a token because no destination/reservation exists yet. Afterdeposit.matched, your backend callsPOST /v1/pay/deposits/{id}/slip-tokenwith 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 inhtu,rate_subject, API version, and the reservation'shold_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_urlbyte for byte. Never construct a host/path fromPG_APIordeposit_id; the URL must match the signedhtuclaim. - Before sending, check decoded bytes against
max_bytesand the media type againstcontent_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:
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.
Shape 1 — multipart (recommended)
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 | required | notes |
|---|---|---|
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.
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
| multipart | base64 | |
|---|---|---|
| Bytes on the wire | same as the file | +33% |
| Memory on your side | streamable | whole file must be in memory |
| Auth | capability token + Idempotency-Key; no HMAC | same |
| Good for | everything | legacy 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.
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
| Item | Value |
|---|---|
| File types | Read slip_upload.content_types from this capability (example: image/jpeg · image/png · image/webp) |
| Maximum size | Read slip_upload.max_bytes from this capability (example response: 4,194,304 bytes) |
| Type detection | From 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.
// 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
{
"receipt_id": "rcpt_01K3M9DR6T2VXN8QY0KZH4WB7F",
"status": "accepted",
"received_at": "2026-08-21T04:19:02.180Z"
}| field | Meaning |
|---|---|
receipt_id | Receipt for the durably stored blob; keep it for support and retry with the same idempotency key |
status | Always accepted · 🔴 not the deposit status and never permission to release anything |
received_at | When 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
// 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"
}// ❌ 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 button | Left enabled | Disable it immediately on 202 to prevent duplicate uploads |
| Crediting the user's balance | At this step | Only on the deposit.settled webhook |
| Duplicate slip | Error shown right there | Arrives later as deposit.under_review with ['DUPLICATE_SLIP'] |
| Unreadable image | Error shown right there | Arrives 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
| Outcome | webhook | Final status | What to do |
|---|---|---|---|
| Exact amount and all six dimensions pass | deposit.settled | settled | Credit with amount_received_minor · release the goods |
| Overpayment and the other five dimensions pass | deposit.settled with overpayment{} | settled | Credit only the ordered amount · release goods · show the claim/contact-support action for the excess |
| Short-pay and the other five dimensions pass | deposit.settled | settled | Credit 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 / ambiguous | deposit.under_review | under_review | Show review.reasons[0].display_th · 🔴 do not release anything, no "transfer again" button |
| Image unreadable and not yet expired | deposit.slip_rejected (retryable: true) | back to awaiting_payment | Ask the user for a new image · check attempts_left |
| Image unreadable and already expired | deposit.slip_rejected (retryable: true) | expired | Late 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.
| Situation | Result |
|---|---|
Slip uploaded while expired, inside the window | 202 accepted → async claim into the normal pipeline |
Slip uploaded while canceled, inside the window | 202 accepted → async claim, then 🔴 forced into under_review (CANCELED_ORDER_SLIP) every time |
| Slip uploaded after the window closed | 410 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
| HTTP | code | When | What to do |
|---|---|---|---|
410 | late_slip_window_closed | Upload after the late-slip window closed | Send the user to details.claim_url to open a claim |
404 | resource_not_found | The deposit_id does not exist or is not yours | Check the id |
400 | unsupported_media_type | Unsupported file type / wrong Content-Type | Check magic bytes on your side before sending |
422 | validation_error | File too large / malformed field | Read details.fields[] |
429 | rate_limited | Hit 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 |
409 | idempotency_in_flight | The user tapped upload twice | Wait 1 second and retry with the same key |
409 | idempotency_key_reused | Same key, different versioned logical evidence_fingerprint | Different 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. |
503 | slip_intake_unavailable | Durable intake is not fully confirmed, for example R2 succeeded but queue.send() failed | Respect 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
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.jpgIt 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
- Deposit status — reading the verification result that follows the
202 - Webhook order is not guaranteed — the version guard you need before accepting results
- Verifying Ed25519 signatures — always verify before you parse
- Short transfers — clean short-pay settles automatically; the compatibility endpoint is only for a case that already has another review reason
- The sender account — the number-one reason slips end up in manual review
