Sandbox
Availability
This page describes the wider sandbox design, including advanced slip scenarios. See Supported sandbox payment API for the implemented simulator profile and remaining gaps such as signed callbacks.
Sandbox is the same system as live, line for line — minus real money and real banks. The state machine, the signatures, the error codes and the payload shapes are identical. Whatever passes here behaves the same way on live.
How it differs from live
| topic | sandbox | live |
|---|---|---|
livemode on every event | false | true |
| keys | your sandbox key | sk_live_* |
| webhook endpoint | registered separately from live | — |
| money | nothing real moves | lands in a real person's account |
| slip verification against the bank | simulated — the outcome is chosen by the amount | verified against the real bank, 6 checks |
| slip image content | not inspected, but must still pass magic bytes and the capability's content_types/max_bytes | really inspected under the same capability |
| timings in the flow | compressed to seconds | real time |
Sandbox and live cannot cross, and this is more than application logic
A sandbox event can never reach a live endpoint or the reverse — enforced by a composite foreign key at the database level.
What you have to do: always check that event.livemode matches your handler's environment. On a mismatch, discard and alert. A handler that skips this check will credit real money from a test event the day somebody registers an endpoint in the wrong slot.
if (event.livemode !== (process.env.NODE_ENV === 'production')) {
logger.error({ eventId: event.id }, 'livemode does not match this environment');
return res.writeHead(200).end(); // answer 200 so retries do not pile up, then alert
}Recommended order
- Integrate a read-only call first —
GET /v1/pay/banksandGET /v1/pay/quote— to prove your request signing works before touching money. Compare against the test vectors. - Create a deposit for 100.00 and let it run to completion (Quickstart).
- Work through the amounts in the table below, one path at a time.
- Fire
**replay.json` format** — name the sandbox object to replay and the scenario you want.
{
"target": "deposit",
"object_id": "dep_01K3N2P8QT4V6X8Z0B2D4F6H8J",
"scenario": "expired_after_settled",
"delivery_gap_ms": 0
}| field | accepted values | meaning |
|---|---|---|
target | deposit | withdrawal | object type to replay |
object_id | our sandbox id | must already have reached a terminal state |
scenario | expired_after_settled (case A) | settled_after_reversed (case B) | the order the events are sent back in |
delivery_gap_ms | 0–60000 (optional, default 0) | delay between the two deliveries — use 0 to force a real race |
Returns 202 with a replay_id and the list of events to be sent (type + version + scheduled_at). Both deliveries are signed with the real sandbox key and carry attempt > 1, exactly like a genuine retry.
replay-out-of-order` and confirm your totals are still correct. 5. Then ask for live keys.
Magic amount
The amount is the switch that forces a path in sandbox. Create a deposit with one of the amounts below and the system drives that path for you.
| amount (THB) | amount_minor | resulting path |
|---|---|---|
| 100.00 | 10000 | matched → slip verified → settled within ~5 seconds |
| 101.00 | 10100 | → under_review ['SENDER_NAME_MISMATCH'] (stays there until approved/rejected) |
| 102.00 | 10200 | → expired → automatic late slip at +60 seconds → under_review ['LATE_SLIP'] → settled |
| 103.00 | 10300 | settled → reversed at +60 seconds |
| 104.00 | 10400 | external_gateway, 2 legs (TTL ~4 minutes) |
| 105.00 | 10500 | sits in awaiting_liquidity for a full 25 seconds, then matches with liquidity_source: "external_gateway" |
| 106.00 | 10600 | → under_review ['BANK_DISABLED'] |
| 107.00 | 10700 | the withdrawal on the other side is split into 3 fills |
| 108.00 | 10800 | double transfer — settled, then deposit.overpaid at +45 seconds |
| 109.00 | 10900 | cancelled, then paid anyway — canceled with is_terminal:false → late slip → under_review ['CANCELED_ORDER_SLIP'] |
| 110.00 | 11000 | ALLOCATION_CONFLICT — the late slip covers a share already matched to someone else, plus withdrawal.disputed on the other side |
| 111.00 | 11100 | automatic clean short-pay — underpaid with all five non-amount checks valid → settled at A=V<X, partial:true, settled_via:'auto' · no force-complete call and no review |
| 112.00 | 11200 | late-slip window closed → uploading a slip gives 410 late_slip_window_closed + claim_url |
| 113.00 | 11300 | 402 insufficient_balance when creating a withdrawal (the money is still in pending_clearing) |
| 114.00 | 11400 | key rotation — webhook.key.rotating, then the kid switches 60 seconds later |
| 115.00 | 11500 | idempotency conflict — same key, different body |
| 116.00 | 11600 | same-slip overpayment — the bank verifies V=12100 against X=11600; the other 5 checks pass → settled at A=X, with an open/contact-support flag for E=500 and no automatic refund |
Amounts must be round numbers
This system does not use cent-level randomisation to match orders. An amount with a fractional part gets 422 amount_not_supported. Amounts outside this table have no predetermined path — when you need a guaranteed outcome, use an amount from the table.
Simulating the failures that happen most
Slip does not match what was declared — amount 101.00
# amount_minor = 10100
mapfile -t H < <(./pg-sign.sh POST /v1/pay/deposits '' body-101.json "qs-101-$(date +%s)")
curl -sS -X POST "$PG_API/v1/pay/deposits" \
-H "Authorization: Bearer $PG_API_KEY" -H "Content-Type: application/json" \
"${H[@]}" --data-binary @body-101.jsonThe webhook you will get
{
"id": "evt_01K3M9F2XN8VQKR4T0BDHY7WZE",
"object": "event",
"type": "deposit.under_review",
"api_version": "2026-08-01",
"livemode": false,
"created_at": "2026-08-21T04:19:08.774Z",
"sequence": 88231,
"account": { "merchant_id": "mch_01K2G4H9TVBN0PQ", "white_label_id": "wl_01K1QZ2MRT7XY45" },
"data": {
"object": {
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 6,
"status": "under_review",
"is_terminal": false,
"merchant_reference": "ORD-SBX-0101",
"amount_minor": 10100,
"currency": "THB",
"review": {
"opened_at": "2026-08-21T04:19:08.601Z",
"escalated": false,
"four_eyes_required": false,
"estimated_resolution_bucket": "under_1h",
"reasons": [
{
"code": "SENDER_NAME_MISMATCH",
"display_th": "ชื่อบัญชีผู้โอนในสลิปไม่ตรงกับชื่อที่แจ้งไว้ตอนสร้างรายการ เงินของคุณไม่หาย ไม่ต้องโอนซ้ำ",
"display_en": "The sender name on the slip does not match the name registered for this order.",
"actionable_by": "end_user",
"detail": { "registered_name_masked": "ส*** ใ***", "matched": false }
}
]
},
"slip": { "received_at": "2026-08-21T04:19:02.180Z", "is_late": false },
"verification": {
"amount_exact": true,
"within_time_window": true,
"sender_bank_match": true,
"sender_account_match": true,
"sender_name_match": false,
"receiver_match": true
},
"metadata": { "order_id": "ORD-SBX-0101" }
},
"previous_attributes": { "version": 5, "status": "slip_processing", "review": null, "verification": null }
}
}What your UI must do with this payload
- Show
review.reasons[0].display_thverbatim — it is Thai because your end users are Thai, and we have already written all three sentences it needs (we received your slip / here is what is holding it up / your money is safe, do not transfer again). Every reason also carries adisplay_enwith the same message in English — we write both, so you never have to compose the wording yourself. - Yellow or blue, not red, and no "transfer again" button.
- If you want to state a timeframe, use
review.estimated_resolution_bucket(under_15m/under_1h/under_4h/extended). 🔴 Never hardcode "within 15 minutes". verification{}is six plain booleans. Useful for debugging, but do not show it to the user directly.
RECEIVER_MISMATCH carries no detail at all, and that is deliberate
The destination account belongs to the withdrawer. Saying "it should have been xxx-x-x1234" would confirm somebody else's bank account to the person who transferred to the wrong place. Code that reads reason.detail.expected without a null check breaks on this case.
Bank not accepting automatic verification — amount 106.00
You get under_review with ['BANK_DISABLED'], where detail.bank_code is only ever the sender's own bank code.
BANK_DISABLED is not a refusal of the money
is_terminal is still false. A human verifies it manually and it normally ends at settled. Never tell the user the order failed, and never auto-cancel.
The other side of the same coin: a bank with accept_new = false makes new order creation fail with 409 sender_bank_unavailable plus details.alternative_banks[] — 🔴 but slips for existing orders are always still accepted. Do not disable the slip-upload button because a bank is marked unavailable.
Expired, then the user finally uploads a slip — amount 102.00
The path you will see:
deposit.expired is_terminal:false · late_slip_accepted_until = expires_at + 24h
deposit.slip_received is_late:true
deposit.under_review reasons=['LATE_SLIP']
deposit.settledThis is exactly why you must not close an order the moment you see expired
Code that says case 'deposit.expired': cancelOrder() cancels an order that is about to settle — and the user really loses money.
🔴 Check is_terminal, not the status name. An expired or canceled order carrying late_slip_accepted_until must keep its slip-upload button.
Cancelled, then paid anyway — amount 109.00
deposit.canceled arrives with is_terminal: false and a late_slip_accepted_until ⇒ your UI must behave exactly as it does for expired. This happens several times a week on production.
Late-slip window already closed — amount 112.00
Uploading a slip returns
HTTP/1.1 410 Gone{
"error": {
"code": "late_slip_window_closed",
"type": "invalid_request_error",
"message": "The late-slip window for this deposit has closed.",
"message_th": "หมดหน้าต่างรับสลิปย้อนหลังของรายการนี้แล้ว",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01K3PB6QW2ZR9YT5N4KDXCVH8M",
"details": { "claim_url": "https://pay.example-wl.com/claims/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B" }
}
}🔴 This 410 does not mean the money is gone. Open a case through POST /v1/bo/claims, attaching the trans_ref or the slip image. The possible outcomes are credited / refund_issued / rejected, delivered by the claim.updated webhook.
Reversal after a credit — amount 103.00
settled, then 60 seconds later:
deposit.reversed ← cannot be unsubscribed
balance.debited ← cannot be unsubscribedPass this case before go-live, not after
deposit.reversed, withdrawal.fill_reversed and balance.debited take money out of your wallet. The subscription is mandatory and cannot be disabled. Merchants without a handler ship goods and find out at month end.
Note what balance.debited carries: the object is a balance_transaction, which is immutable ⇒ no version, no previous_attributes ⇒ dedupe by id (btxn_...) and accumulate the amounts, never overwrite with balance_after.
A withdrawal split into several fills — amount 107.00
The withdrawal on the other side gets withdrawal.filled three times, then withdrawal.settled. Use it to prove your UI really is a multi-line timeline, and that you close the order only when status === 'settled' and remaining_minor === 0.
Money not withdrawable because it is in pending_clearing — amount 113.00
Creating a withdrawal returns
{
"error": {
"code": "insufficient_balance",
"type": "invalid_request_error",
"message": "Available balance is lower than amount plus fee.",
"message_th": "ยอดเงินที่ถอนได้ไม่พอสำหรับยอดถอนบวกค่าธรรมเนียม",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01K3PC8RX3ZS0AU6P5LEYDWJ9N",
"details": {
"available_minor": 1002000,
"required_minor": 1003000,
"pending_clearing_minor": 500000,
"pending_clearing_available_at": "2026-08-24T14:26:44.000Z"
}
}
}🔴 No withdrawal was created and no money was locked ⇒ release the hold in your own user ledger immediately, and explain the wait using pending_clearing_available_at.
Webhook key rotation — amount 114.00
You get webhook.key.rotating carrying next_kid + next_public_key + activates_at, and 60 seconds later events are signed with the new kid. Use it to prove your code refetches the keyset when it meets an unknown kid (throttled to once per minute) instead of answering 401 to everything.
Same key, different body — amount 115.00
You get 409 idempotency_key_reused with details.original_request_id — see how to handle it in Idempotency.
Overpayment on the same slip — amount 116.00
Sandbox simulates X=11600, a bank-verified V=12100, and amount_exact:false while the other five dimensions are true. It emits one deposit.settled with A=11600, partial:false, and:
"overpayment": {
"claim_id": "clm_01K3PB6QW1M8HN0DZ2XKQ7RV4T",
"cause": "same_transfer_excess",
"amount_minor": 500,
"status": "open",
"action_required": "contact_support"
}Credit the user once for A=X, show the claim_id and contact-support action, and never auto-credit or auto-refund E. This case emits no deposit.overpaid; that event is reserved for another transfer after settlement.
Simulating out-of-order events
This is the case that costs merchants the most real money, and the one you cannot test by hand.
mapfile -t H < <(./pg-sign.sh POST /v1/test/webhooks/replay-out-of-order '' replay.json "replay-$(date +%s)")
curl -sS -X POST "$PG_API/v1/test/webhooks/replay-out-of-order" \
-H "Authorization: Bearer $PG_API_KEY" -H "Content-Type: application/json" \
"${H[@]}" --data-binary @replay.jsonPass criteria — after the replay, the status and totals in your system must still be correct, in both cases.
| case | what happens | your system must |
|---|---|---|
| A | deposit.expired v6 (retry attempt 4) arrives after deposit.settled v9 | keep the status at settled · answer 200 · not cancel the order |
| B | deposit.settled v9 (retry attempt 3) arrives after deposit.reversed v12 | keep the status at reversed · not credit again and not release goods |
What makes it pass — a version guard that is a single UPDATE, not SELECT then if then UPDATE.
UPDATE deposits
SET status = $2, version = $3, payload = $4, updated_at = now()
WHERE deposit_id = $1
AND version < $3;
-- rowCount = 0 ⇒ the event is older than what you hold ⇒ answer 200 and do nothing elseWhy this is safe: the system guarantees that no two events for the same object share a version, so you can tie your side effects directly to rowCount.
The five cases we check automatically before issuing live keys
Not a checklist you tick yourself — the system verifies it
| case | amount |
|---|---|
| reversal after a credit | 103.00 |
| double transfer | 108.00 |
| cancelled, then paid anyway | 109.00 |
same-slip overpay: settle A=X + open/contact-support flag | 116.00 |
| out-of-order events | POST /v1/test/webhooks/replay-out-of-order |
These five are where merchants lose real money most often, so your code has to prove it survives them before you get live keys.
Checklist before requesting live keys
- [ ]
GET /v1/pay/bankspasses the signature check on the first try - [ ] all 3 test vectors pass, and changing
Idempotency-Keymakes the signature fail - [ ] Ed25519 verified against the raw body — flipping one byte of the payload must give
401 - [ ] Transactional inbox has
UNIQUE(event_id), kept ≥ 31 days, and a repeated event answers200 - [ ]
livemodeis checked against the environment - [ ] neither
202(deposit creation, slip upload) is treated as an error - [ ] the UI covers all 9 deposit statuses and all 7 withdrawal statuses
- [ ] amounts 102.00 and 109.00 leave the slip-upload button in place
- [ ] amount 103.00 results in the user's balance being taken back
- [ ] amount 107.00 shows multi-line progress in the UI
- [ ] amount 113.00 releases the internal hold immediately
- [ ] amount 115.00 raises an alert on
409instead of retrying - [ ] amount 116.00 settles once at
A=X, storesV/A/E, shows the open/contact-support claim, and never auto-refunds - [ ]
replay-out-of-orderpasses both case A and case B - [ ] a daily reconcile job has actually run 3 days in a row
Next
- Send your first order on sandbox → Quickstart
- Signature failing → Request authentication
409when creating an order → Idempotency- The full pre-production checklist → Go-live checklist
