Skip to content

Go-live checklist ​

Every item on this page comes from a real incident that cost a merchant money or customers. Nothing here is filler.

How to use it: work through section by section. Each item ends with how to prove it. If you cannot prove it, it has not passed — confidence is not proof.

Five conditions the system verifies automatically; these are not self-certified

Before we issue your sk_live_*, we check that you have actually driven these paths in sandbox.

  1. Amount 103.00 — settled, then reversed at +60 seconds (proves you handle events that cannot be unsubscribed).
  2. Amount 108.00 — a duplicate transfer ⇒ deposit.overpaid at +45 seconds (proves you do not roll back an order that already succeeded).
  3. Amount 109.00 — cancel then accidental transfer ⇒ canceled with is_terminal: false, followed by under_review.
  4. Amount 116.00 — same-slip overpayment with the other 5 checks valid ⇒ one deposit.settled, credit A = X, open a support claim/flag, and no automatic refund.
  5. POST /v1/test/webhooks/replay-out-of-order — proves your version guard actually works.

Fail any of the five and no live key is issued.


Section 1 — Signing and sending requests ​

  • [ ] All 9 lines of the canonical string — prove it: pass all three test vectors from GET /v1/webhooks/test-vectors (≥ 2 query params · Thai unicode · emoji).
  • [ ] Idempotency-Key and PG-Version really are in the canonical string — prove it: change Idempotency-Key, send with the old signature, and confirm you get 401 signature_invalid.
  • [ ] A new PG-Nonce on every attempt, but the same Idempotency-Key for the whole logical operation — prove it: three consecutive retries yield one deposit_id, not three.
  • [ ] The key is written to the database before the request is sent — prove it: kill the process mid-request; the retry still uses the same key.
  • [ ] Repeating POST /v1/pay/deposits with the same key returns the same deposit_id — prove it: the response carries the header Idempotent-Replay: true.
  • [ ] 429 is handled with backoff + jitter + Retry-After — prove it: hit the bucket deliberately and confirm there is no tight retry loop.
  • [ ] 503 does not hang the UI — prove it: the screen shows "ระบบหนาแน่น ลองใหม่" (System busy, try again), not a stuck spinner.
  • [ ] sk_live_* lives in a secret manager and rotates without a deploy — prove it: rotate the key in staging and confirm the system keeps working with no build.
  • [ ] client{device_fingerprint, ip, user_agent} is sent every time — prove it: no request in the logs is missing it (omitting it drops the open-order cap to 1 and puts you in the merchant-wide shared rate-limit bucket).

Section 2 — Webhooks ​

  • [ ] Ed25519 verified against the raw body before parsing — prove it: a negative test that flips one byte of the body returns 401.
  • [ ] X-PG-Signature parsed as key=value, not by position — prove it: swap the order of kid and t; verification still passes.
  • [ ] The signature is decoded as base64url (no = padding) — prove it: all three test vectors verify.
  • [ ] Reject when the timestamp is more than 300 seconds off — prove it: replay an old vector with a stale t and get 400.
  • [ ] event.id in the body must match X-PG-Event-Id — prove it: a body that disagrees with the header is rejected.
  • [ ] livemode is checked against the environment — prove it: a sandbox event posted to the live endpoint is rejected.
  • [ ] Public keys cached ≤ 24 hours · an unknown kid triggers exactly one throttled refetch — prove it: rotate the kid in sandbox (amount 114.00) and confirm you catch up within 60 seconds.
  • [ ] Subscribed to webhook.key.rotating, with a procedure for taking the new kid — prove it: a runbook exists that someone else on the team can follow.
  • [ ] Transactional inbox has UNIQUE(event_id), retained ≥ 31 days · duplicates answer 200 — prove it: resend the same event; no balance moves and the response is 200.
  • [ ] The version guard is an atomic UPDATE … WHERE version < $new, not SELECT-then-if-then-UPDATE — prove it: pass POST /v1/test/webhooks/replay-out-of-order for both case A and case B.
  • [ ] balance_transaction deduped by btxn_id and then accumulated, never overwritten from balance_after — prove it: send events out of order and confirm the balance is still correct.
  • [ ] A default: in every switch over status / reasons[].code / balance_transaction.type / fee components — prove it: feed in an unknown code and confirm nothing throws.
  • [ ] 2xx within 3 seconds, heavy work queued — prove it: handler p99 under 3 seconds in a load test.
  • [ ] No 3xx responses · real, valid TLS (self-signed fails) · no dependence on an IP allowlist — prove it: we have no fixed IPs to allowlist.
  • [ ] Handlers for all 5 events that cannot be unsubscribed — deposit.reversed · withdrawal.fill_reversed · balance.debited · webhook.key.rotating · webhook.endpoint.disabled.

