Take money in, pay money out, read the result off a webhook

These three are all there is to build. Deposits bring money in, withdrawals send it out, and webhooks are the only way to learn how either one ended.

01

Taking a deposit

One invoice per order. The platform hands back a QR or a wallet address, and the webhook arrives once the customer has paid.

  1. 1

    Create the invoice

    Call createInvoicePayment with your own order_id. The platform returns payment_url and payment_qr.

  2. 2

    Send the customer to pay

    A baht invoice lives 900 seconds, a crypto one 3,600. Past that it expires quietly, with no webhook.

  3. 3

    The platform checks the payment

    The status reads PAYMENT_CHECKING meanwhile; poll if you need to follow it.

  4. 4

    PAYMENT_PAID arrives

    Post to your ledger from this one. With auto convert on, PAYMENT_CONVERTED follows under a different system_id — join back on data.payment.id.

Baht deposit

PromptPay QR, bank transfer, slip or TrueMoney

POST/v1/payment/createInvoicePayment/fiat

order_id · order_user_reference · payment_method_type · amount · from_currency · to_currency · payer_bank_provider · payer_bank_account_number · payer_bank_account_name · payment_domain · url_return · url_success · url_failed · additional_data (optional)

Crypto deposit

USDT on tron, ethereum, bsc, polygon, solana

POST/v1/payment/createInvoicePayment/crypto

order_id · order_user_reference · amount · from_currency · to_currency · chain · payment_domain · one_time_address · payer_pay_network_fee · fee_subtract · discount_percent (optional)

Automatic currency conversion

Add auto_convert: true and convert_to to the same endpoint: take USDT and be credited in baht, or take baht and be credited in USDT. The Binance TH rate is locked when the invoice is created, and the response returns merchant_amount, the amount you will actually receive.

How a P2P deposit differs

The destination is the withdrawer’s account, not the central one

Who creates the record

A P2P deposit session is opened by another system, not by the merchant. Follow it with GET /v1/p2p/public/flow/:flowId

The money skips the central account

The depositor pays the withdrawer directly, and that single transfer closes both the deposit and the withdrawal

Rules the depositor follows

They have 10 minutes from seeing the destination account, and must pay from the account registered at creation. The QR belongs to one order and cannot be reused unconfirmed

It still completes without a match

With nobody to match, the platform switches to the bank channel within the same request and order, and deposit_type comes back as CLASSIC

What the deposit side sends you

eventeventwhat to do
The customer paidPAYMENT_PAIDPost to your ledger here. deposit_type says whether it came P2P or CLASSIC
Auto convert is onPAYMENT_CONVERTEDFollows PAYMENT_PAID under a different system_id
Duplicate slipPAYMENT_FAILEDThe only case that produces it: DUPLICATE_SIGNATURE
The record was cancelledPAYMENT_CANCELEDIncludes the platform auto-cancelling an older invoice when the same payer creates a new one
Expired, cancelled, or slip under reviewno webhookUse GET /v1/payment/info instead
02

Making a withdrawal

Create the request and wait for the terminal event. The status changes several times on the way, but only the last webhook is safe to post from.

  1. 1

    Pre-approval, if you enabled it

    WITHDRAWAL_VERIFY arrives before the record exists. Answer 2xx within 10 seconds; answering late or otherwise means rejected.

  2. 2

    The record is created and the balance reserved

    It starts at PENDING. No webhook yet.

  3. 3

    The platform works the record

    Through APPROVED and INPROGRESS, which you only see by polling. On P2P you also get a progress webhook each time someone funds a leg.

  4. 4

    The terminal event arrives

    Branch on outcome.result rather than the event name, and post to your ledger only here.

Baht withdrawal

To a bank account number or PromptPay

POST/v1/withdrawal/createRequest/fiat

withdrawal_mode · order_id · amount · currency · receiver_bank · receiver_name · withdrawal_address · chain · asset_type · additional

Crypto withdrawal

USDT by chain and token standard

POST/v1/withdrawal/createRequest/crypto

order_id · currency · amount · withdrawal_address · chain · asset_type · additional (optional)

Supported destinations

receiver_bank takes either "PromptPay" or a bank code; the bank list comes live from GET /v1/ebank/bankConfig. PromptPay covers four proxy types — mobile number, national ID, tax ID and e-wallet ID — declared through promptpay_id_type.

Status and event do not line up one to one

withdrawal_statusmeansevent sent
PENDINGBalance reserved, waiting to be workednone yet
APPROVEDApprovedpoll for it
INPROGRESSTransfer under waypoll for it
COMPLETEDThe customer received the full amountWITHDRAWAL_COMPLETED
FAILEDClosed having paid part of it; the customer already has some of the moneyWITHDRAWAL_COMPLETED_PARTIALLY
REJECTEDNothing paid out, refunded in fullWITHDRAWAL_REJECT
FREEZEDBounced by the bank — the money neither reached the customer nor came backno webhook

Three traps that throw the numbers off

  • The event name misleads — status FAILED sends an event called WITHDRAWAL_COMPLETED_PARTIALLY. Read outcome.result instead: FULLY_SETTLED, PARTIALLY_SETTLED or NOT_SETTLED
  • The amount to post — use summary.withdrawer_received, not withdrawal.amount
  • The refund amount — refund the customer summary.withdrawer_pending_refund only. outcome.refunded_amount is the merchant’s refund, fee recovery included, and is always larger

How a P2P withdrawal differs

