Merchant API — Client Integration Guide
Audience. Developers integrating a merchant / client application against the FlowX payment gateway. This is the client-facing surface only — the endpoints you call with your API key + request signature, plus the webhooks you receive. Operator-console (admin) and internal endpoints are not here.
What this system is
A multi-tenant THB payment gateway. You create deposits (collections — your customer pays in, usually by PromptPay QR or bank transfer) and payouts (disbursements — we pay out to a destination bank account, debiting your wallet). You authenticate create calls with an API key and an HMAC request signature. Terminal state changes are delivered to you as signed webhooks.
Base URL
Every endpoint hangs off a single base URL, and it is deliberately not printed in this public guide. Sign in to your Client Management console and read it from the API page — that page is the one place we publish it.
| Placeholder | Meaning |
|---|---|
{BASE_URL} | The API base URL, shown on the API page of your console. |
Substitute it once in your client config; every path below is written relative to it —
e.g. POST {BASE_URL}/deposits-create, POST {BASE_URL}/deposits-upload-slip/<id>,
GET {BASE_URL}/deposits-qr/<id>, POST {BASE_URL}/payouts-create.
⚠️ Static egress IP — required before you can go live
Your integration must call us from a fixed, static set of source IPs, and you must tell us
what they are. deposits-create and payouts-create are gated by a per-client IP
allowlist; a request that arrives from an address we do not have on file for you is
rejected before it is processed:
// 403
{ "code": "IP_NOT_ALLOWED", "message": "client IP is not on the configured allowlist" }
- Applies to the two money endpoints only —
deposits-createandpayouts-create. The read/poll, QR, list, cancel and key-management endpoints are not IP-gated. - 🔴 The gate is fail-CLOSED. An EMPTY allowlist does not mean "allow everything" — it means nothing gets through. "We have not sent our IPs yet" is not a working state; it is a blocked one. Send them BEFORE you cut over.
- Send every address you can egress from, not just the primary: NAT gateways, each availability zone, your DR site, and any queue/worker host that retries a call. One unregistered failover address is an outage you will only discover during a failover.
- Dynamic IPs will not work. If your egress address can change (a consumer connection, a provider that re-allocates on restart, a serverless platform with a shared pool), put a NAT gateway or a proxy with a reserved address in front of your calls and register that.
- Hand the list to your account manager; an administrator records it against your client. Ask them to re-check it whenever your infrastructure moves.
Authentication
There are four auth modes on the client surface — pick by endpoint (see each doc):
| Mode | Endpoints | How you authenticate |
|---|---|---|
| API key + HMAC | deposits-create, payouts-create | X-Client-Id + X-Signature headers (below). The request signature is verified before the call is processed. |
| API key only | deposits-upload-slip, client-deposits, client-payouts, transaction, client-wallet-balance, client-bank-codes, client-deposit-cancel | X-Client-Id: <api_key> header (matched to your client record). No signature. Reads/actions are scoped to your own rows. |
| Public (no auth) | deposits-qr, client-deposit-status, client-payout-status | None — the resource UUID is the capability. |
| Console session | client-self-rotate-key, client-self-revoke-key, client-self-set-callback, client-self-test-callback | The Bearer token from your Client Management console login (2FA-backed) — not your API key. See Key lifecycle and Callbacks. |
You get your API key (X-Client-Id) and API key secret from your account manager /
the Client Management console. The secret is shown once at create/rotation and never
again. Keys support a two-slot rotation with an overlap window — treat the key as opaque
(its prefix can change across rotations).
Key lifecycle (self-service)
A client-admin console user can rotate or revoke its own API key from a logged-in
Client Management console session (the Bearer token from your 2FA login). These calls
deliberately do not use the API-key path — authenticating with the very key you are
rotating would lock you out. There is no client_id in the body: you can only ever act on your
own key.
Rotate — POST {BASE_URL}/client-self-rotate-key
-
Body:
{ "overlap_seconds"?: <int> }— optional; omitted → a 3600 s overlap window. -
Response
200:{ "rotated": true, "api_key": "pk_new_key", "api_key_secret": "sk_shown_once", "retiring": { "api_key": "pk_old_key", "retire_at": "2024-01-01T13:00:00Z" } }api_key_secretis the once-shown secret — capture it now; it is never returned again. The previous key keeps working untilretire_at(the overlap window) so you can roll over with no downtime. -
Errors:
401(missing/invalid/2FA-incomplete/expired session) ·403(insufficient role or blocked) ·404 unknown_client·409 no_active_key·500 rotate_failed.
Revoke — POST {BASE_URL}/client-self-revoke-key
- Body:
{ "reason"?: "<string>" }— optional. - Response
200:{ "revoked": true }. Immediate kill of both key slots — no overlap, no grace. The very next call on either key is unauthenticated. Carries no credential material back. - Errors:
401·403·404 unknown_client·500 revoke_failed.
Request signing (X-Signature) — signed endpoints
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>.<rawBody>" ) // lowercase hex
tis the current time in Unix milliseconds. The server rejects a signature whose timestamp is outside the allowed skew window — currently 60 s (|now − t| > 60s→401 { "error": "signature_timestamp_skew" }). Treat 60 s as the current value, not a fixed constant. Generatetright before you sign.rawBodyis the exact JSON bytes you POST. Sign the bytes you send (do not re-serialize after signing — a single byte difference fails the HMAC).- The signature covers
"<t>.<rawBody>"(the literalt, a dot, then the raw body).
Node.js example:
const crypto = require('crypto');
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}.${rawBody}`)
.digest('hex');
const res = await fetch(`${BASE_URL}/deposits-create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': apiKey,
'X-Signature': `t=${t},v1=${v1}`,
'Idempotency-Key': crypto.randomUUID(),
},
body: rawBody,
});
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 + body), so a captured signature cannot be replayed on a different request or outside the 60-second window.
Idempotency
Every create call (deposits-create, payouts-create) and deposits-upload-slip must
include an Idempotency-Key header.
- We recommend a UUID v4 — the server accepts any non-empty string, but the key must be unique per transaction (1 transaction = 1 key). Never reuse a key for a different transaction.
- A retry must reuse the same key + identical body → the server replays the original response instead of creating a duplicate.
- The body is canonicalized before it is compared: JSON key order and insignificant
whitespace do not affect the dedup identity, so a re-serialized retry (same fields, keys
reordered / reformatted) still replays. Any genuine field or value change is still a
conflict →
409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY(use a new key). - Keys live 24 hours (TTL 86 400 s); after expiry the key is treated as new.
These rejections carry { "code": "…", "message": "…" } (there is no error key on
these) — branch on code:
| HTTP | code | When |
|---|---|---|
400 | IDEMPOTENCY_KEY_REQUIRED | Header missing on an endpoint that requires it. |
400 | INVALID_JSON | Body is not valid JSON. |
409 | IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY | Same key, different body (a client bug — use a new key). |
409 | IDEMPOTENCY_KEY_CONCURRENT_INFLIGHT | The first request with this key is still processing — wait, then retry with the same key. |
Rate limiting
Create calls (deposits-create, payouts-create) are rate-limited per client, per
lane (deposit / payout), over a rolling minute and rolling day window. The caps
are provisioned per account (there is no single fixed public number) — ask your account
manager for yours.
- Over a cap →
429with aRetry-Afterheader (seconds to wait) and a JSON body that always carriescode(RATE_LIMIT_EXCEEDED),scope(deposit|payout) andretry_after_s. Branch oncodeand honorRetry-After. Additional fields —messageand the per-window counters (window,minute_count,minute_cap,day_count,day_cap) — MAY be present depending on which tier rejected you (the edge cf-worker returns only the minimal{ code, scope, retry_after_s }; the Edge-Function backstop addsmessage+ the counters). Do not depend onmessageor the counters being there. - Honor
Retry-After: back off for that many seconds, then retry (fall back to a short exponential back-off if the header is absent). A429is safe to retry — reuse the sameIdempotency-Keyso the eventual create is not duplicated.
Conventions
- Success bodies are resource-shaped JSON (see each endpoint).
- Error shapes vary by layer — when a stable
codeis present, branch on it; never parse theerrortext. Three shapes:- Most validation / signature / auth failures →
{ "error": "<snake_token>" }(often withdetail), e.g.{ "error": "missing_route" },{ "error": "signature_timestamp_skew" }. - Recognized create-time business rejections →
{ "error": "<full raised message>", "code": "<UPPER_SNAKE>" }— hereerrorholds the full message (e.g."insufficient_funds: …"), not the bare token. Commoncode→status:INSUFFICIENT_FUNDS→402,NO_BANK_AVAILABLE*/*_MAINTENANCE→503,DEPOSIT_DISABLED_FOR_CLIENT→403,CLIENT_DISABLED→403,PAYOUT_DISABLED/UNSUPPORTED_DEST_BANK/AMOUNT_OUT_OF_RANGE→400,CALLBACK_ENDPOINT_NOT_CONFIGURED→409. CLIENT_DISABLEDis a special case of the above: it can come from TWO different layers, not just the create-time RPC. Your account (client.status, set by an operator — distinct from the per-flow enable/disable toggles) is checked (a) at the edge gateway, before your request even reaches a create call, and (b) again insidedeposits-create/payouts-createitself, as a second independent check. Layer (a)'s body is the shorter{ "error": "client_disabled", "code": "CLIENT_DISABLED" }(the bare token, not a raised message); layer (b) follows the general shape above (error= the full message). Always branch oncode, never on which shapeerrortook — thecodeand the403status are identical either way, and which layer catches it is an implementation detail (e.g. cache timing) you should not depend on.- Idempotency rejections →
{ "code": "…", "message": "…" }with noerrorkey. Rate-limit rejections → alwayscode+scope+retry_after_s(noerrorkey);messageand the per-window counters are optional (tier-dependent — see Rate limiting). See also Idempotency.
- Most validation / signature / auth failures →
- Wrong method →
404or405depending on the route: via the edge cf-worker a wrong method is an unmatched route →404; directly against an Edge Function it is405 { "error": "method_not_allowed" }. Treat both as "wrong method / not routable." - Callback URL is preconfigured. You do not pass a raw
callback_urlon create — it is rejected (400 callback_url_not_allowed). Instead you reference a preconfigured endpoint bycallback_endpoint_key(set up during onboarding). This closes an SSRF/open-redirect surface.
Documents
- deposit.md — create deposit, upload slip, render QR
- payout.md — create payout (route selection)
- status.md — status poll / get-by-id / list / self-cancel deposit
- balance-banks.md — wallet balance, bank-code list
- callbacks.md — webhooks you receive: signature, events, retries, dedup
Deposit API
The collection lane. You create a deposit, your customer pays (PromptPay QR or bank transfer), and the deposit reaches a terminal state — at which point we deliver a webhook. See INDEX for auth, signing, and idempotency.
⚠️ This endpoint is IP-gated. It only accepts calls from the static source IPs registered against your client — an unregistered address gets
403 IP_NOT_ALLOWEDbefore the request is processed, and the gate is fail-closed (an empty allowlist blocks everything). Register every address you can egress from, including failover and DR, before you go live. See Static egress IP.
Create Deposit
POST {BASE_URL}/deposits-create (scope deposit)
- Auth: API key + HMAC (
X-Client-Id+X-Signature) — see INDEX. - Idempotency: required
Idempotency-Key.
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
amount | number | ✅ | THB. Integer baht — any decimal is silently floored (100.99 → 100). |
method | string | ✅ | "qr" or "manual_transfer". |
request_id | string | ✅ | Your own reference id for this deposit (e.g. your order id) — returned in the response and echoed in callbacks so you can reconcile on your side. Must be unique per deposit. |
customer_bank_account_number | string | ✅ | Payer's bank account number. (Alias: expected_source_account_no.) |
customer_bank_account_name | string | ✅ | Payer's account holder name. |
customer_bank_bank_code | string | ✅ | Payer's bank code. Must be a recognized Thai bank code (case-insensitive; portal variants like KTBBIZ→ktb are normalized). Unknown/junk → 400 UNSUPPORTED_SOURCE_BANK. Enumerate the valid codes via balance-banks.md. |
customer_bank_bank_name | string | — | Optional payer bank name. |
promptpay_id | string | — | Optional PromptPay proxy. |
client_reference_id | string | — | Optional extra client-side reference — echoed back as clientReferenceId on this deposit's callbacks (see callbacks.md). |
callback_endpoint_key | string | — | Preconfigured callback endpoint (see below). |
metadata | object | — | ≤ 2048 bytes & ≤ 20 keys. Echoed back verbatim in deposit callbacks. |
cURL
# build X-Signature first (see INDEX): v1 = HMAC-SHA256(secret, "<t>.<rawBody>")
curl -X POST "${BASE_URL}/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,
"method": "qr",
"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
{
"deposit": {
"id": "…uuid…",
"deposit_id": "…uuid…",
"request_id": "ORDER-12345",
"amount": 1000,
"channel": "QR",
"fee": 15,
"feePercent": 1.5,
"netAmount": 985,
"qr_type": "mobile",
"qrcode": "00020101021229370016A000000677010111…6304ABCD",
"promptpay_number": "…",
"payment_account_number": "…",
"payment_account_name": "…",
"payment_bank_code": "SCB",
"payment_promptpay_id": "…",
"expires_at": "2024-01-01T12:10:00Z"
}
}
- The deposit's own id is
id(also returned asdeposit_idfor back-compat — new integrations should bind toid). Persist it — it is the id you pass to the QR, slip-upload, status-poll, get-by-id, and cancel endpoints. - No
statusfield is returned on create — a new deposit is implicitly awaiting payment. Obtain status via deposit status poll / get-by-id. channelis"QR"or"TRANSFER"— chosen by the system from the assigned bank.qrcodeis an EMVCo PromptPay payload string for client-side QR rendering (ornullfor a transfer channel). To get a rendered PNG, use the QR endpoint below.qr_typeis the PromptPay proxy type of the QR — one of"mobile","nationalId","taxId","ewallet"(present only on a QR channel;nullotherwise).fee/feePercent/netAmountcome from your MDR profile;netAmountis the net credited to your wallet at finalize.payment_account_*/payment_promptpay_idare the assigned collection bank block the payer transfers to.
Errors
- Auth/signing:
401 missing_credentials/invalid_signature_format/signature_timestamp_skew/invalid_client/invalid_signature;401 wrong_scope. The assertion401 { code }set (missing_assertion/expired/request_hash_mismatch/…) is gateway-internal — the edge (cf-worker) mints the assertion for you, so you should not normally see these on the client surface. - Idempotency:
400 IDEMPOTENCY_KEY_REQUIRED/400 INVALID_JSON/409 …_REUSED_WITH_DIFFERENT_BODY/409 …_CONCURRENT_INFLIGHT. - Validation:
400 missing_fields/invalid_method/missing_required_field(MISSING_REQUIRED_FIELD) /amount_out_of_range(AMOUNT_OUT_OF_RANGE) /unsupported_source_bank(UNSUPPORTED_SOURCE_BANK— the payercustomer_bank_bank_codeis not a recognized bank code) /metadata_too_large/callback_url_not_allowed(CALLBACK_URL_NOT_ALLOWED) /client_supplied_expires_in_seconds. - Business:
503 NO_BANK_AVAILABLE/NO_BANK_AVAILABLE_AFTER_EXCLUSION/DEPOSIT_MAINTENANCE·403 DEPOSIT_DISABLED_FOR_CLIENT·403 CLIENT_DISABLED·403 BLACKLISTED_PAYER_ACCOUNT·409 CALLBACK_ENDPOINT_NOT_CONFIGURED·400 INVALID_CALLBACK_ENDPOINT[_KEY].
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.
403 BLACKLISTED_PAYER_ACCOUNT— the payer's source bank account is on the gateway blocklist and may not make deposits. A hard fail before any state write; nothing is created.
503 NO_BANK_AVAILABLE_AFTER_EXCLUSIONis returned when no eligible system bank remains after the server-derived intra-bank exclusion — a deliberate hard-fail. The gateway will not silently route a payer to a collection bank on their own network.
Deposit lifecycle (statuses you'll see in webhooks)
| Status | Meaning |
|---|---|
pending | Awaiting payment / slip / statement match. |
paid | Payment confirmed (→ deposit.paid webhook). |
rejected | Rejected after review (→ deposit.rejected). |
expired | Window elapsed unpaid (→ deposit.expired). |
cancelled | Cancelled — by an operator, or by you via self-cancel while still pending & slip-less. |
Upload Payment Slip
POST {BASE_URL}/deposits-upload-slip/<deposit_id>
Attach a payment-slip image to a pending deposit — or rescue an expired one
(e.g. your customer paid by transfer, or paid late after the deposit already expired).
- Auth: API key only —
X-Client-Id: <api_key>matched to your client record. Not HMAC-signed. The deposit must belong to your client. - Idempotency: required
Idempotency-Key. - Request: path
deposit_id; bodyslip_image_url(req — a pre-hosted URL string, not a multipart file upload); optionaluploader{ type: "customer"|"client"|"sub-client", id?, username? }(non-customer needs id+username).
curl -X POST "${BASE_URL}/deposits-upload-slip/DEPOSIT_UUID" \
-H 'Content-Type: application/json' \
-H 'X-Client-Id: pk_your_api_key' \
-H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440001' \
-d '{ "slip_image_url": "https://your-cdn/slip/abc.jpg" }'
- Response — depends on the deposit's current status (check the status code):
202(deposit ispending):{ "status": "pending", "deposit_id": "…", "uploaded_by_type": "client", "note": "…" }— deferred: the slip is recorded and the deposit stayspendinguntil the slip-escalation sweep promotes it tochecking.200(deposit isexpired— rescue):{ "status": "checking", "deposit_id": "…", "uploaded_by_type": "…", "note": "…" }— synchronous: the late slip re-opens the expired deposit — it flips straight tocheckingand a slip verification is queued immediately. An operator still owns the final approval (this never credits your wallet by itself).
- Errors:
401 missing_x_client_id/invalid_client·400 missing_slip_image_url/invalid_uploader_type/missing_uploader_identity· idempotency400/409·403 FORBIDDEN_CROSS_CLIENT(not your deposit) ·404 deposit_not_found·409 deposit_not_pending_or_missing(a non-pending, non-expiredterminal — paid/rejected/cancelled/failed) ·409 deposit_not_expired(the expired-rescue lost a race — the deposit leftexpiredbetween lookup and flip; re-fetch its status).
Expired-deposit rescue: branch on the status code, not just the body —
202= deferredpending,200= the deposit wasexpiredand is nowchecking. Poll deposit status afterwards; the operator's approval is what finalizes it.
Note: this endpoint takes a pre-hosted URL (
slip_image_url), not a multipart file upload. Host the image yourself first and pass its URL.
Render Deposit QR (PNG)
GET {BASE_URL}/deposits-qr/<deposit_id>?size=N
- Auth: none — any caller with the deposit UUID gets the PNG. Treat the UUID as a capability/secret (the QR embeds the PromptPay proxy + amount).
- Request: path
deposit_id;size(opt, clamped 64–2048, default 512). - Response
200:image/pngbytes; headersX-Deposit-Id,X-Qr-Type,X-Deposit-Request-Id,Cache-Control: public, max-age=300. - Errors:
400 missing_deposit_id_in_path·404 deposit_not_found/no_qr_for_deposit(no QR payload).
curl "${BASE_URL}/deposits-qr/DEPOSIT_UUID?size=512" --output deposit-qr.png
Prefer rendering the
qrcodeEMV payload from the create response yourself; this endpoint is a convenience for a ready-made PNG.
Cancel a Deposit (API-key self-serve)
POST {BASE_URL}/client-deposit-cancel — cancel your own still-pending, slip-less
deposit with your API key (idempotent re-cancel). Full request/response/errors in
status.md.
Once a slip is attached or the deposit reaches any non-
pendingstate, self-cancel is rejected (409).
Payout API
The disbursement lane. You create a payout, which places a hold on (freezes) amount + fee
in your wallet; a bank bot claims and executes it; you receive a webhook when
it reaches a terminal state. The debit is finalized only on success — on failed or
cancelled the hold is released back to your wallet.
See INDEX for auth, signing, and idempotency.
⚠️ This endpoint is IP-gated. It only accepts calls from the static source IPs registered against your client — an unregistered address gets
403 IP_NOT_ALLOWEDbefore the request is processed, and the gate is fail-closed (an empty allowlist blocks everything). Register every address you can egress from, including failover and DR, before you go live. See Static egress IP.
Create Payout
POST {BASE_URL}/payouts-create (scope payout)
- Auth: API key + HMAC (
X-Client-Id+X-Signature) — see INDEX. - Idempotency: required
Idempotency-Key.
Request body
| Field | Type | Req | Notes |
|---|---|---|---|
amount | number | ✅ | THB withdrawal amount. You must have sufficient available balance (balance − frozen) to cover amount + fee. |
dest_bank_code | string | ✅ | Destination bank code (e.g. KBANK, SCB). |
dest_account_number | string | ✅ | Destination account number. |
request_id | string | ✅ | Your own reference id for this payout (e.g. your withdrawal id) — returned in the response and echoed in callbacks so you can reconcile on your side. Must be unique per payout. |
dest_bank_name | string | — | Defaults to dest_bank_code. |
dest_account_name | string | — | Destination account holder name. |
client_reference_id | string | — | Extra client-side reference. |
callback_endpoint_key | string | — | Preconfigured callback endpoint (default "default"). |
metadata | object | — | ≤ 30 keys & ≤ 8192 bytes. Echoed back verbatim in payout callbacks. |
callback_urlis forbidden (400 callback_url_not_allowed) — the callback target is preconfigured; reference it bycallback_endpoint_key.
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>.<rawBody>")
curl -X POST "${BASE_URL}/payouts-create" \
-H 'Content-Type: application/json' \
-H 'X-Client-Id: pk_your_api_key' \
-H 'X-Signature: t=1709123456789,v1=def456…' \
-H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440002' \
-d '{
"amount": 5000,
"dest_bank_code": "SCB",
"dest_account_number": "9876543210",
"dest_account_name": "Jane Doe",
"request_id": "WD-98765",
"callback_endpoint_key": "default"
}'
Response 200
{
"payout": {
"id": "…uuid…",
"payout_id": "…uuid…",
"request_id": "WD-98765",
"amount": 5000,
"fee": 75,
"payout_fee": 75,
"dest_bank_code": "SCB",
"dest_account_number": "9876543210",
"status": "pending"
}
}
- The payout's own id is
idand the fee isfee(also returned aspayout_id/payout_feefor back-compat — new integrations should bind toid/fee). Persistid— it is the id you pass to the status-poll and get-by-id endpoints. amountis the sum sent to the destination. On create,amount + feeis frozen in your wallet — your available balance drops by that much, but the ledgerbalanceis unchanged. The debit is finalized onsuccess; onfailed/cancelledthe hold is released back to your wallet.statusis"pending"at create.- The response does not echo
dest_account_name(you supply it on create, but it is not returned here).
Errors
- Auth/signing: same family as deposit —
401 missing_credentials/invalid_signature_format/signature_timestamp_skew/invalid_client/invalid_signature;401 wrong_scope. The assertion401 { code }set (missing_assertion/expired/request_hash_mismatch/…) is gateway-internal — the edge (cf-worker) mints the assertion for you, so you should not normally see it. - Idempotency:
400/409set (see INDEX). - Validation:
400 callback_url_not_allowed(CALLBACK_URL_NOT_ALLOWED) ·400 missing_fields·400 no_pool_configured(routing not set up on our side) ·400 metadata_invalid/metadata_too_large(bothcode: METADATA_TOO_LARGE). - Business:
400 PAYOUT_DISABLED·400 UNSUPPORTED_DEST_BANK·400 AMOUNT_OUT_OF_RANGE·402 INSUFFICIENT_FUNDS·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 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). - Routing:
400 no_pool_configured— your account has no withdrawal route configured on our side. Nothing in the request can fix it; contact your account manager. - Callback endpoint:
409 callback_endpoint_not_configured(CALLBACK_ENDPOINT_NOT_CONFIGURED) /400 invalid_callback_endpoint_key(INVALID_CALLBACK_ENDPOINT_KEY) —callback_endpoint_keydoes not resolve to a configured, active payout endpoint for your client.
Payout lifecycle (statuses you'll see in webhooks)
| Status | Meaning |
|---|---|
pending | Created, awaiting claim. |
processing | Claimed; payout in flight. |
success | Paid out (→ payout.success webhook). |
failed | Execution failed (→ payout.failed). |
review | Held for operator review — no callback; poll for the terminal outcome. |
cancelled | Cancelled by operator (→ payout.cancelled). |
Status / get-by-id / list
Poll a payout's status (GET {BASE_URL}/client-payout-status/<id>, public), fetch one by id
(GET {BASE_URL}/client-payouts/<id>, API key), or list your payouts with cursor pagination +
filters (GET {BASE_URL}/client-payouts, API key). Full contracts in
status.md. Webhooks remain the lowest-latency signal — see
callbacks.md.
Status, Get-by-ID, List & Self-Cancel
The client-facing read/poll surface plus the merchant self-cancel for deposits. You can now poll a transaction by id, fetch one by id, list your own deposits/payouts with filters and cursor pagination, and cancel your own still-pending deposit — all with your API key (the two status-poll endpoints are public). See INDEX for auth and conventions.
Two auth styles here. The status-poll endpoints are public (the transaction UUID is the capability — same model as the QR endpoint). The get-by-id, list, wallet and cancel endpoints take your API key only (
X-Client-Id, no HMAC) and are scoped to your own rows — another tenant's id reads as404.
All reads return the resource's effective status with zero lag — e.g. a past-deadline,
slip-less pending deposit reads expired the instant its window elapses (no write-on-read).
Status values are the lowercase lifecycle statuses (see the lifecycle tables in
deposit.md /
payout.md); webhook payloads use
the UPPERCASE form of the same status.
Get Deposit Status (public poll)
GET {BASE_URL}/client-deposit-status/<deposit_id>
- Auth: none — the deposit UUID is the capability. Treat it as a secret.
- Request: path
deposit_id(UUID).
Response 200
{
"txnId": "…uuid…",
"status": "paid",
"amount": 1000,
"paidAmount": 1000,
"paidAt": "2024-01-01T12:05:00Z",
"expiresAt": "2024-01-01T12:10:00Z"
}
paidAmountis the amount whenstatusispaid, otherwisenull.paidAt/expiresAtarenulluntil set.
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,
"failureReason": null,
"bankTransactionId": "…"
}
failureReasonis set only on afailedpayout;bankTransactionIdis set once the bank leg carries one (otherwisenull).
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"
}
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,
"fee": 75,
"netAmount": 4925,
"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"
}
- For a payout,
netAmountisamount − fee— a reporting figure only, not a wallet movement. It is neither the sum sent to the destination (that isamount) nor the amount debited from your wallet (that isamount + fee, held on create and finalized only onsuccess). Do not reconcile your wallet againstnetAmount.
Errors
401 missing_x_client_id/invalid_client·405 method_not_allowed·404 payout_not_found(unknown or cross-tenant) ·500 read_failed.
curl "${BASE_URL}/client-payouts/PAYOUT_UUID" -H 'X-Client-Id: pk_your_api_key'
Get a Transaction by ID (unified) (API-key, own)
GET {BASE_URL}/transaction/<txnId>
One lookup that returns either a deposit or a payout by its gateway transaction id — so an
integrator that stored only the txnId can fetch the record without knowing which kind it is.
- Auth: API key only —
X-Client-Id: <api_key>. No HMAC. - Scope: your own rows only. An unknown id or another tenant's id →
404(never403— a cross-tenant id is simply unreachable, indistinguishable from an unknown one). txnIdis the gateway transaction UUID (thetxnIdreturned on create / status / list), not your ownrequestIdorder id.- Resolution is deposit-first: the id is looked up as a deposit, then (only if no deposit matched) as a payout. The two id-spaces are independent random UUIDs, so a collision is astronomically improbable; deposit-first is the deterministic tie-break.
- The response is the same body as the matching Get Deposit by ID / Get Payout by ID
above, with a leading
typediscriminator you branch on.
Response 200 — a deposit (type: "deposit")
{
"type": "deposit",
"txnId": "…uuid…",
"requestId": "ORDER-12345",
"status": "paid",
"amount": 1000,
"fee": 15,
"netAmount": 985,
"method": "qr",
"paidAmount": 1000,
"paidAt": "2024-01-01T12:05:00Z",
"expiresAt": "2024-01-01T12:10:00Z",
"expiredAt": null,
"cancelledAt": null,
"failedAt": null,
"failureCode": null,
"failureReason": null,
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:05:00Z"
}
Response 200 — a payout (type: "payout")
{
"type": "payout",
"txnId": "…uuid…",
"requestId": "WD-98765",
"status": "success",
"amount": 5000,
"fee": 75,
"netAmount": 4925,
"destBankCode": "SCB",
"destBankName": "Siam Commercial Bank",
"destAccountNumber": "9876543210",
"destAccountName": "Jane Doe",
"bankTransactionId": "…",
"failureCode": null,
"failureReason": null,
"completedAt": "2024-01-01T14:30:00Z",
"createdAt": "2024-01-01T14:00:00Z"
}
Errors
401 missing_x_client_id/invalid_client·405 method_not_allowed·400 missing_txn_id_in_path(no id in the path) ·404 transaction_not_found(unknown or cross-tenant) ·500 read_failed.
curl "${BASE_URL}/transaction/TXN_UUID" -H 'X-Client-Id: pk_your_api_key'
List Deposits / Payouts (API-key, own)
GET {BASE_URL}/client-deposits[?<filters>]
GET {BASE_URL}/client-payouts[?<filters>]
- Auth: API key only —
X-Client-Id: <api_key>. No HMAC. - Scope: your own rows only. Filters can only narrow within your rows — they never widen beyond your tenant.
- Order: newest first (
createdAtdescending, then id).
Query filters (all optional)
| Param | Type | Effect |
|---|---|---|
status | string | Exact match on effective status (e.g. pending, paid, expired). |
dateFrom | ISO-8601 | createdAt >= dateFrom. |
dateTo | ISO-8601 | createdAt <= dateTo. |
merchantId | uuid | Narrow to rows under the given merchant. |
transactionId | string | Exact match on your requestId. |
amount | number | Exact amount. |
amountMin | number | amount >= amountMin. |
amountMax | number | amount <= amountMax. |
cursor | string | Opaque keyset cursor — pass back the prior page's nextCursor. |
limit | int | Page size. Default 50, clamped to 1–200. |
Response 200
{
"data": [ { "txnId": "…", "requestId": "…", "status": "paid", "amount": 1000, "…": "…" } ],
"nextCursor": "MjAyNC0wMS0wMVQxMjowMDowMFp8…",
"count": 50
}
- Each
dataelement has the same shape as the matching get-by-id response above. nextCursoris an opaque string — present only when a full page was returned (more rows may follow). It isnullon the last page. Pass it back ascursorto fetch the next page; do not parse or construct it yourself.countis the number of rows in this page.
Errors
401 missing_x_client_id/invalid_client·405 method_not_allowed·400 invalid_filter(a non-numericamount*, an unparseabledate*, a non-numericlimit, or a malformedmerchantId— not a valid UUID) ·400 invalid_cursor(a malformedcursor) ·500 list_failed.
# first page, paid only, page size 25
curl "${BASE_URL}/client-deposits?status=paid&limit=25" -H 'X-Client-Id: pk_your_api_key'
# next page
curl "${BASE_URL}/client-deposits?status=paid&limit=25&cursor=MjAyNC0wMS0wMVQ…" \
-H 'X-Client-Id: pk_your_api_key'
Cancel a Deposit (API-key, self-serve)
POST {BASE_URL}/client-deposit-cancel
Cancel your own still-pending, slip-less deposit with your API key.
- Auth: API key only —
X-Client-Id: <api_key>. No HMAC. - Body:
{ "deposit_id": "<uuid>" }. - Cancellable only while the deposit is
pendingand has no uploaded slip. Once a slip is attached or the deposit reaches any other state, cancel is rejected (409).
Response 200
{ "cancelled": true, "deposit_id": "…uuid…", "status": "cancelled" }
Idempotent. Re-cancelling an already-cancelled deposit returns the same
200(no error, no duplicate side-effect).
Errors
| HTTP | error / code | When |
|---|---|---|
401 | missing_x_client_id / invalid_client | Missing/unknown API key. |
400 | missing_deposit_id | Body has no deposit_id. |
403 | forbidden (cross_tenant_access_denied) | The deposit belongs to another tenant. |
404 | deposit_not_found | Unknown deposit id. |
409 | deposit_not_cancellable (NOT_PENDING) | Deposit is no longer pending (the current status is echoed). |
409 | deposit_not_cancellable (SLIP_PRESENT) | A payment slip is already attached. |
405 | method_not_allowed | Non-POST. |
curl -X POST "${BASE_URL}/client-deposit-cancel" \
-H 'Content-Type: application/json' \
-H 'X-Client-Id: pk_your_api_key' \
-d '{ "deposit_id": "DEPOSIT_UUID" }'
Wallet Balance & Bank Codes
Two API-key reads: your wallet balance, and the bank-code catalogue you validate
customer_bank_bank_code (deposit) and dest_bank_code (payout) against. Both take your
API key only (X-Client-Id, no HMAC). See INDEX for auth and conventions.
Check Wallet Balance
GET {BASE_URL}/client-wallet-balance
- Auth: API key only —
X-Client-Id: <api_key>. No HMAC. - Scope: your own wallet.
Response 200
{
"clientId": "…uuid…",
"name": "Acme Co",
"balance": 150000.00,
"available": 120000.00,
"frozen": 30000.00,
"updatedAt": "2024-01-01T12:00:00Z"
}
| Field | Meaning |
|---|---|
balance | Total wallet balance (THB). |
frozen | Amount held against in-flight payouts. |
available | Spendable balance = balance − frozen. A payout is rejected 402 INSUFFICIENT_FUNDS when it exceeds this. |
updatedAt | Timestamp of the most recent balance change. |
Errors
401 missing_x_client_id/invalid_client·405 method_not_allowed·404 wallet_not_found(no wallet provisioned for your client) ·500 balance_read_failed.
curl "${BASE_URL}/client-wallet-balance" -H 'X-Client-Id: pk_your_api_key'
List Bank Codes
GET {BASE_URL}/client-bank-codes
The supported bank registry the create paths validate against. Use it to populate / validate
customer_bank_bank_code (deposit) and dest_bank_code (payout) programmatically instead of a
hard-coded list.
- Auth: API key only —
X-Client-Id: <api_key>. No HMAC.
Response 200
{
"data": [
{ "code": "bbl", "name": "Bangkok Bank" },
{ "code": "kbank", "name": "Kasikornbank" },
{ "code": "scb", "name": "Siam Commercial Bank" }
]
}
- Each entry is the load-bearing
code/namepair only —codeis the value you send incustomer_bank_bank_code/dest_bank_code. Entries are ordered bycode. - Codes are returned lowercase (
bbl,kbank,scb). Input is case-insensitive — aKBANKyou send is normalized — but build any validation set you keep from the lowercase values returned here.
Errors
401 missing_x_client_id/invalid_client·405 method_not_allowed·500 bank_codes_read_failed.
curl "${BASE_URL}/client-bank-codes" -H 'X-Client-Id: pk_your_api_key'
A payout create still rejects an unsupported destination with
400 UNSUPPORTED_DEST_BANK— this endpoint lets you enumerate the valid codes up front rather than discovering them on failure.
Callbacks / Webhooks
When a deposit or payout reaches a terminal state, we POST a signed webhook to your preconfigured callback endpoint. This is the lowest-latency way to track transaction state; you can also poll or list status on demand (see status.md). Verify the signature and de-dup on the event id.
Your callback target is preconfigured (referenced by
callback_endpoint_keyon create) — you do not pass a rawcallback_url. The stored URL must be HTTPS on port 443.
Test your endpoint before real money moves.
POST {BASE_URL}/client-self-test-callback(Console session auth — the Bearer token from your 2FA login, same asclient-self-rotate-key) fires one real, signed callback at your already-configured endpoint for{ "flow": "deposit" | "payout" }and returns{ ok, url, http_status, latency_ms, response_excerpt, error_class }synchronously — no queue, no retry, nothing to poll for. The payload is shaped exactly like a real terminal event but is unmistakably a test:"test": true, aTEST--prefixedtxnId, and"statusRevision": 0(a value no real terminal event can ever carry — do not treat a missing/zerostatusRevisionas "act on it," see Terminal status can change). Rate-limited per flow (a short cooldown + a daily cap) — a429means try again inretry_after_s. If you haven't configured an endpoint for that flow yet, you get a cleanno_endpoint_configured, not a guess.
The request we send
- Method / URL:
POST <your preconfigured callback URL>(HTTPS only, port 443). - Headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Gateway-Callback/1.0 |
X-Event-Id | Stable per-callback id — unchanged across retries. De-dup on this. |
X-Signature | t=<unix_ms>,v1=<hex> where v1 = HMAC-SHA256(api_key_secret, "<t>.<rawBody>"). Re-signed per attempt (fresh t). |
X-Request-Id | Per-attempt trace id for this delivery — for support correlation / your logs. Not for de-dup (use X-Event-Id); it changes between retries. |
- Body: the event
payload(camelCase, ISO-8601…ZUTC times) plus an injectedtimestampfield equal to the signedt. Every terminal callback also carriesstatusRevision(integer,1-based, +1 per terminal callback for the sametxnId; if missing, treat as1) — see Terminal status can change.
Verifying the signature
The scheme matches your request signing — the callback is signed with the same
api_key_secret you sign requests with (see INDEX) — over "<t>.<rawBody>":
const crypto = require('crypto');
function verifyCallback(req, apiKeySecret) {
const raw = req.rawBody; // the EXACT received bytes
const m = /^t=(\d+),v1=([0-9a-f]+)$/i.exec(req.headers['x-signature'] || '');
if (!m) return false;
const [, t, v1] = m;
// recommended: reject stale callbacks (replay defense), ~5 min window
if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) return false;
const expected = crypto.createHmac('sha256', apiKeySecret)
.update(`${t}.${raw}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
Key rotation. A callback is always signed with your current
api_key_secret. If you have just rotated, verify against your current secret and keep your previous secret available as a fallback until the new one is fully rolled into your callback receiver — mirroring how we accept either key on your requests during the rotation overlap, so a callback firing during a rotation window still verifies.
Then de-dup on X-Event-Id (same id may arrive more than once — at-least-once
delivery) before acting on the event.
Expected response & retries
- Respond with any HTTP 2xx → we mark the callback
delivered. Return your2xxdirectly on the configured URL. - A 3xx redirect is NOT followed — we treat it as a failed attempt (recorded
callback_redirect_blocked) and it rides the same retry ladder. Do not answer a callback with a redirect; pointcallback_endpoint_keyat the final URL. - Non-2xx or timeout (30 s HTTP timeout) → we retry on a backoff ladder while attempts remain (up to 7 attempts); after the 7th failed attempt we dead-letter it (no further delivery).
- Each attempt is re-signed with a fresh
t; theX-Event-Idstays the same.
Note: make your endpoint idempotent and fast — return 2xx quickly and process asynchronously.
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…", "amount": 1000, "status": "PAID",
"paidAt": "2024-01-01T12:05:00Z", "statusRevision": 1, "metadata": { "orderId": "A-1001" }, "timestamp": 1709123456789 }
// deposit.rejected
{ "event": "deposit.rejected", "txnId": "DEP…", "amount": 1000, "status": "REJECTED",
"failureCode": "…", "failureMessage": "…", "statusRevision": 1, "metadata": { "orderId": "A-1001" }, "timestamp": 1709123456789 }
// deposit.expired
{ "event": "deposit.expired", "txnId": "DEP…", "amount": 1000, "status": "EXPIRED",
"statusRevision": 1, "metadata": { "orderId": "A-1001" }, "timestamp": 1709123456789 }
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…", "amount": 1000, "status": "REFUNDED",
"clientReferenceId": "your-ref", "timestamp": 1709123456789 }
// deposit.refund_failed — refund transfer failed
{ "event": "deposit.refund_failed", "txnId": "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…", "amount": 1000,
"status": "REFUND_PENDING_REVIEW", "clientReferenceId": "your-ref", "timestamp": 1709123456789 }
⚠️ These three deviate from the general contract. Unlike the six terminal events, refund events carry no
statusRevisionand nometadataecho. Do not try to order them bystatusRevision(there is none), and do not expect yourmetadataback on them. They do includeclientReferenceIdwhen the deposit had one. They are not part of the deposit lifecycle-status set — treat them as a separate refund-status stream.
Payout events
// payout.success
{ "event": "payout.success", "txnId": "PAY…", "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…", "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)
{ "event": "payout.cancelled", "txnId": "PAY…", "amount": 5000, "status": "CANCELLED",
"failureCode": "auto_cancelled", "statusRevision": 1, "metadata": { "orderId": "A-1001" }, "timestamp": 1709123456789 }
payout.cancelled failureCode is one of auto_cancelled (stale-payout sweep),
admin_cancelled (operator cancel), bank_maintenance, system_maintenance, or
bank_deactivated (the destination system bank was deactivated — its pending payouts are
cancelled and refunded by the deactivate cascade).
| Event | status | Terminal? |
|---|---|---|
deposit.paid | PAID | yes |
deposit.rejected | REJECTED | yes |
deposit.expired | EXPIRED | yes |
payout.success | SUCCESS | yes |
payout.failed | FAILED | yes |
payout.cancelled | CANCELLED | yes |
The six terminal events above each carry
statusRevision. A payout in plainreviewis callback-silent until it resolves — poll for its outcome.
Terminal status can change (flips)
A terminal status is not always final. The same txnId can reach one terminal state and
later flip to a different terminal state — so you may receive 2+ callbacks for one
txnId, with different statuses, at different times:
- expired → paid: a deposit you saw as
deposit.expiredcan be rescued by a late payment slip and later arrive asdeposit.paid. - paid → rejected: a deposit you saw as
deposit.paidcan be reversed by an operator (a wrongly-credited slip clawed back) and later arrive asdeposit.rejectedwithfailureCode: "admin_unapprove"at a higherstatusRevision. This un-books money you already credited — it is the reversal a merchant most needs to defend against. - success → failed: a payout you saw as
payout.successcan later be corrected / reversed and arrive aspayout.failed.
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, different status.
It is not a duplicate and must not be de-duped away.
To order flips, every terminal callback carries statusRevision (integer, starts at 1,
+1 per terminal callback for that txnId). The highest statusRevision is the current
truth. If statusRevision is missing, treat it as 1.
De-dup vs ordering — both apply.
X-Event-Idde-dups retries of the same event.statusRevisionorders distinct terminal events for the sametxnId. Use both: discard repeatedX-Event-Ids, then among the survivors act on the higheststatusRevision.
⚠️ Money safety — always act on the LATEST terminal status. Do not treat the first terminal callback as final. A later flip can reverse an earlier outcome: a deposit you recorded as
EXPIREDmay later becomePAID(credit the order); a deposit you recorded asPAIDmay later becomeREJECTED(un-book the credit — an operator reversed it); a payout you booked asSUCCESSmay laterFAIL(un-book / claw back). Reconcile to the callback with the higheststatusRevisionfor thattxnId; ignore a callback whosestatusRevisionis lower than one you have already applied.
Flip pair — same txnId, two terminal callbacks over time (note the new X-Event-Id):
// 1) first terminal outcome — statusRevision 1 (X-Event-Id: evt_aaa)
{ "event": "payout.success", "txnId": "PAY-7F3A", "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", "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", "amount": 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", "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 six 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.