Section 3 — Money and reconciliation ​

  • [ ] The end user is credited with amount_received_minor, guarded by credited_user_at IS NULL — prove it: send deposit.settled three times; the user's balance moves once.
  • [ ] The merchant wallet moves by credited_amount_minor — prove it: assert amount_received_minor − fee_total_minor = credited_amount_minor on every row.
  • [ ] The four core amounts are stored separately — ordered_amount_minor (X) · transferred_amount_minor (V) · amount_received_minor (A) · overpayment.amount_minor (E, or zero) — prove it: run 111.00 and 116.00 and distinguish short-pay from overpayment correctly.
  • [ ] A same-slip overpayment succeeds at the ordered amount — prove it: amount 116.00 emits only one deposit.settled, has A = X and V = A + E, credits the user once, shows the claim/contact-support action, and never auto-refunds.
  • [ ] fee_breakdown[] is stored raw and parsed by component — prove it: open any row and see the full array (you will need it in a fee dispute).
  • [ ] pending_clearing and available are separate in your own ledger, and withdrawable funds are computed from available_minor alone — prove it: test amount 113.00 and get 402 with pending_clearing_available_at.
  • [ ] balance.cleared is handled without adding the amount again — prove it: after T+72 hours in the test, the total is unchanged; only the bucket moved.
  • [ ] Withdrawal totals read from filled_minor / remaining_minor through the version guard, never SUM() and never += — prove it: test amount 107.00 (split into 3 fills) and confirm the totals are right in every arrival order.
  • [ ] 402 insufficient_balance releases the user's hold immediately — prove it: no user balance is left locked after the test (on 402 no order is created and nothing is locked on our side).
  • [ ] A withdrawal is closed only when status = "settled" and remaining_minor = 0 — prove it: amount 107.00 is not closed on the first fill.
  • [ ] An expired withdrawal is refunded by refunded_minor, not amount_total_minor — prove it: test an order that partly filled and then expired.
  • [ ] 🔴 A daily reconciliation job exists, using GET /v1/pay/events?since_sequence= + GET /v1/pay/deposits + GET /v1/pay/balance/transactions — prove it: the job runs for real and matches three days in a row before you go to 100%.

The ledger is the truth; webhooks are notifications

Webhooks are not the source of truth — an endpoint down for 25 hours can be disabled automatically, and events still retrying leave temporary gaps in sequence.

A daily reconciliation job is not a nice-to-have; it is a hard requirement before go-live. A sequence gap that persists past 4 days is a genuine loss, so it needs an alert.

Section 4 — Screens and statuses ​

  • [ ] The UI covers all 9 deposit statuses and all 7 withdrawal statuses — prove it: a real screen exists for every value, not a "failed" fallback.
  • [ ] under_review is yellow or blue, never red, shows review.reasons[0].display_th, and carries the line "เงินไม่หาย ไม่ต้องโอนซ้ำ" (your money is safe, do not transfer again) — prove it: amounts 101.00 and 106.00.
  • [ ] 🔴 under_review has no "transfer again" button — prove it: look at the screen (this button makes users pay twice).
  • [ ] expired and canceled with is_terminal: false still show the slip-upload button until late_slip_accepted_until — prove it: amounts 102.00 and 109.00.
  • [ ] Order closing is driven by is_terminal, not by the status name — prove it: grep for code comparing status === 'expired' and then closing the order.
  • [ ] awaiting_liquidity has a waiting screen, not an error page, and shows liquidity.suggested_amounts_minor — prove it: amount 105.00.
  • [ ] partially_filled renders as multi-fill progress, not a single status badge — prove it: amount 107.00.
  • [ ] The "โอนจากบัญชีนี้เท่านั้น" (transfer only from this account) box is more prominent than the QR, with a "เปลี่ยนบัญชีผู้โอน" (change sender account) button and the note "QR นี้ใช้ได้ครั้งเดียวสำหรับรายการนี้" (this QR works once, for this order only) — prove it: look at the screen.
  • [ ] The sender form autofills from GET /v1/pay/senders and greys out banks per GET /v1/pay/banks — prove it: a returning user picks rather than types.
  • [ ] account_name_th is validated to contain no honorific (นาย/นาง/น.ส./คุณ — Mr./Mrs./Ms./generic polite prefix) — prove it: entering a name with a prefix is caught client-side before the request goes out.
  • [ ] legs.length > 1 is supported with a "step 1 of 2" UI — prove it: amount 104.00.
  • [ ] expires_at comes from the API; the countdown is not hardcoded, and it re-syncs every time the user returns to the page — prove it: amount 104.00 (which really does expire in ~4 minutes).
  • [ ] After a slip upload the screen says "กำลังตรวจสอบ" (checking), not "สำเร็จ" (success) — prove it: 202 is treated as success at the HTTP level, not as an error — but the user is never shown "เติมเงินเรียบร้อย" (top-up complete) yet.
  • [ ] 🔴 qr_payload is never logged and never stored · full destination account details are never logged — prove it: grep the staging logs and find nothing.
  • [ ] deposit_id / withdrawal_id are shown to the user with a copy button — prove it: open an order page and see it.

