Skip to content

State machine ​

This page is the map of every status that webhooks and REST responses can hand you. If you read only one page in the reference section, read this one.

The one rule you cannot forget

Only status = "settled" means you may credit. Every other value: do not release goods, do not increase the user's balance, do not ship.

slip_processing and under_review mean the money has moved but the check is not finished. They are not "failed" and they are not "success".

Three levels of status — only two of them drive money decisions ​

LevelRead fromValuesCan it decide money?
Depositdata.object.status on a deposit_order object9✅ Only settled = credit the user.
Withdrawaldata.object.status on a withdrawal_order object, plus filled_minor / remaining_minor7✅ Only settled and remaining_minor = 0 = close the order.
Leg / filllegs[].leg_status (deposit side) · fills[] (withdrawal side)2 (pending | complete)🔴 No. Progress bars only.

leg_status = "complete" does not mean money arrived

legs[] is "where the user has to transfer", not "the state of the money". One deposit can be split into two destinations (leg_count: 2); leg 1 can be complete while the deposit as a whole is nowhere near settled.

The only thing that means money arrived is status = "settled".

Every object that changes state carries a version (integer) that increases on every change, and two events for the same object can never share a version. Use it to stop an older event from overwriting a newer one — see Ordering is not guaranteed — the version guard.


Deposits ​

Diagram ​

mermaid
stateDiagram-v2
    direction TB
    [*] --> awaiting_liquidity: POST /v1/pay/deposits answers 202 (no destination yet)
    [*] --> awaiting_payment: POST /v1/pay/deposits answers 201 (matched immediately)

    awaiting_liquidity --> awaiting_payment: destination found (deposit.matched)
    awaiting_liquidity --> canceled: genuinely out of options (no_liquidity_available)

    awaiting_payment --> slip_processing: consumer claims receipt after HTTP 202
    awaiting_payment --> expired: past expires_at with no slip
    awaiting_payment --> canceled: canceled (only while no slip exists)

    slip_processing --> settled: exact passes all 6, or amount-only short/overpay passes the other 5
    slip_processing --> under_review: a real non-amount, ambiguity, or policy reason needs a human
    slip_processing --> awaiting_payment: image unreadable, time left
    slip_processing --> expired: image unreadable, time is up

    expired --> slip_processing: late slip within 24h
    canceled --> slip_processing: late slip (always forced into the queue)
    expired --> under_review: fallback route confirmed after the order closed
    canceled --> under_review: fallback route confirmed after the order closed

    under_review --> under_review: escalated on timeout (new version, money untouched)
    under_review --> settled: reviewer approves
    under_review --> rejected: reviewer does not accept this slip

    settled --> reversed: bank recall / account freeze / dispute lost

    rejected --> [*]
    reversed --> [*]

    note right of settled
      the only point where you may credit
      deposit.settled + 2 balance.credited
      (deposit_settled and fee)
    end note
    note left of expired
      expired is not a rejection of the money
      the late-slip window closes at
      late_slip_accepted_until
    end note
    note right of under_review
      there is no "timed out, auto-approve" edge
      and there never will be
    end note

What each status means ​

statusMeaning💰 Where the money isActually final?
awaiting_liquidityOrder created, but no destination has been found yet · no QR, no account number yetNot transferredNo
awaiting_paymentDestination matched; legs[] and expires_at are presentNot transferred (still with the user)No
slip_processingSlip received, being checked against the bankTransferred, awaiting the checkNo
under_reviewFailed non-amount check / ambiguity / policy hold / another human decision · reasons in review.reasons[] · amount-only short-pay/overpay with the other 5 valid skips the queueTransferred, awaiting the checkNo
settledCredited; balance_transaction_id is presentIn the system · actually withdrawable after clearing_available_at (T+72h)Almost — the only exit is reversed
expiredTime ran out with no slip · 🔴 not a rejection of the moneyUsually not transferred · in the late-slip case, transferredCheck is_terminal
canceledClosed before transfer — merchant/user canceled, or the system ran out of destinationsUsually not transferredCheck is_terminal
rejectedA reviewer decided not to accept this slip · reason in rejection.display_thNot credited, no ledger entry✅ Final
reversedWas settled, then reversedDebited back (balance.debited)✅ Final

rejected does not mean the money was refunded

