How the system works
Choose the profile enabled by your provider
The Live P2P implementation 0092 profile lists six implemented operations pending validation/deployment. The supported sandbox profile describes isolated testing separately. The expanded flows below describe the Target contract; they do not confirm every endpoint is enabled on your deployment.
By the end of this page you will know where the money goes, what we do, and what your code has to handle that no other gateway ever forced on you.
The mechanism in three sentences
- Money never passes through our accounts. The end user topping up (the payer) transfers directly into the bank account of a withdrawer — a customer of a different merchant.
- We do the matching and the verification. We match a deposit against a withdrawal of the right size, hand you the destination account, verify the slip against the bank, then move the ledger on both sides.
- An exact payment needs all 6 checks; amount-only mismatches settle automatically when the other 5 checks pass. A short-pay is accepted at actual value (
A=V<X,partial:true,settled_via:'auto'); an overpayment credits no more than the order and opens a claim for the excess. Review is only for a real non-amount, ambiguity, or policy reason.
The one condition that means "you may credit"
data.object.status === 'settled' and nothing else. deposit.slip_received, legs[].leg_status === 'complete', HTTP 202 — none of those mean the money arrived.
Where the money goes
flowchart LR
U["Your end user<br/>tops up THB 5,000"]
W["Withdrawer<br/>a customer of another merchant<br/>withdrawing THB 10,000"]
B["Bank"]
subgraph GW["Gateway (us)"]
M["Match deposit to withdrawal<br/>issue legs[] + expires_at"]
V["Verify slip against the bank, 6 checks"]
L["ledger + webhook"]
M --> V --> L
end
U ==>|"real money, sent from a banking app"| B
B ==>|"into the withdrawer's personal account"| W
U -.->|"request destination / upload slip"| GW
GW -.->|"legs[] + webhook"| U
GW -.->|"withdrawal.filled"| WSolid lines are real money, dotted lines are data — and the solid lines never touch the Gateway box. Almost every rule in this documentation follows from that.
Timeline of a single deposit
sequenceDiagram
autonumber
actor U as End user
participant M as Your backend
participant G as Gateway
participant B as Bank
actor P as Withdrawer
U->>M: taps "top up 5,000"
M->>G: GET /v1/pay/senders?end_user_ref=u_88213
G-->>M: already-verified sender accounts (sender_id)
M->>G: POST /v1/pay/deposits (sender_id + client{} + Idempotency-Key)
alt matched immediately
G-->>M: 201 status=awaiting_payment + legs[] + expires_at
else no liquidity yet
G-->>M: 202 status=awaiting_liquidity + suggested_amounts_minor
G-->>M: webhook deposit.matched (status=awaiting_payment)
end
M-->>U: QR / account number + countdown from expires_at
U->>B: transfers from the registered account
B-->>P: money lands in the withdrawer's account directly
U->>M: uploads the slip
M->>G: POST slip_upload.upload_url + slip_upload.token
G-->>M: 202 status=accepted + receipt_id
G->>G: async claim receipt → slip_processing
G->>B: verify the slip against the bank
B-->>G: sender / receiver / amount / time
alt exact amount and all 6 checks pass
G-->>M: webhook deposit.settled
M->>M: credit the user with amount_received_minor
G-->>M: webhook balance.credited (into pending_clearing)
Note over G,M: T+72h → webhook balance.cleared (withdrawable now)
else overpayment and the other 5 checks pass
G-->>M: deposit.settled with overpayment claim/flag
M->>M: credit only the order amount + ask the user to contact support about the excess
else short-pay and the other five checks pass
G-->>M: deposit.settled (A=V<X, partial:true, settled_via:auto)
M->>M: immediately credit the amount actually accepted
else a non-amount, ambiguity, or policy reason exists
G-->>M: webhook deposit.under_review + reasons[].display_th
G-->>M: deposit.settled or deposit.rejected later
endFour amount figures you must not mix up
deposit.settled separates the ordered amount (X), bank-verified transfer (V), amount accepted into the order (A), and excess (E). Every value is an integer in satang. The overpayment key is always present in a settled snapshot; without excess it is null, and E = 0.
| field | what it is for | exact THB 5,000 example |
|---|---|---|
ordered_amount_minor (X) | amount the order was created for | 500000 |
transferred_amount_minor (V) | amount the bank verified as transferred | 500000 |
amount_received_minor (A) | 🔴 amount accepted into the order and credited to your end user | 500000 |
overpayment.amount_minor (E) | excess tracked by a claim; absent on exact/underpay | 0 |
The fee and merchant-wallet movement are two additional values derived from A: fee_total_minor = 7500 and credited_amount_minor = 492500.
The relationship your reconcile job must assert on every order:
amount_received_minor − fee_total_minor = credited_amount_minorFor a same-slip overpayment, assert V = A + E and A = X. The order remains settled, but the user must contact support with the claim_id.
The number-one mistake made by merchants migrating from another gateway
Crediting the end user with credited_amount_minor shorts every single user by the fee, on every order. The fee is borne by the merchant, so the user receives the full amount accepted into the order (A), not the after-fee amount. On overpayment, A is capped at the ordered amount.
Five things that differ from other gateways
1. No house account ⇒ you must declare the sender account when you create the order
What differs — other gateways tell slips apart using cent-level randomisation (1,000.37 / 1,000.42). We tell them apart using the identity of the sender, so amounts are always round numbers that match your product price exactly.
Why — the destination is a withdrawer's personal bank account, receiving transfers from several people at nearly the same time. The system has to know which slip belongs to which order. The cent-randomisation trick makes users type the wrong amount constantly, and it collides once volume is high.
What you have to build — one extra screen where the user picks the account they will transfer from, before the order is created. Send sender{} or sender_id every time; omit both and you get 422 sender_required and no order at all. Delete any code that adds random_cents.
Transferring from a different account is not a rejection
The slip is still accepted. The order moves to under_review with reasons=['SENDER_ACCOUNT_MISMATCH'] and a human decides — it normally ends at settled. 🔴 Never show "you transferred from the wrong account, the money will not arrive" (untrue), and never show a "transfer again" button — that produces a duplicate payment somebody has to chase and refund.
2. Successfully admitted slip uploads answer 202 — there is no immediate 409 duplicate_slip
What differs — POST /v1/pay/deposits/{id}/slip returns 202 after auth/validation, blob storage, and queue confirmation succeed, even for a slip that was already submitted. Other gateways typically run the duplicate check synchronously and hand you a 409 on the spot. If durable intake cannot yet be confirmed, the endpoint returns a retryable 503; resend the same logical evidence with the same Idempotency-Key to reuse the receipt (boundary, key order, and base64 formatting may differ when evidence_fingerprint is unchanged).
Auth differs too — this route does not use sk_live_*/PG HMAC. It uses the one-deposit slip_upload returned with a 201 response; post to its returned upload_url, never a constructed host, and precheck max_bytes/content_types. On the 202 awaiting_liquidity path, your backend obtains the capability with the merchant-HMAC POST /v1/pay/deposits/{id}/slip-token after deposit.matched and before revealing payment instructions.
Why — the slip upload happens after the user's money has already left their account. No code path may lose that evidence. So we write the file to object storage successfully before touching the database, and we always accept the slip first. The duplicate check (a fingerprint of sending_bank_code + trans_ref) runs later in the pipeline.
What you have to build — read the duplicate verdict from the webhook deposit.under_review with reasons[].code = 'DUPLICATE_SLIP', or from GET /v1/pay/deposits/{id} — never from the HTTP status of the upload. After the user taps submit, your UI must say "checking the slip", not "success".
Code that breaks immediately
if (res.status !== 200) throw breaks both the deposit-creation path (202 awaiting_liquidity) and the slip-upload path (202 accepted + receipt). Every 2xx is a success.
3. One withdrawal is filled by several deposits
What differs — elsewhere, 1 withdrawal = 1 callback at the end. Here a 10,000 withdrawal may be filled as 4,000 + 6,000 by two different payers, so withdrawal.filled arrives more than once. A single order can produce 7 webhooks (withdrawal.matched ×3 + withdrawal.filled ×3 + withdrawal.settled ×1).
Why — one payer with exactly 10,000 is far rarer than three payers who together add up to 10,000. Splitting matches faster and shortens the withdrawer's wait.
What you have to build — close a withdrawal only when status === 'settled' and remaining_minor === 0. The UI has to be a multi-line timeline, not a single status pill. Read totals from data.object.filled_minor / remaining_minor through the version guard. 🔴 Never SUM() your own table, and never +=.
4. Webhook order is not guaranteed, and the retry window runs ~92 hours
What differs — other gateways usually send one callback and give up. We retry 11 times over ~92 hours 36 minutes, so a deposit.expired from attempt 4 really can land after deposit.settled.
Why — an endpoint that was down for two days must still receive every event when it comes back, with no manual reconciliation. The price of that is unordered delivery.
What you have to build — every event carries data.object.version. Apply it with an atomic UPDATE ... WHERE version < $new and check rowCount. rowCount = 0 means the event is stale: answer 200 and do nothing. Decide from data.object.status, not from event.type.
5. settled does not mean withdrawable yet
What differs — elsewhere, money in the wallet can be paid out immediately. Here a freshly settled amount sits in pending_clearing for 72 hours before it moves to available.
Why — the withdrawer can raise "I never received the money" within 72 hours, and a true claim has to be reversed. A reversal inside those 72 hours is taken out of pending_clearing, so your available balance never goes negative. Without that window we would have to chase the money afterwards.
What you have to build — keep two separate buckets in your own books, and compute withdrawable funds from available_minor only. Handle balance.cleared at T+72h without adding the amount again (it is a move between buckets, not new money). And brief your finance team about the cash-flow effect before go-live.
expired and canceled are not always final
A user who transferred and then closed their browser can still upload the slip for another 24 hours — so you will receive deposit.settled for an order you already marked as failed. 🔴 Check is_terminal, not the status name. If late_slip_accepted_until is present, keep the slip-upload button visible.
Every status your code has to handle
Deposit — 9 values
| status | where the money is | may you release goods? |
|---|---|---|
awaiting_liquidity | not transferred · no destination yet, do not show a QR | ❌ |
awaiting_payment | not transferred · legs[] + expires_at are available | ❌ |
slip_processing | transferred, verification running | ❌ |
under_review | transferred, waiting on a human | ❌ |
settled | credited | ✅ the only one |
expired | usually not transferred · a late slip may still follow | ❌ |
canceled | usually not transferred · a late slip may still follow | ❌ |
rejected | no credit, no journal entry | ❌ |
reversed | was credited, then pulled back | ❌ take it back from the user |
Withdrawal — 7 values: pending_approval · open · partially_filled · settled · expired · canceled · rejected
expired on a withdrawal does not mean nothing was paid out
A 10,000 withdrawal that received 4,000 and then expired is still expired, with filled_minor equal to 400000. Every payload always carries amount_total_minor / filled_minor / refunded_minor / fee_recognized_minor separately.
Three security mechanisms you have to implement
| mechanism | where | what happens if you skip it |
|---|---|---|
| HMAC-SHA256 on your outbound requests | header PG-Signature on every request | 401 signature_invalid |
| Ed25519 on inbound webhooks | header X-PG-Signature — we hold the private key, you hold the public key | anyone can post a fake settled to you |
| Idempotency-Key | required on every POST · TTL 7 days | 400 idempotency_key_required · retries create duplicate orders |
🔴 The first two are completely unrelated to each other. Do not reuse the code of one for the other.
Units and formats used everywhere
| topic | rule | example |
|---|---|---|
| money | integer in satang · every field name ends in _minor | THB 5,000.00 = 500000 |
| time | RFC3339 UTC, ending in Z, always 3 millisecond digits | 2026-08-21T04:12:33.881Z |
| deposit id | never parse it, never assume the prefix · the column must be text | dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B |
| liquidity origin | liquidity_source ∈ internal | external_gateway only | fine for analytics 🔴 never use it to decide a credit |
Every timestamp in every payload is UTC, not Thai local time
2026-08-21T04:30:47.902Z is 11:30 in Thailand (UTC+7). Convert at render time only — never store local time.
Next
| What you are about to do | Go to |
|---|---|
| Watch your first deposit run end to end in 5 minutes | Quickstart |
| Get past 401 when signing requests (test vectors included) | Request authentication |
| Make retries stop creating duplicate orders | Idempotency |
Simulate under_review / a bank outage / an expiry before you go live | Sandbox |
| Avoid building the QR page yourself | Hosted checkout |
| Look up a term you do not recognise (fill, leg, clearing) | Glossary |
