FlowX

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.

PlaceholderMeaning
{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-create and payouts-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):

ModeEndpointsHow you authenticate
API key + HMACdeposits-create, payouts-createX-Client-Id + X-Signature headers (below). The request signature is verified before the call is processed.
API key onlydeposits-upload-slip, client-deposits, client-payouts, transaction, client-wallet-balance, client-bank-codes, client-deposit-cancelX-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-statusNone — the resource UUID is the capability.
Console sessionclient-self-rotate-key, client-self-revoke-key, client-self-set-callback, client-self-test-callbackThe 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_secret is the once-shown secret — capture it now; it is never returned again. The previous key keeps working until retire_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
  • t is 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. Generate t right before you sign.
  • METHOD is the uppercase HTTP verb — POST on both create endpoints.
  • path is 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%2Dcreate must be signed in that spelling (and is rejected further downstream regardless — send the plain form).
  • \n is a single newline (0x0A), not the two characters \ and n.
  • rawBody is 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-create is 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:

HTTPcodeWhen
400IDEMPOTENCY_KEY_REQUIREDHeader missing on an endpoint that requires it.
400INVALID_JSONBody is not valid JSON.
409IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODYSame key, different body (a client bug — use a new key).
409IDEMPOTENCY_KEY_CONCURRENT_INFLIGHTThe 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 → 429 with a Retry-After header (seconds to wait) and a JSON body that always carries code (RATE_LIMIT_EXCEEDED), scope (deposit|payout) and retry_after_s. Branch on code and honor Retry-After. Additional fields — message and 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 adds message + the counters). Do not depend on message or 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). A 429 is safe to retry — reuse the same Idempotency-Key so 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 code is present, branch on it; never parse the error text. Three shapes:

    • Most validation / signature / auth failures → { "error": "<snake_token>" } (often with detail), e.g. { "error": "missing_route" }, { "error": "signature_timestamp_skew" }.
    • Recognized create-time business rejections → { "error": "<full raised message>", "code": "<UPPER_SNAKE>" } — here error holds the full message (e.g. "insufficient_funds: …"), not the bare token. Common code→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 on INSUFFICIENT_FUNDS alone), 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_DISABLED is 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 inside deposits-create/payouts-create itself, 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 on code, never on which shape error took — the code and the 403 status 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 no error key. Rate-limit rejections → always code + scope + retry_after_s (no error key); message and the per-window counters are optional (tier-dependent — see Rate limiting). See also Idempotency.
  • Wrong method → 404 or 405 depending on the route: via the edge cf-worker a wrong method is an unmatched route → 404; directly against an Edge Function it is 405 { "error": "method_not_allowed" }. Treat both as "wrong method / not routable."

  • Callback URL is preconfigured. You do not pass a raw callback_url on create — it is rejected (400 callback_url_not_allowed). Instead you reference a preconfigured endpoint by callback_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_ALLOWED before 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

FieldTypeReqNotes
amountnumber✅THB. Integer baht — any decimal is silently floored (100.99 → 100).
request_idstring✅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_numberstring✅Payer's bank account number. (Alias: expected_source_account_no.)
customer_bank_account_namestring✅Payer's account holder name.
customer_bank_bank_codestring✅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_namestring—Optional payer bank name.
promptpay_idstring—Optional PromptPay proxy.
client_reference_idstring—Optional extra client-side reference — echoed back as clientReferenceId on this deposit's callbacks (see callbacks.md).
callback_endpoint_keystring—Preconfigured callback endpoint (see below).
metadataobject—≤ 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 is 400 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, the request_id is 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:

channelkeyswhat is absent
QR14payment_account_number, payment_account_name
TRANSFER12qrcode, qr_type, promptpay_number, payment_promptpay_id
P2P11the whole bank/QR block (all six above plus payment_bank_code) — and it adds p2p_status and redirect_url
SLIP_VERIFY11the 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