rejected means "this slip will not be used to credit this order". If real money already left the user's account, that goes through claims; it does not end at this status.

What you must do, and what to tell the end user ​

The Thai copy below is tested wording that measurably reduces support tickets. Use it as-is — your end users read Thai.

statusWhat you must doThai copy for the end user (English gloss)
awaiting_liquidityWaiting screen with a spinner · offer amounts from liquidity.suggested_amounts_minor[] · 🔴 no QR, no "transfer now", no failure message"กำลังจัดหาช่องทางโอน กรุณารอสักครู่" (Finding a transfer route, please wait a moment.)
awaiting_paymentShow every entry in legs[], a countdown from expires_at, and a "transfer only from the account you registered" box more prominent than the QR"โอนเงิน 5,000 บาท จากบัญชีที่แจ้งไว้ ภายใน 08:24 น." (Transfer THB 5,000 from the account you registered, before 08:24.)
slip_processingDisable the re-upload button · 🔴 do not release goods"กำลังตรวจสอบสลิปกับธนาคาร ใช้เวลาไม่เกิน 1 นาที" (Checking the slip with the bank, under 1 minute.)
under_reviewShow review.reasons[0].display_th verbatim · yellow or blue, never red · 🔴 no "transfer again" buttonUse the review.reasons[0].display_th we send. Every string has all three beats: the slip was received / what it is waiting on / the money is safe, do not transfer again.
settledCredit the end user with amount_received_minor · move your own wallet by credited_amount_minor"เติมเงินสำเร็จ 5,000 บาท" (Top-up successful, THB 5,000.)
settled + partial: trueCredit what actually arrived, then offer a new order for the difference"ได้รับยอดที่โอนจริง 3,000 บาท จากรายการ 5,000 บาท" (Received the amount actually transferred, THB 3,000, of a THB 5,000 order.)
expired (is_terminal: false)🔴 Keep the slip-upload button until late_slip_accepted_until. Do not close the order."หมดเวลาแล้ว ถ้าคุณโอนไปแล้ว อัปโหลดสลิปได้ภายใน 24 ชั่วโมง" (Time is up. If you already transferred, you can upload the slip within 24 hours.)
canceled + late_slip_accepted_until present🔴 Identical to expired (the cancel-then-accidentally-transfer race)"ยกเลิกรายการแล้ว ถ้าคุณเผลอโอนไปแล้ว อัปโหลดสลิปได้ถึง …" (Order canceled. If you transferred by mistake, you can still upload the slip until …)
canceled + is_terminal: trueSafe to close · offer a "create a new order" button"ยกเลิกรายการแล้ว" (Order canceled.)
rejectedShow rejection.display_th plus a copyable deposit_id"รายการไม่ผ่านการตรวจสอบ ติดต่อฝ่ายบริการพร้อมเลขที่รายการ" (This order did not pass verification. Contact support with the order number.)
reversed🔴 Debit the user back and always notify — this event cannot be unsubscribed"รายการถูกยกเลิกภายหลัง ยอดถูกปรับกลับ" (This order was reversed; the amount has been adjusted back.)
overpayment{} present (status still settled)Credit only amount_received_minor; show the excess, claim_id, and a contact-support action · do not promise a refund"รายการสำเร็จตามยอดที่สร้าง แต่พบยอดโอนเกิน กรุณาติดต่อฝ่ายบริการพร้อมเลขเรื่อง" (The order succeeded at its ordered amount, but we found an excess transfer. Contact support with the claim number.)

The two statuses merchants close too early

expired and canceled are not always the end. Check is_terminal, not the status name.

While is_terminal is false and late_slip_accepted_until has not passed, the user can still upload a slip after the fact — and you will get deposit.settled for an order you already marked failed. Your handler must be able to accept settled from any internal state of yours.

The under_review reasons ​

review.reasons[] is already ordered by importance — if your screen only has room for one line, use reasons[0].

The middle column below is the gist of each reason, so you can tell them apart at a glance. It is not the string to render: the exact wording ships in the payload as display_th / display_en.