Same endpoint, but the money comes from people who are depositing

PromptPay only

Same endpoint, but the destination has to be PromptPay

Funded one leg at a time

Legs open as people arrive rather than being split up front. Every settled leg sends one WITHDRAWAL_PARTIALLY_FUNDED, the last one included, and none of them may be posted to your ledger

The central account covers the gap

After 30 minutes still short, the central account transfers the remainder through the bank. The withdrawal still ends as WITHDRAWAL_COMPLETED; summary.channel reads HYBRID unconfirmed

THB_P2P credit

Debited for two things: the fee on every transaction, and whatever the central account transfers. That second amount is absent from the payload — reconcile it from balance history. Out of credit means no new transactions

See the real payload for every P2P event, with the cases we ran on testnet
03

Working with webhooks

One endpoint receives both sides, delivery is once with no retry, which makes the order of work inside your handler matter more than you would expect.

Set up on the API Key & Webhook page

Set the Webhook URL, then generate the App Secret for HMAC. The secret is shown once and cannot be read back. With no webhook configured you get neither events nor an error to notice.

Allow these IPs

13.228.225.19 · 18.142.128.26 · 54.254.162.138 · 74.220.52.0/24 · 74.220.60.0/24

Change the URL and withdrawals stop for 24 hours

With Automatic Approve Withdrawal on, changing the Webhook URL or switching it off blocks new withdrawals for 24 hours. Regenerating the secret carries no cooldown.

Verifying the signature

x-signature = "sha256=" + HMAC_SHA256(secret, x-timestamp + "." + JSON.stringify(body.data))

Sign the inner data object only, not the whole body, and read it from the raw body before parsing. Python needs separators=(',',':') with ensure_ascii=False; PHP needs JSON_UNESCAPED_UNICODE and JSON_UNESCAPED_SLASHES, or the strings will not match.

The unit differs from outbound API calls

The x-timestamp that arrives with a webhook is in seconds, 10 digits, while the x-timestamp you send on an API call is milliseconds, 13 digits. Use the wrong unit and every signature fails.

Headers you receive

headerexamplewhat it is for
trust-x-eventWITHDRAWAL_COMPLETEDRoute before parsing JSON; matches body.event
trust-x-request-idreq_1786251928335_…Quote it when raising a support ticket
x-idempotency-key22266595-…:WITHDRAWAL_COMPLETEDThe only key usable for deduplication
x-signaturesha256=19c38d2a…Verify with the formula above
x-timestamp1786251928Freshness check, and part of the signed string
user-agentaxios/1.13.2Nothing to verify; log it

The order inside your handler

Ordered so the cheapest check runs first and the 200 goes out in time

  1. 1

    Check x-timestamp freshness first

    Cheaper than computing the HMAC. Reject anything older than 300 seconds.

  2. 2

    Verify the signature against the raw body

    Compare timing-safe; 401 on a mismatch.

  3. 3

    Claim the x-idempotency-key

    One table is enough. Put a unique index on it and use INSERT … ON CONFLICT DO NOTHING; if you have seen it, return 200 quietly.

  4. 4

    Answer 200 quickly

    Within 10 seconds, then hand the real work to a worker.

  5. 5

    Let the worker branch on the event

    Lock different rows for the deposit and the withdrawal, and let terminal events always beat progress ones.

The one exception

WITHDRAWAL_VERIFY has to be decided inside the same request; never queue it. 2xx approves, anything else rejects.

Delivery rules

Delivered once

No retry, so you always need a reconciliation job that polls back

No ordering guarantee

The closing event can land before the last progress one; let the terminal event always win

You can resend it yourself

POST /v1/webhookRequest/resend/:id reuses the same key and skips deduplication, so the handler has to tolerate repeats

Never error on an unknown event

The platform counts it as a failed delivery and there is no retry. Log it and return 200

Deduplication keys

eventkey formatwhy
general{system_id}:{EVENT}One record, one event, one delivery
WITHDRAWAL_PARTIALLY_FUNDED{match_id}:{event}:{reason}Keyed on the leg rather than the record, so several legs cannot collapse into one, and on the reason, so a leg that settles then expires announces twice
WITHDRAWAL_VERIFYno id yetThe record does not exist yet when it fires

Every event

Deposit side

PAYMENT_PAIDPAYMENT_CANCELEDPAYMENT_FAILEDPAYMENT_CONVERTED

Withdrawal side

WITHDRAWAL_VERIFYWITHDRAWAL_PARTIALLY_FUNDEDWITHDRAWAL_COMPLETEDWITHDRAWAL_COMPLETED_PARTIALLYWITHDRAWAL_REJECT

WITHDRAWAL_REJECT has no -ED on the end; spelt otherwise it never matches

Crypto only

WITHDRAWAL_APPROVEDWITHDRAWAL_EXPIREDWITHDRAWAL_FAILED

Other traffic on the same endpoint

TRANSACTION_NEWclassic recordscrypto records

Unknown ones get logged and answered 200 — never throw

Events that send no webhook

These send no webhook; poll for them instead

eventfind out from
Slip under reviewGET /v1/payment/info
Deposit expiredGET /v1/payment/info
Matched, or the queue expiredGET /v1/p2p/public/flow/:flowId
Dispute opened, and while it waits on an adminGET /v1/p2p/match/:matchId
Withdrawal created successfullyfrom the create response only
Withdrawal bounced by the bankGET /v1/withdrawal/detail/:id
What the central account covered, and refundsbalance history only