Create a Deposit
POST /v1/deposits
Create a deposit so a customer can transfer money in. The response returns an expected_amount (the exact amount the customer must transfer) plus a pay_to destination / qr_payload, with status PENDING. This call must be HMAC-signed (see Authentication) and requires an Idempotency-Key header.
"500.00" — never a number, never satang. Sending 50000 for "500.00" is the 100× bug.Idempotency-Key (mandatory when creating)
POST /v1/deposits requires the Idempotency-Key header (if omitted → 400 IDEMPOTENCY_KEY_REQUIRED). See the mechanism and replay behavior under Idempotency.
- Any unique value — the system does not enforce a format/length, only that it is non-empty. UUID v4 recommended, e.g.
9f1c2e7a-3b4d-4f8a-9c10-2b6d5e7f8a90(or map it to your order id, as long as it is unique per record). - The system caches the key's result for 24 hours — retrying with the same key + same body returns the same deposit (no duplicate, no double amount-slot reservation).
- The idempotency namespace is separated by mode (live/test) — the same key in live and test do not collide.
422IDEMPOTENCY_KEY_MISMATCH.Request body
| Field | Type | Required | Meaning |
|---|---|---|---|
amount | string (baht) | required | The amount you want the customer to pay, e.g. "500.00" (baht string, max 2 decimals). Too small/large vs the limit is rejected with 422 INVALID_AMOUNT. |
currency | string | optional | Currency — only "THB" is supported; empty = default THB; any other value → 422 INVALID_CURRENCY. |
payment_method_type | string | optional | Channel: "PROMPTPAY_QR" or "BANK_TRANSFER"; empty = default PROMPTPAY_QR; any other value → 422 INVALID_PAYMENT_METHOD. |
payer_bank_provider | string | required | The customer's source bank (bank code/alias, e.g. "KBANK", "SCB") — used by the matcher for anti-fraud checks; an unknown code → 422 INVALID_BANK. |
payer_bank_account_name | string | required | The paying customer's account name (declared by the merchant). |
payer_bank_account_number | string | required | The paying customer's account number. |
additional_data | object | optional | Extra metadata with one field: description (string), e.g. an invoice number. |
user_ref | string | optional | Merchant-side reference (e.g. order id / customer id) — usable to search later. |
callback_meta | object (JSON) | optional | JSON data you want attached to the record (stored with the deposit). |
payer_bank_provider, payer_bank_account_name, payer_bank_account_number must all be sent for both PROMPTPAY_QR and BANK_TRANSFER. If any is missing → 422PAYER_REQUIRED. The system uses this to verify that "whoever actually transferred the money" matches the customer the merchant declared (anti-fraud) — the same for both methods.unk_live_... = live, unk_test_... = sandbox).Response body
After creation the status is always PENDING.
| Field | Type | Meaning |
|---|---|---|
id | string (uuid) | deposit id — keep it to check status / cancel. |
amount | string (baht) | The amount the merchant requested (echoed back). |
expected_amount | string (baht) | The exact amount the customer must transfer (see below) — usually differs from amount by a small satang remainder. |
matched_amount | string (baht) | The amount actually received — null/absent until CREDITED (not present at creation). |
currency | string | "THB". |
status | string | Status — at creation, "PENDING". |
payment_method_type | string | "PROMPTPAY_QR" or "BANK_TRANSFER". |
pay_to | object | The destination account/channel the customer transfers into (see sub-table). |
payer | object | Echo of the payer the merchant declared (full: bank, account_no, name). |
display_expires_at | string (RFC3339 timestamp) | When the QR / payment page display expires. |
match_window_until | string (RFC3339 timestamp) | The last moment the system will still match the amount (grace included). |
pay_to (object):
| Field | Condition | Meaning |
|---|---|---|
bank | always | Destination bank. |
account_holder | always | Destination account name. |
account_no | BANK_TRANSFER only | The full destination account number to transfer into (PROMPTPAY_QR does not include this field). |
qr_payload | PROMPTPAY_QR only | The EMVCo string used to render the PromptPay QR with a fixed amount = expected_amount (scanning fills the exact amount automatically). |
qr_payload to render the QR for the customer to scan — it pays into the system's PromptPay proxy (not a raw account number) and already embeds expected_amount, so the customer transfers the correct amount automatically.
BANK_TRANSFER: no QR — show pay_to.bank + pay_to.account_no + pay_to.account_holder for the customer to transfer manually, and remind them to enter exactly expected_amount.Why expected_amount has a satang remainder
The system matches inbound money by comparing the amount arriving in the account to expected_amount exactly, so it can tell which inbound payment belongs to which deposit (many customers may transfer into the same pool account at nearly the same time).
So when a merchant requests amount = "500.00", the system adds a small satang remainder to make a value unique among other outstanding deposits, e.g. expected_amount = "500.37" (a remainder of 1–99 satang, and if truly necessary the system may nudge it up by a small whole baht). This value is the deposit's "signature amount".
expected_amount (not amount) for the system to match and credit it.- PROMPTPAY_QR: with
qr_payloadthe amount is embedded automatically — the customer doesn't type it (recommended). - BANK_TRANSFER: show
expected_amountclearly and remind the customer to enter it down to the satang.
"500.00" instead of "500.37"), the system may fail to match and the deposit will expire as EXPIRED."One customer, one active deposit" rule
The same customer (identified by payer_bank_provider + payer_bank_account_number) can have only one PENDING deposit at a time. Creating another while one is still outstanding returns:
{ "code": "DEPOSIT_ALREADY_ACTIVE", "message": "this customer already has an active deposit — cancel it or wait for it to expire", "details": { "deposit_id": "<existing id>" } }
Use the returned deposit_id with GET /v1/deposits/:id to re-fetch the existing payment instructions, or cancel the existing one first.
Error codes
Common errors for POST /v1/deposits. Branch on the stable code — see the full envelope under Error Envelope & Codes.
| HTTP | code | Cause |
|---|---|---|
| 400 | IDEMPOTENCY_KEY_REQUIRED | The Idempotency-Key header was not sent. |
| 422 | INVALID_AMOUNT | Malformed amount / more than 2 decimals / out of the min–max range. |
| 422 | INVALID_CURRENCY | currency is not THB. |
| 422 | INVALID_PAYMENT_METHOD | method is not PROMPTPAY_QR/BANK_TRANSFER. |
| 422 | PAYER_REQUIRED | payer data was incomplete. |
| 422 | INVALID_BANK | payer_bank_provider is unknown. |
| 422 | IDEMPOTENCY_KEY_MISMATCH | Same Idempotency-Key reused with a different body. |
| 403 | MERCHANT_SUSPENDED | The merchant (or parent entity) is suspended. |
| 409 | DEPOSIT_ALREADY_ACTIVE | This customer already has an outstanding deposit. |
| 409 | DEPOSIT_AMOUNT_POOL_EXHAUSTED | The amount slot pool is temporarily full (retry). |
| 503 | NO_QR_ACCOUNT | No account supports PromptPay QR (try BANK_TRANSFER). |
| 503 | NO_ALLOWED_ACCOUNT | No usable destination account. |
Example request
# X-Signature = hex( HMAC-SHA256( secret, "POST\n/v1/deposits\n{TS}\n{SHA256_HEX(body)}" ) )
curl -X POST 'https://api.unkpay.co/v1/deposits' \
-H 'X-Api-Key: unk_live_xxxxxxxxxxxx' \
-H 'X-Timestamp: 1718790000' \
-H 'X-Signature: <hex hmac-sha256>' \
-H 'Idempotency-Key: order-2026-0001' \
-H 'Content-Type: application/json' \
-d '{
"amount": "500.00",
"currency": "THB",
"payment_method_type": "PROMPTPAY_QR",
"payer_bank_provider": "KBANK",
"payer_bank_account_name": "Somchai Jaidee",
"payer_bank_account_number": "9876543210",
"additional_data": { "description": "inv #42" },
"user_ref": "ord-1"
}'
Example response
Example response (201 Created) for PROMPTPAY_QR:
{
"id": "8f2b1c4e-7a90-4d2f-9b3a-1c2d3e4f5a6b",
"amount": "500.00",
"expected_amount": "500.37",
"currency": "THB",
"status": "PENDING",
"payment_method_type": "PROMPTPAY_QR",
"pay_to": {
"bank": "SCB",
"account_holder": "ACME Holder",
"qr_payload": "00020101021229370016A0000006770101110213..."
},
"payer": {
"bank": "KBANK",
"account_no": "9876543210",
"name": "Somchai Jaidee"
},
"display_expires_at": "2026-06-19T10:05:00Z",
"match_window_until": "2026-06-19T10:07:00Z"
}
Example response for BANK_TRANSFER (has account_no, no qr_payload):
{
"id": "1a2b3c4d-...",
"amount": "500.00",
"expected_amount": "500.37",
"currency": "THB",
"status": "PENDING",
"payment_method_type": "BANK_TRANSFER",
"pay_to": {
"bank": "SCB",
"account_no": "1234567890",
"account_holder": "ACME Holder"
},
"payer": {
"bank": "KBANK",
"account_no": "9876543210",
"name": "Somchai Jaidee"
},
"display_expires_at": "2026-06-19T10:05:00Z",
"match_window_until": "2026-06-19T10:07:00Z"
}
unk_test_... key the system returns a placeholder pay_to (e.g. account_holder = "SANDBOX TEST" and qr_payload starting with "SANDBOX-TEST-QR-", which cannot be scanned for real) so you can test the flow without a real transfer. To simulate an inbound transfer in test mode see Simulate Transfer.Deposit Overview & State Machine
How a merchant takes money in via UnknownPay S2S — from creating a deposit to exact-amount matching, crediting, and the PENDING → CREDITED / EXPIRED / CANCELLED state machine
Get a Deposit
GET /v1/deposits/:id — retrieve / poll a deposit; pay_to is present only while PENDING; caller- and mode-scoped