codeGistWho can fix it (actionable_by)
AMOUNT_MISMATCHThe amount differs and another review reason also exists; an amount-only mismatch never opens reviewend_user
TIME_WINDOWThe transfer time falls outside this order's windowsupport
SENDER_BANK_MISMATCHThe sending bank is not the one registeredend_user
SENDER_ACCOUNT_MISMATCHThe sending account is not the one registeredend_user
SENDER_NAME_MISMATCHThe sender's account name does not match the one registeredend_user
RECEIVER_MISMATCHTransferred to the wrong destination account; contact support (🔴 no account details are given, and you must not guess them)end_user
BANK_DISABLEDThe sending bank has temporarily stopped automated verification; staff will check manuallysupport
DUPLICATE_SLIPThis slip has already been usedend_user
SLIP_INVALIDThe slip failed bank verification; staff are checkingsupport
SLIP_UNREADABLEThe slip image cannot be read (arrives in deposit.slip_rejected, not under_review)end_user
AMBIGUOUS_MATCHThe system could not match the order unambiguously; staff are checkingsupport
PROVIDER_UNAVAILABLEAutomated verification is temporarily unavailable; staff will check manuallysupport
SENDER_ACCOUNT_UNAVAILABLESame as PROVIDER_UNAVAILABLEsupport
LATE_SLIPThe slip arrived after the order expired; staff are checkingsupport
CANCELED_ORDER_SLIPThe slip arrived after the order was canceled; staff are checkingsupport
ALLOCATION_CONFLICTOrders overlap; staff are checking (a dispute is open)support
SETTLE_CONFLICTAutomated verification is temporarily unavailable; staff will check manuallysupport
ROUTE_VERIFICATION_PENDINGThe fallback route needs extra verification; staff are checkingsupport
LATE_ROUTE_CONFIRMATIONThe fallback route confirmed after the order closed; staff are checkingsupport
NO_SLIP_IMAGEA reference number was received; staff are checking with the banksupport
RISK_HOLDThe order is under a safety reviewsupport

You do not write the copy yourself

Every reason ships with display_th and display_en inside the payload. ReviewReasonCode is an open enum — we can add codes without it counting as a breaking change, so your code must always have a default: that falls back to rendering display_th.


Withdrawals ​

Diagram ​

mermaid
stateDiagram-v2
    direction TB
    [*] --> pending_approval: POST /v1/pay/withdrawals (approval required) · locks amount + fee
    [*] --> open: POST /v1/pay/withdrawals (no approval needed) · locks amount + fee

    pending_approval --> open: back office approves
    pending_approval --> rejected: back office rejects · full refund
    pending_approval --> canceled: canceled / 24h approval timeout · full refund

    open --> open: a payer is matched (withdrawal.matched · status unchanged)
    open --> partially_filled: first fill lands (withdrawal.filled)
    partially_filled --> partially_filled: next fill lands (withdrawal.filled again)
    open --> settled: a single fill covers the whole amount
    partially_filled --> settled: filled_minor = amount_total_minor

    open --> expired: past expires_at with remaining left
    partially_filled --> expired: past expires_at with remaining left
    open --> canceled: cancel just the remainder
    partially_filled --> canceled: cancel just the remainder

    settled --> settled: a fill is reversed (withdrawal.fill_reversed · status never goes back)
    partially_filled --> partially_filled: a fill is reversed (status never goes back)

    settled --> [*]
    expired --> [*]
    canceled --> [*]
    rejected --> [*]

    note right of partially_filled
      one withdrawal takes many fills
      withdrawal.filled arrives repeatedly
      close only when settled AND remaining_minor = 0
    end note

What each status means ​

statusMeaning💰 The withdrawer's moneyStill eligible for matching?
pending_approvalCreated, waiting for back-office approvalamount_total_minor + fee_total_minor already deducted from available balance, 0 paid out❌
openApproved, in the matching queue, no fill settled yetFully locked · may have reserved_minor > 0✅ while before expires_at
partially_filledAt least one fill has settled, not complete yetfilled_minor paid out · the rest still locked✅ while remaining_minor > 0
settledfilled_minor = amount_total_minorFully released❌
expiredPast expires_at with an unfilled remainderrefunded_minor returned, plus any unused fee❌
canceledThe remainder was canceled deliberatelySame as expired❌
rejectedBack office rejected it during pending_approval (the only way into this status)Full refund❌

expired / canceled does not mean nothing was paid out

A THB 10,000 withdrawal that paid out 4,000 and then expired is status: "expired" with filled_minor: 400000.