⚠️ TRANSFER omits a real value, not just a null. If the bank we routed you to happens to have a PromptPay proxy, promptpay_number / payment_promptpay_id would 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_VERIFY keeps payment_bank_code and 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 to redirect_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> answers 404 no_qr_for_deposit for a deposit on this channel, because no payload is ever generated for it.

  • The deposit's own id is id (also returned as deposit_id for back-compat — new integrations should bind to id). Persist it — it is the id you pass to the QR, slip-upload, status-poll, get-by-id, and cancel endpoints.

  • channel is "QR", "TRANSFER", "P2P" or "SLIP_VERIFY" — chosen by the system, never by you. QR and TRANSFER are properties of the assigned bank; P2P means the deposit was routed to the peer-to-peer rail, which has no bank account at all; SLIP_VERIFY means 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 P2P deposit there is no bank block at all — those seven fields are absent. On a SLIP_VERIFY deposit payment_account_number and payment_account_name are absent (payment_bank_code is still returned, so you can still name the bank). In both cases you must send your customer to redirect_url. You cannot predict which shape a given request will get, so branch on channel rather than assuming a bank block is present.

  • redirect_url is the page to send your customer to — and it is not P2P-only. It is non-null in two situations, and your job is identical in both: open it for the customer.

    1. channel: "P2P" — the hosted page for the peer-to-peer rail (no bank block at all);
    2. 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_VERIFY there is no transfer screen for you to draw. payment_account_number and payment_account_name are 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 to redirect_url. 🔴 If you lose the 201, poll. GET /client-deposit-status/<id> now returns redirect_url too, 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. 🔴 On P2P, fetch the deposit by id instead. The public poll returns redirect_url: null on 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 P2P and SLIP_VERIFY, absent — not null — on QR and TRANSFER.

    Both situations answer 503 rather than returning a deposit without a usable link: P2P_ROUTE_NOT_READY and PAGE_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 P2P only. 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 deposit pending and redirect_url: null. The deposit exists, so do not create it again. Fetch GET /client-deposits/<id> with your API key until redirect_url is set or the deposit ends. The public poll will not give it to you on this channel.

  • qrcode is an EMVCo PromptPay payload string for client-side QR rendering (or null for a transfer channel). To get a rendered PNG, use the QR endpoint below.

  • qr_type is the PromptPay proxy type of the QR — one of "mobile", "nationalId", "taxId", "ewallet" (present only on a QR channel; null otherwise).

  • fee/feePercent/netAmount come from your MDR profile; netAmount is the net credited to your wallet at finalize. payment_account_* / payment_promptpay_id are 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 assertion 401 { 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 payer customer_bank_bank_code is 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 (see redirect_url above), and no page URL could be composed for it. Same reasoning as P2P_ROUTE_NOT_READY and 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 that request_id. Neither error carries a Retry-After header or a retry_after_s field, 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_EXCLUSION is 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)

StatusMeaning
pendingAwaiting payment / slip / statement match.
paidPayment confirmed (→ deposit.paid webhook).
rejectedRejected after review (→ deposit.rejected).
expiredWindow elapsed unpaid (→ deposit.expired).
cancelledCancelled — 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; body slip_image_url (req — a pre-hosted URL string, not a multipart file upload); optional uploader { 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 is pending): { "status": "pending", "deposit_id": "…", "uploaded_by_type": "client", "note": "…" } — deferred: the slip is recorded and the deposit stays pending until the slip-escalation sweep promotes it to checking.
    • 200 (deposit is expired — rescue): { "status": "checking", "deposit_id": "…", "uploaded_by_type": "…", "note": "…" } — synchronous: the late slip re-opens the expired deposit — it flips straight to checking and 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 · idempotency 400/409 · 403 FORBIDDEN_CROSS_CLIENT (not your deposit) · 404 deposit_not_found · 409 deposit_not_pending_or_missing (a non-pending, non-expired terminal — paid/rejected/cancelled/failed) · 409 deposit_not_expired (the expired-rescue lost a race — the deposit left expired between lookup and flip; re-fetch its status).