Section 5 — Operations and monitoring ​

  • [ ] Support has a playbook stating that under_review means "being checked", not "error", and 🔴 never tell a user to transfer again — prove it: the document exists and the team has read it.
  • [ ] An alert when the under_review rate crosses the threshold (the signal that the sender screen has a problem) — prove it: alert set at 10% and a test alert fired.
  • [ ] An alert when webhook verification fails, or the handler returns non-2xx for more than 1% over 5 minutes — prove it: fire a test alert.
  • [ ] An alert when a sequence gap persists past 4 days — prove it: the reconciliation job can report gaps.
  • [ ] event.id + X-PG-Delivery-Id + X-PG-Attempt logged every time, and request_id logged on every error — delivery headers are observability-only.
  • [ ] A technical contact address someone actually reads — used when an endpoint is auto-disabled and when keys rotate.
  • [ ] You know when an endpoint gets auto-disabled — 24 consecutive hours of failure (0 successes and ≥ 50 failures) ⇒ disabled, and you must re-enable it yourself from the dashboard.
  • [ ] Finance knows about the 72-hour pending_clearing window — 🔴 cash flow changes; tell them before go-live, not after.

Sandbox test amounts ​

Each amount below drives the stated path deterministically in sandbox. No waiting for luck.

Amount (THB)amount_minorPath produced
100.0010000Matched → slip auto-verified → settled in ~5 seconds
101.0010100→ under_review ['SENDER_NAME_MISMATCH'] (stays until approved or rejected)
102.0010200→ expired → automatic late slip at +60 seconds → under_review['LATE_SLIP'] → settled
103.0010300settled → reversed at +60 seconds 🔴 mandatory
104.0010400Fallback route with 2 legs (really expires in ~4 minutes)
105.0010500A full 25 seconds in awaiting_liquidity before matching
106.0010600→ under_review ['BANK_DISABLED']
107.0010700The withdrawal on the other side is split into 3 fills
108.0010800Duplicate transfer — settled, then deposit.overpaid at +45 seconds 🔴 mandatory
109.0010900Cancel then accidental transfer — canceled (is_terminal: false) → late slip → under_review['CANCELED_ORDER_SLIP'] 🔴 mandatory
110.0011000ALLOCATION_CONFLICT — a late slip collides with another order, and the other side gets withdrawal.disputed
111.0011100Automatic clean short-pay → settled at A=V<X, partial:true, settled_via:'auto' without entering review
112.0011200Late-slip window closed → 410 late_slip_window_closed + claim_url
113.0011300402 insufficient_balance on withdrawal creation (funds still in pending_clearing)
114.0011400Key rotation — webhook.key.rotating, then the kid switches within 60 seconds
115.0011500Idempotency collision — same key, different body ⇒ 409 idempotency_key_reused
116.0011600Same-slip overpayment — other 5 checks pass → one deposit.settled with A = X, V > X, overpayment{status:'open',action_required:'contact_support'}, and no automatic refund 🔴 mandatory

How to enable sandbox: Sandbox


The 20 mistakes merchants make most often ​

All from real incidents, ordered by damage.