⇒ Never refund the user the full amount just because you saw expired. Read refunded_minor, which is what was actually returned. Every payload carries amount_total_minor / filled_minor / refunded_minor / fee_recognized_minor as separate fields.

What you must do, and what to tell the end user ​

statusWhat you must doThai copy for the end user (English gloss)
pending_approvalKeep the cancel button available · 🔴 do not allow editing the amount or the destination account (cancel and create a new one)"คำขอถอนอยู่ระหว่างอนุมัติ" (Withdrawal request is awaiting approval.)
openShow the locked total_locked_minor"กำลังจัดหาผู้โอน" (Finding a payer.)
partially_filled🔴 Render it as multi-fill progress, not a single status badge · read the numbers from filled_minor / remaining_minor"ได้รับแล้ว 4,000 จาก 10,000 บาท กำลังรอส่วนที่เหลือ" (Received 4,000 of 10,000 THB, waiting for the rest.)
settledClose the order only if remaining_minor = 0 as well"ถอนสำเร็จ 10,000 บาท" (Withdrawal complete, THB 10,000.)
expiredRefund by refunded_minor, not amount_total_minor"ส่วนที่เหลือ 6,000 บาท ถูกคืนเข้าบัญชีของคุณแล้ว" (The remaining THB 6,000 has been returned to your account.)
canceledSame as expired"ยกเลิกรายการถอนแล้ว ยอดที่เหลือถูกคืนแล้ว" (Withdrawal canceled; the remaining amount has been refunded.)
rejectedShow rejection.display_th · a new request can be created"คำขอถอนไม่ได้รับอนุมัติ" (The withdrawal request was not approved.)
dispute{} present (status unchanged)Submit evidence before dispute.respond_by via POST /v1/pay/disputes/{id}/evidence"เราได้รับแจ้งว่ายังไม่ได้รับเงิน กำลังตรวจสอบ" (We were told the money has not arrived; we are investigating.)

The biggest difference from the market on the withdrawal side

Typical provider: 1 withdrawal = 1 callback at the end. Us: one withdrawal can produce withdrawal.filled 7 times, because the amount is filled by several different payers.

Code written as if (callback) closeWithdrawal() closes the order on the first fill. The correct close condition is status === 'settled' && remaining_minor === 0. Details at One withdrawal, many fills.


Dimensions that are not status but change the answer ​

Status does not tell you everything. These fields run parallel to status and change what the UI has to do.

FieldWhereWhy it matters
is_terminaldeposit_orderAn expired/canceled order with is_terminal: false can still take a slip for 24 more hours.
late_slip_accepted_untildeposit_order (may be null)The single clock for the late-slip window · null = there never was a destination to transfer to, so you may close it.
partialdeposit.settledtrue = short-pay accepted at actual value (A=V<X), with settled_via:'auto' when all five non-amount checks pass and 'review' only after another review reason is resolved; overpayment is false with overpayment{}.
settled_viadeposit.settledauto | review — for reporting, not for decisions.
clearing_available_atdeposit.settledWhen this amount moves from pending_clearing to available (T+72h) ⇒ before this time, creating a withdrawal returns 402.
review.escalateddeposit_order.reviewEscalated after a timeout · version moves, but status and money do not.
review.estimated_resolution_bucketdeposit_order.reviewunder_15m | under_1h | under_4h | extended — safe to show the user.
overpayment{}deposit_orderA same-transfer excess or an additional transfer · 🔴 status stays settled; presence is the flag, while Claim is the latest support state.
dispute{}withdrawal_orderThe withdrawer says the money never arrived · status unchanged · funds_on_hold: true.
filled_minor / remaining_minorwithdrawal_orderThe single source of truth for how much has been filled — 🔴 never compute it yourself by summing the events you received.

So how do you write the code ​

It reduces to three lines.

  1. Read status from data.object.status, not from event.type — event.type only tells you what happened.
  2. Persist it with one UPDATE carrying a version guard, not SELECT-then-if-then-UPDATE.
  3. Hang side effects off the result of the UPDATE (rowCount), not off the event type.

The UPDATE below assumes you inserted both the deposit row and pg_deposit_correlations from the create response (201/202). If that precondition is not guaranteed, use the atomic UPSERT from Event ordering; never mistake rowCount=0 on a missing row for a stale event.

