Merchant API — Client Integration Guide
Audience. Developers integrating a merchant / client application against the FlowX payment gateway. This is the client-facing surface only — the endpoints you call with your API key + request signature, plus the webhooks you receive. Operator-console (admin) and internal endpoints are not here.
What this system is
A multi-tenant THB payment gateway. You create deposits (collections — your customer pays in, usually by PromptPay QR or bank transfer) and payouts (disbursements — we pay out to a destination bank account, debiting your wallet). You authenticate create calls with an API key and an HMAC request signature. Terminal state changes are delivered to you as signed webhooks.
Base URL
Every endpoint hangs off a single base URL, and it is deliberately not printed in this public guide. Sign in to your Client Management console and read it from the API page — that page is the one place we publish it.
| Placeholder | Meaning |
|---|---|
{BASE_URL} | The API base URL, shown on the API page of your console. |
Substitute it once in your client config; every path below is written relative to it —
e.g. POST {BASE_URL}/v2/deposits-create, POST {BASE_URL}/deposits-upload-slip/<id>,
GET {BASE_URL}/deposits-qr/<id>, POST {BASE_URL}/v2/payouts-create.
⚠️ Static egress IP — required before you can go live
Your integration must call us from a fixed, static set of source IPs, and you must tell us
what they are. deposits-create and payouts-create are gated by a per-client IP
allowlist; a request that arrives from an address we do not have on file for you is
rejected before it is processed:
// 403
{ "code": "IP_NOT_ALLOWED", "message": "client IP is not on the configured allowlist" }
- Applies to the two money endpoints only —
deposits-createandpayouts-create. The read/poll, QR, list, cancel and key-management endpoints are not IP-gated. - 🔴 The gate is fail-CLOSED. An EMPTY allowlist does not mean "allow everything" — it means nothing gets through. "We have not sent our IPs yet" is not a working state; it is a blocked one. Send them BEFORE you cut over.
- Send every address you can egress from, not just the primary: NAT gateways, each availability zone, your DR site, and any queue/worker host that retries a call. One unregistered failover address is an outage you will only discover during a failover.
- Dynamic IPs will not work. If your egress address can change (a consumer connection, a provider that re-allocates on restart, a serverless platform with a shared pool), put a NAT gateway or a proxy with a reserved address in front of your calls and register that.
- Hand the list to your account manager; an administrator records it against your client. Ask them to re-check it whenever your infrastructure moves.
Authentication
There are four auth modes on the client surface — pick by endpoint (see each doc):
| Mode | Endpoints | How you authenticate |
|---|---|---|
| API key + HMAC | deposits-create, payouts-create | X-Client-Id + X-Signature headers (below). The request signature is verified before the call is processed. |
| API key only | deposits-upload-slip, client-deposits, client-payouts, transaction, client-wallet-balance, client-bank-codes, client-deposit-cancel | X-Client-Id: <api_key> header (matched to your client record). No signature. Reads/actions are scoped to your own rows. |
| Public (no auth) | deposits-qr, client-deposit-status, client-payout-status | None — the resource UUID is the capability. |
| Console session | client-self-rotate-key, client-self-revoke-key, client-self-set-callback, client-self-test-callback | The Bearer token from your Client Management console login (2FA-backed) — not your API key. See Key lifecycle and Callbacks. |
You get your API key (X-Client-Id) and API key secret from your account manager /
the Client Management console. The secret is shown once at create/rotation and never
again. Keys support a two-slot rotation with an overlap window — treat the key as opaque
(its prefix can change across rotations).
Key lifecycle (self-service)
A client-admin console user can rotate or revoke its own API key from a logged-in
Client Management console session (the Bearer token from your 2FA login). These calls
deliberately do not use the API-key path — authenticating with the very key you are
rotating would lock you out. There is no client_id in the body: you can only ever act on your
own key.
Rotate — POST {BASE_URL}/client-self-rotate-key
-
Body:
{ "overlap_seconds"?: <int> }— optional; omitted → a 3600 s overlap window. -
Response
200:{ "rotated": true, "api_key": "pk_new_key", "api_key_secret": "sk_shown_once", "retiring": { "api_key": "pk_old_key", "retire_at": "2024-01-01T13:00:00Z" } }api_key_secretis the once-shown secret — capture it now; it is never returned again. The previous key keeps working untilretire_at(the overlap window) so you can roll over with no downtime. -
Errors:
401(missing/invalid/2FA-incomplete/expired session) ·403(insufficient role or blocked) ·404 unknown_client·409 no_active_key·500 rotate_failed.
Revoke — POST {BASE_URL}/client-self-revoke-key
- Body:
{ "reason"?: "<string>" }— optional. - Response
200:{ "revoked": true }. Immediate kill of both key slots — no overlap, no grace. The very next call on either key is unauthenticated. Carries no credential material back. - Errors:
401·403·404 unknown_client·500 revoke_failed.
Request signing (X-Signature)
For deposits-create and payouts-create, sign every request:
X-Client-Id : <your api_key>
X-Signature : t=<unix_ms>,v1=<hex>
where v1 = HMAC-SHA256( api_key_secret , "<t>.<METHOD>\n<path>\n<rawBody>" ) // lowercase hex
tis the current time in Unix milliseconds. The server rejects a signature whose timestamp is outside the allowed skew window — currently 60 s (|now − t| > 60s→401 { "error": "signature_timestamp_skew" }). Treat 60 s as the current value, not a fixed constant. Generatetright before you sign.METHODis the uppercase HTTP verb —POSTon both create endpoints.pathis the request path exactly as you send it, leading slash included and query string excluded — e.g./v2/deposits-create. Sign the literal you put in the URL: it is compared byte-for-byte and nothing is normalised, so/v2/deposits%2Dcreatemust be signed in that spelling (and is rejected further downstream regardless — send the plain form).\nis a single newline (0x0A), not the two characters\andn.rawBodyis the exact JSON bytes you POST. Sign the bytes you send (do not re-serialize after signing — a single byte difference fails the HMAC).
Node.js example:
const crypto = require('crypto');
const path = '/v2/deposits-create';
const payload = { /* … your request body … */ };
const rawBody = JSON.stringify(payload); // these EXACT bytes are what you send
const t = Date.now(); // unix ms
const v1 = crypto.createHmac('sha256', apiKeySecret)
.update(`${t}.POST\n${path}\n${rawBody}`)
.digest('hex');
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': apiKey,
'X-Signature': `t=${t},v1=${v1}`,
'Idempotency-Key': crypto.randomUUID(),
},
body: rawBody,
});
A signature the server cannot reproduce returns
401 { "error": "invalid_signature", "detail": "…" } — and detail restates the canonical
string the server expected, so the wire tells you what it wanted.
Why no secret in the request? Your secret never leaves your server — only the HMAC does. The signature is bound to your exact request bytes (timestamp + method + path + body), so a captured signature cannot be replayed with a different body, against a different endpoint, or outside the 60-second window. A signature minted for
/v2/deposits-createis not valid at/v2/payouts-create.
Idempotency
Every create call (deposits-create, payouts-create) and deposits-upload-slip must
include an Idempotency-Key header.
- We recommend a UUID v4 — the server accepts any non-empty string, but the key must be unique per transaction (1 transaction = 1 key). Never reuse a key for a different transaction.
- A retry must reuse the same key + identical body → the server replays the original response instead of creating a duplicate.
- The body is canonicalized before it is compared: JSON key order and insignificant
whitespace do not affect the dedup identity, so a re-serialized retry (same fields, keys
reordered / reformatted) still replays. Any genuine field or value change is still a
conflict →
409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY(use a new key). - Keys live 24 hours (TTL 86 400 s); after expiry the key is treated as new.
These rejections carry { "code": "…", "message": "…" } (there is no error key on
these) — branch on code:
| HTTP | code | When |
|---|---|---|
400 | IDEMPOTENCY_KEY_REQUIRED | Header missing on an endpoint that requires it. |
400 | INVALID_JSON | Body is not valid JSON. |
409 | IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY | Same key, different body (a client bug — use a new key). |
409 | IDEMPOTENCY_KEY_CONCURRENT_INFLIGHT | The first request with this key is still processing — wait, then retry with the same key. |
Rate limiting
Create calls (deposits-create, payouts-create) are rate-limited per client, per
lane (deposit / payout), over a rolling minute and rolling day window. The caps
are provisioned per account (there is no single fixed public number) — ask your account
manager for yours.
- Over a cap →
429with aRetry-Afterheader (seconds to wait) and a JSON body that always carriescode(RATE_LIMIT_EXCEEDED),scope(deposit|payout) andretry_after_s. Branch oncodeand honorRetry-After. Additional fields —messageand the per-window counters (window,minute_count,minute_cap,day_count,day_cap) — MAY be present depending on which tier rejected you (the edge cf-worker returns only the minimal{ code, scope, retry_after_s }; the Edge-Function backstop addsmessage+ the counters). Do not depend onmessageor the counters being there. - Honor
Retry-After: back off for that many seconds, then retry (fall back to a short exponential back-off if the header is absent). A429is safe to retry — reuse the sameIdempotency-Keyso the eventual create is not duplicated.
Conventions
-
🔴 Deposits are closed 20:30–07:00 Thailand time, every day. A create inside that window is REFUSED, not queued — schedule around it rather than relying on retries. Payouts have their own window; if a create is refused outside deposit hours, treat it the same way.
-
Success bodies are resource-shaped JSON (see each endpoint).
-
Error shapes vary by layer — when a stable
codeis present, branch on it; never parse theerrortext. Three shapes:- Most validation / signature / auth failures →
{ "error": "<snake_token>" }(often withdetail), e.g.{ "error": "missing_route" },{ "error": "signature_timestamp_skew" }. - Recognized create-time business rejections →
{ "error": "<full raised message>", "code": "<UPPER_SNAKE>" }— hereerrorholds the full message (e.g."insufficient_funds: …"), not the bare token. Commoncode→status:INSUFFICIENT_FUNDS→402,INSUFFICIENT_P2P_FUNDS→402 (payout only; the peer-to-peer pool is short rather than your payment wallet — branch on the 402, or on both codes, never onINSUFFICIENT_FUNDSalone),NO_BANK_AVAILABLE*/*_MAINTENANCE→503,DEPOSIT_DISABLED_FOR_CLIENT→403,CLIENT_DISABLED→403,PAYOUT_DISABLED→403,UNSUPPORTED_DEST_BANK/AMOUNT_OUT_OF_RANGE→400,CALLBACK_ENDPOINT_NOT_CONFIGURED→409. CLIENT_DISABLEDis a special case of the above: it can come from TWO different layers, not just the create-time RPC. Your account (client.status, set by an operator — distinct from the per-flow enable/disable toggles) is checked (a) at the edge gateway, before your request even reaches a create call, and (b) again insidedeposits-create/payouts-createitself, as a second independent check. Layer (a)'s body is the shorter{ "error": "client_disabled", "code": "CLIENT_DISABLED" }(the bare token, not a raised message); layer (b) follows the general shape above (error= the full message). Always branch oncode, never on which shapeerrortook — thecodeand the403status are identical either way, and which layer catches it is an implementation detail (e.g. cache timing) you should not depend on.- Idempotency rejections →
{ "code": "…", "message": "…" }with noerrorkey. Rate-limit rejections → alwayscode+scope+retry_after_s(noerrorkey);messageand the per-window counters are optional (tier-dependent — see Rate limiting). See also Idempotency.
- Most validation / signature / auth failures →
-
Wrong method →
404or405depending on the route: via the edge cf-worker a wrong method is an unmatched route →404; directly against an Edge Function it is405 { "error": "method_not_allowed" }. Treat both as "wrong method / not routable." -
Callback URL is preconfigured. You do not pass a raw
callback_urlon create — it is rejected (400 callback_url_not_allowed). Instead you reference a preconfigured endpoint bycallback_endpoint_key(set up during onboarding). This closes an SSRF/open-redirect surface.
Documents
- deposit.md — create deposit, upload slip, render QR
- payout.md — create payout (route selection)
- status.md — status poll / get-by-id / list / self-cancel deposit
- balance-banks.md — wallet balance, bank-code list
- callbacks.md — webhooks you receive: signature, events, retries, dedup
Deposit API
The collection lane. You create a deposit, your customer pays (PromptPay QR or bank transfer), and the deposit reaches a terminal state — at which point we deliver a webhook. See INDEX for auth, signing, and idempotency.
⚠️ This endpoint is IP-gated. It only accepts calls from the static source IPs registered against your client — an unregistered address gets
403 IP_NOT_ALLOWEDbefore the request is processed, and the gate is fail-closed (an empty allowlist blocks everything). Register every address you can egress from, including failover and DR, before you go live. See Static egress IP.
Create Deposit
POST {BASE_URL}/v2/deposits-create (scope deposit)
- Auth: API key + HMAC (
X-Client-Id+X-Signature) — see INDEX. - Idempotency: required
Idempotency-Key.
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
amount | number | ✅ | THB. Integer baht — any decimal is silently floored (100.99 → 100). |
request_id | string | ✅ | Your own reference id for this deposit (e.g. your order id) — returned in the response and echoed in callbacks so you can reconcile on your side. Must be unique per deposit. |
customer_bank_account_number | string | ✅ | Payer's bank account number. (Alias: expected_source_account_no.) |
customer_bank_account_name | string | ✅ | Payer's account holder name. |
customer_bank_bank_code | string | ✅ | Payer's bank code. Must be a recognized Thai bank code (case-insensitive; portal variants like KTBBIZ→ktb are normalized). Unknown/junk → 400 UNSUPPORTED_SOURCE_BANK. Enumerate the valid codes via balance-banks.md. |
customer_bank_bank_name | string | — | Optional payer bank name. |
promptpay_id | string | — | Optional PromptPay proxy. |
client_reference_id | string | — | Optional extra client-side reference — echoed back as clientReferenceId on this deposit's callbacks (see callbacks.md). |
callback_endpoint_key | string | — | Preconfigured callback endpoint (see below). |
metadata | object | — | ≤ 2048 bytes & ≤ 20 keys. Echoed back verbatim in deposit callbacks. |
🔴 On the peer-to-peer rail the amount must be a multiple of 50, and at least 100.
100 · 150 · 200 · …— 50 itself is not accepted, and neither is anything off the 50 grid. The same rule applies to payouts. You do not choose the rail, so an amount that cannot go to P2P is simply refused at create — it is not quietly sent down the bank rail instead, and the refusal is400 P2P_AMOUNT_NOT_ALLOWED("error": "p2p_amount_not_allowed: this amount cannot be taken on the P2P rail"). 🔴 Not retryable as sent — change the amount. As with the 503s below, therequest_idis spent and nothing was reserved. Do not treat an off-grid amount as a way to opt out of P2P.
cURL
# build X-Signature first (see INDEX):
# v1 = HMAC-SHA256(secret, "<t>.POST\n/v2/deposits-create\n<rawBody>")
curl -X POST "${BASE_URL}/v2/deposits-create" \
-H 'Content-Type: application/json' \
-H 'X-Client-Id: pk_your_api_key' \
-H 'X-Signature: t=1709123456789,v1=a1b2c3…' \
-H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \
-d '{
"amount": 1000,
"request_id": "ORDER-12345",
"customer_bank_account_number": "1234567890",
"customer_bank_account_name": "John Doe",
"customer_bank_bank_code": "KBANK",
"callback_endpoint_key": "default"
}'
Response 201
The key set depends on the channel. A field that does not apply to the channel is
absent from the object entirely — not present-with-null. Read them with
"key" in deposit, never deposit.key === null; those are different answers and only the
first is correct.
{
"deposit": {
"id": "…uuid…",
"deposit_id": "…uuid…",
"request_id": "ORDER-12345",
"amount": 1000,
"channel": "QR",
"fee": 15,
"feePercent": 1.5,
"netAmount": 985,
"qrcode": "00020101021229370016A000000677010111…6304ABCD",
"qr_type": "mobile",
"promptpay_number": "…",
"payment_bank_code": "SCB",
"payment_promptpay_id": "…",
"expires_at": "2026-01-01T12:10:00Z"
}
}
Nine keys are on every deposit, whatever the channel: id, deposit_id, request_id,
amount, channel, fee, feePercent, netAmount, expires_at. Everything else comes
and goes with the channel:
channel | keys | what is absent |
|---|---|---|
QR | 14 | payment_account_number, payment_account_name |
TRANSFER | 12 | qrcode, qr_type, promptpay_number, payment_promptpay_id |
P2P | 11 | the whole bank/QR block (all six above plus payment_bank_code) — and it adds p2p_status and redirect_url |
SLIP_VERIFY | 11 | the account block and the whole QR block — payment_account_number, payment_account_name, qrcode, qr_type, promptpay_number, payment_promptpay_id. payment_bank_code stays, and redirect_url is added |
⚠️
TRANSFERomits a real value, not just a null. If the bank we routed you to happens to have a PromptPay proxy,promptpay_number/payment_promptpay_idwould have carried it — but you asked for a transfer, so they are not sent. Do not infer "this bank has no proxy" from their absence. 🔴SLIP_VERIFYkeepspayment_bank_codeand nothing else you can charge against. You are told WHICH bank, but not the account and not a QR — so you cannot draw a transfer screen or show a scannable code, and the customer must go toredirect_url. That is the point of the channel, not an omission to work around. There is also no QR to fetch out-of-band:GET /deposits-qr/<id>answers404 no_qr_for_depositfor a deposit on this channel, because no payload is ever generated for it.
-
The deposit's own id is
id(also returned asdeposit_idfor back-compat — new integrations should bind toid). Persist it — it is the id you pass to the QR, slip-upload, status-poll, get-by-id, and cancel endpoints. -
channelis"QR","TRANSFER","P2P"or"SLIP_VERIFY"— chosen by the system, never by you.QRandTRANSFERare properties of the assigned bank;P2Pmeans the deposit was routed to the peer-to-peer rail, which has no bank account at all;SLIP_VERIFYmeans the assigned bank's statement cannot identify who paid, so the customer must complete the payment on a page we host.🔴 Handle all four, always. On a
P2Pdeposit there is no bank block at all — those seven fields are absent. On aSLIP_VERIFYdepositpayment_account_numberandpayment_account_nameare absent (payment_bank_codeis still returned, so you can still name the bank). In both cases you must send your customer toredirect_url. You cannot predict which shape a given request will get, so branch onchannelrather than assuming a bank block is present. -
redirect_urlis the page to send your customer to — and it is not P2P-only. It is non-nullin two situations, and your job is identical in both: open it for the customer.channel: "P2P"— the hosted page for the peer-to-peer rail (no bank block at all);channel: "SLIP_VERIFY"— a deposit routed to a bank whose statement cannot identify who paid. There, the customer must transfer and upload their slip on a page we host, because the slip is the only thing that binds the incoming credit to them.
🔴 On
SLIP_VERIFYthere is no transfer screen for you to draw.payment_account_numberandpayment_account_nameare absent by design. The page we host shows the destination account and takes the slip; a customer who transfers without uploading leaves money that arrived against a deposit that can never be marked paid, which is exactly why the account is no longer yours to render. Send the customer toredirect_url. 🔴 If you lose the 201, poll.GET /client-deposit-status/<id>now returnsredirect_urltoo, so a timeout or a crashed worker on your side never strands a customer holding money they cannot send. Polling does not change or invalidate the link — it is the same URL, every time. 🔴 OnP2P, fetch the deposit by id instead. The public poll returnsredirect_url: nullon that channel.GET /client-deposits/<id>, with your API key, returns the same link the create did. See status.md.⚠️ The key is emitted only when there IS a page: present on
P2PandSLIP_VERIFY, absent — notnull— onQRandTRANSFER.Both situations answer 503 rather than returning a deposit without a usable link:
P2P_ROUTE_NOT_READYandPAGE_URL_UNAVAILABLE. A deposit that exists and cannot be paid is worse than no deposit. Retry, or contact support if it persists.⚠️ One exception, on
P2Ponly. If we cannot tell whether the order was registered for matching (for example, a timeout after we sent it), the create answers 201 with the depositpendingandredirect_url: null. The deposit exists, so do not create it again. FetchGET /client-deposits/<id>with your API key untilredirect_urlis set or the deposit ends. The public poll will not give it to you on this channel. -
qrcodeis an EMVCo PromptPay payload string for client-side QR rendering (ornullfor a transfer channel). To get a rendered PNG, use the QR endpoint below. -
qr_typeis the PromptPay proxy type of the QR — one of"mobile","nationalId","taxId","ewallet"(present only on a QR channel;nullotherwise). -
fee/feePercent/netAmountcome from your MDR profile;netAmountis the net credited to your wallet at finalize.payment_account_*/payment_promptpay_idare the assigned collection bank block the payer transfers to.
Errors
- Auth/signing:
401 missing_credentials/invalid_signature_format/signature_timestamp_skew/invalid_client/invalid_signature;401 wrong_scope. The assertion401 { code }set (missing_assertion/expired/request_hash_mismatch/…) is gateway-internal — the edge (cf-worker) mints the assertion for you, so you should not normally see these on the client surface. - Idempotency:
400 IDEMPOTENCY_KEY_REQUIRED/400 INVALID_JSON/409 …_REUSED_WITH_DIFFERENT_BODY/409 …_CONCURRENT_INFLIGHT. - Validation:
400 missing_fields/invalid_method/missing_required_field(MISSING_REQUIRED_FIELD) /amount_out_of_range(AMOUNT_OUT_OF_RANGE) /unsupported_source_bank(UNSUPPORTED_SOURCE_BANK— the payercustomer_bank_bank_codeis not a recognized bank code) /metadata_too_large/callback_url_not_allowed(CALLBACK_URL_NOT_ALLOWED) /client_supplied_expires_in_seconds. - Business:
503 NO_BANK_AVAILABLE/NO_BANK_AVAILABLE_AFTER_EXCLUSION/DEPOSIT_MAINTENANCE·403 DEPOSIT_DISABLED_FOR_CLIENT·403 CLIENT_DISABLED·403 BLACKLISTED_PAYER_ACCOUNT·403 DEPOSIT_DAILY_CAP_EXCEEDED(your account's per-day deposit amount cap would be exceeded — clears at the next BKK-day rollover, no action needed on your end) ·409 CALLBACK_ENDPOINT_NOT_CONFIGURED·400 INVALID_CALLBACK_ENDPOINT[_KEY]·503 P2P_ROUTE_NOT_READY·503 PAGE_URL_UNAVAILABLE.
403 CLIENT_DISABLED— your account (not this request) has been disabled by an operator. The credential is valid; the client is not. This can surface at more than one layer of the request path (the edge gateway or the create call itself) but the code and status are always the same — treat it as terminal until an operator re-enables the account; retrying will not help.
503 P2P_ROUTE_NOT_READY— the deposit qualified for the P2P rail but no peer-to-peer order could be created for it. 🔴 The gateway deliberately does not fall back to the bank rail here: a fallback that is almost never exercised is broken by the time it is needed, and it would hide a dead P2P rail behind green screens.
503 PAGE_URL_UNAVAILABLE— the deposit was routed to a bank whose statement cannot identify the payer, so it needs the hosted slip page (seeredirect_urlabove), and no page URL could be composed for it. Same reasoning asP2P_ROUTE_NOT_READYand the same remedy — raise it with us if it persists. 🔴 The gateway deliberately does not return a deposit without the link: on that rail the slip is the payment, so a deposit with no page can never be marked paid, and handing you one would invite your customer to transfer into it.🔴 Both are retried with a NEW
request_id, not the same one. The one you sent is spent — it is recorded against an order that did not proceed, and a second create with it is refused as a duplicate. Nothing was reserved from your balance and no callback follows for thatrequest_id. Neither error carries aRetry-Afterheader or aretry_after_sfield, so use your own backoff.
403 BLACKLISTED_PAYER_ACCOUNT— the payer's source bank account is on the gateway blocklist and may not make deposits. A hard fail before any state write; nothing is created.
503 NO_BANK_AVAILABLE_AFTER_EXCLUSIONis returned when no eligible system bank remains after the server-derived intra-bank exclusion — a deliberate hard-fail. The gateway will not silently route a payer to a collection bank on their own network.
Deposit lifecycle (statuses you'll see in webhooks)
| Status | Meaning |
|---|---|
pending | Awaiting payment / slip / statement match. |
paid | Payment confirmed (→ deposit.paid webhook). |
rejected | Rejected after review (→ deposit.rejected). |
expired | Window elapsed unpaid (→ deposit.expired). |
cancelled | Cancelled — by you via self-cancel while still pending & slip-less, by an operator, or (P2P rail) by the payer or an operator on the matching network (→ deposit.cancelled, whoever cancelled). Final — never flips. |
Upload Payment Slip
POST {BASE_URL}/deposits-upload-slip/<deposit_id>
Attach a payment-slip image to a pending deposit — or rescue an expired one
(e.g. your customer paid by transfer, or paid late after the deposit already expired).
- Auth: API key only —
X-Client-Id: <api_key>matched to your client record. Not HMAC-signed. The deposit must belong to your client. - Idempotency: required
Idempotency-Key. - Request: path
deposit_id; bodyslip_image_url(req — a pre-hosted URL string, not a multipart file upload); optionaluploader{ type: "customer"|"client"|"sub-client", id?, username? }(non-customer needs id+username).
curl -X POST "${BASE_URL}/deposits-upload-slip/DEPOSIT_UUID" \
-H 'Content-Type: application/json' \
-H 'X-Client-Id: pk_your_api_key' \
-H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440001' \
-d '{ "slip_image_url": "https://your-cdn/slip/abc.jpg" }'
- Response — depends on the deposit's current status (check the status code):
202(deposit ispending):{ "status": "pending", "deposit_id": "…", "uploaded_by_type": "client", "note": "…" }— deferred: the slip is recorded and the deposit stayspendinguntil the slip-escalation sweep promotes it tochecking.200(deposit isexpired— rescue):{ "status": "checking", "deposit_id": "…", "uploaded_by_type": "…", "note": "…" }— synchronous: the late slip re-opens the expired deposit — it flips straight tocheckingand a slip verification is queued immediately. An operator still owns the final approval (this never credits your wallet by itself).
- Errors:
401 missing_x_client_id/invalid_client·400 missing_slip_image_url/invalid_uploader_type/missing_uploader_identity· idempotency400/409·403 FORBIDDEN_CROSS_CLIENT(not your deposit) ·404 deposit_not_found·409 deposit_not_pending_or_missing(a non-pending, non-expiredterminal — paid/rejected/cancelled/failed) ·409 deposit_not_expired(the expired-rescue lost a race — the deposit leftexpiredbetween lookup and flip; re-fetch its status).
Expired-deposit rescue: branch on the status code, not just the body —
202= deferredpending,200= the deposit wasexpiredand is nowchecking. Poll deposit status afterwards; the operator's approval is what finalizes it.
Note: this endpoint takes a pre-hosted URL (
slip_image_url), not a multipart file upload. Host the image yourself first and pass its URL.
Render Deposit QR (PNG)
GET {BASE_URL}/deposits-qr/<deposit_id>?size=N
- Auth: none — any caller with the deposit UUID gets the PNG. Treat the UUID as a capability/secret (the QR embeds the PromptPay proxy + amount).
- Request: path
deposit_id;size(opt, clamped 64–2048, default 512). - Response
200:image/pngbytes; headersX-Deposit-Id,X-Qr-Type,X-Deposit-Request-Id,Cache-Control: public, max-age=300. - Errors:
400 missing_deposit_id_in_path·404 deposit_not_found/no_qr_for_deposit(no QR payload).
curl "${BASE_URL}/deposits-qr/DEPOSIT_UUID?size=512" --output deposit-qr.png
Prefer rendering the
qrcodeEMV payload from the create response yourself; this endpoint is a convenience for a ready-made PNG.
Cancel a Deposit (API-key self-serve)
POST {BASE_URL}/client-deposit-cancel — cancel your own still-pending, slip-less
deposit with your API key (idempotent re-cancel). Full request/response/errors in
status.md.
Once a slip is attached or the deposit reaches any non-
pendingstate, self-cancel is rejected (409). A successful cancel is also announced by adeposit.cancelledcallback — the same event you receive when an operator or (on the P2P rail) the payer cancels. The200and the callback say the same thing, so you can close the order on whichever arrives first and treat the second as a duplicate. A re-cancel returns the same200and sends no second callback.
Payout API
The disbursement lane. You create a payout, which places a hold on (freezes) amount + fee
in your wallet; a bank bot claims and executes it; you receive a webhook when
it reaches a terminal state. The debit is finalized only on success — on failed or
cancelled the hold is released back to your wallet.
See INDEX for auth, signing, and idempotency.
⚠️ This endpoint is IP-gated. It only accepts calls from the static source IPs registered against your client — an unregistered address gets
403 IP_NOT_ALLOWEDbefore the request is processed, and the gate is fail-closed (an empty allowlist blocks everything). Register every address you can egress from, including failover and DR, before you go live. See Static egress IP.
Create Payout
POST {BASE_URL}/v2/payouts-create (scope payout)
- Auth: API key + HMAC (
X-Client-Id+X-Signature) — see INDEX. - Idempotency: required
Idempotency-Key.
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
amount | number | ✅ | THB withdrawal amount. You must have sufficient available balance (balance − frozen) to cover amount + fee. |
dest_bank_code | string | ✅ | Destination bank code (e.g. KBANK, SCB). |
dest_account_number | string | ✅ | Destination account number. |
request_id | string | ✅ | Your own reference id for this payout (e.g. your withdrawal id) — returned in the response and echoed in callbacks so you can reconcile on your side. Must be unique per payout. |
dest_bank_name | string | — | Defaults to dest_bank_code. |
dest_account_name | string | — | Destination account holder name. |
client_reference_id | string | — | Extra client-side reference. |
callback_endpoint_key | string | — | Preconfigured callback endpoint (default "default"). |
metadata | object | — | ≤ 30 keys & ≤ 8192 bytes. Echoed back verbatim in payout callbacks. |
callback_urlis forbidden (400 callback_url_not_allowed) — the callback target is preconfigured; reference it bycallback_endpoint_key.
🔴 On the peer-to-peer rail the amount must be a multiple of 50, and at least 100 — the same band as deposits. You do not choose the rail, so an amount that cannot go to P2P is refused at create with
400 P2P_AMOUNT_NOT_ALLOWEDrather than rerouted — not retryable as sent, change the amount. Therequest_idis spent and nothing was reserved.
Routing — nothing for you to send
You do not choose which of our bank accounts pays your withdrawal, and there is no field for it. We resolve the route from the configuration agreed at onboarding and select the bank ourselves. This is deliberate: our bank rail changes as accounts are added, rotated or taken out of service, and pinning a route in your integration would break the moment it did.
If your account is not yet configured for withdrawals you get
400 no_pool_configured — that is a setup gap on our side, not something you can fix by
changing the request. Contact your account manager.
cURL
# build X-Signature first (see INDEX):
# v1 = HMAC-SHA256(secret, "<t>.POST\n/v2/payouts-create\n<rawBody>")
curl -X POST "${BASE_URL}/v2/payouts-create" \
-H 'Content-Type: application/json' \
-H 'X-Client-Id: pk_your_api_key' \
-H 'X-Signature: t=1709123456789,v1=def456…' \
-H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440002' \
-d '{
"amount": 5000,
"dest_bank_code": "SCB",
"dest_account_number": "9876543210",
"dest_account_name": "Jane Doe",
"request_id": "WD-98765",
"callback_endpoint_key": "default"
}'
Response 200
{
"payout": {
"id": "…uuid…",
"payout_id": "…uuid…",
"request_id": "WD-98765",
"amount": 5000,
"fee": 75,
"payout_fee": 75,
"dest_bank_code": "SCB",
"dest_account_number": "9876543210",
"status": "pending"
}
}
-
The payout's own id is
idand the fee isfee(also returned aspayout_id/payout_feefor back-compat — new integrations should bind toid/fee). Persistid— it is the id you pass to the status-poll and get-by-id endpoints. -
amountis the sum sent to the destination. On create,amount + feeis frozen in your wallet — your available balance drops by that much, but the ledgerbalanceis unchanged. The debit is finalized onsuccess; onfailed/cancelledthe hold is released back to your wallet. -
statusis"pending"at create. -
🔴 On the peer-to-peer rail the response gains TWO keys, and one of them is a link you must hand to your customer. Every field above keeps its meaning on both rails — the destination is your customer's own bank account either way — but a payout routed to P2P also carries:
key redirect_url🔴 the page your WITHDRAWING customer opens. Same key, same job, as on the deposit side. Without it that customer has no way to proceed. p2p_statuswhere the order sits in the matching network — read it verbatim, never against a list of your own. See deposit. 🔴
redirect_urlis PRESENT-BUT-nullon this rail, not absent. Read the VALUE; do not test whether the key is there. Anulldoes not mean the create failed — the withdrawal is already live — it means there is no page to show yet. On a bank payout both keys are ABSENT entirely. ⚠️ The public poll does not giveredirect_urlback.GET {BASE_URL}/client-payout-status/<id>returns{ txnId, status, amount, … }and does not return it. If you lose the200, fetch the payout by id instead:GET {BASE_URL}/client-payouts/<id>, with your API key, returns the same link. See status.md.amount + feeis frozen at create, and settlement is reported through the samepayout.success/payout.failed/payout.cancelledcallbacks on either rail.⚠️ One qualification, and it is on the ERROR side only. The sentence above is about the success response — that shape really is rail-independent. The refusal set is not quite: if the rail is short of float, the code you get names which pool was short —
INSUFFICIENT_P2P_FUNDSrather thanINSUFFICIENT_FUNDS(both402). You do not have to tell them apart to integrate correctly — treating any402as "retry later or top up" is the correct handling for both — but if you branch oncode, make sure your402handling is keyed on the status or covers both codes, rather than onINSUFFICIENT_FUNDSalone. See Errors. -
The response does not echo
dest_account_name(you supply it on create, but it is not returned here).
Errors
- Auth/signing: same family as deposit —
401 missing_credentials/invalid_signature_format/signature_timestamp_skew/invalid_client/invalid_signature;401 wrong_scope. The assertion401 { code }set (missing_assertion/expired/request_hash_mismatch/…) is gateway-internal — the edge (cf-worker) mints the assertion for you, so you should not normally see it. - Idempotency:
400/409set (see INDEX). - Validation:
400 callback_url_not_allowed(CALLBACK_URL_NOT_ALLOWED) ·400 missing_fields·400 no_pool_configured(routing not set up on our side) ·400 metadata_invalid/metadata_too_large(bothcode: METADATA_TOO_LARGE). - Business:
400 UNSUPPORTED_DEST_BANK·400 AMOUNT_OUT_OF_RANGE·402 INSUFFICIENT_FUNDS·402 INSUFFICIENT_P2P_FUNDS(the peer-to-peer pool behind this payout is short — your payment wallet may well be full, which is exactly why this is a separate code; likeINSUFFICIENT_FUNDSit is a recoverable funding state, not a bad request, and nothing is created or held. Reachable only on the P2P rail) ·403 CLIENT_DISABLED(your account, not this request, has been disabled by an operator — see deposit.md for the full note; same code/status on both flows) ·403 PAYOUT_DISABLED(payouts are not enabled for your account — the payout-side mirror of deposit.md'sDEPOSIT_DISABLED_FOR_CLIENT; contact your account manager) ·403 BLACKLISTED_DESTINATION_ACCOUNT(the destination account is on the gateway blocklist and may not receive payouts — a hard fail before any hold/debit; nothing is created) ·403 POOL_NOT_OWNED/403 BANK_ACCOUNT_NOT_OWNED(thepool_id/required_bank_account_idyou supplied is not one your account owns) ·403 TERMS_NOT_ACCEPTED(your account has not accepted the current Terms & Conditions) ·403 PAYOUT_DAILY_CAP_EXCEEDED(your account's per-day payout amount cap would be exceeded — clears at the next BKK-day rollover, no action needed on your end).
403 PAYOUT_DISABLEDstatus change (2026-08-20): this code previously returned400here (an implementation gap — itscodewas already stable, only the status was wrong). It is now403, consistent with the deposit-side mirrorDEPOSIT_DISABLED_FOR_CLIENT, which has always been403. If you branch on status for this rejection, update that branch; if you branch oncode(PAYOUT_DISABLED, unchanged), you are unaffected — this is the general convention, see Conventions.
- Routing:
400 no_pool_configured— your account has no withdrawal route configured on our side. Nothing in the request can fix it; contact your account manager. - Callback endpoint:
409 callback_endpoint_not_configured(CALLBACK_ENDPOINT_NOT_CONFIGURED) /400 invalid_callback_endpoint_key(INVALID_CALLBACK_ENDPOINT_KEY) —callback_endpoint_keydoes not resolve to a configured, active payout endpoint for your client.
Payout lifecycle (statuses you'll see in webhooks)
| Status | Meaning |
|---|---|
pending | Created, awaiting claim. |
processing | Claimed; payout in flight. |
success | Paid out (→ payout.success webhook). |
failed | Execution failed (→ payout.failed). |
review | Held for operator review — no callback; poll for the terminal outcome. |
cancelled | Cancelled by operator (→ payout.cancelled). |
Status / get-by-id / list
Poll a payout's status (GET {BASE_URL}/client-payout-status/<id>, public), fetch one by id
(GET {BASE_URL}/client-payouts/<id>, API key), or list your payouts with cursor pagination +
filters (GET {BASE_URL}/client-payouts, API key). Full contracts in
status.md. Webhooks remain the lowest-latency signal — see
callbacks.md.
Status, Get-by-ID, List & Self-Cancel
The client-facing read/poll surface plus the merchant self-cancel for deposits. You can now poll a transaction by id, fetch one by id, list your own deposits/payouts with filters and cursor pagination, and cancel your own still-pending deposit — all with your API key (the two status-poll endpoints are public). See INDEX for auth and conventions.
Two auth styles here. The status-poll endpoints are public (the transaction UUID is the capability — same model as the QR endpoint). The get-by-id, list, wallet and cancel endpoints take your API key only (
X-Client-Id, no HMAC) and are scoped to your own rows — another tenant's id reads as404.
All reads return the resource's effective status with zero lag — e.g. a past-deadline,
slip-less pending deposit reads expired the instant its window elapses (no write-on-read).
Status values are the lowercase lifecycle statuses (see the lifecycle tables in
deposit.md /
payout.md); webhook payloads use
the UPPERCASE form of the same status.
Get Deposit Status (public poll)
GET {BASE_URL}/client-deposit-status/<deposit_id>
- Auth: none — the deposit UUID is the capability. Treat it as a secret.
- Request: path
deposit_id(UUID).
Response 200
{
"txnId": "…uuid…",
"status": "paid",
"amount": 1000,
"paidAmount": 1000,
"paidAt": "2024-01-01T12:05:00Z",
"expiresAt": "2024-01-01T12:10:00Z",
"channel": "TRANSFER",
"redirect_url": null
}
On channel: "P2P" the same body carries one extra key:
{
"txnId": "…uuid…",
"status": "pending",
"amount": 500,
"paidAmount": null,
"paidAt": null,
"expiresAt": "2024-01-01T12:30:00Z",
"channel": "P2P",
"redirect_url": null,
"transfer_expires_at": "2024-01-01T12:15:00Z"
}
paidAmountis the amount whenstatusispaid, otherwisenull.paidAt/expiresAtarenulluntil set.channelis the same value the create returned —"QR","TRANSFER","P2P"or"SLIP_VERIFY". The poll and the create are derived from one projection, so they cannot disagree about what kind of deposit this is.- On
channel: "SLIP_VERIFY"this poll is also a REOPEN path. While such a deposit is stillpendingit additionally returns the block that rail's payer screen needs —paymentBankCode,paymentPromptpayId,qrcode,qrType, pluspaymentAccountNumberandpaymentAccountNameasnull(same reason they arenullon the create: on that rail the customer pays on our page, not on yours). Once the deposit is paid, expired or cancelled those keys are absent and only the status fields remain.⚠️ On
QR,TRANSFERandP2Pnone of those keys are returned at all, in any status. This endpoint is unauthenticated — the deposit UUID is the whole capability — so it does not republish a destination bank account to whoever holds that id. Keep the values the create returned; re-polling will not give them back. redirect_urlis returned on every poll,nullincluded, so you never have to branch on whether a key exists. It is non-nullonchannel: "SLIP_VERIFY"and carries the same page URL the create returned.🔴 Polling never changes the link. It is a read: it will not mint a new page token and cannot invalidate one your customer already holds. That is what makes this a safe recovery path when you lose a
201— a timeout on your side, a crashed worker, a retry you never persisted. Without it, a customer on that rail would be left with no account number and no link. ⚠️ Onchannel: "P2P"it is deliberatelynullhere even though the create returned one: revealing the peer-to-peer page is a permission-gated, audited support action and an unauthenticated poll does not route around it. To recover a P2P deposit's link, use Get Deposit by ID with your API key.transfer_expires_at— onchannel: "P2P"only. The deadline by which your customer must complete their transfer to the peer they were matched with, ISO-8601. Render your countdown astransfer_expires_at − nowand nothing else.It is present on every P2P poll and
nulluntil a peer is matched and a transfer is instructed — a P2P deposit starts life in a pool with nobody to pay yet, so there is no transfer window at create time and the create response does not carry one. Poll until it appears. ⚠️ Re-read it on every poll; do not cache the first value you see. The deadline for one transfer instruction is fixed and is never shortened — but if that instruction lapses and your deposit goes back into the pool to be matched again, the next instruction carries its own, later deadline, and this field then reports that one. It can therefore move forward (never backward) whilestatusis stillpending. ⚠️ Do not confuse it withexpiresAt.expiresAtis your deposit's own lifetime.transfer_expires_atis the payer's window for one transfer, and it can be shorter or later thanexpiresAt. ⚠️ The key is absent — notnull— onQR,TRANSFERandSLIP_VERIFY. Those deposits have no transfer window, and anullthere would name a concept that does not apply to them. The set of keys depends only on the channel, which is fixed when the deposit is created, so one deposit answers one shape for its whole life.
Errors
405 method_not_allowed(non-GET) ·400 missing_deposit_id_in_path(no id in path) ·404 deposit_not_found(unknown id) ·500 poll_failed.
curl "${BASE_URL}/client-deposit-status/DEPOSIT_UUID"
Get Payout Status (public poll)
GET {BASE_URL}/client-payout-status/<payout_id>
- Auth: none — the payout UUID is the capability.
- Request: path
payout_id(UUID).
Response 200
{
"txnId": "…uuid…",
"status": "success",
"amount": 5000,
"paidAmount": 5000,
"requestedAmount": 5000,
"failureReason": null,
"bankTransactionId": "…"
}
failureReasonis set only on afailedpayout;bankTransactionIdis set once the bank leg carries one (otherwisenull).amount,paidAmount,requestedAmount: see Payout amounts. Apartialpayout also carriessettledAmount(=paidAmount) andreleasedAmount(requestedAmount − paidAmount, back in your balance).
Payout amounts: amount, paidAmount, requestedAmount
Every payout read (this poll, get-by-id, the list and GET /transaction/:id) carries all three,
and they follow the same rule deposits do:
status | amount | paidAmount | requestedAmount |
|---|---|---|---|
pending / processing / review | the amount you asked for | null | the amount you asked for |
success | what was paid (= the amount you asked for) | what was paid | the amount you asked for |
partial | what was paid — less than you asked for | what was paid | the amount you asked for |
failed / cancelled | the amount you asked for | null | the amount you asked for |
amount on a partial payout is the same number as amount on its payout.closed callback.
A failed or cancelled payout keeps the amount you asked for in amount (never 0), exactly as a
failed or expired deposit does; paidAmount: null is what says nothing was paid.
Errors
405 method_not_allowed·400 missing_payout_id_in_path·404 payout_not_found·500 poll_failed.
curl "${BASE_URL}/client-payout-status/PAYOUT_UUID"
Get Deposit by ID (API-key, own)
GET {BASE_URL}/client-deposits/<deposit_id>
- Auth: API key only —
X-Client-Id: <api_key>. No HMAC. - Scope: your own rows only. An unknown id or another tenant's id →
404.
Response 200
{
"txnId": "…uuid…",
"requestId": "ORDER-12345",
"status": "paid",
"amount": 1000,
"fee": 15,
"netAmount": 985,
"method": "qr",
"paidAmount": 1000,
"paidAt": "2024-01-01T12:05:00Z",
"expiresAt": "2024-01-01T12:10:00Z",
"expiredAt": null,
"cancelledAt": null,
"failedAt": null,
"failureCode": null,
"failureReason": null,
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:05:00Z"
}
On a P2P deposit the same body carries two more keys:
{
"txnId": "…uuid…",
"…": "…",
"p2p_status": "locked",
"redirect_url": "https://<hub page host>/p/<page token>"
}
redirect_urlis the recovery path for your customer's page. It is the same link the create returned, read back rather than re-issued, so a timeout or a crashed worker on your side never strands a P2P customer. Reading it does not change or invalidate the link.It is present on every P2P deposit, and
nullwhen we hold no copy of the page (usually just after create; retry shortly). It stays set after the deposit ispaid,cancelledorexpired. ⚠️ The key is absent, notnull, onQR,TRANSFERandSLIP_VERIFY. For aSLIP_VERIFYdeposit, the status poll returns its page. 🔴 It is a bearer link. Whoever holds it can open the page and cancel the deposit. Give it only to the customer who made the deposit.p2p_statusis where the order sits in the matching network. Read it verbatim; see deposit.md.
🔴
methodis not the channel, and it tells you nothing. It is stored as"qr"on every deposit — including ones whose channel wasTRANSFER,P2PorSLIP_VERIFY— because you do not choose a method and the server records QR-eligibility rather than a request. The field is a constant; do not read anything into it. The channel is thechannelfield of the create response ("QR"·"TRANSFER"·"P2P"·"SLIP_VERIFY") — branch on that, never onmethod.
Errors
401 missing_x_client_id/invalid_client·405 method_not_allowed·404 deposit_not_found(unknown or cross-tenant) ·500 read_failed.
curl "${BASE_URL}/client-deposits/DEPOSIT_UUID" -H 'X-Client-Id: pk_your_api_key'
Get Payout by ID (API-key, own)
GET {BASE_URL}/client-payouts/<payout_id>
- Auth: API key only —
X-Client-Id: <api_key>. No HMAC. - Scope: your own rows only. Unknown or cross-tenant →
404.
Response 200
{
"txnId": "…uuid…",
"requestId": "WD-98765",
"status": "success",
"amount": 5000,
"paidAmount": 5000,
"requestedAmount": 5000,
"fee": 75,
"netAmount": 5075,
"destBankCode": "SCB",
"destBankName": "Siam Commercial Bank",
"destAccountNumber": "9876543210",
"destAccountName": "Jane Doe",
"bankTransactionId": "…",
"failureCode": null,
"failureReason": null,
"completedAt": "2024-01-01T14:30:00Z",
"createdAt": "2024-01-01T14:00:00Z",
"redirect_url": null
}
-
redirect_urlis the page your withdrawing customer opens on a P2P payout: the same link the create returned, read back so that a lost200does not strand that customer. It is present on every payout andnullon a bank payout, or while we hold no copy of the page yet. 🔴 It is a bearer link; give it only to that customer. -
bankTransactionIdnames the bank transfer that paid the payout, and isnullwhen there is none. On a P2P payout whose remainder was filled over the bank rail, it is that bank transfer's reference: the same value the payout'spayout.successcallback carried. The list,GET /transaction/:idand the status poll return the same value. -
For a payout,
netAmountis the total debited from your wallet: the amount paid plusfee(paidAmount + fee). The payout fee is charged on top: a ฿500 payout with a ฿5 fee freezes ฿505 at create, the recipient receives ฿500 (amount), and your wallet is debited ฿505 (netAmount). On apartialpayout both parts follow what was actually paid: a ฿500 payout of which ฿499 was paid, at a 1% fee, readsamount499,fee4.99 andnetAmount503.99, and the unpaid remainder plus its share of the fee returns to your balance. While a payout is still open,netAmountis theamount + feecurrently frozen. On afailedorcancelledpayout nothing was debited, sonetAmountis0and the freeze returns to your balance; thefeeshown on such a payout was not charged. Reconcile your wallet debit againstnetAmount, and the sum your recipient received againstamount. (On a deposit,netAmountis stillamount − fee, the amount credited to you: both mean how much your wallet moved.)
Errors
401 missing_x_client_id/invalid_client·405 method_not_allowed·404 payout_not_found(unknown or cross-tenant) ·500 read_failed.
curl "${BASE_URL}/client-payouts/PAYOUT_UUID" -H 'X-Client-Id: pk_your_api_key'
Get a Transaction by ID (unified) (API-key, own)
GET {BASE_URL}/transaction/<txnId>
One lookup that returns either a deposit or a payout by its gateway transaction id — so an
integrator that stored only the txnId can fetch the record without knowing which kind it is.
- Auth: API key only —
X-Client-Id: <api_key>. No HMAC. - Scope: your own rows only. An unknown id or another tenant's id →
404(never403— a cross-tenant id is simply unreachable, indistinguishable from an unknown one). txnIdis the gateway transaction UUID (thetxnIdreturned on create / status / list), not your ownrequestIdorder id.- Resolution is deposit-first: the id is looked up as a deposit, then (only if no deposit matched) as a payout. The two id-spaces are independent random UUIDs, so a collision is astronomically improbable; deposit-first is the deterministic tie-break.
- The response is the same body as the matching Get Deposit by ID / Get Payout by ID
above, with a leading
typediscriminator you branch on.
Response 200 — a deposit (type: "deposit")
{
"type": "deposit",
"txnId": "…uuid…",
"requestId": "ORDER-12345",
"status": "paid",
"amount": 1000,
"fee": 15,
"netAmount": 985,
"method": "qr",
"paidAmount": 1000,
"paidAt": "2024-01-01T12:05:00Z",
"expiresAt": "2024-01-01T12:10:00Z",
"expiredAt": null,
"cancelledAt": null,
"failedAt": null,
"failureCode": null,
"failureReason": null,
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:05:00Z"
}
Response 200 — a payout (type: "payout")
{
"type": "payout",
"txnId": "…uuid…",
"requestId": "WD-98765",
"status": "success",
"amount": 5000,
"paidAmount": 5000,
"requestedAmount": 5000,
"fee": 75,
"netAmount": 5075,
"destBankCode": "SCB",
"destBankName": "Siam Commercial Bank",
"destAccountNumber": "9876543210",
"destAccountName": "Jane Doe",
"bankTransactionId": "…",
"failureCode": null,
"failureReason": null,
"completedAt": "2024-01-01T14:30:00Z",
"createdAt": "2024-01-01T14:00:00Z"
}
Errors
401 missing_x_client_id/invalid_client·405 method_not_allowed·400 missing_txn_id_in_path(no id in the path) ·404 transaction_not_found(unknown or cross-tenant) ·500 read_failed.
curl "${BASE_URL}/transaction/TXN_UUID" -H 'X-Client-Id: pk_your_api_key'
List Deposits / Payouts (API-key, own)
GET {BASE_URL}/client-deposits[?<filters>]
GET {BASE_URL}/client-payouts[?<filters>]
- Auth: API key only —
X-Client-Id: <api_key>. No HMAC. - Scope: your own rows only. Filters can only narrow within your rows — they never widen beyond your tenant.
- Order: newest first (
createdAtdescending, then id).
Query filters (all optional)
| Param | Type | Effect |
|---|---|---|
status | string | Exact match on effective status (e.g. pending, paid, expired). |
dateFrom | ISO-8601 | createdAt >= dateFrom. |
dateTo | ISO-8601 | createdAt <= dateTo. |
merchantId | uuid | Narrow to rows under the given merchant. |
transactionId | string | Exact match on your requestId. |
amount | number | Exact amount. On payouts this matches the amount you asked for (requestedAmount). |
amountMin | number | amount >= amountMin (payouts: requestedAmount). |
amountMax | number | amount <= amountMax (payouts: requestedAmount). |
cursor | string | Opaque keyset cursor — pass back the prior page's nextCursor. |
limit | int | Page size. Default 50, clamped to 1–200. |
Response 200
{
"data": [ { "txnId": "…", "requestId": "…", "status": "paid", "amount": 1000, "…": "…" } ],
"nextCursor": "MjAyNC0wMS0wMVQxMjowMDowMFp8…",
"count": 50
}
- Each
dataelement has the same shape as the matching get-by-id response above. nextCursoris an opaque string — present only when a full page was returned (more rows may follow). It isnullon the last page. Pass it back ascursorto fetch the next page; do not parse or construct it yourself.countis the number of rows in this page.
Errors
401 missing_x_client_id/invalid_client·405 method_not_allowed·400 invalid_filter(a non-numericamount*, an unparseabledate*, a non-numericlimit, or a malformedmerchantId— not a valid UUID) ·400 invalid_cursor(a malformedcursor) ·500 list_failed.
# first page, paid only, page size 25
curl "${BASE_URL}/client-deposits?status=paid&limit=25" -H 'X-Client-Id: pk_your_api_key'
# next page
curl "${BASE_URL}/client-deposits?status=paid&limit=25&cursor=MjAyNC0wMS0wMVQ…" \
-H 'X-Client-Id: pk_your_api_key'
Cancel a Deposit (API-key, self-serve)
POST {BASE_URL}/client-deposit-cancel
Cancel your own still-pending, slip-less deposit with your API key.
- Auth: API key only —
X-Client-Id: <api_key>. No HMAC. - Body:
{ "deposit_id": "<uuid>" }. - Cancellable only while the deposit is
pendingand has no uploaded slip. Once a slip is attached or the deposit reaches any other state, cancel is rejected (409).
Response 200
{ "cancelled": true, "deposit_id": "…uuid…", "status": "cancelled" }
Idempotent. Re-cancelling an already-cancelled deposit returns the same
200(no error, no duplicate side-effect).
A callback follows. A fresh cancel also sends one
deposit.cancelledcallback to your deposit endpoint — the same event an operator's or (P2P rail) the payer's cancel sends. A re-cancel sends nothing more.
Errors
| HTTP | error / code | When |
|---|---|---|
401 | missing_x_client_id / invalid_client | Missing/unknown API key. |
400 | missing_deposit_id | Body has no deposit_id. |
403 | forbidden (cross_tenant_access_denied) | The deposit belongs to another tenant. |
404 | deposit_not_found | Unknown deposit id. |
409 | deposit_not_cancellable (NOT_PENDING) | Deposit is no longer pending (the current status is echoed). |
409 | deposit_not_cancellable (SLIP_PRESENT) | A payment slip is already attached. |
405 | method_not_allowed | Non-POST. |
curl -X POST "${BASE_URL}/client-deposit-cancel" \
-H 'Content-Type: application/json' \
-H 'X-Client-Id: pk_your_api_key' \
-d '{ "deposit_id": "DEPOSIT_UUID" }'
Wallet Balance & Bank Codes
Two API-key reads: your wallet balance, and the bank-code catalogue you validate
customer_bank_bank_code (deposit) and dest_bank_code (payout) against. Both take your
API key only (X-Client-Id, no HMAC). See INDEX for auth and conventions.
Check Wallet Balance
GET {BASE_URL}/client-wallet-balance
- Auth: API key only —
X-Client-Id: <api_key>. No HMAC. - Scope: your own wallet — both money pools (see below).
You may hold money in two separate pools, and this endpoint reports both, separately:
payment— your ordinary wallet. Deposits credit it; bank payouts are paid from it.p2p— your P2P pool. P2P withdrawals are paid from this pool, not frompayment.
The two pools are never added together. They are backed by different assets, so there is no combined total in this response and you should not compute one — to know whether a given withdrawal can go through, read the pool that withdrawal is paid from.
Response 200
{
"clientId": "…uuid…",
"name": "Acme Co",
"balance": 150000,
"available": 120000,
"frozen": 30000,
"updatedAt": "2024-01-01T12:00:00Z",
"pools": {
"payment": {
"balance": 150000,
"available": 120000,
"frozen": 30000,
"updatedAt": "2024-01-01T12:00:00Z"
},
"p2p": {
"balance": 19960.5,
"available": 19960.5,
"frozen": 0,
"updatedAt": "2024-01-01T09:30:00Z"
}
}
}
Every amount is a JSON number, never a string, and carries no fixed scale: a balance of
150,000.00 baht is on the wire as 150000, not 150000.00. Satang appear only when they are
non-zero (19960.5). Parse these as decimals, not by string-matching two decimal places.
| Field | Meaning |
|---|---|
balance | Total payment-pool balance (THB). |
frozen | Amount of the payment pool held against in-flight payouts. |
available | Spendable payment balance = balance − frozen. A payout is rejected 402 INSUFFICIENT_FUNDS when it exceeds this. |
updatedAt | Timestamp of the most recent payment-pool balance change. |
pools.payment | The same four figures, named. Identical to the four top-level fields above. |
pools.p2p | The same four figures for your P2P pool — the pool your P2P withdrawals are paid from. null if you have no P2P pool. |
The four top-level fields have not changed, and are frozen for backward compatibility.
They were, and remain, the payment pool. pools is additive: an integration written
before pools existed keeps reading exactly what it read before. New integrations should read pools.payment and
pools.p2p rather than treating the top level as "the payment one, by convention".
"p2p": null means you have no P2P pool — it does not mean zero. Render it as "not
applicable", never as 0.00: a 0.00 would say you had P2P money and spent it. If you are
not on the P2P rail, this is the value you will always see.
Errors
401 missing_x_client_id/invalid_client·405 method_not_allowed·404 wallet_not_found·500 balance_read_failed.
404 wallet_not_found means no payment wallet is provisioned for your client. It is the
payment wallet alone that decides this: if you somehow hold a P2P pool and no payment wallet,
this endpoint still answers 404.
curl "${BASE_URL}/client-wallet-balance" -H 'X-Client-Id: pk_your_api_key'
List Bank Codes
GET {BASE_URL}/client-bank-codes
The supported bank registry the create paths validate against. Use it to populate / validate
customer_bank_bank_code (deposit) and dest_bank_code (payout) programmatically instead of a
hard-coded list.
- Auth: API key only —
X-Client-Id: <api_key>. No HMAC.
Response 200
{
"data": [
{ "code": "bbl", "name": "Bangkok Bank" },
{ "code": "kbank", "name": "Kasikornbank" },
{ "code": "scb", "name": "Siam Commercial Bank" }
]
}
- Each entry is the load-bearing
code/namepair only —codeis the value you send incustomer_bank_bank_code/dest_bank_code. Entries are ordered bycode. - Codes are returned lowercase (
bbl,kbank,scb). Input is case-insensitive — aKBANKyou send is normalized — but build any validation set you keep from the lowercase values returned here.
Errors
401 missing_x_client_id/invalid_client·405 method_not_allowed·500 bank_codes_read_failed.
curl "${BASE_URL}/client-bank-codes" -H 'X-Client-Id: pk_your_api_key'
A payout create still rejects an unsupported destination with
400 UNSUPPORTED_DEST_BANK— this endpoint lets you enumerate the valid codes up front rather than discovering them on failure.
Callbacks / Webhooks
When a deposit or payout reaches a terminal state, we POST a signed webhook to your preconfigured callback endpoint. This is the lowest-latency way to track transaction state; you can also poll or list status on demand (see status.md). Verify the signature and de-dup on the event id.
Your callback target is preconfigured (referenced by
callback_endpoint_keyon create) — you do not pass a rawcallback_url. The stored URL must be HTTPS on port 443.
Test your endpoint before real money moves.
POST {BASE_URL}/client-self-test-callback(Console session auth — the Bearer token from your 2FA login, same asclient-self-rotate-key) fires one real, signed callback at your already-configured endpoint for{ "flow": "deposit" | "payout" }and returns{ ok, url, http_status, latency_ms, response_excerpt, error_class }synchronously — no queue, no retry, nothing to poll for. The payload is shaped exactly like a real terminal event but is unmistakably a test:"test": true, aTEST--prefixedtxnId(and the matchingrequestId, same value, as on every real callback), and"statusRevision": 0(a value no real terminal event can ever carry — do not treat a missing/zerostatusRevisionas "act on it," see Terminal status can change). Rate-limited per flow (a short cooldown + a daily cap) — a429means try again inretry_after_s. If you haven't configured an endpoint for that flow yet, you get a cleanno_endpoint_configured, not a guess.
The request we send
- Method / URL:
POST <your preconfigured callback URL>(HTTPS only, port 443). - Headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Gateway-Callback/1.0 |
X-Event-Id | Stable per-callback id — unchanged across retries. De-dup on this. |
X-Signature | t=<unix_ms>,v1=<hex> where v1 = HMAC-SHA256(api_key_secret, "<t>.<rawBody>"). Re-signed per attempt (fresh t). |
X-Request-Id | Per-attempt trace id for this delivery — for support correlation / your logs. Not for de-dup (use X-Event-Id); it changes between retries. |
- Body: the event
payload(camelCase, ISO-8601…ZUTC times) plus an injectedtimestampfield equal to the signedt. Every terminal callback also carriesstatusRevision(integer,1-based, +1 per terminal callback for the sametxnId; if missing, treat as1) — see Terminal status can change.
requestId — your own reference, on every callback
Every callback body on this page carries requestId, and it carries exactly the same
value as txnId: the request_id you sent when you created the deposit or payout.
It is present on every event of both families — no exceptions, no conditions.
🔴 Which one to key on. txnId means two different things depending on which surface
answered you:
| Surface | txnId carries |
|---|---|
| a callback (this page) | your request_id |
GET /transaction/<id>, client-deposit-status, client-payout-status, client-deposits | our internal id |
So feeding a callback's txnId straight into GET /transaction/… returns a 404 that reads
as "transaction not found" when it actually means "wrong kind of id". The read endpoints
have always also returned requestId for your own reference; callbacks now do too.
⇒ Key on requestId. It means your reference on every surface, callbacks and reads
alike, so the mix-up above cannot happen.
⚠️ txnId still works and its value has not changed — requestId is an additional
key beside it, not a replacement for it today. 🔴 But txnId is being retired from the
callback body in a future version. The date is not set and you will be told well before it
moves. Move new work to requestId now; you do not have to rewrite what already reconciles
on txnId.
Verifying the signature
The callback is signed with the same api_key_secret you sign requests with (see
INDEX) — but 🔴 the canonical string is NOT the one you sign requests
with. A callback is us POSTing to your URL, so there is no path of ours to bind it to;
it is signed over "<t>.<rawBody>" and nothing else:
| direction | canonical string |
|---|---|
| your request → us | "<t>.<METHOD>\n<path>\n<rawBody>" |
| our callback → you | "<t>.<rawBody>" |
Verify with the second one. Building the request form here makes every callback fail verification, and the usual next step — rejecting the delivery — makes us retry it seven times before it dead-letters:
const crypto = require('crypto');
function verifyCallback(req, apiKeySecret) {
const raw = req.rawBody; // the EXACT received bytes
const m = /^t=(\d+),v1=([0-9a-f]+)$/i.exec(req.headers['x-signature'] || '');
if (!m) return false;
const [, t, v1] = m;
// recommended: reject stale callbacks (replay defense), ~5 min window
if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) return false;
const expected = crypto.createHmac('sha256', apiKeySecret)
.update(`${t}.${raw}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
Key rotation. A callback is always signed with your current
api_key_secret. If you have just rotated, verify against your current secret and keep your previous secret available as a fallback until the new one is fully rolled into your callback receiver — mirroring how we accept either key on your requests during the rotation overlap, so a callback firing during a rotation window still verifies.
Then de-dup on X-Event-Id (same id may arrive more than once — at-least-once
delivery) before acting on the event.
Expected response & retries
- Respond with any HTTP 2xx → we mark the callback
delivered. Return your2xxdirectly on the configured URL. - A 3xx redirect is NOT followed — we treat it as a failed attempt (recorded
callback_redirect_blocked) and it rides the same retry ladder. Do not answer a callback with a redirect; pointcallback_endpoint_keyat the final URL. - Non-2xx or timeout (30 s HTTP timeout) → we retry on a backoff ladder while attempts remain (up to 7 attempts); after the 7th failed attempt we dead-letter it (no further delivery).
- Each attempt is re-signed with a fresh
t; theX-Event-Idstays the same.
Note: make your endpoint idempotent and fast — return 2xx quickly and process asynchronously.
🔴 Answer
2xxto every event — including ones you do not handle. We add events over time (deposit.cancelledis the newest). A receiver that answers an event name it does not recognise with4xx/5xxgets that delivery retried up to the 7th attempt and then dead-lettered, exactly like a real failure. Acknowledge with2xxand ignore what you do not need.
Pre-send safety gate: before each send we re-validate the stored URL and skip the send
(no delivery; callback_endpoint_unsafe) if it is not HTTPS, has a fragment/credentials,
uses a non-443 port, or its host is a literal localhost/private/loopback address
(the check inspects the literal host, not a DNS lookup). A skipped-as-unsafe send still
counts as a failed attempt — it consumes retry budget and will dead-letter after the
7th, exactly like a non-2xx. Keep your stored callback URL a valid public HTTPS:443 endpoint.
Event taxonomy & payloads
Deposit events
// deposit.paid
{ "event": "deposit.paid", "txnId": "DEP…", "requestId": "DEP…", "amount": 1000, "requestedAmount": 1000, "status": "PAID",
"paidAt": "2024-01-01T12:05:00Z", "statusRevision": 1, "metadata": { "orderId": "A-1001" }, "timestamp": 1709123456789 }
// deposit.rejected
{ "event": "deposit.rejected", "txnId": "DEP…", "requestId": "DEP…", "amount": 1000, "status": "REJECTED",
"failureCode": "…", "failureMessage": "…", "statusRevision": 1, "metadata": { "orderId": "A-1001" }, "timestamp": 1709123456789 }
// deposit.expired
{ "event": "deposit.expired", "txnId": "DEP…", "requestId": "DEP…", "amount": 1000, "status": "EXPIRED",
"statusRevision": 1, "metadata": { "orderId": "A-1001" }, "timestamp": 1709123456789 }
// deposit.cancelled
{ "event": "deposit.cancelled", "txnId": "DEP…", "requestId": "DEP…", "amount": 1000, "status": "CANCELLED",
"statusRevision": 1, "metadata": { "orderId": "A-1001" }, "timestamp": 1709123456789 }
For deposit.paid, amount is the gross amount actually paid before fees; requestedAmount is the original merchant-requested deposit amount. They are equal on a normal bank deposit. A partially settled P2P deposit can have amount < requestedAmount. The merchant callback does not expose acceptedGross, depositFee, or merchantNet.
deposit.cancelled is sent when a still-pending deposit is cancelled — whoever cancelled
it: you (POST /client-deposit-cancel), an operator, or, on the P2P rail, the payer or an
operator on the matching network. It is the same event every time and carries no field
saying who cancelled. Its body has exactly deposit.expired's shape: no cancel timestamp (read
cancelledAt from the status endpoints if you need it), and no failureCode —
with one exception:
-
failureCode: "operator_cancelled"— on the P2P rail, an operator on the matching network cancelled the deposit. This can happen at any stage, including after the payer was shown the destination account or uploaded a slip, so the payer may already have transferred. Nothing is credited to you. If your customer says they paid, treat it as a support case; the deposit itself will not become paid. -
Exactly one per deposit. Cancelling again returns the same
200and sends nothing more. -
Every API version, both rails (bank and P2P).
-
You receive it for your own cancel too. The
200fromclient-deposit-canceland this callback say the same thing — treat whichever arrives second as a duplicate. -
One case sends none: a P2P create refused with
400 P2P_AMOUNT_NOT_ALLOWEDor503 P2P_ROUTE_NOT_READY. That deposit is closed without a callback, because the create response already told you (see deposit.md).
Deposit refund events (opt-in — different payload shape)
If the operator has deposit refunds enabled for your account (enable_deposit_refund; off
by default), a refunded deposit additionally emits one of three refund-lifecycle events:
// deposit.refunded — refund settled (money returned to the payer)
{ "event": "deposit.refunded", "txnId": "DEP…", "requestId": "DEP…", "amount": 1000, "status": "REFUNDED",
"clientReferenceId": "your-ref", "timestamp": 1709123456789 }
// deposit.refund_failed — refund transfer failed
{ "event": "deposit.refund_failed", "txnId": "DEP…", "requestId": "DEP…", "amount": 1000, "status": "REFUND_FAILED",
"clientReferenceId": "your-ref", "timestamp": 1709123456789 }
// deposit.refund_pending_review — held for operator reconciliation
{ "event": "deposit.refund_pending_review", "txnId": "DEP…", "requestId": "DEP…", "amount": 1000,
"status": "REFUND_PENDING_REVIEW", "clientReferenceId": "your-ref", "timestamp": 1709123456789 }
⚠️ These three deviate from the general contract. Unlike the eight terminal events, refund events carry no
statusRevisionand nometadataecho. Do not try to order them bystatusRevision(there is none), and do not expect yourmetadataback on them. They do includeclientReferenceIdwhen the deposit had one. They are not part of the deposit lifecycle-status set — treat them as a separate refund-status stream.
Payout events
// payout.success
{ "event": "payout.success", "txnId": "PAY…", "requestId": "PAY…", "amount": 5000, "fee": 75, "status": "SUCCESS",
"completedAt": "2024-01-01T14:30:00Z", "bankTransactionId": "…", "statusRevision": 1, "metadata": { "orderId": "A-1001" }, "timestamp": 1709123456789 }
// payout.failed
{ "event": "payout.failed", "txnId": "PAY…", "requestId": "PAY…", "amount": 5000, "fee": 75, "status": "FAILED",
"failureCode": "…", "failureMessage": "…", "statusRevision": 1, "metadata": { "orderId": "A-1001" }, "timestamp": 1709123456789 }
// payout.cancelled (failureCode ∈ auto_cancelled | admin_cancelled | bank_maintenance | system_maintenance | bank_deactivated | client_cancelled | fill_cancelled | operator_cancelled)
{ "event": "payout.cancelled", "txnId": "PAY…", "requestId": "PAY…", "amount": 5000, "status": "CANCELLED",
"failureCode": "auto_cancelled", "statusRevision": 1, "metadata": { "orderId": "A-1001" }, "timestamp": 1709123456789 }
The payout terminal events — which one arrives, from the numbers alone
A payout closes against three buckets:
settled + released + held == requested
Which event you receive is decided by the shape of that close, never by the road the money
took. A payout that closes as a single transfer sends payout.success or payout.failed,
exactly as it always has — whether it was paid over a bank or peer-to-peer. Only a close with
more than one bucket in play sends payout.closed.
| How it closed | Arithmetic | Event | Buckets? |
|---|---|---|---|
| Settled in full | settled >= requested | payout.success | no |
| Released in full | settled == 0, held == 0, released >= requested | payout.failed | no |
| Paid in part | 0 < settled < requested, held == 0 | payout.closed, status: "PARTIAL" | yes |
| Not finished | held > 0 (whether or not part is already paid) | nothing yet | — |
🔴 One terminal callback per payout, sent only when it is final. While any part of a payout is
still held (a transfer under review, a fill still in flight) you receive nothing terminal for it.
When it ends you receive exactly one of payout.success (paid in full), payout.failed (nothing
paid) or payout.closed PARTIAL (part paid). The only later terminal event for the same payout is
a correction after a reversal, at a higher statusRevision (see below).
🔴 payout.closed carries status: "PARTIAL" and nothing else. It no longer arrives with
SUCCESS or FAILED — those are single-outcome closes and they arrive under their own names.
🔴 The buckets appear on payout.closed alone, together with channel. That is the test to
key on: if settled / released / held are present, reconcile from all three; if they are
absent, the payout closed as a single transfer and amount is the whole story. Do not key on a
rail name — there is none on the wire.
🔴 On payout.closed, amount is what the recipient was actually PAID; requested is what you
asked for. The same convention as deposit.paid (amount paid, requestedAmount asked). A
payout of 500 that paid 499 arrives as amount 499.00, requested 500.00. amount includes any
part a bank transfer paid; it never includes released (never paid). On this event amount
always equals settled — both are sent, and settled is unchanged.
Key on payout.closed | Meaning |
|---|---|
amount | paid to the recipient — equals settled |
requested | the amount you asked to pay out |
settled | paid out — final, does not move |
released | could not be paid — already back in your balance |
held | always 0.00 — a payout with money still held is not final and sends nothing yet |
The payout reads say the same thing: once a payout is partial or success, amount and
paidAmount on GET /client-payouts/<id>, the list, the status poll and GET /transaction/<id> are
this event's amount, and requestedAmount is this event's requested. See
status.md — Payout amounts.
// payout.closed — paid in part
{ "event": "payout.closed", "txnId": "WD-98765", "requestId": "WD-98765",
"amount": 3000.00, // what was paid — equals settled
"requested": 5000.00, // what you asked for
"settled": 3000.00, // paid out — final, does not move
"released": 2000.00, // could not be paid — already back in your balance
"held": 0.00, // always 0 — nothing terminal is sent while money is held
"status": "PARTIAL", // always PARTIAL on this event
"channel": "P2P",
"statusRevision": 4, "metadata": { }, "timestamp": 1709123456789 }
// payout.failed — published to the network, came back unpaid
{ "event": "payout.failed", "txnId": "WD-98765", "requestId": "WD-98765",
"amount": 5000.00, "fee": 75.00, "status": "FAILED",
"failureCode": "p2p_unfilled",
"failureMessage": "The payout was not taken up by any recipient before it expired.",
"statusRevision": 2, "timestamp": 1709123456789 }
The boundaries, stated as arithmetic
| Case | Numbers | Event |
|---|---|---|
| Settled exactly in full | settled == requested, released == 0, held == 0 | payout.success |
| Released exactly in full | released == requested, settled == 0, held == 0 | payout.failed |
| Short by one satang | settled == requested - 0.01, released == 0.01 | payout.closed, PARTIAL — amount == requested - 0.01 |
| Part paid, part still held | 0 < settled < requested, held > 0 | nothing yet — one event when the held part ends |
| Nothing paid, everything still reserved | settled == 0, held > 0 | nothing yet |
🔴 "Nearly full" is not full. A shortfall of one satang is a PARTIAL, not a SUCCESS. The
comparison is settled >= requested with no tolerance — do not round, and do not treat a small
remainder as a rounding artifact. It is money that did not move.
🔴 Held money is never reported as an outcome. While any part is held you hear nothing
terminal; when the held part is decided you hear the payout's one final event, computed from
the total actually paid — payout.success if it all arrived, payout.failed if none did,
otherwise payout.closed PARTIAL with amount = the total paid.
On every payout that closes against buckets, settled + released + held == requested. If that
identity does not hold, we are wrong, not you. The money fields are JSON numbers, not
strings — 5000.00, never "5000.00".
🔴 bankTransactionId — test for it, never assume it
bankTransactionId tells you a bank transfer happened. It is not an identifier every payout
has.
- A payout paid over a bank carries it.
- A payout settled entirely peer-to-peer never touched a bank, so there is no bank
transaction to name and the key is absent — not
null, not"". Absent. - A payout whose remainder was filled over the bank rail carries the bank leg's id, because a real bank transfer paid that remainder.
Write this test, today, before P2P reaches you:
const ref = payload.bankTransactionId;
if (ref) { /* a bank transfer happened; ref identifies it */ }
else { /* no bank leg, or the bank reported no reference — this is normal */ }
⚠️ Why this one deserves your attention above everything else on this page. The key has been
present on 100% of the payout.success events we have ever sent — no exceptions, across
119,590 of them as of 2026-09-20. So if you have integrated with us so far you have never
seen it missing, and code that requires it has never failed. It will start being absent the
first time a payout settles peer-to-peer, and that is the change this page is warning you about.
And presence has never meant you have a usable reference: 15,698 of those events carry an empty string, because the bank reported none. ⚠️ Read that as history, not as a rate you should expect — those are concentrated in 2026-08-01 → 2026-08-18 (99.9% of them), and in the 30 days to 2026-09-20 only 4 of 76,288 were empty. But they are in your history, and nothing guarantees the condition cannot recur.
⇒ Both facts point at the same check, and it is the one in the snippet above: test the value for
truthiness, never the key for presence. That is the only form correct on both rails — an empty
string from the bank rail and an absent key from the peer-to-peer rail both fall to the else.
The counts above are a measurement of our production traffic on the date shown, not a promise. They will drift. The 100% present and the shape of the check are what this contract commits to; the digits are only there so you can judge how much your existing code is relying on them.
payout.cancelled vs payout.failed — who decided
Both end with the money back in your balance. The difference is who stopped it:
-
payout.failed— it was published and came back unpaid. We tried and it did not happen. CarriesfailureCodeandfailureMessage.🔴 Branch on
failureCode, never onfailureMessage.failureCodeis a closed, reviewed vocabulary and is the field this contract keeps stable.failureMessageis the human-readable rendering of that code, it is sent in English, and its exact wording may be reworded without notice — treat it as text to show or log, not as a value to match on. -
payout.cancelled— it was stopped before it was ever paid. CarriesfailureCodealone; there is nofailureMessageand one will never arrive, so do not write a branch waiting for it.
On the peer-to-peer rail the line is exactly who decided: payout.cancelled with
failureCode: "client_cancelled" means your side cancelled it — you, or your customer from the
withdrawal page — before it was published, so nothing was paid and the whole amount and fee are back
in your balance. A P2P payout that we published and
that came back unpaid is payout.failed, with failureCode: "p2p_unfilled" — nobody took it
before it timed out.
operator_cancelled — Cancelled by an operator on the matching network. A P2P payout that an
operator cancelled before any of it was paid arrives as payout.cancelled with
failureCode: "operator_cancelled", and the whole amount and fee are back in your balance. If part
of it had already been paid over peer-to-peer when the operator cancelled it, it is not a cancel: you
receive payout.closed PARTIAL with the real buckets (settled = what was paid and debited,
released = what is back in your balance), exactly as any partial close, and the unpaid remainder is
not paid by bank transfer. If all of it had been paid, you receive payout.success.
fill_cancelled — Cancelled: the payment fill could not be completed. When a P2P payout
closes with an unpaid remainder, we may pay that remainder by bank transfer. If that bank transfer
is cancelled before it is attempted and nothing was paid over peer-to-peer, the payout
arrives as payout.cancelled with failureCode: "fill_cancelled" and your balance is untouched.
It is one code whatever stopped the transfer; there is no finer reason to key on. If part of the
payout WAS paid over peer-to-peer, you receive payout.closed PARTIAL with the real buckets
instead, and a bank transfer that was attempted and failed still ends payout.failed /
p2p_unfilled.
⚠️ p2p_unfilled can also arrive at statusRevision: 2, after a payout.success. That is a
payout whose unpaid remainder we paid by bank transfer, and whose paying transfer was then reversed
after review because the bank did not confirm it. Nothing was paid over the peer-to-peer rail, so the
payout ends payout.failed and the money is back in your balance. The failureCode is the same
p2p_unfilled; only the failureMessage wording differs. Key on failureCode and the highest
statusRevision, never on the text.
On the bank rail, payout.cancelled today also covers cases where the system stopped the
payout before paying it — the stale-payout sweep (auto_cancelled), an operator cancel
(admin_cancelled), a maintenance window (system_maintenance), or a deactivated destination
bank (bank_deactivated). Read failureCode if you need to tell those apart.
If two terminal events arrive for one payout
🔴 Order them by statusRevision — highest wins. That rule governs every terminal payout
event and it is the one to rely on.
On the peer-to-peer rail specifically: a terminal event MAY be corrected by a later terminal
event with a HIGHER statusRevision. This happens when the bank transfer that paid part or all
of the payout is reversed after review:
- a payout whose whole amount was paid by bank transfer, told
payout.success, is corrected bypayout.failed(failureCode: "p2p_unfilled"); - a payout paid partly over peer-to-peer and partly by bank transfer, told
payout.success, is corrected bypayout.closedPARTIALwith the real buckets; - a payout told
payout.closedPARTIALbecause a leg settled short (for exampleamountandsettled999,released1) is corrected by a secondpayout.closedPARTIALwith the corrected buckets and the correctedamount.
🔴 Always act on the highest statusRevision you have received for a payout, and key on
status and failureCode. Never key on failureMessage text, and never on the order in which
callbacks arrived.
What changes for you
Today a P2P payout that settles in full arrives as payout.closed with status: "SUCCESS". It
will arrive as payout.success, which is what this page has always said a single-transfer close
sends. If you followed this page, nothing changes for you. If you coded against the observed
behaviour and dispatch on payout.closed alone, add payout.success.
The one change that can affect a correct integration is bankTransactionId becoming absent
on a purely peer-to-peer payout. See the section above and add the presence test.
| Event | status | Terminal? |
|---|---|---|
deposit.paid | PAID | yes |
deposit.rejected | REJECTED | yes |
deposit.expired | EXPIRED | yes |
deposit.cancelled | CANCELLED | yes — final, never flips |
payout.success | SUCCESS | yes |
payout.failed | FAILED | yes |
payout.cancelled | CANCELLED | yes |
payout.closed | PARTIAL | yes — read the buckets, not the status alone |
The eight terminal events above each carry
statusRevision. A payout in plainreviewis callback-silent until it resolves — poll for its outcome.
Terminal status can change (flips)
A terminal status is not always final. The same txnId can reach one terminal state and
later flip to a different terminal state, or be corrected within the same one — so you may receive 2+ callbacks for one
txnId, usually with different statuses (a peer-to-peer payout.closed correction keeps
its status and changes its buckets, below), at different times:
- expired → paid: a deposit you saw as
deposit.expiredcan be rescued by a late payment slip and later arrive asdeposit.paid. - paid → rejected: a deposit you saw as
deposit.paidcan be reversed by an operator (a wrongly-credited slip clawed back) and later arrive asdeposit.rejectedwithfailureCode: "admin_unapprove"at a higherstatusRevision. This un-books money you already credited — it is the reversal a merchant most needs to defend against. - success → failed: a payout you saw as
payout.successcan later be corrected / reversed and arrive aspayout.failed. On the peer-to-peer rail this is how a payout arrives when the bank transfer that paid it is reversed:payout.failedwithfailureCode: "p2p_unfilled"if nothing was paid over peer-to-peer, orpayout.closedPARTIALif part was. - PARTIAL → a smaller PARTIAL: a peer-to-peer payout whose remainder was paid by a bank
transfer can arrive as
payout.closedPARTIAL(for exampleamount999,requested1000,settled999,released1), and if that bank transfer is later reversed, a secondpayout.closedarrives with the corrected numbers (amount499,settled499,released501) at a higherstatusRevision. Same event name, same status word, different numbers — it is a correction, not a retry: it has its ownX-Event-Id. Reconcile to the one with the higheststatusRevision.
A cancel never flips. A deposit that reached deposit.cancelled can never later become paid,
expired or rejected — no payment can be credited to it.
A flip is NOT a delivery retry. A retry re-sends the same event — same X-Event-Id, same
status — and must be de-duped. A flip is a new event: new X-Event-Id, a higher
statusRevision, and usually a different status. The peer-to-peer correction of a payout.closed
keeps the same status word and changes the buckets. Either way it is not a duplicate and must
not be de-duped away.
To order flips, every terminal callback carries statusRevision (integer, starts at 1,
+1 per terminal callback for that txnId). The highest statusRevision is the current
truth. If statusRevision is missing, treat it as 1.
De-dup vs ordering — both apply.
X-Event-Idde-dups retries of the same event.statusRevisionorders distinct terminal events for the sametxnId. Use both: discard repeatedX-Event-Ids, then among the survivors act on the higheststatusRevision.
⚠️ Money safety — always act on the LATEST terminal status. Do not treat the first terminal callback as final. A later flip can reverse an earlier outcome: a deposit you recorded as
EXPIREDmay later becomePAID(credit the order); a deposit you recorded asPAIDmay later becomeREJECTED(un-book the credit — an operator reversed it); a payout you booked asSUCCESSmay laterFAIL(un-book / claw back). Reconcile to the callback with the higheststatusRevisionfor thattxnId; ignore a callback whosestatusRevisionis lower than one you have already applied.
Flip pair — same txnId, two terminal callbacks over time (note the new X-Event-Id):
// 1) first terminal outcome — statusRevision 1 (X-Event-Id: evt_aaa)
{ "event": "payout.success", "txnId": "PAY-7F3A", "requestId": "PAY-7F3A", "amount": 5000, "fee": 75, "status": "SUCCESS",
"completedAt": "2024-01-01T14:30:00Z", "bankTransactionId": "…", "statusRevision": 1, "metadata": { "orderId": "A-1001" }, "timestamp": 1709123456789 }
// 2) later FLIP to failed — NEW event, statusRevision 2 → THIS one wins (X-Event-Id: evt_bbb)
{ "event": "payout.failed", "txnId": "PAY-7F3A", "requestId": "PAY-7F3A", "amount": 5000, "fee": 75, "status": "FAILED",
"failureCode": "admin_reverse_settle", "failureMessage": "…", "statusRevision": 2, "metadata": { "orderId": "A-1001" }, "timestamp": 1709209876543 }
Money-critical deposit flip — paid → rejected (a previously-PAID deposit reversed by an operator):
// 1) first terminal outcome — statusRevision 1 (X-Event-Id: evt_ccc)
{ "event": "deposit.paid", "txnId": "DEP-9B2C", "requestId": "DEP-9B2C", "amount": 1000, "requestedAmount": 1000, "status": "PAID",
"paidAt": "2024-01-01T12:05:00Z", "statusRevision": 1, "metadata": { "orderId": "A-1001" }, "timestamp": 1709123456789 }
// 2) later FLIP to rejected — NEW event, statusRevision 2 → THIS one wins; UN-BOOK the credit (X-Event-Id: evt_ddd)
{ "event": "deposit.rejected", "txnId": "DEP-9B2C", "requestId": "DEP-9B2C", "amount": 1000, "status": "REJECTED",
"failureCode": "admin_unapprove", "failureMessage": "…", "statusRevision": 2, "metadata": { "orderId": "A-1001" }, "timestamp": 1709209876543 }
Echoed metadata
Every terminal callback echoes back, verbatim, the metadata object you supplied when you created
the deposit/payout (subject to the per-rail caps documented in deposit.md /
payout.md). It is optional — present only if you sent metadata on create.
Use it to correlate the callback to your own order/record (alongside clientReferenceId).
Echoed clientReferenceId
Every callback payload also carries clientReferenceId — the client_reference_id you
supplied when creating the deposit/payout — whenever the transaction had one. Unlike
metadata, it is echoed on all callback event types (the eight terminal events and the
opt-in refund events above); it is simply absent when you did not send a client_reference_id
on create. Use it, alongside metadata, to correlate a callback back to your own record.