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}/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 onlydeposits-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.

RotatePOST {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.

RevokePOST {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
  • 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| > 60s401 { "error": "signature_timestamp_skew" }). Treat 60 s as the current value, not a fixed constant. Generate t right before you sign.
  • 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).
  • The signature covers "<t>.<rawBody>" (the literal t, 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:

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

  • 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, 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_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 method404 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}/deposits-create (scope deposit)

  • Auth: API key + HMAC (X-Client-Id + X-Signature) — see INDEX.
  • Idempotency: required Idempotency-Key.

Request body

FieldTypeReqNotes
amountnumberTHB. Integer baht — any decimal is silently floored (100.99 → 100).
methodstring"qr" or "manual_transfer".
request_idstringYour 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_numberstringPayer's bank account number. (Alias: expected_source_account_no.)
customer_bank_account_namestringPayer's account holder name.
customer_bank_bank_codestringPayer's bank code. Must be a recognized Thai bank code (case-insensitive; portal variants like KTBBIZktb are normalized). Unknown/junk → 400 UNSUPPORTED_SOURCE_BANK. Enumerate the valid codes via balance-banks.md.
customer_bank_bank_namestringOptional payer bank name.
promptpay_idstringOptional PromptPay proxy.
client_reference_idstringOptional extra client-side reference — echoed back as clientReferenceId on this deposit's callbacks (see callbacks.md).
callback_endpoint_keystringPreconfigured callback endpoint (see below).
metadataobject≤ 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 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.
  • No status field is returned on create — a new deposit is implicitly awaiting payment. Obtain status via deposit status poll / get-by-id.
  • channel is "QR" or "TRANSFER" — chosen by the system from the assigned bank.
  • 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 · 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_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 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 onlyX-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 expiredrescue): { "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).

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}/payouts-create (scope payout)

  • Auth: API key + HMAC (X-Client-Id + X-Signature) — see INDEX.
  • Idempotency: required Idempotency-Key.

Request body

FieldTypeReqNotes
amountnumberTHB withdrawal amount. You must have sufficient available balance (balance − frozen) to cover amount + fee.
dest_bank_codestringDestination bank code (e.g. KBANK, SCB).
dest_account_numberstringDestination account number.
request_idstringYour 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_namestringDefaults to dest_bank_code.
dest_account_namestringDestination account holder name.
client_reference_idstringExtra client-side reference.
callback_endpoint_keystringPreconfigured 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.

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 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.
  • 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 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_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"
}
  • paidAmount is the amount when status is paid, otherwise null.
  • paidAt / expiresAt are null until 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": "…"
}
  • failureReason is set only on a failed payout; bankTransactionId is set once the bank leg carries one (otherwise null).

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 onlyX-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 onlyX-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, netAmount is amount − fee — a reporting figure only, not a wallet movement. It is neither the sum sent to the destination (that is amount) nor the amount debited from your wallet (that is amount + fee, held on create and finalized only on success). Do not reconcile your wallet against netAmount.

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 onlyX-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,
  "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 onlyX-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.
amountMinnumberamount >= amountMin.
amountMaxnumberamount <= amountMax.
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 onlyX-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).

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 onlyX-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"
}
FieldMeaning
balanceTotal wallet balance (THB).
frozenAmount held against in-flight payouts.
availableSpendable balance = balance − frozen. A payout is rejected 402 INSUFFICIENT_FUNDS when it exceeds this.
updatedAtTimestamp 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 onlyX-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 "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.

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

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 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…", "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).

EventstatusTerminal?
deposit.paidPAIDyes
deposit.rejectedREJECTEDyes
deposit.expiredEXPIREDyes
payout.successSUCCESSyes
payout.failedFAILEDyes
payout.cancelledCANCELLEDyes

The six 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 — so you may receive 2+ callbacks for one txnId, with different statuses, 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.

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-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", "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.