Skip to content

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 ​

#RuleExample
1Every money field ends in _minor, no exceptions, and is an integer in satangTHB 5,000.00 = 500000 · THB 75.00 = 7500
2Every 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 nullif ('legs' in obj) is safer than obj.legs !== null
4Fields that can be null say so explicitly in the "Always" columnlate_slip_accepted_until: null means there is no late-slip window
5Open 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
6Counts (leg_count, settled_splits, attempt) are plain integers, not _minorleg_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.

FieldMeaningWhat it is for
ordered_amount_minor (X)The amount that was ordered when the deposit was createdComparing against the transfer · printing receipts
transferred_amount_minor (V)The amount the bank authoritatively verified as transferredClassifying 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 overpaymentOpen 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_minor

A 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:

json
{
  "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:

json
{
  "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:

json
{
  "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.

FieldTypeAlwaysMeaning
idstring (evt_ + ULID)✅Event id · globally unique forever · the same value on every attempt and on every manual resend ⇒ safe as your dedupe key
objectstring✅Constant "event"
typestring (open enum)✅Event name, e.g. deposit.settled · 🔴 it only tells you what happened; never decide money with it
api_versionstring YYYY-MM-DD✅The version you are pinned to when the event was created
livemodeboolean✅false = sandbox · 🔴 events never cross between modes — check it matches your environment
created_atstring RFC3339 UTC✅When the event was created; unchanged by retries
sequenceinteger (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_idstring (mch_ + ULID)✅The merchant account this event belongs to
account.white_label_idstring (wl_ + ULID)✅The brand the merchant belongs to
request.idstring (req_ + ULID)optionalPresent when the event came from an API call · matches request_id in error bodies
request.idempotency_keystringoptionalThe key you sent on creation
data.objectobject✅The complete business object as of that moment ⇒ no GET needed
data.previous_attributesobject | 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 ​

FieldTypeUnitAlwaysMeaning
idstring (dep_ + ULID)—✅Deposit id · 🔴 never parse it, never assume the prefix · your column must be text, not varchar(20)
objectstring—✅Constant "deposit_order"
versioninteger—✅Change counter, used for the version guard
statusstring (closed enum, 9 values)—✅See State machine
is_terminalboolean—✅🔴 Is it actually final — tied to late_slip_accepted_until, not to the status name
merchant_referencestring ≤ 64—✅Your own reference, sent on creation
amount_minorintegersatang✅The amount ordered at creation
currencystring—✅Currently "THB" only
created_atstring RFC3339 UTC—✅When the order was created
expires_atstring 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_urlstring URL—optionalHosted checkout page on the brand's domain · the safer path and the recommended one
liquidity_source"internal" | "external_gateway"—optionalWhere the route came from · 🔴 never use it to decide crediting; reporting only
leg_countinteger—optionalHow many destinations must be paid · can be > 1
legs[]array—optionalThe destinations; see the next table · empty [] while awaiting_liquidity
senderobject—✅The sender account you registered (masked)
metadataobject ≤ 20 keys—optionalYour own values, returned unchanged · ≤ 500 bytes each

legs[] — where the user must transfer ​

FieldTypeUnitAlwaysMeaning
leg_refstring—✅Your id for this leg · 🔴 the withdrawal side gets a different value — cross-merchant joining is impossible by design
sequenceinteger—✅Position (1, 2, …), for "step 1 of 2" UI
amount_minorintegersatang✅The amount for this leg · all legs sum to the order's amount_minor
expires_atstring RFC3339 UTC—✅Expiry of this leg
leg_status"pending" | "complete"—✅🔴 Not a money status. Progress display only
methodstring—✅Channel, e.g. "promptpay_qr"
qr_payloadstring—optionalRaw QR string · 🔴 never log it, never store it, never forward it — render and discard · a brand can turn this field off
qr_image_urlstring URL—optionalQR image on the brand's domain
receiver.bank_codestring, 3 digits—✅Destination bank code
receiver.bank_name_thstring—✅Bank name in Thai
receiver.account_number_maskedstring—✅Masked destination account number, e.g. "xxx-x-x1234" · 🔴 the full number is never provided, under any circumstances
receiver.account_name_maskedstring—✅Masked receiver name, e.g. "ส*** ใ***"
json
{
  "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 ​

FieldTypeAlwaysMeaning
sender.bank_codestring, 3 digits✅Sending bank
sender.account_number_maskedstring✅The account number you registered, masked
sender.account_name_th_maskedstring✅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) ​

FieldTypeMeaning
verification.amount_exactbooleanAmount matches exactly (no tolerance)
verification.within_time_windowbooleanThe transfer time falls inside the order's window
verification.sender_bank_matchbooleanSending bank matches
verification.sender_account_matchbooleanSending account number matches
verification.sender_name_matchbooleanSending account name matches
verification.receiver_matchbooleanDestination 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" ​

FieldTypeAlwaysMeaning
review.opened_atstring RFC3339 UTC✅When it entered the queue
review.escalatedboolean✅Whether it has been escalated · 🔴 escalation touches neither money nor status, but version moves
review.four_eyes_requiredboolean✅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[]

FieldTypeAlwaysMeaning
codestring (open enum)✅Reason code, e.g. SENDER_NAME_MISMATCH
display_thstring✅🔴 Thai copy we already wrote for you — display it as-is; do not write your own
display_enstring✅English copy
actionable_by"end_user" | "support"✅Who can fix it · end_user = the user can act · support = only staff can
detailobjectoptionalExtra detail, shape varies by code · 🔴 RECEIVER_MISMATCH never carries a detail

slip{} and liquidity{} ​

FieldTypeAlwaysMeaning
slip.received_atstring RFC3339 UTCoptionalWhen the system accepted the slip
slip.is_latebooleanoptionaltrue = uploaded after the order expired or was canceled
liquidity.wait_started_atstring RFC3339 UTCoptionalWhen waiting started (present during awaiting_liquidity)
liquidity.estimated_wait_msinteger (milliseconds)optionalEstimated wait
liquidity.retry_interval_msinteger (milliseconds)optionalHow often the system re-attempts matching
liquidity.suggested_amounts_minor[]array of integersatangAmounts that would match immediately if the user changes ⇒ offer them as buttons

Fields that appear at settled ​

FieldTypeUnitAlwaysMeaning
ordered_amount_minorintegersatang✅The amount ordered at creation
transferred_amount_minorintegersatang✅The bank-verified transfer (V)
amount_received_minorintegersatang✅🔴 The amount accepted into the order (A) — credit the user with this
overpaymentobject | null—required for settled/reversedFlag/claim for E; an object on overpayment, otherwise null · never omit the key from a terminal financial snapshot
fee_total_minorintegersatang✅Total fee charged to the merchant
credited_amount_minorintegersatang✅🔴 What reaches the merchant wallet — move the wallet by this
partialboolean—✅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_idstring (btxn_ + ULID)—✅The matching balance movement
clearing_available_atstring 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

FieldTypeUnitAlwaysMeaning
componentstring (open enum)—✅Type: "processing_fee" | "fixed_fee" | "vat"
amount_minorintegersatang✅The amount for this component
rate_bpsintegerbasis pointsoptionalThe rate, e.g. 150 = 1.50% (only on components charged as a rate)
json
{
  "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 ​

FieldTypeAlwaysMeaning
late_slip_accepted_untilstring 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_reasonstringoptionalCurrently one value only: "no_payment"
canceled_by"merchant" | "end_user" | "system"optionalWho canceled
cancel_reason_codestring (open enum)optional"no_liquidity_available" | "merchant_request" | "end_user_request" | "approval_ttl_expired"
rejection.codestring (open enum)optionalWhy the slip was not accepted
rejection.display_th / display_enstringoptionalCopy you can display directly · 🔴 we never expose the reviewer's internal notes
reversal.reason_codestring (open enum)optional"BANK_RECALL" | "ACCOUNT_FROZEN" | "SLIP_INVALIDATED_AFTER_SETTLEMENT" | "DUPLICATE_CREDIT" | "OPS_ERROR" | "MERCHANT_DISPUTE_LOST"
reversal.reverses_balance_transaction_idstring (btxn_)optionalPoints at the original movement being reversed ⇒ lets you reverse precisely on your side
reversal.balance_transaction_idstring (btxn_)optionalThe movement created by the reversal itself
overpayment.claim_idstring✅ when overpayment is an objectCase number for the excess
overpayment.causesame_transfer_excess | additional_transfer✅ when overpayment is an objectSame-transfer excess or an additional transfer
overpayment.amount_minorinteger, satang✅ when overpayment is an objectThe excess amount
overpayment.slip_received_atstring RFC3339 UTC✅ when overpayment is an objectWhen the slip that raised the flag arrived
overpayment.statusopen✅ when overpayment is an objectImmutable creation snapshot; read Claim.version for current state
overpayment.action_requiredcontact_support✅ when overpayment is an objectAsk the user to contact support with the claim id; no automatic refund
overpayment.expected_response_atstring RFC3339 UTCoptionalFirst-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 ​

FieldTypeUnitAlwaysMeaning
idstring (wdr_ + ULID)—✅Withdrawal id
objectstring—✅Constant "withdrawal_order"
versioninteger—✅Change counter
statusstring (closed enum, 7 values)—✅See State machine
merchant_referencestring ≤ 64—✅Your own reference
amount_total_minorintegersatang✅The withdrawal amount requested
fee_total_minorintegersatang✅Total fee, locked at creation time · 🔴 on the withdrawal side the fee is added on top, not deducted from the amount
total_locked_minorintegersatang✅ at creationWhat was actually locked = amount_total_minor + fee_total_minor
fee_recognized_minorintegersatang✅The portion of the fee consumed so far, proportional to what has been paid out
filled_minorintegersatang✅🔴 How much has actually been paid out — the single source of truth
remaining_minorintegersatang✅🔴 How much is left — the single source of truth
refunded_minorintegersatang✅ at closeWhat was actually returned to the balance
settled_splitsinteger—✅How many fills have settled
max_splitsinteger—✅Maximum number of fills allowed
expires_atstring RFC3339 UTC—✅After this the order leaves the matching queue
fills[]array—optionalDetail of settled fills (present on GET)
disputeobject—optionalPresent when the withdrawer reports the money never arrived
refund_of_deposit_idstring (dep_)—optionalPresent when this order is a refund to a user (a refund is a kind of withdrawal)
replacement_of_withdrawal_idstring (wdr_)—optionalPresent 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 ​

FieldTypeUnitAlwaysMeaning
fill.leg_refstring—✅🔴 Id of this fill — use it as the unique key of your fill row
fill.sequenceinteger—✅Which fill this is
fill.amount_minorintegersatang✅The amount of this fill
fill.fee_recognized_minorintegersatang✅The fee consumed by this fill
fill.settled_atstring RFC3339 UTC—✅When this fill settled
fill.balance_transaction_idstring (btxn_)—✅The matching balance movement
fill.auto_confirm_atstring RFC3339 UTC—✅If the withdrawer does nothing, receipt is assumed at this time (3 hours)
fill.receipt_statestring—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_ref is 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 when version is higher.

These do two different jobs; you need both. See One withdrawal, many fills.

dispute{} ​

FieldTypeAlwaysMeaning
dispute.idstring✅Dispute number
dispute.reason_codestring (open enum)✅Why it was opened
dispute.amount_minorinteger, satang✅The disputed amount
dispute.opened_atstring RFC3339 UTC✅When it was opened
dispute.respond_bystring RFC3339 UTC✅🔴 Evidence must be submitted before this time, via POST /v1/pay/disputes/{id}/evidence
dispute.evidence_urlstring URLoptionalLink to the evidence-submission page
funds_on_holdboolean✅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.

FieldTypeUnitAlwaysMeaning
idstring (btxn_ + ULID)—✅🔴 The unique key for dedupe
objectstring—✅Constant "balance_transaction"
typestring (open enum)—✅"deposit_settled" | "clearing_released" | "withdrawal_reserved" | "withdrawal_settled" | "withdrawal_released" | "fee" | "reversal" | "adjustment"
direction"credit" | "debit"—✅Direction
amount_minorintegersatang✅Amount (always positive; the sign lives in direction)
currencystring—✅"THB"
bucket_from"available" | "pending_clearing" | "reserved" | null—✅Which bucket the money left
bucket_tosame as bucket_from—✅Which bucket it entered
available_atstring RFC3339 UTC—optionalWhen this amount becomes withdrawable
balance_afterobject—✅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.idstring—✅Origin, e.g. {"type":"deposit","id":"dep_…"}
reverses_balance_transaction_idstring (btxn_)—optionalOnly on reversal entries
reason_code / reason_display_thstring—optionalReason for the reversal
created_atstring RFC3339 UTC—✅When the movement happened
json
{
  "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) ​

FieldTypeUnitMeaning
available_minorintegersatang🔴 What you can actually create a withdrawal against — compute withdrawals from this alone
pending_clearing_minorintegersatangCredited but not yet 72 hours old ⇒ not withdrawable yet
reserved_minorintegersatangLocked against withdrawals that are still open
currencystring—"THB"
as_ofstring 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 ​

FieldTypeRequiredNotes
amount_minorinteger, satang✅🔴 Round amounts, no satang remainder — we match by sender identity, not by a unique satang suffix
currencystring✅"THB"
merchant_referencestring ≤ 64✅Unique per merchant
end_user_refstring ≤ 64✅Your user id · also used with GET /v1/pay/senders
sender{} or sender_idobject / string✅🔴 One of the two is required; neither ⇒ 422 sender_required
sender.bank_codestring, 3 digits✅ (when using sender{})From GET /v1/pay/banks
sender.account_numberstring, digits only✅ (when using sender{})Strip - and spaces before sending
sender.account_name_thstring✅ (when using sender{})🔴 No honorific prefix (นาย/นาง/น.ส./คุณ — Mr./Mrs./Ms./generic polite prefix)
sender.account_name_enstringoptionalImproves the automated-verification pass rate
client.device_fingerprintstringtechnically optional, send it anywayOmitting it drops the open-order cap to 1 and puts you in the shared rate-limit bucket
client.ipstringsame as above
client.user_agentstringsame as above
fill_preference"any" | "internal_only"optionalDefaults to "any"
min_ttl_secondsinteger (seconds)optional0–86,400; values that cannot fit the clamped order lifetime return 422
expires_in_secondsinteger (seconds)optional60–86,400 and further capped by brand policy
return_urlstring URLoptional
metadataobject ≤ 20 keysoptional≤ 500 bytes each

POST /v1/pay/withdrawals ​

FieldTypeRequiredNotes
amount_minorinteger, satang✅The withdrawal amount (fee not included)
currencystring✅"THB"
merchant_referencestring ≤ 64✅
end_user_refstring ≤ 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_idstring✅ (when using beneficiary{})promptpay_id is optional
client{}objectsame as the deposit side
metadataobjectoptional

Headers on every request ​

HeaderRequiredNotes
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 ​

HeaderNotes
X-PG-SignatureFormat v1,kid=<kid>,t=<unix_seconds>,s=<base64url, no padding> · 🔴 parse it as key=value pairs, never by position
X-PG-Event-IdMust match id in the body
X-PG-Event-TypeEvent name
X-PG-Delivery-IdId of this delivery
X-PG-Endpoint-IdDestination endpoint for this delivery
X-PG-AttemptAttempt number (1–11)
X-PG-ResendOptional · absent normally · manual on a manual resend
X-PG-Api-VersionThe version the payload was rendered with
X-PG-Livemodetrue / 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 pairThe differenceHow to remember
transferred_amount_minor vs amount_received_minorBank-verified settlement evidence V vs accepted/credited AThey differ on same-slip overpayment; a later additional transfer stays in E and does not make V cumulative
amount_minor vs amount_received_minoramount_minor = what was ordered · amount_received_minor = what was accepted into the orderThey differ on partial:true; overpayment makes them equal and adds a flag
amount_received_minor vs credited_amount_minorThe user's number vs the merchant's numberThe user gets A; the merchant gets A after fees
ordered_amount_minor vs amount_minorSame value, but ordered_amount_minor appears in deposit.settled so one payload contains ordered, bank-verified and accepted amountsClassify 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 haveDeposit received − fee = credited · Withdrawal needs available ≥ amount + fee
fee_total_minor vs fee_recognized_minorLocked in full at creation vs actually consumed, proportional to what was paid outAn order that expires mid-way refunds the difference between them
filled_minor vs amount_total_minorPaid out so far vs ordered in totalClose only when they are equal and remaining_minor = 0
remaining_minor vs reserved_minorNot claimed by anyone vs someone is transferring right nowtotal = filled + reserved + remaining, always
refunded_minor vs amount_total_minorActually returned vs originally ordered🔴 An expired order that partly paid out returns only refunded_minor
available_minor vs pending_clearing_minorWithdrawable vs not withdrawable until 72 hours passDisplay the sum; compute withdrawals from available_minor
expires_at (order) vs legs[].expires_atThe whole order's deadline vs that leg's deadlineThe on-screen countdown uses the order's
late_slip_accepted_until vs expires_atThe late-slip window vs the deadline to transferlate_slip_accepted_until is always later (24 hours more)
is_terminal vs statusActually final vs the status name🔴 Check is_terminal, not the status name
leg_ref vs deposit_id / withdrawal idId of a leg/fill vs id of the orderThe two sides' leg_ref values do not match, by design
event.type vs data.object.statusWhat happened vs what is true now🔴 Decide from data.object.status
event.id vs X-PG-Delivery-IdEvent id in the body (constant across attempts) vs delivery id in the header (changes every time)Dedupe by event.id only
sequence vs versionEvent order across the whole account vs change order of one objectDetect missing events with sequence; stop stale overwrites with version
merchant_reference vs idYour number vs our number🔴 Use our id for tickets and reconciliation
balance.credited vs balance.clearedNew 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.

RetiredCorrect name
amount_received · fee_total · credited_amountamount_received_minor · fee_total_minor · credited_amount_minor
payment_instructions[] · bank_account · promptpaylegs[]
allocation_idleg_ref
sourceliquidity_source
late_slip_untillate_slip_accepted_until
cancel_reasoncancel_reason_code
reversal_of · ledger_txn_idreversal.reverses_balance_transaction_id and reversal.balance_transaction_id
reference · order_refmerchant_reference (a name valid only on your side)
decision_sourcesettled_via
Timestamps with a +07:00 offsetRFC3339 UTC ending in Z, 3 millisecond digits

Next ​

If you are…Go to
Designing your own tablesState machine, to see which columns you need
Writing a webhook handlerOverview and envelope · Every event
Seeing wrong withdrawal totalsOrdering is not guaranteed — the version guard
Wondering what details each error carriesEvery error code
Migrating from another gatewayMigrating from another provider
About to go liveGo-live checklist

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01