Create a deposit
POST /v1/pay/deposits starts every top-up. The destination your end user transfers to is not a gateway account — it is the bank account of another merchant who is withdrawing. So the system must know who will press "send" in their banking app at creation time, not later when the slip arrives.
This endpoint has two success responses.
| HTTP | status | Meaning |
|---|---|---|
201 | awaiting_payment | Matched. legs[] contains where to transfer. |
202 | awaiting_liquidity | Order created, but there is no destination to transfer to yet. Wait. |
Read this before you write any code
202 is not an error, and no other gateway in this market has this path. Code written as if (res.status !== 200) throw or if (!res.ok) retry breaks on it — your user sees "top-up failed" while the order is alive and about to receive a QR a few seconds later. Details in the 202 section.
Three things to do before you call it
| # | Do this | Why |
|---|---|---|
| 1 | Call GET /v1/pay/senders and let the user pick the sending account | Users who pick from the list almost never end up in the manual review queue. Users who type it in often do. |
| 2 | Grey out bank buttons using GET /v1/pay/banks | A closed bank returns 409 at the last step — that is where users abandon the flow. |
| 3 | Write merchant_reference + Idempotency-Key into your DB before sending the request | A retry must read the same key back from the DB. Generating a new key each attempt gives you duplicate orders and two QRs. |
Request
Required headers
Authorization: Bearer sk_live_xxx
PG-Timestamp: 1755749553
PG-Nonce: 01JQA5T8V0X2Z4B6D8F0H2J4L6
PG-Version: 2026-08-01
Idempotency-Key: topup-ORD-9981-01
PG-Signature: v1=<lowercase hex of HMAC-SHA256>
Content-Type: application/jsonIdempotency-Key and PG-Version are part of the canonical signing string. Leaving one out returns 401, not 400. See request authentication.
Body
| field | type | required | constraints |
|---|---|---|---|
amount_minor | integer | ✅ | > 0 · unit is satang · 🔴 whole baht only, no satang remainder (THB 5,000.00 = 500000) · outside policy range or with a remainder → 422 amount_not_supported |
currency | string | ✅ | "THB" only, for now |
merchant_reference | string | ✅ | unique per merchant · ≤ 64 chars |
end_user_ref | string | ✅ | the user's id in your system · ≤ 64 chars · also used by GET /v1/pay/senders |
sender | object | ✅ (or sender_id) | the account the user will actually transfer from · see the structure below |
sender_id | string | ✅ (or sender) | id from GET /v1/pay/senders · 🔴 this form guarantees no review queue caused by a name or account-number mismatch |
client | object | ⚠️ | required in practice — omitting it changes your caps and rate limits, see what omitting it costs |
fill_preference | string | — | "any" (default) or "internal_only" |
min_ttl_seconds | integer | — | 0–86,400 · shorter routes are skipped; a value that cannot fit the clamped order lifetime returns 422. |
expires_in_seconds | integer | — | 60–86,400 and clamped again by policy · 🔴 only response expires_at applies. |
return_url | string | — | Where the user returns after paying on the checkout_url page |
metadata | object | — | ≤ 20 keys · ≤ 500 bytes per value · echoed back in every webhook for this order |
sender option A (manual entry)
| field | type | required | constraints |
|---|---|---|---|
sender.bank_code | string | ✅ | 3 digits, from GET /v1/pay/banks |
sender.account_number | string | ✅ | digits only — the client must strip - and spaces before sending |
sender.account_name_th | string | ✅ | 🔴 no honorific prefix (นาย Mr., นาง Mrs., น.ส. Ms., คุณ Khun) — banks do not print honorifics on slips |
sender.account_name_en | string | — | Optional, but it raises the automatic-approval rate |
client — skipping it hurts
Sending client{} | Cap on open orders per user | Rate-limit bucket |
|---|---|---|
Complete (device_fingerprint + ip) | counted per (merchant, end_user_ref, device_fingerprint) — cap 2 | pay_create_per_user, 10/min per user |
| Omitted | 🔴 counted per (merchant, end_user_ref) only, which is free to forge ⇒ we drop the cap to 1 | 🔴 falls into a coarse bucket shared by all of your users ⇒ a mid-size merchant hits 429 within the first minute |
⇒ In practice: always send client{}.
Example request
curl -X POST https://api.example-wl.com/v1/pay/deposits \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755749553" \
-H "PG-Nonce: 01JQA5T8V0X2Z4B6D8F0H2J4L6" \
-H "PG-Version: 2026-08-01" \
-H "Idempotency-Key: topup-ORD-9981-01" \
-H "PG-Signature: v1=9c2f...b41e" \
-H "Content-Type: application/json" \
-d '{
"amount_minor": 500000,
"currency": "THB",
"merchant_reference": "ORD-9981",
"end_user_ref": "u_88213",
"sender": {
"bank_code": "004",
"account_number": "1234567890",
"account_name_th": "สมชาย ใจดี",
"account_name_en": "SOMCHAI JAIDEE"
},
"client": {
"device_fingerprint": "df_9a1c7f2b0e",
"ip": "203.0.113.44",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X)"
},
"metadata": { "campaign": "aug-cashback", "order_id": "ORD-9981" }
}'If the user has topped up before, send sender_id instead of the whole object.
{
"amount_minor": 500000,
"currency": "THB",
"merchant_reference": "ORD-9982",
"end_user_ref": "u_88213",
"sender_id": "snd_01K2QF7V8H3NPD0YXW4RJ6ZB5C",
"client": {
"device_fingerprint": "df_9a1c7f2b0e",
"ip": "203.0.113.44",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X)"
}
}Response 201 — matched immediately
{
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 2,
"status": "awaiting_payment",
"is_terminal": false,
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"currency": "THB",
"created_at": "2026-08-21T04:10:47.902Z",
"expires_at": "2026-08-21T04:30:47.902Z",
"checkout_url": "https://pay.example-wl.com/c/9f2c1ab6e0d74f",
"slip_upload": {
"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"]
},
"liquidity_source": "internal",
"leg_count": 1,
"legs": [
{
"leg_ref": "leg_7QF2H8KM",
"sequence": 1,
"amount_minor": 500000,
"expires_at": "2026-08-21T04:30:47.902Z",
"leg_status": "pending",
"method": "promptpay_qr",
"qr_payload": "00020101021229370016A00000067701011201150105560012345530376454065000.005802TH6304A1B2",
"qr_image_url": "https://pay.example-wl.com/qr/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/1.png",
"receiver": {
"bank_code": "014",
"bank_name_th": "ธนาคารไทยพาณิชย์",
"account_number_masked": "xxx-x-x1234",
"account_name_masked": "ส*** ใ***"
}
}
],
"sender": {
"bank_code": "004",
"account_number_masked": "xxx-x-x7890",
"account_name_th_masked": "ส*** ใ***"
},
"metadata": { "campaign": "aug-cashback", "order_id": "ORD-9981" }
}What to do with a 201
- Render the QR or account number from
legs[]. - Keep
slip_uploadonly in a short-lived secure checkout session: send to the returnedupload_urlexactly (never construct its host or path), usetokenas the bearer credential, and precheck the payload againstmax_bytesandcontent_types. - Count down from the
expires_atyou received. 🔴 Never hardcode 15 minutes — some routes really do expire in about 4 minutes. - Show the "transfer from this account only" box more prominently than the QR itself (sender account).
- Tell the user the QR is single-use for this order and must not be saved for next time.
legs[] can hold more than one entry
legs.length > 1 means the user must make several transfers, not pick one of them. legs[0] is not the full amount. Your UI needs a "step 1 of 2" flow that shows each leg's own amount_minor. See user choices.
Never store, never log
qr_payload, full destination account details, and slip_upload.token must not be persisted, logged, put in a URL or analytics, or forwarded anywhere. Keep the token only in a short-lived secure checkout session until expiry; render payment data and drop it. The safer path is always to send the user to checkout_url.
Response 202 — no liquidity yet
This is the clearest difference from other gateways, and the place merchants get the integration wrong most often.
How others do it — when no destination account is available, they return an error (503, no_channel_available) and let you retry. No order is created.
How we do it — we always create the order and return 202 with status awaiting_liquidity. The order has an id, an expires_at, and a full event stream from that second onward. The system keeps looking for a destination roughly every 10 seconds and fires the deposit.matched webhook when it finds one.
Why — if we returned an error and created nothing, a user who tops up during a thin-liquidity moment sees a failure screen even though a destination appears 15 seconds later. Having an id from the first second also lets support trace the order, and it ties your Idempotency-Key to a real order instead of to nothing.
{
"id": "dep_01K3M8B2VQ7HYD4NKR0XF9TW6E",
"object": "deposit_order",
"version": 1,
"status": "awaiting_liquidity",
"is_terminal": false,
"merchant_reference": "ORD-9983",
"amount_minor": 500000,
"currency": "THB",
"created_at": "2026-08-21T04:10:47.902Z",
"expires_at": "2026-08-21T04:30:47.902Z",
"legs": [],
"liquidity": {
"wait_started_at": "2026-08-21T04:10:47.902Z",
"estimated_wait_ms": 25000,
"retry_interval_ms": 10000,
"suggested_amounts_minor": [300000, 400000, 1000000]
},
"retry_after_seconds": 10,
"sender": {
"bank_code": "004",
"account_number_masked": "xxx-x-x7890",
"account_name_th_masked": "ส*** ใ***"
},
"metadata": { "order_id": "ORD-9983" }
}What to do with a 202
| ✅ Do | ❌ Don't |
|---|---|
Store id in your DB exactly as you would for a 201 | ❌ Discard the response and tell the user it failed |
| Show a "finding a transfer route, please wait" screen with a spinner | ❌ Show a QR — legs[] is empty, there is no destination yet |
Offer the amounts in liquidity.suggested_amounts_minor[] if the user is in a hurry | ❌ Re-send POST /v1/pay/deposits — idempotency returns the same order anyway, and changing the key gives you two orders |
Wait for deposit.matched, then have your backend call POST /v1/pay/deposits/{id}/slip-token with API key + HMAC; reveal the QR only after it has the capability | ❌ Poll every second — use retry_after_seconds as the floor |
Start the countdown from the expires_at you got, and re-read it on deposit.matched | ❌ Treat the 202 expires_at as final |
How an awaiting_liquidity order ends
| Outcome | When | webhook |
|---|---|---|
awaiting_payment | A destination was found | deposit.matched — the only event that carries the QR. Re-read all of legs[] and expires_at. |
canceled | The wait ran out with no destination · cancel_reason_code: "no_liquidity_available" | deposit.canceled |
Out of liquidity closes the order as canceled, not expired
expired opens a 24-hour late-slip window, but this order never had a destination to transfer to ⇒ an "I already transferred" button could only create an empty ticket. On this path deposit.canceled carries late_slip_accepted_until: null and is_terminal: true.
deposit.created fires on both paths
Both 201 and 202 fire deposit.created, with data.object.status set to the real status of that path (awaiting_payment or awaiting_liquidity). Your handler must not assume deposit.created means a QR exists.
slip_upload.token is a bearer secret, so it appears in no webhook. When a 202 path moves to awaiting_payment, use the deposit_id from deposit.matched to mint it through /slip-token from your backend before exposing payment instructions.
The idempotency trap on the 202 path
Re-sending the same POST with the same Idempotency-Key and the same body returns the identical response, including the same HTTP status, plus the header Idempotent-Replay: true.
⇒ If your first call returned 202 and you retry 30 seconds later, you get 202 again — even though the order has already moved to awaiting_payment. The real status comes from GET /v1/pay/deposits/{id} or from webhooks only. Never read it from a replayed body.
Code that handles both 201 and 202
Node (fetch)
// sign() is your own HMAC signing helper, see /en/get-started/authentication
async function createDeposit(order) {
const body = JSON.stringify({
amount_minor: order.amountMinor,
currency: 'THB',
merchant_reference: order.ref,
end_user_ref: order.userRef,
sender_id: order.senderId,
client: {
device_fingerprint: order.deviceFp,
ip: order.ip,
user_agent: order.userAgent,
},
metadata: { order_id: order.ref },
})
// 🔴 The key must already be in your DB before this line, and a retry must read the same value back
const res = await fetch('https://api.example-wl.com/v1/pay/deposits', {
method: 'POST',
headers: sign('POST', '/v1/pay/deposits', '', body, order.idempotencyKey),
body,
})
const data = await res.json()
// ✅ Every 2xx here is a success. Never check for === 200
if (res.status === 201) {
return { kind: 'ready', deposit: data } // render legs[] + countdown
}
if (res.status === 202) {
return { kind: 'waiting', deposit: data } // render the wait screen + suggested_amounts_minor
}
// Everything else is a real error — branch on the code, not on the HTTP status alone
throw Object.assign(new Error(data.error.message), {
code: data.error.code,
retryable: data.error.retryable,
retryAfter: data.error.retry_after_seconds,
details: data.error.details,
requestId: data.error.request_id,
})
}PHP
<?php
// $sign() is your own HMAC signing helper, see /en/get-started/authentication
function createDeposit(array $order, array $headers): array
{
$body = json_encode([
'amount_minor' => $order['amount_minor'],
'currency' => 'THB',
'merchant_reference' => $order['ref'],
'end_user_ref' => $order['user_ref'],
'sender_id' => $order['sender_id'],
'client' => [
'device_fingerprint' => $order['device_fp'],
'ip' => $order['ip'],
'user_agent' => $order['user_agent'],
],
'metadata' => ['order_id' => $order['ref']],
], JSON_UNESCAPED_UNICODE);
$ch = curl_init('https://api.example-wl.com/v1/pay/deposits');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers, // already includes Idempotency-Key + PG-Signature
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($raw, true);
// ✅ 201 and 202 are both success
if ($status === 201) {
return ['kind' => 'ready', 'deposit' => $data];
}
if ($status === 202) {
return ['kind' => 'waiting', 'deposit' => $data];
}
throw new RuntimeException(
$data['error']['code'] . ': ' . $data['error']['message_th'],
$status
);
}Every error this endpoint returns
The error body has the same shape on every endpoint.
{
"error": {
"code": "sender_bank_unavailable",
"type": "invalid_request_error",
"message": "The sending bank is not accepting new deposits right now.",
"message_th": "ธนาคารต้นทางปิดรับรายการใหม่ชั่วคราว",
"retryable": true,
"retry_after_seconds": null,
"request_id": "req_01JQA5T8V0X2Z4B6D8F0H2J4L6",
"details": {
"bank_code": "004",
"retry_after_hint_seconds": 1800,
"public_message_th": "ธนาคารกสิกรไทยปิดปรับปรุงระบบชั่วคราว กรุณาเลือกธนาคารอื่น",
"alternative_banks": ["002", "014", "006"]
}
}
}| HTTP | code | When | Retryable | What to do |
|---|---|---|---|---|
422 | sender_required | Neither sender nor sender_id was sent | ❌ | Add a sender-account screen — read this |
422 | validation_error | A field is malformed | ❌ | Read details.fields[] and fix the payload |
422 | amount_not_supported | Amount below/above policy, or has a satang remainder | ❌ | Round to whole baht · use details.min_minor / details.max_minor |
409 | sender_bank_unavailable | The sender's bank is not accepting new orders | ✅ | Let the user pick from details.alternative_banks[] · show details.public_message_th · 🔴 slips for existing orders still go through — closing a bank does not touch orders that are already open |
409 | duplicate_open_deposit | An awaiting_payment order with the same amount and the same sender account is already open | ❌ | Send the user back to details.open_deposit_ids[0]. Do not create another order. |
409 | too_many_open_deposits | Over the open-order cap for this user (2 · 1 without client{}) | ❌ | Have the user close or cancel an existing order first — details.open_deposit_ids[] · handling open orders |
400 | idempotency_key_required | No Idempotency-Key header | ❌ | Add the header (and write it to your DB first) |
409 | idempotency_key_reused | Same key, different body | ❌ | When the body changes, the key must change too · details.original_request_id points at the first request |
409 | idempotency_in_flight | Same key, first request still running (double-click) | ✅ | Wait retry_after_seconds (= 1) and retry with the same key |
401 | signature_invalid · timestamp_out_of_window · nonce_replayed | Signature / clock / nonce | Some | See request authentication · PG-Nonce must be new on every attempt, Idempotency-Key must stay the same |
403 | tenant_suspended · scope_denied | Account suspended / key lacks the scope | ❌ | Contact your provider |
429 | rate_limited | Hit pay_write (600/min per key) or pay_create_per_user (10/min per user) | ✅ | Respect Retry-After plus ±20% jitter · 🔴 no tight retry loops, the penalty compounds |
503 | route_unavailable | The platform is shedding load | ✅ | Show "system busy, please try again" plus Retry-After · 🔴 not a bug, not a 500 |
503 | service_unavailable | Platform-wide load shedding | ✅ | Same as above |
500 | internal_error | Our fault | ✅ | Send request_id to support |
429 and 503 are different problems
429 means you went over your own quota — fix your pacing. 503 means the platform is shedding load — wait out Retry-After. When the platform is under pressure, creating new deposits is the first thing shed, because nobody has transferred money yet, so nobody loses anything. Slip upload is never shed.
What to persist
| When | What |
|---|---|
| Before the call | merchant_reference (UNIQUE) · Idempotency-Key (UNIQUE) |
After 201/202 | deposit_id (your primary key) · version · status · expires_at · a masked snapshot of sender.* |
| On the payment screen | expires_at only · 🔴 qr_payload and full destination account numbers must not be stored or logged |
The deposit_id column must be text
If you are migrating from another gateway, do not use varchar(20) and do not parse the prefix — our id format differs and its length may change.
Next
- Why the sender account is required — the single biggest lever on your review-queue rate. Read it before designing the top-up screen.
- Available banks — use
availableto grey out buttons before the user hits409. - Upload a slip — the next step after the user transfers, and why successful durable intake answers
202. - Deposit status — which status means you can credit, and how often to poll.
- Cancel a deposit — when cancelling is possible, and why
canceledis not always the end. - Webhook order is not guaranteed — the version guard you need before accepting live events.
