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.
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
Create the invoice
Call createInvoicePayment with your own order_id. The platform returns payment_url and payment_qr.
- 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
The platform checks the payment
The status reads PAYMENT_CHECKING meanwhile; poll if you need to follow it.
- 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/fiatorder_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/cryptoorder_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
| event | event | what to do |
|---|---|---|
| The customer paid | PAYMENT_PAID | Post to your ledger here. deposit_type says whether it came P2P or CLASSIC |
| Auto convert is on | PAYMENT_CONVERTED | Follows PAYMENT_PAID under a different system_id |
| Duplicate slip | PAYMENT_FAILED | The only case that produces it: DUPLICATE_SIGNATURE |
| The record was cancelled | PAYMENT_CANCELED | Includes the platform auto-cancelling an older invoice when the same payer creates a new one |
| Expired, cancelled, or slip under review | no webhook | Use GET /v1/payment/info instead |
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
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
The record is created and the balance reserved
It starts at PENDING. No webhook yet.
- 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
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/fiatwithdrawal_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/cryptoorder_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_status | means | event sent |
|---|---|---|
| PENDING | Balance reserved, waiting to be worked | none yet |
| APPROVED | Approved | poll for it |
| INPROGRESS | Transfer under way | poll for it |
| COMPLETED | The customer received the full amount | WITHDRAWAL_COMPLETED |
| FAILED | Closed having paid part of it; the customer already has some of the money | WITHDRAWAL_COMPLETED_PARTIALLY |
| REJECTED | Nothing paid out, refunded in full | WITHDRAWAL_REJECT |
| FREEZED | Bounced by the bank — the money neither reached the customer nor came back | no 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
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
| header | example | what it is for |
|---|---|---|
| trust-x-event | WITHDRAWAL_COMPLETED | Route before parsing JSON; matches body.event |
| trust-x-request-id | req_1786251928335_… | Quote it when raising a support ticket |
| x-idempotency-key | 22266595-…:WITHDRAWAL_COMPLETED | The only key usable for deduplication |
| x-signature | sha256=19c38d2a… | Verify with the formula above |
| x-timestamp | 1786251928 | Freshness check, and part of the signed string |
| user-agent | axios/1.13.2 | Nothing to verify; log it |
The order inside your handler
Ordered so the cheapest check runs first and the 200 goes out in time
- 1
Check x-timestamp freshness first
Cheaper than computing the HMAC. Reject anything older than 300 seconds.
- 2
Verify the signature against the raw body
Compare timing-safe; 401 on a mismatch.
- 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
Answer 200 quickly
Within 10 seconds, then hand the real work to a worker.
- 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
| event | key format | why |
|---|---|---|
| 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_VERIFY | no id yet | The record does not exist yet when it fires |
Every event
Deposit side
PAYMENT_PAIDPAYMENT_CANCELEDPAYMENT_FAILEDPAYMENT_CONVERTEDWithdrawal side
WITHDRAWAL_VERIFYWITHDRAWAL_PARTIALLY_FUNDEDWITHDRAWAL_COMPLETEDWITHDRAWAL_COMPLETED_PARTIALLYWITHDRAWAL_REJECTWITHDRAWAL_REJECT has no -ED on the end; spelt otherwise it never matches
Crypto only
WITHDRAWAL_APPROVEDWITHDRAWAL_EXPIREDWITHDRAWAL_FAILEDOther traffic on the same endpoint
TRANSACTION_NEWclassic recordscrypto recordsUnknown ones get logged and answered 200 — never throw
Events that send no webhook
These send no webhook; poll for them instead
| event | find out from |
|---|---|
| Slip under review | GET /v1/payment/info |
| Deposit expired | GET /v1/payment/info |
| Matched, or the queue expired | GET /v1/p2p/public/flow/:flowId |
| Dispute opened, and while it waits on an admin | GET /v1/p2p/match/:matchId |
| Withdrawal created successfully | from the create response only |
| Withdrawal bounced by the bank | GET /v1/withdrawal/detail/:id |
| What the central account covered, and refunds | balance history only |