sql
-- version guard: a late-arriving old event cannot overwrite newer state
-- (rowCount = 0 ⇒ answer 200 and do nothing)
UPDATE deposits
   SET status        = $2,
       version       = $3,
       is_terminal   = $4,
       late_slip_accepted_until = $5,
       ordered_amount_minor = $6,
       transferred_amount_minor = $7,
       amount_received_minor = $8,
       fee_total_minor = $9,
       credited_amount_minor = $10,
       overpayment   = $11,
       updated_at    = now()
 WHERE deposit_id = $1
   AND version    < $3
RETURNING status, amount_received_minor, credited_user_at;
js
// Run inside the inbox worker: version + DB money + outbox + processed_at commit together.
await db.tx(async (t) => {
  const { rows: [correlation] } = await t.query(
    `SELECT our_order_id, our_user_id
       FROM pg_deposit_correlations
      WHERE pg_deposit_id = $1`, [obj.id]);
  if (!correlation) throw new Error('missing deposit correlation; retry webhook');

  const { rowCount, rows } = await t.query(UPDATE_SQL, [
    obj.id, obj.status, obj.version, obj.is_terminal, obj.late_slip_accepted_until ?? null,
    obj.ordered_amount_minor ?? null, obj.transferred_amount_minor ?? null,
    obj.amount_received_minor ?? null, obj.fee_total_minor ?? null,
    obj.credited_amount_minor ?? null, JSON.stringify(obj.overpayment ?? null)
  ]);

  // Run even for an older snapshot version: every D17 transfer has a distinct claim_id.
  if (obj.overpayment) await db.appendOrValidateOverpaymentFlag(t, obj)

  if (rowCount === 0) {
    await t.query(`UPDATE pg_webhook_inbox SET processed_at = now() WHERE event_id = $1`, [event.id]);
    return;                                               // stale event — safe to drop
  }

  if (obj.status === 'settled' && rows[0].credited_user_at === null) {
    const credited = await t.query(
      `UPDATE deposits SET credited_user_at = now()
        WHERE deposit_id = $1 AND status = 'settled'
          AND credited_user_at IS NULL`, [obj.id]);
    if (credited.rowCount === 1) {
      await t.query(`UPDATE users SET balance_minor = balance_minor + $1 WHERE id = $2`,
        [rows[0].amount_received_minor, correlation.our_user_id]);
      await enqueueEffect(t, `deposit:${obj.id}`, 'release_goods', {
        order_id: correlation.our_order_id });
    }
  }

  if (obj.status === 'reversed') {
    const reversed = await t.query(
      `UPDATE deposits SET reversed_at = now()
        WHERE deposit_id = $1 AND status = 'reversed'
          AND credited_user_at IS NOT NULL AND reversed_at IS NULL
        RETURNING amount_received_minor`, [obj.id]);
    if (reversed.rowCount === 1) {
      await t.query(`UPDATE users SET balance_minor = balance_minor - $1 WHERE id = $2`,
        [reversed.rows[0].amount_received_minor, correlation.our_user_id]);
      await enqueueEffect(t, `deposit:${obj.id}`, 'hold_goods', {
        order_id: correlation.our_order_id });
    }
  }

  await t.query(`UPDATE pg_webhook_inbox SET processed_at = now() WHERE event_id = $1`, [event.id]);
});

Persist pg_deposit_correlations from the create request + response before processing webhooks, as shown in webhook ordering. metadata is optional and may be absent altogether. If the correlation has not arrived yet, roll back and return non-2xx for a retry—never commit the version and look for the user afterwards.

appendOrValidateOverpaymentFlag must execute the pg_deposit_overpayment_flags insert-or-validate from the ordering page, always before the stale-version return. The deposit row's overpayment value is only the latest projection; it cannot replace the history of multiple claims.

enqueueEffect is the transactional-outbox helper from that page. Never call an external service inside the database transaction. One-shot work uses a resource scope (deposit:${obj.id}) and is enqueued only when the business guard returns rowCount = 1.

Four X/V/A/E amounts with different meanings

ordered_amount_minor (X) = what the order was created for.

transferred_amount_minor (V) = what the bank authoritatively verified as transferred.

amount_received_minor (A) = what the gateway accepted into this order → this is what you credit the end user with; on overpayment it equals X.