Expired-deposit rescue: branch on the status code, not just the body — 202 = deferred pending, 200 = the deposit was expired and is now checking. 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/png bytes; headers X-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 qrcode EMV 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-pending state, self-cancel is rejected (409). A successful cancel is also announced by a deposit.cancelled callback — the same event you receive when an operator or (on the P2P rail) the payer cancels. The 200 and 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 same 200 and 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_ALLOWED before 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

FieldTypeReqNotes
amountnumber✅THB withdrawal amount. You must have sufficient available balance (balance − frozen) to cover amount + fee.
dest_bank_codestring✅Destination bank code (e.g. KBANK, SCB).
dest_account_numberstring✅Destination account number.
request_idstring✅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_namestring—Defaults to dest_bank_code.
dest_account_namestring—Destination account holder name.
client_reference_idstring—Extra client-side reference.
callback_endpoint_keystring—Preconfigured callback endpoint (default "default").
metadataobject—≤ 30 keys & ≤ 8192 bytes. Echoed back verbatim in payout callbacks.

callback_url is forbidden (400 callback_url_not_allowed) — the callback target is preconfigured; reference it by callback_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_ALLOWED rather than rerouted — not retryable as sent, change the amount. The request_id is 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 id and the fee is fee (also returned as payout_id / payout_fee for back-compat — new integrations should bind to id/fee). Persist id — it is the id you pass to the status-poll and get-by-id endpoints.

  • amount is the sum sent to the destination. On create, amount + fee is frozen in your wallet — your available balance drops by that much, but the ledger balance is unchanged. The debit is finalized on success; on failed/cancelled the hold is released back to your wallet.

  • status is "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_url is PRESENT-BUT-null on this rail, not absent. Read the VALUE; do not test whether the key is there. A null does 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 give redirect_url back. GET {BASE_URL}/client-payout-status/<id> returns { txnId, status, amount, … } and does not return it. If you lose the 200, fetch the payout by id instead: GET {BASE_URL}/client-payouts/<id>, with your API key, returns the same link. See status.md.

    amount + fee is frozen at create, and settlement is reported through the same payout.success / payout.failed / payout.cancelled callbacks 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_FUNDS rather than INSUFFICIENT_FUNDS (both 402). You do not have to tell them apart to integrate correctly — treating any 402 as "retry later or top up" is the correct handling for both — but if you branch on code, make sure your 402 handling is keyed on the status or covers both codes, rather than on INSUFFICIENT_FUNDS alone. 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 assertion 401 { 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/409 set (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 (both code: 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; like INSUFFICIENT_FUNDS it 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's DEPOSIT_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 (the pool_id / required_bank_account_id you 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_DISABLED status change (2026-08-20): this code previously returned 400 here (an implementation gap — its code was already stable, only the status was wrong). It is now 403, consistent with the deposit-side mirror DEPOSIT_DISABLED_FOR_CLIENT, which has always been 403. If you branch on status for this rejection, update that branch; if you branch on code (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_key does not resolve to a configured, active payout endpoint for your client.

Payout lifecycle (statuses you'll see in webhooks)

StatusMeaning
pendingCreated, awaiting claim.
processingClaimed; payout in flight.
successPaid out (→ payout.success webhook).
failedExecution failed (→ payout.failed).
reviewHeld for operator review — no callback; poll for the terminal outcome.
cancelledCancelled 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 as 404.

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"
}
  • paidAmount is the amount when status is paid, otherwise null.
  • paidAt / expiresAt are null until set.
  • channel is 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 still pending it additionally returns the block that rail's payer screen needs — paymentBankCode, paymentPromptpayId, qrcode, qrType, plus paymentAccountNumber and paymentAccountName as null (same reason they are null on 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, TRANSFER and P2P none 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_url is returned on every poll, null included, so you never have to branch on whether a key exists. It is non-null on channel: "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. ⚠️ On channel: "P2P" it is deliberately null here 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 — on channel: "P2P" only. The deadline by which your customer must complete their transfer to the peer they were matched with, ISO-8601. Render your countdown as transfer_expires_at − now and nothing else.

    It is present on every P2P poll and null until 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) while status is still pending. ⚠️ Do not confuse it with expiresAt. expiresAt is your deposit's own lifetime. transfer_expires_at is the payer's window for one transfer, and it can be shorter or later than expiresAt. ⚠️ The key is absent — not null — on QR, TRANSFER and SLIP_VERIFY. Those deposits have no transfer window, and a null there 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": "…"
}
  • failureReason is set only on a failed payout; bankTransactionId is set once the bank leg carries one (otherwise null).
  • amount, paidAmount, requestedAmount: see Payout amounts. A partial payout also carries settledAmount (= paidAmount) and releasedAmount (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:

statusamountpaidAmountrequestedAmount
pending / processing / reviewthe amount you asked fornullthe amount you asked for
successwhat was paid (= the amount you asked for)what was paidthe amount you asked for
partialwhat was paid — less than you asked forwhat was paidthe amount you asked for
failed / cancelledthe amount you asked fornullthe 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_url is 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 null when we hold no copy of the page (usually just after create; retry shortly). It stays set after the deposit is paid, cancelled or expired. ⚠️ The key is absent, not null, on QR, TRANSFER and SLIP_VERIFY. For a SLIP_VERIFY deposit, 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_status is where the order sits in the matching network. Read it verbatim; see deposit.md.

🔴 method is not the channel, and it tells you nothing. It is stored as "qr" on every deposit — including ones whose channel was TRANSFER, P2P or SLIP_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 the channel field of the create response ("QR" · "TRANSFER" · "P2P" · "SLIP_VERIFY") — branch on that, never on method.

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_url is the page your withdrawing customer opens on a P2P payout: the same link the create returned, read back so that a lost 200 does not strand that customer. It is present on every payout and null on a bank payout, or while we hold no copy of the page yet. 🔴 It is a bearer link; give it only to that customer.

  • bankTransactionId names the bank transfer that paid the payout, and is null when 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's payout.success callback carried. The list, GET /transaction/:id and the status poll return the same value.

  • For a payout, netAmount is the total debited from your wallet: the amount paid plus fee (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 a partial payout both parts follow what was actually paid: a ฿500 payout of which ฿499 was paid, at a 1% fee, reads amount 499, fee 4.99 and netAmount 503.99, and the unpaid remainder plus its share of the fee returns to your balance. While a payout is still open, netAmount is the amount + fee currently frozen. On a failed or cancelled payout nothing was debited, so netAmount is 0 and the freeze returns to your balance; the fee shown on such a payout was not charged. Reconcile your wallet debit against netAmount, and the sum your recipient received against amount. (On a deposit, netAmount is still amount − 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 (never 403 — a cross-tenant id is simply unreachable, indistinguishable from an unknown one).
  • txnId is the gateway transaction UUID (the txnId returned on create / status / list), not your own requestId order 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 type discriminator 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 (createdAt descending, then id).

Query filters (all optional)

ParamTypeEffect
statusstringExact match on effective status (e.g. pending, paid, expired).
dateFromISO-8601createdAt >= dateFrom.
dateToISO-8601createdAt <= dateTo.
merchantIduuidNarrow to rows under the given merchant.
transactionIdstringExact match on your requestId.
amountnumberExact amount. On payouts this matches the amount you asked for (requestedAmount).
amountMinnumberamount >= amountMin (payouts: requestedAmount).
amountMaxnumberamount <= amountMax (payouts: requestedAmount).
cursorstringOpaque keyset cursor — pass back the prior page's nextCursor.
limitintPage size. Default 50, clamped to 1–200.

Response 200

{
  "data": [ { "txnId": "…", "requestId": "…", "status": "paid", "amount": 1000, "…": "…" } ],
  "nextCursor": "MjAyNC0wMS0wMVQxMjowMDowMFp8…",
  "count": 50
}
  • Each data element has the same shape as the matching get-by-id response above.
  • nextCursor is an opaque string — present only when a full page was returned (more rows may follow). It is null on the last page. Pass it back as cursor to fetch the next page; do not parse or construct it yourself.
  • count is the number of rows in this page.

Errors

  • 401 missing_x_client_id / invalid_client · 405 method_not_allowed · 400 invalid_filter (a non-numeric amount*, an unparseable date*, a non-numeric limit, or a malformed merchantId — not a valid UUID) · 400 invalid_cursor (a malformed cursor) · 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 pending and 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.cancelled callback 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

HTTPerror / codeWhen
401missing_x_client_id / invalid_clientMissing/unknown API key.
400missing_deposit_idBody has no deposit_id.
403forbidden (cross_tenant_access_denied)The deposit belongs to another tenant.
404deposit_not_foundUnknown deposit id.
409deposit_not_cancellable (NOT_PENDING)Deposit is no longer pending (the current status is echoed).
409deposit_not_cancellable (SLIP_PRESENT)A payment slip is already attached.
405method_not_allowedNon-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 from payment.

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.

FieldMeaning
balanceTotal payment-pool balance (THB).
frozenAmount of the payment pool held against in-flight payouts.
availableSpendable payment balance = balance − frozen. A payout is rejected 402 INSUFFICIENT_FUNDS when it exceeds this.
updatedAtTimestamp of the most recent payment-pool balance change.
pools.paymentThe same four figures, named. Identical to the four top-level fields above.
pools.p2pThe 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 / name pair only — code is the value you send in customer_bank_bank_code / dest_bank_code. Entries are ordered by code.
  • Codes are returned lowercase (bbl, kbank, scb). Input is case-insensitive — a KBANK you 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_key on create) — you do not pass a raw callback_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 as client-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, a TEST--prefixed txnId (and the matching requestId, same value, as on every real callback), and "statusRevision": 0 (a value no real terminal event can ever carry — do not treat a missing/zero statusRevision as "act on it," see Terminal status can change). Rate-limited per flow (a short cooldown + a daily cap) — a 429 means try again in retry_after_s. If you haven't configured an endpoint for that flow yet, you get a clean no_endpoint_configured, not a guess.


The request we send

  • Method / URL: POST <your preconfigured callback URL> (HTTPS only, port 443).
  • Headers:
HeaderValue
Content-Typeapplication/json
User-AgentGateway-Callback/1.0
X-Event-IdStable per-callback id — unchanged across retries. De-dup on this.
X-Signaturet=<unix_ms>,v1=<hex> where v1 = HMAC-SHA256(api_key_secret, "<t>.<rawBody>"). Re-signed per attempt (fresh t).
X-Request-IdPer-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 …Z UTC times) plus an injected timestamp field equal to the signed t. Every terminal callback also carries statusRevision (integer, 1-based, +1 per terminal callback for the same txnId; if missing, treat as 1) — 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:

SurfacetxnId carries
a callback (this page)your request_id
GET /transaction/<id>, client-deposit-status, client-payout-status, client-depositsour 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:

directioncanonical 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 your 2xx directly 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; point callback_endpoint_key at 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; the X-Event-Id stays the same.

Note: make your endpoint idempotent and fast — return 2xx quickly and process asynchronously.

🔴 Answer 2xx to every event — including ones you do not handle. We add events over time (deposit.cancelled is the newest). A receiver that answers an event name it does not recognise with 4xx/5xx gets that delivery retried up to the 7th attempt and then dead-lettered, exactly like a real failure. Acknowledge with 2xx and 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 200 and sends nothing more.

  • Every API version, both rails (bank and P2P).

  • You receive it for your own cancel too. The 200 from client-deposit-cancel and 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_ALLOWED or 503 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 statusRevision and no metadata echo. Do not try to order them by statusRevision (there is none), and do not expect your metadata back on them. They do include clientReferenceId when 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 closedArithmeticEventBuckets?
Settled in fullsettled >= requestedpayout.successno
Released in fullsettled == 0, held == 0, released >= requestedpayout.failedno
Paid in part0 < settled < requested, held == 0payout.closed, status: "PARTIAL"yes
Not finishedheld > 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.closedMeaning
amountpaid to the recipient — equals settled
requestedthe amount you asked to pay out
settledpaid out — final, does not move
releasedcould not be paid — already back in your balance
heldalways 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
CaseNumbersEvent
Settled exactly in fullsettled == requested, released == 0, held == 0payout.success
Released exactly in fullreleased == requested, settled == 0, held == 0payout.failed
Short by one satangsettled == requested - 0.01, released == 0.01payout.closed, PARTIAL — amount == requested - 0.01
Part paid, part still held0 < settled < requested, held > 0nothing yet — one event when the held part ends
Nothing paid, everything still reservedsettled == 0, held > 0nothing 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. Carries failureCode and failureMessage.

    🔴 Branch on failureCode, never on failureMessage. failureCode is a closed, reviewed vocabulary and is the field this contract keeps stable. failureMessage is 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. Carries failureCode alone; there is no failureMessage and 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 by payout.failed (failureCode: "p2p_unfilled");
  • a payout paid partly over peer-to-peer and partly by bank transfer, told payout.success, is corrected by payout.closed PARTIAL with the real buckets;
  • a payout told payout.closed PARTIAL because a leg settled short (for example amount and settled 999, released 1) is corrected by a second payout.closed PARTIAL with the corrected buckets and the corrected amount.

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

EventstatusTerminal?
deposit.paidPAIDyes
deposit.rejectedREJECTEDyes
deposit.expiredEXPIREDyes
deposit.cancelledCANCELLEDyes — final, never flips
payout.successSUCCESSyes
payout.failedFAILEDyes
payout.cancelledCANCELLEDyes
payout.closedPARTIALyes — read the buckets, not the status alone

The eight terminal events above each carry statusRevision. A payout in plain review is 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.expired can be rescued by a late payment slip and later arrive as deposit.paid.
  • paid → rejected: a deposit you saw as deposit.paid can be reversed by an operator (a wrongly-credited slip clawed back) and later arrive as deposit.rejected with failureCode: "admin_unapprove" at a higher statusRevision. 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.success can later be corrected / reversed and arrive as payout.failed. On the peer-to-peer rail this is how a payout arrives when the bank transfer that paid it is reversed: payout.failed with failureCode: "p2p_unfilled" if nothing was paid over peer-to-peer, or payout.closed PARTIAL if part was.
  • PARTIAL → a smaller PARTIAL: a peer-to-peer payout whose remainder was paid by a bank transfer can arrive as payout.closed PARTIAL (for example amount 999, requested 1000, settled 999, released 1), and if that bank transfer is later reversed, a second payout.closed arrives with the corrected numbers (amount 499, settled 499, released 501) at a higher statusRevision. Same event name, same status word, different numbers — it is a correction, not a retry: it has its own X-Event-Id. Reconcile to the one with the highest statusRevision.

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-Id de-dups retries of the same event. statusRevision orders distinct terminal events for the same txnId. Use both: discard repeated X-Event-Ids, then among the survivors act on the highest statusRevision.

⚠️ 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 EXPIRED may later become PAID (credit the order); a deposit you recorded as PAID may later become REJECTED (un-book the credit — an operator reversed it); a payout you booked as SUCCESS may later FAIL (un-book / claw back). Reconcile to the callback with the highest statusRevision for that txnId; ignore a callback whose statusRevision is 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.