Testing webhooks
The fastest order to work in: verify offline → fire real events from sandbox → test the out-of-order cases → run the checklist.
Step 0 — get verification passing offline (no endpoint required)
You cannot forge your own webhooks — and that is a good thing
With gateways that use HMAC, you can build a fake payload with a valid signature yourself, because you hold the same secret they do.
We sign with Ed25519 — the private key exists only on our side ⇒ no script on your machine can produce an event that verifies.
⇒ Test your verification function with the test vectors. Test the whole path with sandbox.
curl -s https://api.example-wl.com/v1/webhooks/test-vectors -o vectors.jsonYou get three sets, each with raw_body.bin, t, event_id, kid, signature, public_key.
| set | payload | bug it catches |
|---|---|---|
| 1 | Thai text only | Re-serialization (Thai becomes \uXXXX) |
| 2 | Contains a URL with / | Re-serialization (/ becomes \/) |
| 3 | Contains emoji + a surrogate pair | Reading the body as a string instead of bytes |
Tests that must go with them:
| test | required result |
|---|---|
| Verify all three sets | All pass |
| Change one byte of the raw body | 401 |
Change t to a different value | 401 (because t is part of the canonical string) |
Set t more than 300 seconds in the past | 400 |
Send an event.id in the body that differs from X-PG-Event-Id | 400 |
| Send the same valid payload twice | The second time returns 🔴 200 with no side effects |
The last row is the most important test on this page
If the second delivery produces a side effect, your dedupe is not working. In production that means crediting up to 11× — once per retry attempt.
Step 1 — fire real events from sandbox
Sandbox uses a separate key set, and every event carries livemode: false.
livemode cannot cross over — enforced in the database, not in application logic
A sandbox event can never reach a production endpoint, or the other way round.
You still have to check it in your own code: if (event.livemode !== isProduction) return 400 — in case your own config points at the wrong environment.
How to fire: create a deposit with a trigger amount
Sandbox binds scenarios to amounts. Whichever amount you create the order with, you get that scenario's event sequence.
| amount (THB) | amount_minor | scenario you get |
|---|---|---|
| 100.00 | 10000 | matched → automatic slip check → settled in ~5 seconds |
| 101.00 | 10100 | → under_review ['SENDER_NAME_MISMATCH'] (stays there until you approve or reject) |
| 102.00 | 10200 | → expired → automatic late slip upload at +60 seconds → under_review['LATE_SLIP'] → settled |
| 103.00 | 10300 | settled → reversed at +60 seconds (tests a non-optional event plus ordering case B) |
| 104.00 | 10400 | fallback route with 2 legs (TTL ~4 minutes) |
| 105.00 | 10500 | a full 25 seconds of awaiting_liquidity before a destination appears |
| 106.00 | 10600 | → under_review ['BANK_DISABLED'] |
| 107.00 | 10700 | the counterparty withdrawal is split into 3 chunks |
| 108.00 | 10800 | duplicate transfer — settled, then deposit.overpaid at +45 seconds |
| 109.00 | 10900 | canceled, then transferred anyway — canceled(is_terminal:false) → late slip upload → under_review['CANCELED_ORDER_SLIP'] |
| 110.00 | 11000 | ALLOCATION_CONFLICT — a late slip for a portion already matched to someone else, plus withdrawal.disputed on the counterparty side |
| 111.00 | 11100 | automatic clean short-pay → settled at A=V<X, partial:true, settled_via:'auto' · no review and no force-complete call |
| 112.00 | 11200 | late-slip window closed → 410 late_slip_window_closed + claim_url |
| 113.00 | 11300 | 402 insufficient_balance when creating a withdrawal (the funds are still in pending_clearing) |
| 114.00 | 11400 | key rotation — webhook.key.rotating, then the kid switches within 60 seconds |
| 115.00 | 11500 | idempotency conflict — same key, different body |
| 116.00 | 11600 | same-slip overpayment — X=11600, V=12100, other 5 checks pass → one deposit.settled with A=X, E=500, an open/contact-support flag, and no automatic refund |
# example: trigger the reversed scenario (THB 103.00)
curl -X POST https://api.example-wl.com/v1/pay/deposits \
-H "Authorization: Bearer <sandbox api key>" \
-H "Content-Type: application/json" \
-H "PG-Signature: ..." -H "PG-Timestamp: ..." -H "PG-Nonce: ..." \
-H "Idempotency-Key: sbx-reversed-001" \
-H "PG-Version: 2026-08-01" \
-d '{
"amount_minor": 10300,
"currency": "THB",
"merchant_reference": "SBX-REVERSED-001",
"end_user_ref": "u_88213",
"sender": {
"bank_code": "002",
"account_number": "1234567890",
"account_name_th": "สมชาย ใจดี"
},
"client": {
"device_fingerprint": "df_9a1c7f20b4",
"ip": "203.0.113.44",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X)"
},
"metadata": { "user_id": "u_88213", "order_id": "SBX-REVERSED-001" }
}'
account_name_thmust be the Thai account name exactly as the bank prints it on the slip."สมชาย ใจดี"is a placeholder Thai name (Somchai Jaidee).bank_code002is Bangkok Bank.
Five scenarios the system verifies automatically before issuing production keys
103.00 (reversed) · 108.00 (duplicate transfer) · 109.00 (canceled then transferred) · 116.00 (same-slip overpayment) and the out-of-order event test.
These five are not self-attested checkboxes — the system confirms your handler behaves correctly before your production key is issued.
Step 2 — test out-of-order events
curl -X POST https://api.example-wl.com/v1/test/webhooks/replay-out-of-order \
-H "Authorization: Bearer <sandbox api key>" \
-H "PG-Signature: ..." -H "PG-Timestamp: ..." -H "PG-Nonce: ..." \
-H "Idempotency-Key: sbx-ooo-001" \
-H "PG-Version: 2026-08-01"We deliver the test order's events out of sequence. Required results:
| case | correct result | wrong result (means there is no version guard) |
|---|---|---|
A — expired (older version) arrives after settled | The order is still settled · no side effect from the stale event · answer 200 | The order gets canceled |
B — settled (older version) arrives after reversed | No credit, no goods released · answer 200 | Credits again and releases goods on a reversed order |
C — withdrawal.filled chunks out of order | remaining_minor matches the highest-version delivery | The amount stays stuck at a stale value |
The code that passes all three cases
It is on Order is not guaranteed — version guard. The core is WHERE pg_version < EXCLUDED.pg_version, with side effects tied to rowCount.
Step 3 — resend from the dashboard
The webhook dashboard has three actions, and all three have endpoints you can call from CI:
| button / endpoint | what it does |
|---|---|
Resend one delivery — POST /v1/bo/webhooks/{endpoint_id}/deliveries/{delivery_id}/resend | Sends the same event again, same bytes, same event.id |
Bulk resend — POST /v1/bo/webhooks/{endpoint_id}/replay | Selects by event_types, created_after, created_before, only_failed |
| Replay failed deliveries (last 72 h) | Shortcut for after an endpoint was auto-disabled and then re-enabled |
The full rules are on Retry · resending.
Resend is the most realistic dedupe test you have
Resend a deposit.settled you have already processed, three times in a row. event.id is identical every time.
The user's balance must not move by a single satang, and you must answer 200 all three times.
Forwarding to localhost
We only deliver to public URLs. http://localhost is impossible from the start, because:
- The URL must be
https://· TLS ≥ 1.2 · with a valid certificate — self-signed = fail - 🔴 We do not follow redirects ⇒ a tunnel that answers
301/302to another page counts as a failure - 🔴 We have no fixed source IP ⇒ do not set an allowlist at the tunnel or at your WAF
⇒ Use an HTTPS tunnel that issues a real certificate, point it at your machine, and set that URL as the deposit_url of a sandbox endpoint.
# shape of the setup: <public https url> → http://127.0.0.1:3000/webhooks/pgTunnel pitfalls
| symptom | most common cause |
|---|---|
| Verification passes locally but fails behind the tunnel | Framework middleware parses JSON before your handler ⇒ the raw body is gone. Make that route raw ahead of every other middleware. |
Always 400 | Your machine's clock has drifted — abs(now − t) > 300 seconds. Sync NTP. |
| Nothing arrives at all | The tunnel answers 3xx or shows the tunnel provider's interstitial page. It has to answer 2xx directly. |
| Arrives, but slowly, and gets retried | Tunnel plus dev machine is slower than 15 seconds ⇒ answer 200 first and do the work afterwards. |
Do not use the tunnel as your latency benchmark
Response time through a tunnel does not reflect production. But if you are still over 3 seconds through the tunnel, your handler is definitely doing too much — move the work into a queue first.
Minimal test endpoint (Node)
Run this alongside a tunnel as-is. Business logic is stripped out, leaving only the correct skeleton.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.post('/webhooks/pg', express.raw({ type: '*/*', limit: '512kb' }), async (req, res) => {
const raw = req.body;
const header = req.get('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.status(400).end();
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return res.status(400).end();
const eventId = req.get('X-PG-Event-Id') || '';
if (!/^evt_[A-Za-z0-9_]{10,80}$/.test(eventId)) return res.status(400).end();
const signed = Buffer.concat([Buffer.from(`v1.${t}.${eventId}.`, 'utf8'), raw]);
const pub = await getPublicKey(kid); // see /en/webhooks/signature
if (!pub) return res.status(400).end();
if (!crypto.verify(null, signed, pub, Buffer.from(sig, 'base64url')))
return res.status(401).end();
const event = JSON.parse(raw.toString('utf8'));
if (event.id !== eventId) return res.status(400).end();
if (event.livemode !== (process.env.NODE_ENV === 'production')) return res.status(400).end();
// log these on every delivery — support will ask for them
console.log(JSON.stringify({
event_id: event.id, event_type: event.type,
delivery_id: req.get('X-PG-Delivery-Id'), attempt: Number(req.get('X-PG-Attempt')),
object_id: event.data.object.id, version: event.data.object.version ?? null,
status: event.data.object.status ?? null,
}));
// This helper inserts/dedupes the inbox row and inserts the outbox row in one DB transaction.
// Use the schema and implementation on /en/webhooks/signature; retain inbox rows ≥ 31 days.
await persistWebhookAtomically({ event, raw });
return res.status(200).end(); // answer only after commit
});
app.listen(3000);Pre-production checklist
Signature and reception
- [ ] Verify Ed25519 over the raw body before parsing · negative test: change 1 byte, must return
401 - [ ] Parse
X-PG-Signatureas key=value, not by position · decode the signature as base64url - [ ] Timestamp older than 300 seconds → reject
- [ ]
event.idin the body matchesX-PG-Event-Id - [ ]
livemodechecked against your environment - [ ] Public key cached ≤ 24 h · unknown
kid→ refetch once (throttled) · subscribed towebhook.key.rotatingwith a procedure for accepting the new kid - [ ] Transactional inbox has
UNIQUE(event_id), retained ≥ 31 days · duplicate → answer200 - [ ] Answer
2xxwithin < 3 seconds · never3xx· valid TLS · no reliance on an IP allowlist - [ ] All three test vectors verify
Ordering and state correctness
- [ ] The version guard is an atomic UPDATE ... WHERE version < new, not SELECT-then-UPDATE
- [ ]
POST /v1/test/webhooks/replay-out-of-orderpasses with correct results for cases A and B - [ ]
balance_transactiondeduped onbtxn_idand accumulated ·balance_afternever used to overwrite - [ ] A
default:in everyswitchoverstatus/reasons[].code/balance_transaction.type/fee_breakdown[].component - [ ] Handlers exist for all 5 non-optional events (
deposit.reversed·withdrawal.fill_reversed·balance.debited·webhook.key.rotating·webhook.endpoint.disabled) - [ ]
expired/canceledcheckis_terminal, not the status name — test with amount 109.00
Money and reconciliation
- [ ] The end user is credited with
amount_received_minor, guarded bycredited_user_at IS NULL - [ ]
X/V/A/Eare stored separately, with assertions thatA = min(V,X),0 ≤ fee ≤ A, andamount_received_minor − fee_total_minor = credited_amount_minor - [ ] Amount 116.00 emits one
deposit.settled, creditsA=Xonce, shows the open/contact-support claim, and never auto-credits/refundsE - [ ]
fee_breakdown[]stored raw — parsed bycomponent - [ ]
pending_clearingandavailableare separate in your internal books · withdrawable funds computed fromavailableonly - [ ]
balance.clearedhandled without adding the amount again - [ ] The withdrawal side reads amounts from
filled_minor/remaining_minorthrough the version guard (noSUM, no+=) - [ ] A withdrawal is closed only when
status = 'settled'andremaining_minor = 0 - [ ] A daily reconcile job runs against
GET /v1/pay/events?since_sequence=(🔴 mandatory)
Operations
- [ ] Alerts on signature verification failures, and on the handler returning non-2xx for more than 1% over 5 minutes
- [ ]
event.id+X-PG-Delivery-Id+X-PG-Attemptlogged on every delivery (delivery headers are observability-only) - [ ] A technical contact that a human actually reads (used when an endpoint is auto-disabled)
- [ ] 🔴
qr_payloadnever logged and never stored · full destination account details never logged - [ ] The support team has a playbook:
under_reviewmeans waiting on a check, not an error · never tell the user to transfer again
Next
| read next | when |
|---|---|
| Full go-live checklist | You want every area covered, not just webhooks. |
| Order is not guaranteed — version guard | You failed replay-out-of-order. |
| Verify the Ed25519 signature | Verification is failing. |
| Retry and endpoint outages | You want to know the behaviour when an endpoint goes down. |
| Migrating from your current provider | You are moving off an existing system and need to run both in parallel for a while. |
