Glossary
The terms used across this documentation, with the place you actually meet each one in code.
If you only have a minute, read these three pairs
| pair | the difference |
|---|---|
amount_received_minor ↔ credited_amount_minor | the first is what you credit the end user · the second is what lands in your merchant wallet |
leg ↔ fill | a leg is a destination to transfer to (deposit side) · a fill is one chunk of money that arrived (withdrawal side) |
pending_clearing ↔ available | credited but not yet withdrawable ↔ genuinely withdrawable (72 hours apart) |
People and orders
Payer (depositor) Your end user, the one topping up. They are the person who taps "transfer" in a real banking app.
Withdrawer The owner of the bank account that receives the transfer. They are a customer of a different merchant, not a gateway account. This is why the system has no house account in the normal path.
Deposit order — dep_... One top-up order. Has a status with 9 possible values. Never parse the id, never assume the prefix, and store it in a text column.
Withdrawal order — wdr_... One payout order. Has a status with 7 possible values. A single order can be filled by several different payers.
merchant_reference Your own reference. Must be unique per merchant, at most 64 characters. Useful for lookups, but deposit_id is the primary key support uses to trace a case.
end_user_ref The user's id in your system. Used to tie into GET /v1/pay/senders and GET /v1/pay/beneficiaries. 🔴 It is a value you supply and can be spoofed, so the system never uses it alone as a rate-limit dimension.
The transfer side
leg — legs[]One destination the user has to transfer to. A 5,000 order may be split into 2 legs (3,000 + 2,000) going to different accounts, so your UI needs "step 1 of 2". 🔴 legs[0] is not the whole order.
leg_ref The id of a leg as you see it. It is a transformed value, so two merchants on opposite sides of the same order receive different values and cannot join on it. Use it as the key for timeline rows on your side.
leg_status — pending | complete An internal leg state that is surfaced to you. 🔴 complete does not mean the money arrived. Only status === 'settled' means that. Fine for a progress bar, never for a decision about money.
qr_payload The PromptPay string used to render the QR. It unavoidably contains the withdrawer's PromptPay ID ⇒ 🔴 never log it, never store it in a DB, never forward it. Render it and drop it. It lives exactly as long as expires_at.
expires_at The order's real expiry time. 🔴 Read it from the response every time — never hardcode it. Some routes really do expire in ~4 minutes, not 15–20.
The receiving side
fillOne chunk of money arriving against a withdrawal order. A 10,000 withdrawal filled as 4,000 + 6,000 is 2 fills ⇒ the withdrawal.filled webhook arrives twice, not once at the end. withdrawal.filled is the only event in the system that fires more than once for the same object.
allocation The internal pairing between one deposit order and one withdrawal order. 🔴 You never see this object — not its id and not its state. What you see are its two shadows: legs[] on the deposit side, and fills[] / withdrawal.filled on the withdrawal side. ⇒ You do not need an allocation table in your database.
filled_minor / remaining_minor / reserved_minor Received so far / still outstanding / in flight from someone who has not completed yet. 🔴 The source of truth is the value in the payload, applied through the version guard. Never SUM() your own table, never +=.
settled_splits / max_splits Chunks completed so far / the cap on the number of chunks for that order. Use it for UI like "2 of 4 chunks received".
partial fill The state where a withdrawal has received part of its amount. The status is partially_filled. 🔴 You may close the order only when status === 'settled' and remaining_minor === 0.
fee_recognized_minor The portion of the fee recognised so far, in proportion to what actually paid out. A fixed fee is bound to the order, not to each fill, so four chunks do not cost four fixed fees. 🔴 Never compute the fee per fill yourself.
Money in the wallet
balance_transaction — btxn_... One movement line on your wallet. Immutable ⇒ no version, no previous_attributes ⇒ dedupe by id, and accumulate the amounts, never overwrite with balance_after.
bucket — available | pending_clearing | reserved
| bucket | meaning | withdrawable? |
|---|---|---|
pending_clearing | just credited, still inside the 72-hour window | ❌ you get a 402 |
available | cleared | ✅ |
reserved | locked against a withdrawal that is still open | ❌ |
clearing The process of moving money from pending_clearing to available once 72 hours have passed since settled_at.
- The move time is announced in advance as
clearing_available_aton the deposit itself. - When it actually moves you receive the webhook
balance.cleared. - 🔴
balance.clearedis a move between buckets, not new money — adding the amount again here doubles the balance.
Why 72 hours — the withdrawer can dispute within that window, and a true dispute has to be reversed. A reversal inside 72 hours is taken from pending_clearing, so your available balance never goes negative.
When things do not go to plan
under_review A slip with a failed non-amount check, ambiguity, or policy hold goes to a human queue. An amount-only short-pay with the other five checks valid settles automatically at actual value; an overpayment settles at the order amount with a claim/flag. 🔴 It is not an error and it is not a refusal of the money. Show review.reasons[0].display_th verbatim, in yellow or blue rather than red, and with no "transfer again" button.
The 6 checks — verification{}amount_exact · within_time_window · sender_bank_match · sender_account_match · sender_name_match · receiver_match. All are booleans. Exact needs all six. When the other five pass, a short-pay settles automatically at A=V<X, while an overpayment settles at the order amount with a claim. An amount mismatch alone never opens review.
review.estimated_resolution_bucket — under_15m | under_1h | under_4h | extended A rough timeframe you may show to the user. 🔴 It is the only thing you may use to state a wait time. Never hardcode "within 15 minutes" — the timeframe belongs to each White Label.
review.escalated The case has run past its window and was escalated. You receive deposit.under_review again, with a new version. 🔴 There is no automatic approval when time runs out, and there never will be — there is no event called deposit.auto_approved.
late slip — late_slip_accepted_until A 24-hour window for accepting a slip after the fact, for the user who transferred and then closed their browser, or who cancelled and then transferred anyway. ⇒ expired and canceled are not final while this value is present. After the window closes, uploading a slip returns 410 late_slip_window_closed + claim_url.
is_terminal 🔴 The only field that decides whether an order can be closed — not the status name. An expired or canceled order that can still take a late slip has this as false. Even settled is false, because it can still go to reversed.
dispute window The period in which the withdrawer can raise "I did not receive the money" = 72 hours from the moment the fill was completed.
- As soon as a fill completes, the receipt state is "awaiting confirmation" and the payload carries
auto_confirm_at. If the withdrawer does nothing, the system confirms it at 3 hours (webhookwithdrawal.receipt_confirmedwithconfirmed_by: "auto"). - 🔴 That auto-confirmation is not an approval of the money. The money was already settled on the strength of a slip verified against the bank; it only closes the user's window to dispute.
- When a dispute is opened you get
withdrawal.disputed(funds_on_hold: true, andstatusdoes not change), and the payer-side amount is frozen so it cannot clear. - Respond before
dispute.respond_byusingPOST /v1/pay/disputes/{id}/evidence. The outcome arrives aswithdrawal.dispute_resolvedwithoutcome ∈ confirmed | reversed.
reversal — deposit.reversed / withdrawal.fill_reversed / balance.debited A debit or correction after crediting, with the emitted events determined by the reason:
- a true transfer failure (
BANK_RECALL/ACCOUNT_FROZEN/SLIP_INVALIDATED_AFTER_SETTLEMENT) may emitwithdrawal.fill_reversed - a deposit-only correction (
DUPLICATE_CREDIT/OPS_ERROR/MERCHANT_DISPUTE_LOST) emits onlydeposit.reversed+balance.debited, notwithdrawal.fill_reversed, and does not change the withdrawal, its fills, or its withdrawal-side fee
🔴 All three event types are mandatory subscriptions. Handle them from day one, but do not assume every reason emits all three events.
overpayment / claim There are two paths. Excess on the same slip is included in the single deposit.settled event as overpayment{cause:'same_transfer_excess'}. A transfer made after settlement arrives as deposit.overpaid with cause:'additional_transfer'. Both keep status: 'settled', and you credit only amount_received_minor, capped at the ordered amount. 🔴 The excess opens a claim with status:'open' and action_required:'contact_support'. Do not touch the original order, never credit the excess to the merchant wallet, and never say it has been refunded: there is no automatic refund.
claim — POST /v1/bo/claims The manual escalation channel for cases the system cannot recover automatically (transferred but never uploaded a slip · past the 24-hour window · transferred against an old QR outside any order). Possible outcomes: credited / refund_issued / rejected, delivered by the claim.updated webhook.
Liquidity and routing
liquidity_source — internal | external_gateway Where the destination-side liquidity came from. Only those two values exist. 🔴 Never use it to decide a credit. Fine for analytics or debugging; the deciding field is always status.
external gateway The fallback route used when no withdrawer in the system is available to receive. From your point of view it differs in exactly two ways: liquidity_source is external_gateway, and expires_at is usually much shorter (~4 minutes). The flow, the events, the statuses and the way you credit are all identical. Orders that run through this route come with no third-party reference number, so have the user quote the deposit_id on every channel.
suggested_amounts_minor[] Amounts that can be matched immediately right now. It comes with 202 awaiting_liquidity ⇒ offer them to the user instead of making them wait for nothing.
Accounts on both sides
sender / sender_id The account the user will actually transfer from — not "the account holder in your system". A husband transferring on his wife's behalf means you send the husband's account. 🔴 Required. Omitting it gives 422 sender_required. A sender_id obtained from GET /v1/pay/senders guarantees the order will not land in the review queue over a mismatched name or account number.
beneficiary_id The account that receives a withdrawal. It has to pass name verification with the bank first (webhooks beneficiary.verified / beneficiary.rejected), otherwise creating a withdrawal returns 409 beneficiary_not_verified and no money is locked.
API vocabulary
_minor Every money field ends in _minor and is an integer in satang, with no exceptions. THB 5,000.00 = 500000.
version A number that increases every time the object changes. The system guarantees that no two events for the same object share a version, so you can use it as a guard in UPDATE ... WHERE version < $new and safely tie side effects to rowCount.
sequence A per-merchant event sequence number, increasing by creation time, not delivery time. Temporary gaps are normal (events still being retried). A gap that persists beyond 4 days means something is genuinely missing — go and reconcile.
event.id — evt_... The same value on every attempt and on every manual resend ⇒ use it as the unique key in a transactional inbox. Keep it ≥ 31 days. 🔴 A repeat must answer 200, not 409.
livemodefalse = sandbox · true = live. Events can never cross between the two, so your handler must check that it matches its own environment.
api_version The YYYY-MM-DD version you pinned. Adding a new field, or a new value to an enum declared "open", does not count as a breaking change ⇒ 🔴 every switch over status, reasons[].code, balance_transaction.type and fee_breakdown[].component needs a default:.
canonical string The 9-line string used as the input to the HMAC-SHA256 signature on your outbound requests — see authentication.
Idempotency-Key and PG-Nonce These two do opposite jobs. PG-Nonce must be new on every attempt; Idempotency-Key must stay the same for the whole logical operation.
magic amount The special sandbox amounts from THB 100.00 to 116.00 that force an order down a chosen path — see sandbox.
Things this system does not have, and never will
| what you may know from other gateways | what we have instead |
|---|---|
| cent-level randomisation to match orders | matching by the identity of the sender — amounts are always round |
payment_instructions[] / bank_account / promptpay | legs[] |
an immediate 409 duplicate_slip | successful durable intake answers 202; the duplicate verdict arrives later by webhook |
a callback envelope {code, message, result} that always returns HTTP 200 | real HTTP statuses (201 202 402 409 410 422 429 503) |
| a single number credited to your wallet | four core amounts: ordered_amount_minor (X) / transferred_amount_minor (V) / amount_received_minor (A) / overpayment.amount_minor (E, or zero), plus separate fee_total_minor / credited_amount_minor values |
| "one callback and you are done" | order is not guaranteed · 11 retries over ~92 hours · you need a version guard |
| automatic approval when an SLA expires | none — only review.escalated |
Next
- Still not sure where the money goes → How the system works
- Want to see it running → Quickstart
- Want to simulate the cases these terms come from → Sandbox
- Which field name to use and which to drop → Field dictionary
