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.
- Amount 103.00 —
settled, thenreversedat +60 seconds (proves you handle events that cannot be unsubscribed). - Amount 108.00 — a duplicate transfer ⇒
deposit.overpaidat +45 seconds (proves you do not roll back an order that already succeeded). - Amount 109.00 — cancel then accidental transfer ⇒
canceledwithis_terminal: false, followed byunder_review. - Amount 116.00 — same-slip overpayment with the other 5 checks valid ⇒ one
deposit.settled, creditA = X, open a support claim/flag, and no automatic refund. 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-KeyandPG-Versionreally are in the canonical string — prove it: changeIdempotency-Key, send with the old signature, and confirm you get401 signature_invalid. - [ ] A new
PG-Nonceon every attempt, but the sameIdempotency-Keyfor the whole logical operation — prove it: three consecutive retries yield onedeposit_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/depositswith the same key returns the samedeposit_id— prove it: the response carries the headerIdempotent-Replay: true. - [ ]
429is handled with backoff + jitter +Retry-After— prove it: hit the bucket deliberately and confirm there is no tight retry loop. - [ ]
503does 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-Signatureparsed as key=value, not by position — prove it: swap the order ofkidandt; 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
tand get400. - [ ]
event.idin the body must matchX-PG-Event-Id— prove it: a body that disagrees with the header is rejected. - [ ]
livemodeis checked against the environment — prove it: a sandbox event posted to the live endpoint is rejected. - [ ] Public keys cached ≤ 24 hours · an unknown
kidtriggers 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 answer200— prove it: resend the same event; no balance moves and the response is200. - [ ] The version guard is an atomic
UPDATE … WHERE version < $new, not SELECT-then-if-then-UPDATE — prove it: passPOST /v1/test/webhooks/replay-out-of-orderfor both case A and case B. - [ ]
balance_transactiondeduped bybtxn_idand then accumulated, never overwritten frombalance_after— prove it: send events out of order and confirm the balance is still correct. - [ ] A
default:in everyswitchoverstatus/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 bycredited_user_at IS NULL— prove it: senddeposit.settledthree times; the user's balance moves once. - [ ] The merchant wallet moves by
credited_amount_minor— prove it: assertamount_received_minor − fee_total_minor = credited_amount_minoron 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, hasA = XandV = A + E, credits the user once, shows the claim/contact-support action, and never auto-refunds. - [ ]
fee_breakdown[]is stored raw and parsed bycomponent— prove it: open any row and see the full array (you will need it in a fee dispute). - [ ]
pending_clearingandavailableare separate in your own ledger, and withdrawable funds are computed fromavailable_minoralone — prove it: test amount 113.00 and get402withpending_clearing_available_at. - [ ]
balance.clearedis 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_minorthrough the version guard, neverSUM()and never+=— prove it: test amount 107.00 (split into 3 fills) and confirm the totals are right in every arrival order. - [ ]
402 insufficient_balancereleases the user's hold immediately — prove it: no user balance is left locked after the test (on402no order is created and nothing is locked on our side). - [ ] A withdrawal is closed only when
status = "settled"andremaining_minor = 0— prove it: amount 107.00 is not closed on the first fill. - [ ] An
expiredwithdrawal is refunded byrefunded_minor, notamount_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_reviewis yellow or blue, never red, showsreview.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_reviewhas no "transfer again" button — prove it: look at the screen (this button makes users pay twice). - [ ]
expiredandcanceledwithis_terminal: falsestill show the slip-upload button untillate_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 comparingstatus === 'expired'and then closing the order. - [ ]
awaiting_liquidityhas a waiting screen, not an error page, and showsliquidity.suggested_amounts_minor— prove it: amount 105.00. - [ ]
partially_filledrenders 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/sendersand greys out banks perGET /v1/pay/banks— prove it: a returning user picks rather than types. - [ ]
account_name_this 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 > 1is supported with a "step 1 of 2" UI — prove it: amount 104.00. - [ ]
expires_atcomes 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:
202is treated as success at the HTTP level, not as an error — but the user is never shown "เติมเงินเรียบร้อย" (top-up complete) yet. - [ ] 🔴
qr_payloadis never logged and never stored · full destination account details are never logged — prove it: grep the staging logs and find nothing. - [ ]
deposit_id/withdrawal_idare 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_reviewmeans "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_reviewrate 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
sequencegap persists past 4 days — prove it: the reconciliation job can report gaps. - [ ]
event.id+X-PG-Delivery-Id+X-PG-Attemptlogged every time, andrequest_idlogged 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_clearingwindow — 🔴 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_minor | Path produced |
|---|---|---|
| 100.00 | 10000 | Matched → slip auto-verified → settled in ~5 seconds |
| 101.00 | 10100 | → under_review ['SENDER_NAME_MISMATCH'] (stays until approved or rejected) |
| 102.00 | 10200 | → expired → automatic late slip at +60 seconds → under_review['LATE_SLIP'] → settled |
| 103.00 | 10300 | settled → reversed at +60 seconds 🔴 mandatory |
| 104.00 | 10400 | Fallback route with 2 legs (really expires in ~4 minutes) |
| 105.00 | 10500 | A full 25 seconds in awaiting_liquidity before matching |
| 106.00 | 10600 | → under_review ['BANK_DISABLED'] |
| 107.00 | 10700 | The withdrawal on the other side is split into 3 fills |
| 108.00 | 10800 | Duplicate transfer — settled, then deposit.overpaid at +45 seconds 🔴 mandatory |
| 109.00 | 10900 | Cancel then accidental transfer — canceled (is_terminal: false) → late slip → under_review['CANCELED_ORDER_SLIP'] 🔴 mandatory |
| 110.00 | 11000 | ALLOCATION_CONFLICT — a late slip collides with another order, and the other side gets withdrawal.disputed |
| 111.00 | 11100 | Automatic clean short-pay → settled at A=V<X, partial:true, settled_via:'auto' without entering review |
| 112.00 | 11200 | Late-slip window closed → 410 late_slip_window_closed + claim_url |
| 113.00 | 11300 | 402 insufficient_balance on withdrawal creation (funds still in pending_clearing) |
| 114.00 | 11400 | Key rotation — webhook.key.rotating, then the kid switches within 60 seconds |
| 115.00 | 11500 | Idempotency collision — same key, different body ⇒ 409 idempotency_key_reused |
| 116.00 | 11600 | Same-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 mistake | What you will see | The fix |
|---|---|---|---|
| 1 | Crediting 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 back | The only condition is status === 'settled' and credited_user_at IS NULL |
| 2 | 🔴 Crediting the user with credited_amount_minor | Every user is short by exactly the fee on every order; complaints from day one | User = amount_received_minor · merchant wallet = credited_amount_minor |
| 3 | Not verifying the signature, or verifying after JSON.parse | Anyone can post a fake settled / verification fails randomly because key order changed | Verify against the raw bytes before touching the body, always |
| 4 | No version guard | Statuses go backwards — a stale under_review overwrites settled and the customer sees the order back in review | UPDATE … WHERE version < $new, then check rowCount · rowCount = 0 ⇒ answer 200 and do nothing |
| 5 | Moving the wallet by amount_received_minor | Your books run high by a full month of fees; you find out at month-end | Assert amount_received_minor − fee_total_minor = credited_amount_minor on every row |
| 6 | Assuming credited money is immediately withdrawable | Withdrawals return 402 while the dashboard shows money; the team loses a day to confusion | Separate pending_clearing / available buckets · withdraw from available_minor only (T+72 hours) |
| 7 | Polling every order every second | You hit 429, the whole API key backs off ⇒ no user in your system can create an order | Webhooks first · polling only as a fallback at 3→5→10→30 seconds · batch with GET /v1/pay/deposits?status= |
| 8 | Not handling 202 | if (res.status !== 200) throw retries · the user sees "upload failed" when the slip is safely stored, and transfers again | All 2xx are successes, 201 202 204 included |
| 9 | Not storing deposit_id, only your own reference | Tickets cannot be resolved · reconciliation is impossible · support cannot talk to us | Make deposit_id a primary key on your side too, and show it to the user with a copy button |
| 10 | Retrying a POST with a fresh Idempotency-Key | Duplicate orders · two QRs · over-locked funds on the withdrawal side | Write the key to the database before sending · retries read it back · only PG-Nonce changes |
| 11 | Reusing a key with a different body | Unexplained 409 idempotency_key_reused | New body means new key · the key is a function of content, not of a session |
| 12 | Not handling deposit.reversed / balance.debited | Goods 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) |
| 13 | Closing the order the moment expired appears | The user uploads a slip afterwards and gets settled for an order you closed; the two systems disagree | Check is_terminal, not the status name |
| 14 | 🔴 Closing the order the moment canceled appears | The cancel-then-transfer race means the user really loses money and we have nowhere to put it | canceled can carry late_slip_accepted_until too ⇒ keep the upload button (test amount 109.00) |
| 15 | Hardcoding a TTL, e.g. 15 minutes | The countdown is wrong · some routes really expire in ~4 minutes ⇒ users transfer after the deadline | Use expires_at from the response and re-sync every time the page is reopened |
| 16 | Not greying out banks per GET /v1/pay/banks | The user fills in everything and hits 409 sender_bank_unavailable at the last step; drop-off is high | Read available and message_th at the top of the form |
| 17 | Processing webhooks synchronously and slowly | Timeout → we retry → you process twice → double credit | Answer 200 within 3 seconds, do the heavy work async |
| 18 | Not deduping on event.id | A repeated delivery inflates balances | Transactional inbox has UNIQUE(event_id), kept ≥ 31 days · duplicates get 200, not 409 |
| 19 | Treating withdrawal.filled as "withdrawal complete" | You close an order the user was underpaid on | Close on status === 'settled' and remaining_minor === 0 |
| 20 | Letting users type all their sender account details | The under_review rate climbs past 30%, the review queue overflows, users wait | Autofill 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 windowNext
| If you are… | Go to |
|---|---|
| Working through the sandbox amounts | Sandbox |
| Checking your handlers cover every status | State machine |
| Checking your handlers cover every event | Every event |
| Writing the error mapping | Every error code |
| Checking you are reading the right fields | Field dictionary |
| Migrating from another gateway | Migrating from another provider |
| Configuring endpoints and retries | Retries and endpoint outages |
