Skip to content

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.

HTTPstatusMeaning
201awaiting_paymentMatched. legs[] contains where to transfer.
202awaiting_liquidityOrder 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 thisWhy
1Call GET /v1/pay/senders and let the user pick the sending accountUsers who pick from the list almost never end up in the manual review queue. Users who type it in often do.
2Grey out bank buttons using GET /v1/pay/banksA closed bank returns 409 at the last step — that is where users abandon the flow.
3Write merchant_reference + Idempotency-Key into your DB before sending the requestA 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/json

Idempotency-Key and PG-Version are part of the canonical signing string. Leaving one out returns 401, not 400. See request authentication.

Body ​

fieldtyperequiredconstraints
amount_minorinteger✅> 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
currencystring✅"THB" only, for now
merchant_referencestring✅unique per merchant · ≤ 64 chars
end_user_refstring✅the user's id in your system · ≤ 64 chars · also used by GET /v1/pay/senders
senderobject✅ (or sender_id)the account the user will actually transfer from · see the structure below
sender_idstring✅ (or sender)id from GET /v1/pay/senders · 🔴 this form guarantees no review queue caused by a name or account-number mismatch
clientobject⚠️required in practice — omitting it changes your caps and rate limits, see what omitting it costs
fill_preferencestring—"any" (default) or "internal_only"
min_ttl_secondsinteger—0–86,400 · shorter routes are skipped; a value that cannot fit the clamped order lifetime returns 422.
expires_in_secondsinteger—60–86,400 and clamped again by policy · 🔴 only response expires_at applies.
return_urlstring—Where the user returns after paying on the checkout_url page
metadataobject—≤ 20 keys · ≤ 500 bytes per value · echoed back in every webhook for this order

sender option A (manual entry) ​

fieldtyperequiredconstraints
sender.bank_codestring✅3 digits, from GET /v1/pay/banks
sender.account_numberstring✅digits only — the client must strip - and spaces before sending
sender.account_name_thstring✅🔴 no honorific prefix (นาย Mr., นาง Mrs., น.ส. Ms., คุณ Khun) — banks do not print honorifics on slips
sender.account_name_enstring—Optional, but it raises the automatic-approval rate

client — skipping it hurts ​

Sending client{}Cap on open orders per userRate-limit bucket
Complete (device_fingerprint + ip)counted per (merchant, end_user_ref, device_fingerprint) — cap 2pay_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 ​

bash
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.

json
{
  "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 ​

json
{
  "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_upload only in a short-lived secure checkout session: send to the returned upload_url exactly (never construct its host or path), use token as the bearer credential, and precheck the payload against max_bytes and content_types.
  • Count down from the expires_at you 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.

json
{
  "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 ​

OutcomeWhenwebhook
awaiting_paymentA destination was founddeposit.matched — the only event that carries the QR. Re-read all of legs[] and expires_at.
canceledThe 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) ​

js
// 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
<?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.

json
{
  "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"]
    }
  }
}
HTTPcodeWhenRetryableWhat to do
422sender_requiredNeither sender nor sender_id was sent❌Add a sender-account screen — read this
422validation_errorA field is malformed❌Read details.fields[] and fix the payload
422amount_not_supportedAmount below/above policy, or has a satang remainder❌Round to whole baht · use details.min_minor / details.max_minor
409sender_bank_unavailableThe 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
409duplicate_open_depositAn 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.
409too_many_open_depositsOver 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
400idempotency_key_requiredNo Idempotency-Key header❌Add the header (and write it to your DB first)
409idempotency_key_reusedSame key, different body❌When the body changes, the key must change too · details.original_request_id points at the first request
409idempotency_in_flightSame key, first request still running (double-click)✅Wait retry_after_seconds (= 1) and retry with the same key
401signature_invalid · timestamp_out_of_window · nonce_replayedSignature / clock / nonceSomeSee request authentication · PG-Nonce must be new on every attempt, Idempotency-Key must stay the same
403tenant_suspended · scope_deniedAccount suspended / key lacks the scope❌Contact your provider
429rate_limitedHit 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
503route_unavailableThe platform is shedding load✅Show "system busy, please try again" plus Retry-After · 🔴 not a bug, not a 500
503service_unavailablePlatform-wide load shedding✅Same as above
500internal_errorOur 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 ​

WhenWhat
Before the callmerchant_reference (UNIQUE) · Idempotency-Key (UNIQUE)
After 201/202deposit_id (your primary key) · version · status · expires_at · a masked snapshot of sender.*
On the payment screenexpires_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 ​

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