Open deposits
"Open" on this page means an order that has not finished and still consumes the user's quota.
status | Open in what sense | What the user can do |
|---|---|---|
awaiting_liquidity | Still looking for a destination | Wait, or cancel |
awaiting_payment | QR issued, waiting for the transfer | Transfer, or cancel (as long as no slip exists) |
slip_processing | Paid, verification running | Wait — cancelling is no longer possible |
under_review | Paid, waiting on a human | Wait — cancelling is no longer possible |
expired / canceled with is_terminal: false | Closed, but late slips are still accepted | Upload 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 counted | Cap |
|---|---|---|
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
{
"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
{
"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.
// 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 — 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
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.
{
"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.
-- 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;-- 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.
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.
Recommended order of operations when the user taps "top up"
- Read open orders from your own table — is anything open?
- Yes → ask first: "continue paying" or "cancel and start over"
- No → call
GET /v1/pay/sendersso they can pick a sending account POST /v1/pay/deposits- Still got a
409(e.g. the user has two tabs open)? → readdetails.open_deposit_ids[]and go back to step 2
Next
- Cancel a deposit — how to close an open order correctly, and when you no longer can
- Create a deposit — every error, with the
detailsyou need to read - Deposit status — what each status means, and polling that stays under the rate limit
- Webhook order is not guaranteed — the version guard that keeps your table from going backwards
