Skip to content

Open deposits ​

"Open" on this page means an order that has not finished and still consumes the user's quota.

statusOpen in what senseWhat the user can do
awaiting_liquidityStill looking for a destinationWait, or cancel
awaiting_paymentQR issued, waiting for the transferTransfer, or cancel (as long as no slip exists)
slip_processingPaid, verification runningWait — cancelling is no longer possible
under_reviewPaid, waiting on a humanWait — cancelling is no longer possible
expired / canceled with is_terminal: falseClosed, but late slips are still acceptedUpload a late slip until late_slip_accepted_until

Why you must look before creating another one ​

There is a cap on open orders per user. Over the cap, you cannot create an order at all.

What you send in client{}How it is countedCap
Complete (device_fingerprint + ip)(merchant, end_user_ref, device_fingerprint)2 orders
Omitted(merchant, end_user_ref) only🔴 1 order

And there is a tighter rule on top: two awaiting_payment orders with the same amount and the same sending account can never be open at once.

This rule exists to protect you

If a user opens two orders of THB 5,000 from the same account and then transfers once, the system has no way to tell which order that slip belongs to — all six verification dimensions match both orders equally ⇒ both go to manual review (AMBIGUOUS_MATCH). So we cut the problem off at the source and refuse the second order.


Option 1 — read it from the 409 at creation time (most accurate) ​

When you hit the rule, we hand you the ids of the open orders. No lookup needed.

409 duplicate_open_deposit ​

json
{
  "error": {
    "code": "duplicate_open_deposit",
    "type": "invalid_request_error",
    "message": "An open deposit with the same amount and sender already exists.",
    "message_th": "มีรายการฝากยอดเท่ากันจากบัญชีผู้โอนเดียวกันค้างอยู่แล้ว",
    "retryable": false,
    "retry_after_seconds": null,
    "request_id": "req_01JQA5TC6P8R0T2V4X6Z8B0D2F",
    "details": {
      "open_deposit_ids": ["dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B"]
    }
  }
}

Do: send the user back to the existing order. Do not create another one, and do not silently change the amount for them.

409 too_many_open_deposits ​

json
{
  "error": {
    "code": "too_many_open_deposits",
    "type": "invalid_request_error",
    "message": "Too many unpaid deposits are open for this end user.",
    "message_th": "มีรายการฝากที่ยังไม่ได้ชำระค้างอยู่หลายรายการ",
    "retryable": false,
    "retry_after_seconds": null,
    "request_id": "req_01JQA5TD8Q0S2U4W6Y8A0C2E4G",
    "details": {
      "open_deposit_ids": [
        "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
        "dep_01K3M8B2VQ7HYD4NKR0XF9TW6E"
      ]
    }
  }
}

Do: show the open orders to the user and let them choose which one to continue paying and which to cancel.

js
// Node — handle both 409s in one place
async function startTopup(order) {
  try {
    return await createDeposit(order)
  } catch (err) {
    if (err.code === 'duplicate_open_deposit' || err.code === 'too_many_open_deposits') {
      const ids = err.details.open_deposit_ids ?? []
      const open = await Promise.all(ids.map(getDeposit))
      return { kind: 'resume', open }        // ❌ never create another order ❌ never retry silently
    }
    throw err
  }
}
php
<?php
// PHP — same logic
try {
    return createDeposit($order, $headers);
} catch (ApiError $e) {
    if (in_array($e->code, ['duplicate_open_deposit', 'too_many_open_deposits'], true)) {
        $open = [];
        foreach ($e->details['open_deposit_ids'] ?? [] as $id) {
            $open[] = getDeposit($id, $headers);
        }
        return ['kind' => 'resume', 'open' => $open];
    }
    throw $e;
}

The screen to show:

คุณมีรายการเติมเงินที่ยังไม่เสร็จ

  5,000.00 บาท   กสิกรไทย xxx-x-x1234   หมดเวลาใน 12:31
     [ โอนต่อ ]   [ ยกเลิกรายการนี้ ]

สร้างรายการใหม่ได้เมื่อปิดรายการเดิมแล้ว

English gloss: "You have an unfinished top-up / THB 5,000.00 · Kasikornbank xxx-x-x1234 · expires in 12:31 / [ Continue paying ] [ Cancel this order ] / You can create a new order once this one is closed."


Option 2 — list all of your open orders ​

bash
curl -G https://api.example-wl.com/v1/pay/deposits \
  --data-urlencode "status=awaiting_payment" \
  --data-urlencode "limit=200" \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "PG-Version: 2026-08-01" \
  -H "PG-Signature: v1=5c92...ae10"

The filters the contract defines for this endpoint are status · updated_after · cursor · limit, and nothing else.

json
{
  "data": [
    {
      "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",
      "sender": {
        "bank_code": "004",
        "account_number_masked": "xxx-x-x7890",
        "account_name_th_masked": "ส*** ใ***"
      },
      "metadata": { "user_id": "u_88213", "order_id": "ORD-9981" }
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Option 3 — your own index (required, not optional) ​

Finding one user's open orders requires an index on your side

The filter set the contract defines for GET /v1/pay/deposits has no search by end_user_ref ⇒ you must keep your own end_user_ref → deposit_id mapping from the moment the order is created, on both the 201 and the 202 path.

This is not extra burden: you already have to store deposit_id for reconciliation and for support. One more index on (end_user_ref, status) makes a "my open orders" screen work without a single API call.

sql
-- The merchant-side table that makes the open-orders screen fast and rate-limit-free
CREATE TABLE deposits (
  deposit_id            text PRIMARY KEY,          -- 🔴 text, not varchar(20), and never parse the prefix
  merchant_reference    text NOT NULL UNIQUE,
  idempotency_key       text NOT NULL UNIQUE,
  end_user_ref          text NOT NULL,
  status                text NOT NULL,
  is_terminal           boolean NOT NULL DEFAULT false,
  version               bigint NOT NULL,
  amount_minor          bigint NOT NULL,
  expires_at            timestamptz,
  late_slip_accepted_until timestamptz,
  created_at            timestamptz NOT NULL DEFAULT now(),
  updated_at            timestamptz NOT NULL DEFAULT now()
);

-- One user's open-orders screen reads from this table, no API call needed
CREATE INDEX deposits_open_idx ON deposits (end_user_ref)
  WHERE is_terminal = false;
sql
-- Orders that still consume this user's quota
SELECT deposit_id, status, amount_minor, expires_at
  FROM deposits
 WHERE end_user_ref = $1
   AND is_terminal = false
   AND status IN ('awaiting_liquidity', 'awaiting_payment', 'slip_processing', 'under_review')
 ORDER BY created_at DESC;

Always update this table from webhooks through the version guard.

sql
UPDATE deposits
   SET status = $2,
       is_terminal = $3,
       version = $4,
       late_slip_accepted_until = $5,
       updated_at = now()
 WHERE deposit_id = $1
   AND version < $4;

Open orders that must not vanish from the UI ​

These two statuses still need an upload button

expired and canceled with is_terminal: false no longer consume quota, but they must stay in the "my orders" list until late_slip_accepted_until, because a user who already transferred has to be able to upload a late slip.

If your screen filters on is_terminal = false alone, both are already included. But if you filter with status IN (...), you have to add both values, or the user cannot find the upload button — and will transfer again.


  1. Read open orders from your own table — is anything open?
  2. Yes → ask first: "continue paying" or "cancel and start over"
  3. No → call GET /v1/pay/senders so they can pick a sending account
  4. POST /v1/pay/deposits
  5. Still got a 409 (e.g. the user has two tabs open)? → read details.open_deposit_ids[] and go back to step 2

Next ​

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