Quickstart
Check the supported profile first
For real payments, read the Live P2P implementation 0092 profile, pending validation/deployment. For isolated tests, use the supported sandbox profile. The checkout and slip steps below describe the Target contract, not the complete feature set of the current simulator.
Goal: watch one deposit go from awaiting_payment to settled, and receive a webhook whose signature verifies — in 5 minutes.
You need: bash + curl + openssl + Node.js 18 or later, and sandbox keys from your White Label provider.
Everything on this page runs on sandbox
Every order you create carries livemode: false. A sandbox event can never reach a live endpoint or the other way round — that is enforced at the database level, not in application logic.
Step 0 — set up your environment (30 seconds)
Ask your White Label provider for sandbox keys. You get three values: a base URL, an API key, and a secret key for signing.
export PG_API='https://api.example-wl.com' # replace with your WL's domain
export PG_API_KEY='sk_test_01K2QF7H3X9M4TB0YR6WCZ8V2N' # goes in Authorization: Bearer
export PG_SECRET_KEY='sk_test_01K2QF7H3X9M4TB0YR6WCZ8V2N' # used to sign PG-Signature
export PG_VERSION='2026-08-01'Your clock has to be right
A PG-Timestamp more than ±300 seconds off gets an immediate 401 timestamp_out_of_window. Check before you start: date -u must be within ±5 minutes of real time.
The signing script — save it as pg-sign.sh, run chmod +x pg-sign.sh, and use it for the rest of this page.
#!/usr/bin/env bash
# pg-sign.sh METHOD PATH [SORTED_QUERY] [BODY_FILE] [IDEMPOTENCY_KEY]
# Prints the headers curl needs, one argument per line.
set -euo pipefail
METHOD="$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')"
REQ_PATH="$2"
QUERY="${3:-}"
BODY_FILE="${4:-}"
IDEM="${5:-}"
: "${PG_SECRET_KEY:?run export PG_SECRET_KEY=... first}"
PG_VERSION="${PG_VERSION:-2026-08-01}"
TS="$(date -u +%s)"
NONCE="$(openssl rand -hex 13 | tr 'a-f' 'A-F')"
if [ -n "$BODY_FILE" ]; then
BODY_SHA="$(openssl dgst -sha256 < "$BODY_FILE" | awk '{print $NF}')"
else
BODY_SHA="$(printf '' | openssl dgst -sha256 | awk '{print $NF}')"
fi
# 9 lines, every line terminated by \n including the last one
SIG="$(printf 'v1\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \
"$TS" "$NONCE" "$METHOD" "$REQ_PATH" "$QUERY" "$BODY_SHA" "$IDEM" "$PG_VERSION" \
| openssl dgst -sha256 -hmac "$PG_SECRET_KEY" | awk '{print $NF}')"
printf -- '-H\nPG-Timestamp: %s\n-H\nPG-Nonce: %s\n-H\nPG-Version: %s\n-H\nPG-Signature: v1=%s\n' \
"$TS" "$NONCE" "$PG_VERSION" "$SIG"
[ -n "$IDEM" ] && printf -- '-H\nIdempotency-Key: %s\n' "$IDEM"
exit 0Why the canonical string is exactly those 9 lines is explained in request authentication.
Step 1 — prove your signature works before touching money (30 seconds)
Call a read-only endpoint first. If this passes, your signing is correct everywhere.
mapfile -t H < <(./pg-sign.sh GET /v1/pay/banks)
curl -sS "$PG_API/v1/pay/banks" \
-H "Authorization: Bearer $PG_API_KEY" \
"${H[@]}" | head -c 400What you should see
{
"data": [
{ "bank_code": "002", "name_th": "ธนาคารกรุงเทพ", "name_en": "Bangkok Bank",
"available": true, "side": ["sender", "receiver"] },
{ "bank_code": "004", "name_th": "ธนาคารกสิกรไทย", "name_en": "Kasikornbank",
"available": true, "side": ["sender", "receiver"] },
{ "bank_code": "014", "name_th": "ธนาคารไทยพาณิชย์", "name_en": "Siam Commercial Bank",
"available": false, "message_th": "ธนาคารปิดปรับปรุงระบบชั่วคราว", "side": ["receiver"] }
]
}message_th is written for your Thai end users ("the bank is temporarily down for maintenance") — show it as-is.
If you get a 401 here, stop and fix it before going further
code | how to fix |
|---|---|
signature_invalid | compare your canonical string byte by byte against the test vectors |
timestamp_out_of_window | sync NTP |
nonce_replayed | PG-Nonce must be new on every attempt (unlike Idempotency-Key, which must stay the same) |
api_key_invalid | check that you are using the sandbox key, not the live key |
Keep the available: false flag — use it to grey out banks in your sender form, otherwise users hit a 409 at the last step.
Step 2 — create your first deposit (1 minute)
THB 100.00 is a sandbox magic amount that drives the happy path automatically (full table).
cat > body.json <<'JSON'
{
"amount_minor": 10000,
"currency": "THB",
"merchant_reference": "ORD-QS-0001",
"end_user_ref": "u_88213",
"sender": {
"bank_code": "002",
"account_number": "1234567890",
"account_name_th": "สมชาย ใจดี",
"account_name_en": "SOMCHAI JAIDEE"
},
"client": {
"device_fingerprint": "df_9a1c4e77b2",
"ip": "203.0.113.44",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X)"
},
"return_url": "https://shop.example.com/topup/ORD-QS-0001",
"metadata": { "order_id": "ORD-QS-0001" }
}
JSON
IDEM='qs-ORD-QS-0001' # 🔴 in production, write this to your DB before sending, and read it back on retry
mapfile -t H < <(./pg-sign.sh POST /v1/pay/deposits '' body.json "$IDEM")
curl -sS -X POST "$PG_API/v1/pay/deposits" \
-H "Authorization: Bearer $PG_API_KEY" \
-H "Content-Type: application/json" \
"${H[@]}" --data-binary @body.json | tee created.jsonWhat you should see — HTTP 201
{
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 2,
"status": "awaiting_payment",
"is_terminal": false,
"merchant_reference": "ORD-QS-0001",
"amount_minor": 10000,
"currency": "THB",
"created_at": "2026-08-21T04:10:47.902Z",
"expires_at": "2026-08-21T04:30:47.902Z",
"checkout_url": "https://pay.example-wl.com/c/9f2c1ab6e0d74f",
"slip_upload": {
"token": "pgsut_eyJraWQiOiJvcmRlc...",
"expires_at": "2026-08-22T04:30:47.902Z",
"upload_url": "https://slip-api.example.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/slip",
"max_bytes": 4194304,
"content_types": ["image/jpeg", "image/png", "image/webp", "application/json"]
},
"liquidity_source": "internal",
"leg_count": 1,
"legs": [
{
"leg_ref": "leg_7QF2H8KM",
"sequence": 1,
"amount_minor": 10000,
"expires_at": "2026-08-21T04:30:47.902Z",
"leg_status": "pending",
"method": "promptpay_qr",
"qr_payload": "00020101021229370016A00000067701011201150105560012345530376454060100.005802TH6304A1B2",
"qr_image_url": "https://pay.example-wl.com/qr/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/1.png",
"receiver": {
"bank_code": "014",
"bank_name_th": "ธนาคารไทยพาณิชย์",
"account_number_masked": "xxx-x-x1234",
"account_name_masked": "ส*** ใ***"
}
}
],
"sender": {
"bank_code": "002",
"account_number_masked": "xxx-x-x7890",
"account_name_th_masked": "ส*** ใ***"
},
"metadata": { "order_id": "ORD-QS-0001" }
}Keep the id for the next steps:
DEP_ID="$(node -e "console.log(require('./created.json').id)")"
SLIP_UPLOAD_TOKEN="$(node -e "console.log(require('./created.json').slip_upload.token)")"
SLIP_UPLOAD_URL="$(node -e "console.log(require('./created.json').slip_upload.upload_url)")"
echo "$DEP_ID"Five traps in this one step
- Omitting both
senderandsender_idgives422 sender_requiredand no order is created. This is the difference that breaks existing integrations first. 202is not an error. With no liquidity yet you get202withstatus: "awaiting_liquidity",legs: []andliquidity.suggested_amounts_minor. Show a waiting screen, do not POST again, and wait for thedeposit.matchedwebhook.legs.length > 1happens. Your UI needs "step 1 of 2".legs[0]is not the whole order.- Never log or store
qr_payload. Render it and drop it. The only part you may keep isexpires_at. slip_uploadis a capability for one order. Never log its token, send it to analytics, or put it in a URL. POST only to the returnedupload_url(never construct the host/path), precheck againstmax_bytes+content_types, and do not add merchant HMAC headers.
The 202 awaiting_liquidity path has no slip_upload yet because no reservation exists. After deposit.matched, your backend signs POST /v1/pay/deposits/{id}/slip-token with merchant HMAC and receives {token, expires_at, upload_url, max_bytes, content_types} before revealing payment instructions.
client{} is not really optional
Without device_fingerprint + ip, the cap on open orders per user drops to 1, and your rate limit falls back to a coarse bucket shared across all of your merchant's users — so you hit 429 in the first minute of real traffic.
Step 3 — open the checkout page (30 seconds)
echo "$(node -e "console.log(require('./created.json').checkout_url)")"Open that URL in a browser. You get a payment page hosted on your WL's domain, with the QR, a countdown driven by expires_at, a "transfer only from this account" box, and a slip-upload button — none of which you had to build.
This is the recommended way to start. The trade-offs against building the page yourself are in Hosted checkout.
If you do build it yourself, use legs[] from the response directly, and remember:
- drive the countdown from
expires_at— never hardcode it (some routes really do expire in ~4 minutes) - the "transfer only from this account" box must be more prominent than the QR itself
- include the line "this QR is valid once, for this order only — do not save the image"
Step 4 — upload a test slip (1 minute)
Create a 1×1 px image to test with. Sandbox ignores the image content, but the client must still validate its type and size against the capability.
printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' \
| base64 -d > slip.png
# Precheck decoded bytes and media type from the capability; the server rechecks magic bytes.
node - 'image/png' <<'NODE'
const fs = require('node:fs')
const capability = require('./created.json').slip_upload
const mime = process.argv[2]
if (!capability.content_types.includes(mime)) throw new Error(`media type not allowed: ${mime}`)
if (fs.statSync('slip.png').size > capability.max_bytes) throw new Error('slip exceeds capability.max_bytes')
NODE
curl -sS -X POST "$SLIP_UPLOAD_URL" \
-H "Authorization: Bearer $SLIP_UPLOAD_TOKEN" \
-H "Idempotency-Key: slip-$DEP_ID-1" \
-F "file=@slip.png;type=image/png"What you should see — HTTP 202
{
"receipt_id": "rcpt_01K3M8N2QW7ZR4TY6BD0HXKV9C",
"status": "accepted",
"received_at": "2026-08-21T04:12:28.410Z"
}This is a durable receipt, not the deposit status. A consumer claims it and transitions the order to slip_processing asynchronously.
This is where we differ most from the rest of the market — successful durable intake is 202, with no immediate 409 duplicate_slip
Most gateways run the duplicate-slip check synchronously and hand you a 409 on the spot. We do not.
We return 202 after object storage and queue confirmation succeed, because by this moment the user's money has already left their account and no code path may lose that evidence. If durable intake is not fully confirmed, the endpoint returns a retryable 503; resend the same logical evidence with the same key. evidence_fingerprint is stable across boundary, key-order, and base64-format changes; duplicate-slip detection runs later in the pipeline.
What you have to build: the duplicate verdict arrives as a deposit.under_review webhook with review.reasons[].code = "DUPLICATE_SLIP", or you read it from GET /v1/pay/deposits/{id}. Do not wait for the upload's HTTP status to tell you. The screen after submit must say "checking the slip", not "success".
Other shapes this endpoint accepts
| what you send | when to use it | result |
|---|---|---|
multipart field file (type/size must fit content_types/max_bytes) | the normal case | goes into the automatic verification pipeline |
JSON {"image_base64": "...", "payload": "..."} | your client can decode the mini-QR on the slip itself | same as above, but more accurate |
JSON {"trans_ref": "...", "sending_bank_code": "002", "occurred_at": "..."} | the user lost the slip and only has the reference number from their banking app | 🔴 always forced into under_review with ['NO_SLIP_IMAGE'] — never automatic |
Once the late-slip window closes you get 410, not 202
A slip that arrives after the 24-hour window returns 410 late_slip_window_closed with details.claim_url. Open a case through POST /v1/bo/claims instead. Simulate this with amount 112.00.
Sandbox drives the flow on its own
Amount 100.00 is pushed all the way to settled within ~5 seconds of creation. If your slip upload arrives later than that, you may see the status has already moved on — that is not an error. Always trust what GET /v1/pay/deposits/{id} returns.
Step 5 — read the real status (30 seconds)
mapfile -t H < <(./pg-sign.sh GET "/v1/pay/deposits/$DEP_ID")
curl -sS "$PG_API/v1/pay/deposits/$DEP_ID" \
-H "Authorization: Bearer $PG_API_KEY" \
"${H[@]}"What you should see once the flow has finished
{
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 7,
"status": "settled",
"is_terminal": false,
"merchant_reference": "ORD-QS-0001",
"ordered_amount_minor": 10000,
"amount_minor": 10000,
"currency": "THB",
"transferred_amount_minor": 10000,
"amount_received_minor": 10000,
"fee_total_minor": 250,
"fee_breakdown": [
{ "component": "processing_fee", "amount_minor": 130, "rate_bps": 130 },
{ "component": "fixed_fee", "amount_minor": 100 },
{ "component": "vat", "amount_minor": 20 }
],
"credited_amount_minor": 9750,
"overpayment": null,
"partial": false,
"settled_via": "auto",
"liquidity_source": "internal",
"balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"settled_at": "2026-08-21T04:12:33.702Z",
"clearing_available_at": "2026-08-24T04:12:33.702Z",
"metadata": { "order_id": "ORD-QS-0001" }
}Four core amounts, plus fee and wallet movement
| number | value | what to do with it |
|---|---|---|
ordered_amount_minor (X) | 10000 | amount the order was created for |
transferred_amount_minor (V) | 10000 | amount the bank verified as transferred |
amount_received_minor (A) | 10000 | 🔴 credit the end user with the amount accepted into the order |
overpayment.amount_minor (E) | absent (0) | excess in a claim; if present, direct the user to support and do not credit it |
fee_total_minor | 250 | book it as an expense |
credited_amount_minor | 9750 | what lands in your merchant wallet |
On a same-slip overpayment, V = A + E and A = X. It still emits only one flagged deposit.settled, not deposit.overpaid.
is_terminal is still false even at settled
The only exit from settled is reversed, so do not delete the order from your system when you see settled. And clearing_available_at tells you when this money becomes withdrawable (T+72 hours) — it is not withdrawable immediately.
Step 6 — receive a webhook and verify its signature (2 minutes)
6.1 Fetch the public key (no auth needed)
curl -sS "$PG_API/v1/webhooks/signing-keys"{
"keys": [
{ "kid": "whk_2026a", "alg": "Ed25519", "public_key": "MCowBQYDK2VwAyEA...",
"format": "spki_der_base64", "raw_public_key": "5Nn2q...", "status": "active",
"not_before": "2026-02-01T00:00:00.000Z", "not_after": null }
]
}6.2 A minimal receiver that really verifies — database wiring is omitted. Implement persistWebhookAtomically using the transactional inbox/outbox on the signature page before running it; never replace it with an in-memory Set.
import http from 'node:http';
import crypto from 'node:crypto';
const API = process.env.PG_API;
let keys = new Map();
async function loadKeys() {
const res = await fetch(`${API}/v1/webhooks/signing-keys`);
const { keys: list } = await res.json();
keys = new Map(list.map((k) => [k.kid, crypto.createPublicKey({
key: Buffer.from(k.public_key, 'base64'), format: 'der', type: 'spki',
})]));
}
await loadKeys();
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', async () => {
const raw = Buffer.concat(chunks); // 🔴 raw bytes — never parse before verifying
// 1) parse the header as key=value pairs, never by position
const header = req.headers['x-pg-signature'] || '';
const parts = Object.fromEntries(
header.split(',').map((s) => s.trim()).filter((s) => s.includes('='))
.map((s) => { const i = s.indexOf('='); return [s.slice(0, i), s.slice(i + 1)]; }));
const { kid, t, s: sig } = parts;
if (!header.startsWith('v1,') || !kid || !t || !sig) return res.writeHead(400).end();
// 2) replay window of ±300 seconds
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return res.writeHead(400).end();
// 3) verify against the raw bytes
const eventId = req.headers['x-pg-event-id'] || '';
const signed = Buffer.concat([Buffer.from(`v1.${t}.${eventId}.`, 'utf8'), raw]);
let key = keys.get(kid);
if (!key) { await loadKeys(); key = keys.get(kid); } // unknown kid → refetch once
if (!key) return res.writeHead(401).end();
if (!crypto.verify(null, signed, key, Buffer.from(sig, 'base64url'))) return res.writeHead(401).end();
// 4) only parse after verifying — and always trust the body over the header
const event = JSON.parse(raw.toString('utf8'));
if (event.id !== eventId) return res.writeHead(400).end();
// 5) This helper inserts/dedupes the inbox row and inserts an outbox row in one DB transaction.
// Never markSeen and enqueue separately. The schema is on /en/webhooks/signature.
await persistWebhookAtomically({ event, raw });
console.log(event.type, event.data.object.status ?? '-', 'v' + (event.data.object.version ?? '-'));
if (event.type === 'deposit.settled') {
console.log(' credit to user =', event.data.object.amount_received_minor);
console.log(' into wallet =', event.data.object.credited_amount_minor);
}
res.writeHead(200).end(); // answer only after the DB commit
});
}).listen(8787, () => console.log('waiting for webhooks on :8787'));6.3 Make it reachable from the outside — the endpoint must be https:// with a real certificate (self-signed will not pass). Use a tunnelling service that gives you an https URL, then register that URL as deposit_url in your WL dashboard.
What you should see in the console
deposit.created v1
deposit.matched awaiting_payment v2
deposit.slip_received slip_processing v5
deposit.settled settled v7
credit to user = 10000
into wallet = 9750
balance.credited - v-Three things that break this receiver in production
- Using
express.json()on this path destroys the raw bytes, and verification can never pass. - No version guard means a retried
deposit.expiredv6 arriving late overwrites thesettledv7 you already credited. Event order is not guaranteed and the retry window runs ~92 hours. - Deciding from
event.type. The truth isdata.object.status;event.typeonly tells you that something happened.
These five events cannot be turned off — have a handler from day one
deposit.reversed · withdrawal.fill_reversed · balance.debited · webhook.key.rotating · webhook.endpoint.disabled
The first three take money out of your wallet. The last two are what stops your webhook from failing silently. The subscription is enforced at the database level and cannot be disabled. Merchants without a handler find out at month end.
Before you go to production
| ✔ | What you need | Read |
|---|---|---|
| ☐ | canonical string passes all 3 test vectors | authentication |
| ☐ | Idempotency-Key written to your DB before sending, and read back on retry | idempotency |
| ☐ | version guard is an atomic UPDATE ... WHERE version < $new | webhook ordering |
| ☐ | Transactional inbox has UNIQUE(event_id), kept ≥ 31 days; repeats answer 200 | signature verification |
| ☐ | UI covers all 9 deposit statuses and all 7 withdrawal statuses | deposit statuses |
| ☐ | amounts 103.00 / 108.00 / 109.00 / 116.00 and replay-out-of-order all pass | sandbox |
| ☐ | a daily reconcile job | go-live checklist |
Next
- Still unclear where the money goes → How the system works
- Stuck on
401→ Request authentication - Want to simulate
under_review/ a bank outage / an expiry → Sandbox - Do not want to build the QR page → Hosted checkout
- Ran into a term you do not know → Glossary
