Field dictionary
This page lists every field you will encounter. Use it as the reference while you design your own schema.
Six rules that apply to every field
| # | Rule | Example |
|---|---|---|
| 1 | Every money field ends in _minor, no exceptions, and is an integer in satang | THB 5,000.00 = 500000 · THB 75.00 = 7500 |
| 2 | Every timestamp is RFC3339 UTC ending in Z with exactly 3 millisecond digits | "2026-08-21T04:12:33.881Z" · 🔴 no Thai offset in any payload |
| 3 | "Always" = the key is guaranteed present · optional = the key may be absent entirely, not null | if ('legs' in obj) is safer than obj.legs !== null |
| 4 | Fields that can be null say so explicitly in the "Always" column | late_slip_accepted_until: null means there is no late-slip window |
| 5 | Open enums can gain new values without it counting as a breaking change ⇒ every switch needs a default: | reasons[].code, rejection.code, balance_transaction.type, fee components |
| 6 | Counts (leg_count, settled_splits, attempt) are plain integers, not _minor | leg_count: 2 |
Minor units in languages where floats will hurt you
amount_minor is an integer, so never convert it to a float. JavaScript is safe up to 9,007,199,254,740,991 satang (~90 trillion baht), but do not parseFloat and multiply by 100 back and forth.
PHP: use int and intdiv(), not /. MySQL/Postgres: the column must be BIGINT — not DECIMAL, and not INT (a mid-sized merchant's monthly running total overflows INT within a year).
🔴 The four X/V/A/E amounts that get confused most often
This is where merchants lose real money more than anywhere else in this documentation. Read it before you write any crediting code.
| Field | Meaning | What it is for |
|---|---|---|
ordered_amount_minor (X) | The amount that was ordered when the deposit was created | Comparing against the transfer · printing receipts |
transferred_amount_minor (V) | The amount the bank authoritatively verified as transferred | Classifying exact/short/over; never credit it directly on overpayment |
amount_received_minor (A) | The amount the gateway accepted into this order | 🔴 Credit the end user with this · capped at the ordered amount |
overpayment.amount_minor (E) | The excess; absent/treated as zero without overpayment | Open a claim and direct the user to support · never auto-credit or auto-refund it |
Fee and merchant-wallet movement are two additional values with separate jobs: fee_total_minor and credited_amount_minor. These relationships hold on every order:
0 < amount_received_minor = min(transferred_amount_minor, ordered_amount_minor)
0 ≤ fee_total_minor ≤ amount_received_minor
amount_received_minor − fee_total_minor = credited_amount_minor
same-slip overpay: transferred_amount_minor = amount_received_minor + overpayment.amount_minorA later transfer after settled (D17) creates a separate claim/flag and does not rewrite the original settlement evidence's transferred_amount_minor or amount_received_minor.
A real example — the user tops up THB 5,000.00 with a THB 75.00 fee:
{
"ordered_amount_minor": 500000,
"transferred_amount_minor": 500000,
"amount_received_minor": 500000,
"fee_total_minor": 7500,
"credited_amount_minor": 492500,
"partial": false,
"settled_via": "auto"
}- The end user must receive THB 5,000.00 (
amount_received_minor). On an exact payment this equals the transfer; the fee remains the merchant's cost. - The merchant wallet moves by +THB 4,925.00 (
credited_amount_minor).
An amount-only underpayment — THB 5,000.00 ordered, THB 3,000.00 actually transferred, with all five non-amount checks valid, so the system accepts the actual value automatically:
{
"ordered_amount_minor": 500000,
"transferred_amount_minor": 300000,
"amount_received_minor": 300000,
"fee_total_minor": 4500,
"credited_amount_minor": 295500,
"partial": true,
"settled_via": "auto"
}An overpayment — THB 5,000.00 ordered and THB 5,300.00 bank-verified; the order succeeds immediately at THB 5,000.00:
{
"ordered_amount_minor": 500000,
"transferred_amount_minor": 530000,
"amount_received_minor": 500000,
"fee_total_minor": 7500,
"credited_amount_minor": 492500,
"partial": false,
"overpayment": {
"claim_id": "clm_01K3PB6QW1M8HN0DZ2XKQ7RV4T",
"cause": "same_transfer_excess",
"amount_minor": 30000,
"slip_received_at": "2026-08-21T04:19:02.180Z",
"status": "open",
"action_required": "contact_support"
}
}Credit the user and calculate fees on 500000 only. Show the claim id and ask them to contact support about 30000; do not delay deposit success and do not promise an automatic refund.
Two mistakes that are exact opposites
Crediting the user with credited_amount_minor ⇒ every user is short by exactly the fee on every order ⇒ complaints from day one. Moving the wallet by amount_received_minor ⇒ your books run higher than reality by a full month of fees ⇒ you find out at month-end reconciliation, too late to fix cleanly.
The defence: store X/V/A/E separately, plus separate fee and merchant-credit values. In the daily job, assert each row satisfies A = min(V,X), amount_received_minor − fee_total_minor = credited_amount_minor, and, for a same-slip overpayment, V = A + E.
credited_amount_minor is not withdrawable yet
Freshly credited money sits in pending_clearing first, then normally moves to available at clearing_available_at (T+72 hours). You get balance.cleared then; while funds are held this timestamp is null until a release time can be stated.
🔴 balance.cleared is a bucket move, not new money ⇒ never add the amount again. Creating a withdrawal before that time returns 402 insufficient_balance.
The webhook envelope
This shape is identical on every event, with no exceptions.
| Field | Type | Always | Meaning |
|---|---|---|---|
id | string (evt_ + ULID) | ✅ | Event id · globally unique forever · the same value on every attempt and on every manual resend ⇒ safe as your dedupe key |
object | string | ✅ | Constant "event" |
type | string (open enum) | ✅ | Event name, e.g. deposit.settled · 🔴 it only tells you what happened; never decide money with it |
api_version | string YYYY-MM-DD | ✅ | The version you are pinned to when the event was created |
livemode | boolean | ✅ | false = sandbox · 🔴 events never cross between modes — check it matches your environment |
created_at | string RFC3339 UTC | ✅ | When the event was created; unchanged by retries |
sequence | integer (int64) | ✅ | Always increasing per merchant · use it to detect missing events · temporary gaps are normal (deliveries still retrying) · a gap that persists past 4 days is a real loss |
account.merchant_id | string (mch_ + ULID) | ✅ | The merchant account this event belongs to |
account.white_label_id | string (wl_ + ULID) | ✅ | The brand the merchant belongs to |
request.id | string (req_ + ULID) | optional | Present when the event came from an API call · matches request_id in error bodies |
request.idempotency_key | string | optional | The key you sent on creation |
data.object | object | ✅ | The complete business object as of that moment ⇒ no GET needed |
data.previous_attributes | object | null | ✅ (except on immutable objects) | Only the keys that actually changed, plus the old version · null on *.created events · the key is absent entirely for balance_transaction |
version is the heart of persisting state
data.object.version is an integer that increases every time the object changes, and two events for the same object can never share a version.
⇒ Persist with UPDATE … WHERE id = $1 AND version < $new and check rowCount. rowCount = 0 means a stale event arrived late: answer 200 and do nothing. See the version guard.
The deposit_order object
Core — present on every deposit-side event
| Field | Type | Unit | Always | Meaning |
|---|---|---|---|---|
id | string (dep_ + ULID) | — | ✅ | Deposit id · 🔴 never parse it, never assume the prefix · your column must be text, not varchar(20) |
object | string | — | ✅ | Constant "deposit_order" |
version | integer | — | ✅ | Change counter, used for the version guard |
status | string (closed enum, 9 values) | — | ✅ | See State machine |
is_terminal | boolean | — | ✅ | 🔴 Is it actually final — tied to late_slip_accepted_until, not to the status name |
merchant_reference | string ≤ 64 | — | ✅ | Your own reference, sent on creation |
amount_minor | integer | satang | ✅ | The amount ordered at creation |
currency | string | — | ✅ | Currently "THB" only |
created_at | string RFC3339 UTC | — | ✅ | When the order was created |
expires_at | string RFC3339 UTC | — | ✅ | 🔴 When it expires — always use this value, never hardcode. It can be as short as ~4 minutes, and it changes when an order moves from awaiting_liquidity to awaiting_payment |
checkout_url | string URL | — | optional | Hosted checkout page on the brand's domain · the safer path and the recommended one |
liquidity_source | "internal" | "external_gateway" | — | optional | Where the route came from · 🔴 never use it to decide crediting; reporting only |
leg_count | integer | — | optional | How many destinations must be paid · can be > 1 |
legs[] | array | — | optional | The destinations; see the next table · empty [] while awaiting_liquidity |
sender | object | — | ✅ | The sender account you registered (masked) |
metadata | object ≤ 20 keys | — | optional | Your own values, returned unchanged · ≤ 500 bytes each |
legs[] — where the user must transfer
| Field | Type | Unit | Always | Meaning |
|---|---|---|---|---|
leg_ref | string | — | ✅ | Your id for this leg · 🔴 the withdrawal side gets a different value — cross-merchant joining is impossible by design |
sequence | integer | — | ✅ | Position (1, 2, …), for "step 1 of 2" UI |
amount_minor | integer | satang | ✅ | The amount for this leg · all legs sum to the order's amount_minor |
expires_at | string RFC3339 UTC | — | ✅ | Expiry of this leg |
leg_status | "pending" | "complete" | — | ✅ | 🔴 Not a money status. Progress display only |
method | string | — | ✅ | Channel, e.g. "promptpay_qr" |
qr_payload | string | — | optional | Raw QR string · 🔴 never log it, never store it, never forward it — render and discard · a brand can turn this field off |
qr_image_url | string URL | — | optional | QR image on the brand's domain |
receiver.bank_code | string, 3 digits | — | ✅ | Destination bank code |
receiver.bank_name_th | string | — | ✅ | Bank name in Thai |
receiver.account_number_masked | string | — | ✅ | Masked destination account number, e.g. "xxx-x-x1234" · 🔴 the full number is never provided, under any circumstances |
receiver.account_name_masked | string | — | ✅ | Masked receiver name, e.g. "ส*** ใ***" |
{
"leg_count": 2,
"legs": [
{
"leg_ref": "leg_7QF2H8KM",
"sequence": 1,
"amount_minor": 300000,
"expires_at": "2026-08-21T04:14:41.000Z",
"leg_status": "pending",
"method": "promptpay_qr",
"qr_image_url": "https://pay.example-wl.com/qr/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/1.png",
"receiver": {
"bank_code": "014",
"bank_name_th": "ธนาคารไทยพาณิชย์",
"account_number_masked": "xxx-x-x1234",
"account_name_masked": "ส*** ใ***"
}
},
{
"leg_ref": "leg_7QF2H8KN",
"sequence": 2,
"amount_minor": 200000,
"expires_at": "2026-08-21T04:14:41.000Z",
"leg_status": "pending",
"method": "promptpay_qr",
"qr_image_url": "https://pay.example-wl.com/qr/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/2.png",
"receiver": {
"bank_code": "004",
"bank_name_th": "ธนาคารกสิกรไทย",
"account_number_masked": "xxx-x-x9887",
"account_name_masked": "ว*** ป***"
}
}
]
}bank_name_th is always Thai, because it is displayed to Thai end users. In the example above, 014 / ธนาคารไทยพาณิชย์ is Siam Commercial Bank and 004 / ธนาคารกสิกรไทย is Kasikornbank. Other common codes: 002 Bangkok Bank, 006 Krungthai Bank, 030 Government Savings Bank.
legs[0] is not the whole story
One order can be split across several destinations, so your UI needs a "step 1 of 2" mode. Code that reads legs[0].qr_payload and stops leaves the user underpaid, which sends every one of those orders into the review queue.
sender — the sender account you registered
| Field | Type | Always | Meaning |
|---|---|---|---|
sender.bank_code | string, 3 digits | ✅ | Sending bank |
sender.account_number_masked | string | ✅ | The account number you registered, masked |
sender.account_name_th_masked | string | ✅ | The name you registered, masked |
🔴 We never return the name or account number read off the actual slip. The person who really transferred may not be the person you registered (a spouse's account, for example), and that is third-party personal data. What you get instead are boolean comparison results in verification{}.
verification{} — the 6 checks (all boolean)
| Field | Type | Meaning |
|---|---|---|
verification.amount_exact | boolean | Amount matches exactly (no tolerance) |
verification.within_time_window | boolean | The transfer time falls inside the order's window |
verification.sender_bank_match | boolean | Sending bank matches |
verification.sender_account_match | boolean | Sending account number matches |
verification.sender_name_match | boolean | Sending account name matches |
verification.receiver_match | boolean | Destination matches |
An exact payment needs all 6 checks. When the other 5 pass, a short-pay settles automatically at A=V<X, while a same-slip overpayment settles at the order amount with a claim. Only a real non-amount, ambiguity, or policy reason sends the order to under_review; that status is not a rejection.
review{} — present when status = "under_review"
| Field | Type | Always | Meaning |
|---|---|---|---|
review.opened_at | string RFC3339 UTC | ✅ | When it entered the queue |
review.escalated | boolean | ✅ | Whether it has been escalated · 🔴 escalation touches neither money nor status, but version moves |
review.four_eyes_required | boolean | ✅ | Two approvers required ⇒ you can warn the user it may take longer |
review.estimated_resolution_bucket | "under_15m" | "under_1h" | "under_4h" | "extended" | ✅ | Rough time band · safe to show the user directly |
review.reasons[] | array | ✅ | Reasons, ordered by importance · reasons[0] is the one to show if you only have room for one |
Each entry in review.reasons[]
| Field | Type | Always | Meaning |
|---|---|---|---|
code | string (open enum) | ✅ | Reason code, e.g. SENDER_NAME_MISMATCH |
display_th | string | ✅ | 🔴 Thai copy we already wrote for you — display it as-is; do not write your own |
display_en | string | ✅ | English copy |
actionable_by | "end_user" | "support" | ✅ | Who can fix it · end_user = the user can act · support = only staff can |
detail | object | optional | Extra detail, shape varies by code · 🔴 RECEIVER_MISMATCH never carries a detail |
slip{} and liquidity{}
| Field | Type | Always | Meaning |
|---|---|---|---|
slip.received_at | string RFC3339 UTC | optional | When the system accepted the slip |
slip.is_late | boolean | optional | true = uploaded after the order expired or was canceled |
liquidity.wait_started_at | string RFC3339 UTC | optional | When waiting started (present during awaiting_liquidity) |
liquidity.estimated_wait_ms | integer (milliseconds) | optional | Estimated wait |
liquidity.retry_interval_ms | integer (milliseconds) | optional | How often the system re-attempts matching |
liquidity.suggested_amounts_minor[] | array of integer | satang | Amounts that would match immediately if the user changes ⇒ offer them as buttons |
Fields that appear at settled
| Field | Type | Unit | Always | Meaning |
|---|---|---|---|---|
ordered_amount_minor | integer | satang | ✅ | The amount ordered at creation |
transferred_amount_minor | integer | satang | ✅ | The bank-verified transfer (V) |
amount_received_minor | integer | satang | ✅ | 🔴 The amount accepted into the order (A) — credit the user with this |
overpayment | object | null | — | required for settled/reversed | Flag/claim for E; an object on overpayment, otherwise null · never omit the key from a terminal financial snapshot |
fee_total_minor | integer | satang | ✅ | Total fee charged to the merchant |
credited_amount_minor | integer | satang | ✅ | 🔴 What reaches the merchant wallet — move the wallet by this |
partial | boolean | — | ✅ | true = short-pay accepted at actual value (A=V<X), normally with settled_via:'auto' when all five non-amount checks pass and with 'review' only after another real review reason is resolved · same-slip overpayment is false with overpayment{} |
settled_via | "auto" | "review" | — | ✅ | Passed the automated checks, or decided by a reviewer |
balance_transaction_id | string (btxn_ + ULID) | — | ✅ | The matching balance movement |
clearing_available_at | string RFC3339 UTC | null | — | ✅ | When this amount moves to available; null while the funds are held and no release time can be stated |
fee_breakdown[] | array | — | ✅ | Fee detail; see below |
fee_breakdown[] — split by fee component
| Field | Type | Unit | Always | Meaning |
|---|---|---|---|---|
component | string (open enum) | — | ✅ | Type: "processing_fee" | "fixed_fee" | "vat" |
amount_minor | integer | satang | ✅ | The amount for this component |
rate_bps | integer | basis points | optional | The rate, e.g. 150 = 1.50% (only on components charged as a rate) |
{
"fee_total_minor": 7500,
"fee_breakdown": [
{ "component": "processing_fee", "amount_minor": 6500, "rate_bps": 130 },
{ "component": "fixed_fee", "amount_minor": 1000 }
]
}🔴 Split by component only — there is no breakdown of who each portion goes to. When a fee is disputed, this array is the evidence, so store it raw and always parse it by component. component is an open enum, so you need a default:.
Fields for expiry, cancel, rejection, reversal and overpayment
| Field | Type | Always | Meaning |
|---|---|---|---|
late_slip_accepted_until | string RFC3339 UTC | null | ✅ on expiry/cancel events | 🔴 The single clock for the late-slip window · null = there never was a destination to transfer to ⇒ you may close the order |
expired_reason | string | optional | Currently one value only: "no_payment" |
canceled_by | "merchant" | "end_user" | "system" | optional | Who canceled |
cancel_reason_code | string (open enum) | optional | "no_liquidity_available" | "merchant_request" | "end_user_request" | "approval_ttl_expired" |
rejection.code | string (open enum) | optional | Why the slip was not accepted |
rejection.display_th / display_en | string | optional | Copy you can display directly · 🔴 we never expose the reviewer's internal notes |
reversal.reason_code | string (open enum) | optional | "BANK_RECALL" | "ACCOUNT_FROZEN" | "SLIP_INVALIDATED_AFTER_SETTLEMENT" | "DUPLICATE_CREDIT" | "OPS_ERROR" | "MERCHANT_DISPUTE_LOST" |
reversal.reverses_balance_transaction_id | string (btxn_) | optional | Points at the original movement being reversed ⇒ lets you reverse precisely on your side |
reversal.balance_transaction_id | string (btxn_) | optional | The movement created by the reversal itself |
overpayment.claim_id | string | ✅ when overpayment is an object | Case number for the excess |
overpayment.cause | same_transfer_excess | additional_transfer | ✅ when overpayment is an object | Same-transfer excess or an additional transfer |
overpayment.amount_minor | integer, satang | ✅ when overpayment is an object | The excess amount |
overpayment.slip_received_at | string RFC3339 UTC | ✅ when overpayment is an object | When the slip that raised the flag arrived |
overpayment.status | open | ✅ when overpayment is an object | Immutable creation snapshot; read Claim.version for current state |
overpayment.action_required | contact_support | ✅ when overpayment is an object | Ask the user to contact support with the claim id; no automatic refund |
overpayment.expected_response_at | string RFC3339 UTC | optional | First-response time, if assigned |
overpayment{} arrives while status is still settled
A same-transfer excess is included in the single deposit.settled snapshot and credited only up to the ordered amount; a later additional transfer arrives as deposit.overpaid. Both open a claim while data.object.status remains "settled". Ask the user to contact support; do not say a refund has already happened.
⇒ Code that does if (event.type === 'deposit.overpaid') markFailed() cancels an order that already succeeded.
The withdrawal_order object
| Field | Type | Unit | Always | Meaning |
|---|---|---|---|---|
id | string (wdr_ + ULID) | — | ✅ | Withdrawal id |
object | string | — | ✅ | Constant "withdrawal_order" |
version | integer | — | ✅ | Change counter |
status | string (closed enum, 7 values) | — | ✅ | See State machine |
merchant_reference | string ≤ 64 | — | ✅ | Your own reference |
amount_total_minor | integer | satang | ✅ | The withdrawal amount requested |
fee_total_minor | integer | satang | ✅ | Total fee, locked at creation time · 🔴 on the withdrawal side the fee is added on top, not deducted from the amount |
total_locked_minor | integer | satang | ✅ at creation | What was actually locked = amount_total_minor + fee_total_minor |
fee_recognized_minor | integer | satang | ✅ | The portion of the fee consumed so far, proportional to what has been paid out |
filled_minor | integer | satang | ✅ | 🔴 How much has actually been paid out — the single source of truth |
remaining_minor | integer | satang | ✅ | 🔴 How much is left — the single source of truth |
refunded_minor | integer | satang | ✅ at close | What was actually returned to the balance |
settled_splits | integer | — | ✅ | How many fills have settled |
max_splits | integer | — | ✅ | Maximum number of fills allowed |
expires_at | string RFC3339 UTC | — | ✅ | After this the order leaves the matching queue |
fills[] | array | — | optional | Detail of settled fills (present on GET) |
dispute | object | — | optional | Present when the withdrawer reports the money never arrived |
refund_of_deposit_id | string (dep_) | — | optional | Present when this order is a refund to a user (a refund is a kind of withdrawal) |
replacement_of_withdrawal_id | string (wdr_) | — | optional | Present when this order replaces one whose fill was reversed |
Never compute filled_minor / remaining_minor yourself
You will get withdrawal.filled many times for one order, and the arrival order is not guaranteed.
⇒ Do not write remaining += x, and do not SUM() your own event table. Overwrite with the filled_minor / remaining_minor from the payload, through the version guard, and nothing else. Your own fill table exists for timelines and audit, not to drive money logic.
fills[] and the fill{} inside withdrawal.filled
| Field | Type | Unit | Always | Meaning |
|---|---|---|---|---|
fill.leg_ref | string | — | ✅ | 🔴 Id of this fill — use it as the unique key of your fill row |
fill.sequence | integer | — | ✅ | Which fill this is |
fill.amount_minor | integer | satang | ✅ | The amount of this fill |
fill.fee_recognized_minor | integer | satang | ✅ | The fee consumed by this fill |
fill.settled_at | string RFC3339 UTC | — | ✅ | When this fill settled |
fill.balance_transaction_id | string (btxn_) | — | ✅ | The matching balance movement |
fill.auto_confirm_at | string RFC3339 UTC | — | ✅ | If the withdrawer does nothing, receipt is assumed at this time (3 hours) |
fill.receipt_state | string | — | optional (on GET) | Receipt-confirmation state of this fill |
How to store fill rows so reordering cannot hurt you
- Fill rows:
INSERT … ON CONFLICT (leg_ref) DO NOTHING—leg_refis unique, so a repeated event adds no row. - Order totals:
UPDATE … SET filled_minor = $x, remaining_minor = $y WHERE id = $1 AND version < $v— overwrite only whenversionis higher.
These do two different jobs; you need both. See One withdrawal, many fills.
dispute{}
| Field | Type | Always | Meaning |
|---|---|---|---|
dispute.id | string | ✅ | Dispute number |
dispute.reason_code | string (open enum) | ✅ | Why it was opened |
dispute.amount_minor | integer, satang | ✅ | The disputed amount |
dispute.opened_at | string RFC3339 UTC | ✅ | When it was opened |
dispute.respond_by | string RFC3339 UTC | ✅ | 🔴 Evidence must be submitted before this time, via POST /v1/pay/disputes/{id}/evidence |
dispute.evidence_url | string URL | optional | Link to the evidence-submission page |
funds_on_hold | boolean | ✅ | true = the related amount is temporarily held · the order's status does not change |
The balance_transaction object
This object is immutable ⇒ no version and no previous_attributes ⇒ dedupe by id and accumulate, never overwrite.
| Field | Type | Unit | Always | Meaning |
|---|---|---|---|---|
id | string (btxn_ + ULID) | — | ✅ | 🔴 The unique key for dedupe |
object | string | — | ✅ | Constant "balance_transaction" |
type | string (open enum) | — | ✅ | "deposit_settled" | "clearing_released" | "withdrawal_reserved" | "withdrawal_settled" | "withdrawal_released" | "fee" | "reversal" | "adjustment" |
direction | "credit" | "debit" | — | ✅ | Direction |
amount_minor | integer | satang | ✅ | Amount (always positive; the sign lives in direction) |
currency | string | — | ✅ | "THB" |
bucket_from | "available" | "pending_clearing" | "reserved" | null | — | ✅ | Which bucket the money left |
bucket_to | same as bucket_from | — | ✅ | Which bucket it entered |
available_at | string RFC3339 UTC | — | optional | When this amount becomes withdrawable |
balance_after | object | — | ✅ | The three bucket balances after this movement · 🔴 for verification only — never overwrite your own balances with it (events can arrive out of order) |
source.type / source.id | string | — | ✅ | Origin, e.g. {"type":"deposit","id":"dep_…"} |
reverses_balance_transaction_id | string (btxn_) | — | optional | Only on reversal entries |
reason_code / reason_display_th | string | — | optional | Reason for the reversal |
created_at | string RFC3339 UTC | — | ✅ | When the movement happened |
{
"id": "btxn_01K3PB6QW1M8HN0DZ2XKQ7RV4T",
"object": "balance_transaction",
"type": "reversal",
"direction": "debit",
"amount_minor": 492500,
"currency": "THB",
"bucket_from": "pending_clearing",
"bucket_to": null,
"balance_after": { "available_minor": 1284300, "pending_clearing_minor": 0, "reserved_minor": 200000 },
"source": { "type": "deposit", "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B" },
"reverses_balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"reason_code": "SLIP_INVALIDATED_AFTER_SETTLEMENT",
"reason_display_th": "รายการถูกยกเลิกหลังจากตรวจพบว่าสลิปไม่ถูกต้อง",
"created_at": "2026-08-21T09:41:19.884Z"
}reason_display_th above reads: "this order was reversed after the slip was found to be invalid." It is Thai because it is written for Thai end users.
The three balance buckets (GET /v1/pay/balance)
| Field | Type | Unit | Meaning |
|---|---|---|---|
available_minor | integer | satang | 🔴 What you can actually create a withdrawal against — compute withdrawals from this alone |
pending_clearing_minor | integer | satang | Credited but not yet 72 hours old ⇒ not withdrawable yet |
reserved_minor | integer | satang | Locked against withdrawals that are still open |
currency | string | — | "THB" |
as_of | string RFC3339 UTC | — | The point in time this balance is for |
To display to a user, use available_minor + pending_clearing_minor. To compute what can be withdrawn, use available_minor alone.
Fields you send to us
POST /v1/pay/deposits
| Field | Type | Required | Notes |
|---|---|---|---|
amount_minor | integer, satang | ✅ | 🔴 Round amounts, no satang remainder — we match by sender identity, not by a unique satang suffix |
currency | string | ✅ | "THB" |
merchant_reference | string ≤ 64 | ✅ | Unique per merchant |
end_user_ref | string ≤ 64 | ✅ | Your user id · also used with GET /v1/pay/senders |
sender{} or sender_id | object / string | ✅ | 🔴 One of the two is required; neither ⇒ 422 sender_required |
sender.bank_code | string, 3 digits | ✅ (when using sender{}) | From GET /v1/pay/banks |
sender.account_number | string, digits only | ✅ (when using sender{}) | Strip - and spaces before sending |
sender.account_name_th | string | ✅ (when using sender{}) | 🔴 No honorific prefix (นาย/นาง/น.ส./คุณ — Mr./Mrs./Ms./generic polite prefix) |
sender.account_name_en | string | optional | Improves the automated-verification pass rate |
client.device_fingerprint | string | technically optional, send it anyway | Omitting it drops the open-order cap to 1 and puts you in the shared rate-limit bucket |
client.ip | string | same as above | |
client.user_agent | string | same as above | |
fill_preference | "any" | "internal_only" | optional | Defaults to "any" |
min_ttl_seconds | integer (seconds) | optional | 0–86,400; values that cannot fit the clamped order lifetime return 422 |
expires_in_seconds | integer (seconds) | optional | 60–86,400 and further capped by brand policy |
return_url | string URL | optional | |
metadata | object ≤ 20 keys | optional | ≤ 500 bytes each |
POST /v1/pay/withdrawals
| Field | Type | Required | Notes |
|---|---|---|---|
amount_minor | integer, satang | ✅ | The withdrawal amount (fee not included) |
currency | string | ✅ | "THB" |
merchant_reference | string ≤ 64 | ✅ | |
end_user_ref | string ≤ 64 | ✅ | |
beneficiary_id or beneficiary{} | string / object | ✅ | 🔴 The account must already be verified, otherwise 409 beneficiary_not_verified |
beneficiary.bank_code / account_number / account_name_th / promptpay_id | string | ✅ (when using beneficiary{}) | promptpay_id is optional |
client{} | object | same as the deposit side | |
metadata | object | optional |
Headers on every request
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer sk_live_* | ✅ | Keep it in a secret manager so it can be rotated without a deploy |
PG-Timestamp | ✅ | Unix seconds · may differ from real time by ±300 seconds |
PG-Nonce | ✅ | 🔴 New on every attempt; no repeats within 10 minutes |
PG-Version | ✅ | The API version you pinned, e.g. 2026-08-01 · 🔴 it is part of the canonical string |
Idempotency-Key | ✅ on POST only | 🔴 Unchanged for the whole logical operation · also part of the canonical string |
PG-Signature | ✅ | v1= + lowercase hex HMAC-SHA256 |
Headers on webhooks we send you
| Header | Notes |
|---|---|
X-PG-Signature | Format v1,kid=<kid>,t=<unix_seconds>,s=<base64url, no padding> · 🔴 parse it as key=value pairs, never by position |
X-PG-Event-Id | Must match id in the body |
X-PG-Event-Type | Event name |
X-PG-Delivery-Id | Id of this delivery |
X-PG-Endpoint-Id | Destination endpoint for this delivery |
X-PG-Attempt | Attempt number (1–11) |
X-PG-Resend | Optional · absent normally · manual on a manual resend |
X-PG-Api-Version | The version the payload was rendered with |
X-PG-Livemode | true / false |
The immutable business event is in the body; delivery metadata is in headers only. Delivery headers are not part of the canonical signature, so use them only for logging/tracing after verification, never for money, authorization, or state decisions.
Pairs that get confused
| Confusable pair | The difference | How to remember |
|---|---|---|
transferred_amount_minor vs amount_received_minor | Bank-verified settlement evidence V vs accepted/credited A | They differ on same-slip overpayment; a later additional transfer stays in E and does not make V cumulative |
amount_minor vs amount_received_minor | amount_minor = what was ordered · amount_received_minor = what was accepted into the order | They differ on partial:true; overpayment makes them equal and adds a flag |
amount_received_minor vs credited_amount_minor | The user's number vs the merchant's number | The user gets A; the merchant gets A after fees |
ordered_amount_minor vs amount_minor | Same value, but ordered_amount_minor appears in deposit.settled so one payload contains ordered, bank-verified and accepted amounts | Classify short/exact/over with transferred_amount_minor, then credit amount_received_minor |
fee_total_minor (deposit) vs fee_total_minor (withdrawal) | Deposit: deducted from what reaches the wallet · Withdrawal: added on top of what you must have | Deposit received − fee = credited · Withdrawal needs available ≥ amount + fee |
fee_total_minor vs fee_recognized_minor | Locked in full at creation vs actually consumed, proportional to what was paid out | An order that expires mid-way refunds the difference between them |
filled_minor vs amount_total_minor | Paid out so far vs ordered in total | Close only when they are equal and remaining_minor = 0 |
remaining_minor vs reserved_minor | Not claimed by anyone vs someone is transferring right now | total = filled + reserved + remaining, always |
refunded_minor vs amount_total_minor | Actually returned vs originally ordered | 🔴 An expired order that partly paid out returns only refunded_minor |
available_minor vs pending_clearing_minor | Withdrawable vs not withdrawable until 72 hours pass | Display the sum; compute withdrawals from available_minor |
expires_at (order) vs legs[].expires_at | The whole order's deadline vs that leg's deadline | The on-screen countdown uses the order's |
late_slip_accepted_until vs expires_at | The late-slip window vs the deadline to transfer | late_slip_accepted_until is always later (24 hours more) |
is_terminal vs status | Actually final vs the status name | 🔴 Check is_terminal, not the status name |
leg_ref vs deposit_id / withdrawal id | Id of a leg/fill vs id of the order | The two sides' leg_ref values do not match, by design |
event.type vs data.object.status | What happened vs what is true now | 🔴 Decide from data.object.status |
event.id vs X-PG-Delivery-Id | Event id in the body (constant across attempts) vs delivery id in the header (changes every time) | Dedupe by event.id only |
sequence vs version | Event order across the whole account vs change order of one object | Detect missing events with sequence; stop stale overwrites with version |
merchant_reference vs id | Your number vs our number | 🔴 Use our id for tickets and reconciliation |
balance.credited vs balance.cleared | New money vs a bucket move from pending_clearing to available | 🔴 balance.cleared must not be added again |
Retired names
If you find a name on the left in older docs or in your own code, replace it with the one on the right.
| Retired | Correct name |
|---|---|
amount_received · fee_total · credited_amount | amount_received_minor · fee_total_minor · credited_amount_minor |
payment_instructions[] · bank_account · promptpay | legs[] |
allocation_id | leg_ref |
source | liquidity_source |
late_slip_until | late_slip_accepted_until |
cancel_reason | cancel_reason_code |
reversal_of · ledger_txn_id | reversal.reverses_balance_transaction_id and reversal.balance_transaction_id |
reference · order_ref | merchant_reference (a name valid only on your side) |
decision_source | settled_via |
Timestamps with a +07:00 offset | RFC3339 UTC ending in Z, 3 millisecond digits |
Next
| If you are… | Go to |
|---|---|
| Designing your own tables | State machine, to see which columns you need |
| Writing a webhook handler | Overview and envelope · Every event |
| Seeing wrong withdrawal totals | Ordering is not guaranteed — the version guard |
Wondering what details each error carries | Every error code |
| Migrating from another gateway | Migrating from another provider |
| About to go live | Go-live checklist |