#The mistakeWhat you will seeThe fix
1Crediting on an event that is not settled — deposit.slip_received, or seeing leg_status: "complete"The user's balance was already increased, the order is rejected later, the balance goes negative and cannot be clawed backThe only condition is status === 'settled' and credited_user_at IS NULL
2🔴 Crediting the user with credited_amount_minorEvery user is short by exactly the fee on every order; complaints from day oneUser = amount_received_minor · merchant wallet = credited_amount_minor
3Not verifying the signature, or verifying after JSON.parseAnyone can post a fake settled / verification fails randomly because key order changedVerify against the raw bytes before touching the body, always
4No version guardStatuses go backwards — a stale under_review overwrites settled and the customer sees the order back in reviewUPDATE … WHERE version < $new, then check rowCount · rowCount = 0 ⇒ answer 200 and do nothing
5Moving the wallet by amount_received_minorYour books run high by a full month of fees; you find out at month-endAssert amount_received_minor − fee_total_minor = credited_amount_minor on every row
6Assuming credited money is immediately withdrawableWithdrawals return 402 while the dashboard shows money; the team loses a day to confusionSeparate pending_clearing / available buckets · withdraw from available_minor only (T+72 hours)
7Polling every order every secondYou hit 429, the whole API key backs off ⇒ no user in your system can create an orderWebhooks first · polling only as a fallback at 3→5→10→30 seconds · batch with GET /v1/pay/deposits?status=
8Not handling 202if (res.status !== 200) throw retries · the user sees "upload failed" when the slip is safely stored, and transfers againAll 2xx are successes, 201 202 204 included
9Not storing deposit_id, only your own referenceTickets cannot be resolved · reconciliation is impossible · support cannot talk to usMake deposit_id a primary key on your side too, and show it to the user with a copy button
10Retrying a POST with a fresh Idempotency-KeyDuplicate orders · two QRs · over-locked funds on the withdrawal sideWrite the key to the database before sending · retries read it back · only PG-Nonce changes
11Reusing a key with a different bodyUnexplained 409 idempotency_key_reusedNew body means new key · the key is a function of content, not of a session
12Not handling deposit.reversed / balance.debitedGoods released, then the money is pulled back; discovered at month-end, uncollectable🔴 These two cannot be unsubscribed — handlers from day one (test with amount 103.00)
13Closing the order the moment expired appearsThe user uploads a slip afterwards and gets settled for an order you closed; the two systems disagreeCheck is_terminal, not the status name
14🔴 Closing the order the moment canceled appearsThe cancel-then-transfer race means the user really loses money and we have nowhere to put itcanceled can carry late_slip_accepted_until too ⇒ keep the upload button (test amount 109.00)
15Hardcoding a TTL, e.g. 15 minutesThe countdown is wrong · some routes really expire in ~4 minutes ⇒ users transfer after the deadlineUse expires_at from the response and re-sync every time the page is reopened
16Not greying out banks per GET /v1/pay/banksThe user fills in everything and hits 409 sender_bank_unavailable at the last step; drop-off is highRead available and message_th at the top of the form
17Processing webhooks synchronously and slowlyTimeout → we retry → you process twice → double creditAnswer 200 within 3 seconds, do the heavy work async
18Not deduping on event.idA repeated delivery inflates balancesTransactional inbox has UNIQUE(event_id), kept ≥ 31 days · duplicates get 200, not 409
19Treating withdrawal.filled as "withdrawal complete"You close an order the user was underpaid onClose on status === 'settled' and remaining_minor === 0
20Letting users type all their sender account detailsThe under_review rate climbs past 30%, the review queue overflows, users waitAutofill from GET /v1/pay/senders · let them pick, not type

Three of these cost real money, not just a bad UX

Items 1, 2 and 12 — all three make your numbers disagree with ours, and none of them can be repaired automatically.

The only defence that works is a daily reconciliation job running from day one, not one you intend to build later.


Paste this into the final PR before you go live ​

[ ] all 3 test vectors pass
[ ] changing Idempotency-Key breaks the signature (proves it is in the canonical string)
[ ] Ed25519 verified on the raw body · negative test: 1 flipped byte returns 401
[ ] transactional inbox: UNIQUE(event_id) kept >= 31 days · duplicates answered 200
[ ] version guard is an atomic UPDATE · replay-out-of-order passes case A and case B
[ ] user credited with amount_received_minor · wallet moved by credited_amount_minor
[ ] X / V / A / E stored separately + A = min(V,X), 0 ≤ fee ≤ A, and A - fee = credited asserted on every row
[ ] amount 116.00 passes: same-slip overpay settles A=X once + open/contact_support flag · E is not auto-credited/refunded
[ ] withdrawable funds computed from available_minor only
[ ] withdrawal closed only when settled AND remaining_minor = 0
[ ] handlers for all 5 events that cannot be unsubscribed
[ ] is_terminal drives order closing, not the status name
[ ] after slip upload the screen says "checking", not "success"
[ ] sender form autofills from /v1/pay/senders
[ ] daily reconciliation job running, matching 3 days in a row
[ ] alerts: under_review over threshold · webhook failures > 1% · sequence gap > 4 days
[ ] finance briefed on the 72-hour pending_clearing window

Next ​

If you are…Go to
Working through the sandbox amountsSandbox
Checking your handlers cover every statusState machine
Checking your handlers cover every eventEvery event
Writing the error mappingEvery error code
Checking you are reading the right fieldsField dictionary
Migrating from another gatewayMigrating from another provider
Configuring endpoints and retriesRetries and endpoint outages

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