overpayment.amount_minor (E) = the excess in an open support claim. Same-slip overpayment must satisfy V = A + E; never auto-credit or auto-refund it.

credited_amount_minor = the amount after fees → this is what moves your merchant wallet.

amount_received_minor − fee_total_minor = credited_amount_minor, on every order, always. Swap them and either every user is short by exactly the fee, or your books run high all month.


Transition → event map ​

Use this to check your handlers cover everything.

Deposits
────────
  (create)                      → awaiting_liquidity  ⇒ deposit.created + deposit.awaiting_liquidity
  (create)                      → awaiting_payment    ⇒ deposit.created + deposit.matched
  awaiting_liquidity            → awaiting_payment    ⇒ deposit.matched
  awaiting_liquidity            → canceled            ⇒ deposit.canceled (no_liquidity_available)
  awaiting_payment              → slip_processing     ⇒ deposit.slip_received
  awaiting_payment              → expired             ⇒ deposit.expired (is_terminal=false)
  awaiting_payment              → canceled            ⇒ deposit.canceled (is_terminal=false)
  slip_processing               → awaiting_payment    ⇒ deposit.slip_rejected (retryable=true)
  slip_processing               → expired             ⇒ deposit.slip_rejected + deposit.expired
  slip_processing               → settled             ⇒ deposit.settled + balance.credited ×2
  slip_processing               → under_review        ⇒ deposit.under_review
  expired | canceled            → slip_processing     ⇒ deposit.slip_received (is_late=true)
  expired | canceled            → under_review        ⇒ deposit.under_review (LATE_ROUTE_CONFIRMATION)
  under_review [escalate]         under_review        ⇒ deposit.under_review (escalated=true, new version)
  under_review                  → settled             ⇒ deposit.settled + balance.credited ×2
  under_review                  → rejected            ⇒ deposit.rejected
  [a leg moves]                   (status unchanged)  ⇒ deposit.leg_updated
  settled [T+72h]                 (status unchanged)  ⇒ balance.cleared
  settled                       → reversed            ⇒ deposit.reversed + balance.debited
  settled [a second slip]         (status unchanged)  ⇒ deposit.overpaid
  expired | canceled [window closes] (status unchanged) ⇒ deposit.expired | deposit.canceled (is_terminal=true)

  🔴 There is no "timed out, auto-approve" edge, and there never will be.
  🔴 There is no awaiting_liquidity → expired edge (an order that never had a destination is always canceled).

Withdrawals
───────────
  (create)                      → pending_approval | open ⇒ withdrawal.created + balance.debited
  pending_approval              → open                ⇒ (no dedicated event — read data.object.status)
  pending_approval              → rejected            ⇒ withdrawal.rejected + balance.credited
  pending_approval              → canceled            ⇒ withdrawal.canceled + balance.credited
  open [on every match]           open                ⇒ withdrawal.matched
  open                          → partially_filled    ⇒ withdrawal.filled + balance.debited
  partially_filled              → partially_filled    ⇒ withdrawal.filled + balance.debited
  open | partially_filled       → settled             ⇒ withdrawal.filled + withdrawal.settled
  open | partially_filled       → expired             ⇒ withdrawal.expired + balance.credited
  open | partially_filled       → canceled            ⇒ withdrawal.canceled + balance.credited
  [a fill is reversed]            (status never goes back) ⇒ withdrawal.fill_reversed + balance.credited
  [withdrawer confirms receipt]   (status unchanged)   ⇒ withdrawal.receipt_confirmed
  [a dispute opens]               (status unchanged)   ⇒ withdrawal.disputed → withdrawal.dispute_resolved

The 5 events you cannot unsubscribe from

deposit.reversed · withdrawal.fill_reversed · balance.debited · webhook.key.rotating · webhook.endpoint.disabled

The first three take money out of your wallet. Every endpoint is required to accept all five; you cannot turn them off, so you need handlers on day one.


Next ​

If you are…Go to
Looking for every deposit status in detail with sample payloadsThe 9 deposit statuses
Handling repeated withdrawal.filled correctlyOne withdrawal, many fills
Seeing statuses go backwards or wrong withdrawal totalsOrdering is not guaranteed — the version guard
Wondering what a field means and whether it is always presentField dictionary
Getting an HTTP error and unsure whether to retryEvery error code
About to go liveGo-live checklist

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