Skip to content

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 ​

topicsandboxlive
livemode on every eventfalsetrue
keysyour sandbox keysk_live_*
webhook endpointregistered separately from live—
moneynothing real moveslands in a real person's account
slip verification against the banksimulated — the outcome is chosen by the amountverified against the real bank, 6 checks
slip image contentnot inspected, but must still pass magic bytes and the capability's content_types/max_bytesreally inspected under the same capability
timings in the flowcompressed to secondsreal 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.

js
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
}

  1. Integrate a read-only call first — GET /v1/pay/banks and GET /v1/pay/quote — to prove your request signing works before touching money. Compare against the test vectors.
  2. Create a deposit for 100.00 and let it run to completion (Quickstart).
  3. Work through the amounts in the table below, one path at a time.
  4. Fire **replay.json` format** — name the sandbox object to replay and the scenario you want.
json
{
  "target": "deposit",
  "object_id": "dep_01K3N2P8QT4V6X8Z0B2D4F6H8J",
  "scenario": "expired_after_settled",
  "delivery_gap_ms": 0
}
fieldaccepted valuesmeaning
targetdeposit | withdrawalobject type to replay
object_idour sandbox idmust already have reached a terminal state
scenarioexpired_after_settled (case A) | settled_after_reversed (case B)the order the events are sent back in
delivery_gap_ms0–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_minorresulting path
100.0010000matched → slip verified → settled within ~5 seconds
101.0010100→ under_review ['SENDER_NAME_MISMATCH'] (stays there until approved/rejected)
102.0010200→ expired → automatic late slip at +60 seconds → under_review ['LATE_SLIP'] → settled
103.0010300settled → reversed at +60 seconds
104.0010400external_gateway, 2 legs (TTL ~4 minutes)
105.0010500sits in awaiting_liquidity for a full 25 seconds, then matches with liquidity_source: "external_gateway"
106.0010600→ under_review ['BANK_DISABLED']
107.0010700the withdrawal on the other side is split into 3 fills
108.0010800double transfer — settled, then deposit.overpaid at +45 seconds
109.0010900cancelled, then paid anyway — canceled with is_terminal:false → late slip → under_review ['CANCELED_ORDER_SLIP']
110.0011000ALLOCATION_CONFLICT — the late slip covers a share already matched to someone else, plus withdrawal.disputed on the other side
111.0011100automatic 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.0011200late-slip window closed → uploading a slip gives 410 late_slip_window_closed + claim_url
113.0011300402 insufficient_balance when creating a withdrawal (the money is still in pending_clearing)
114.0011400key rotation — webhook.key.rotating, then the kid switches 60 seconds later
115.0011500idempotency conflict — same key, different body
116.0011600same-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 ​

bash
# 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.json

The webhook you will get

json
{
  "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_th verbatim — 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 a display_en with 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.settled

This 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
HTTP/1.1 410 Gone
json
{
  "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 unsubscribed

Pass 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

json
{
  "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:

json
"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.

bash
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.json

Pass criteria — after the replay, the status and totals in your system must still be correct, in both cases.

casewhat happensyour system must
Adeposit.expired v6 (retry attempt 4) arrives after deposit.settled v9keep the status at settled · answer 200 · not cancel the order
Bdeposit.settled v9 (retry attempt 3) arrives after deposit.reversed v12keep 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.

sql
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 else

Why 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

caseamount
reversal after a credit103.00
double transfer108.00
cancelled, then paid anyway109.00
same-slip overpay: settle A=X + open/contact-support flag116.00
out-of-order eventsPOST /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/banks passes the signature check on the first try
  • [ ] all 3 test vectors pass, and changing Idempotency-Key makes 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 answers 200
  • [ ] livemode is 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 409 instead of retrying
  • [ ] amount 116.00 settles once at A=X, stores V/A/E, shows the open/contact-support claim, and never auto-refunds
  • [ ] replay-out-of-order passes both case A and case B
  • [ ] a daily reconcile job has actually run 3 days in a row

Next ​

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