Skip to content

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.

bash
curl -s https://api.example-wl.com/v1/webhooks/test-vectors -o vectors.json

You get three sets, each with raw_body.bin, t, event_id, kid, signature, public_key.

setpayloadbug it catches
1Thai text onlyRe-serialization (Thai becomes \uXXXX)
2Contains a URL with /Re-serialization (/ becomes \/)
3Contains emoji + a surrogate pairReading the body as a string instead of bytes

Tests that must go with them:

testrequired result
Verify all three setsAll pass
Change one byte of the raw body401
Change t to a different value401 (because t is part of the canonical string)
Set t more than 300 seconds in the past400
Send an event.id in the body that differs from X-PG-Event-Id400
Send the same valid payload twiceThe 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_minorscenario you get
100.0010000matched → automatic slip check → settled in ~5 seconds
101.0010100→ under_review ['SENDER_NAME_MISMATCH'] (stays there until you approve or reject)
102.0010200→ expired → automatic late slip upload at +60 seconds → under_review['LATE_SLIP'] → settled
103.0010300settled → reversed at +60 seconds (tests a non-optional event plus ordering case B)
104.0010400fallback route with 2 legs (TTL ~4 minutes)
105.0010500a full 25 seconds of awaiting_liquidity before a destination appears
106.0010600→ under_review ['BANK_DISABLED']
107.0010700the counterparty withdrawal is split into 3 chunks
108.0010800duplicate transfer — settled, then deposit.overpaid at +45 seconds
109.0010900canceled, then transferred anyway — canceled(is_terminal:false) → late slip upload → under_review['CANCELED_ORDER_SLIP']
110.0011000ALLOCATION_CONFLICT — a late slip for a portion already matched to someone else, plus withdrawal.disputed on the counterparty side
111.0011100automatic clean short-pay → settled at A=V<X, partial:true, settled_via:'auto' · no review and no force-complete call
112.0011200late-slip window closed → 410 late_slip_window_closed + claim_url
113.0011300402 insufficient_balance when creating a withdrawal (the funds are still in pending_clearing)
114.0011400key rotation — webhook.key.rotating, then the kid switches within 60 seconds
115.0011500idempotency conflict — same key, different body
116.0011600same-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
bash
# 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_th must be the Thai account name exactly as the bank prints it on the slip. "สมชาย ใจดี" is a placeholder Thai name (Somchai Jaidee). bank_code 002 is 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 ​

bash
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:

casecorrect resultwrong result (means there is no version guard)
A — expired (older version) arrives after settledThe order is still settled · no side effect from the stale event · answer 200The order gets canceled
B — settled (older version) arrives after reversedNo credit, no goods released · answer 200Credits again and releases goods on a reversed order
C — withdrawal.filled chunks out of orderremaining_minor matches the highest-version deliveryThe 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 / endpointwhat it does
Resend one delivery — POST /v1/bo/webhooks/{endpoint_id}/deliveries/{delivery_id}/resendSends the same event again, same bytes, same event.id
Bulk resend — POST /v1/bo/webhooks/{endpoint_id}/replaySelects 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/302 to 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.

bash
# shape of the setup: <public https url> → http://127.0.0.1:3000/webhooks/pg

Tunnel pitfalls ​

symptommost common cause
Verification passes locally but fails behind the tunnelFramework middleware parses JSON before your handler ⇒ the raw body is gone. Make that route raw ahead of every other middleware.
Always 400Your machine's clock has drifted — abs(now − t) > 300 seconds. Sync NTP.
Nothing arrives at allThe tunnel answers 3xx or shows the tunnel provider's interstitial page. It has to answer 2xx directly.
Arrives, but slowly, and gets retriedTunnel 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.

js
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-Signature as key=value, not by position · decode the signature as base64url
  • [ ] Timestamp older than 300 seconds → reject
  • [ ] event.id in the body matches X-PG-Event-Id
  • [ ] livemode checked against your environment
  • [ ] Public key cached ≤ 24 h · unknown kid → refetch once (throttled) · subscribed to webhook.key.rotating with a procedure for accepting the new kid
  • [ ] Transactional inbox has UNIQUE(event_id), retained ≥ 31 days · duplicate → answer 200
  • [ ] Answer 2xx within < 3 seconds · never 3xx · 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-order passes with correct results for cases A and B
  • [ ] balance_transaction deduped on btxn_id and accumulated · balance_after never used to overwrite
  • [ ] A default: in every switch over status / 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 / canceled check is_terminal, not the status name — test with amount 109.00

Money and reconciliation ​

  • [ ] The end user is credited with amount_received_minor, guarded by credited_user_at IS NULL
  • [ ] X/V/A/E are stored separately, with assertions that A = min(V,X), 0 ≤ fee ≤ A, and amount_received_minor − fee_total_minor = credited_amount_minor
  • [ ] Amount 116.00 emits one deposit.settled, credits A=X once, shows the open/contact-support claim, and never auto-credits/refunds E
  • [ ] fee_breakdown[] stored raw — parsed by component
  • [ ] pending_clearing and available are separate in your internal books · withdrawable funds computed from available only
  • [ ] balance.cleared handled without adding the amount again
  • [ ] The withdrawal side reads amounts from filled_minor / remaining_minor through the version guard (no SUM, no +=)
  • [ ] A withdrawal is closed only when status = 'settled' and remaining_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-Attempt logged on every delivery (delivery headers are observability-only)
  • [ ] A technical contact that a human actually reads (used when an endpoint is auto-disabled)
  • [ ] 🔴 qr_payload never logged and never stored · full destination account details never logged
  • [ ] The support team has a playbook: under_review means waiting on a check, not an error · never tell the user to transfer again

Next ​

read nextwhen
Full go-live checklistYou want every area covered, not just webhooks.
Order is not guaranteed — version guardYou failed replay-out-of-order.
Verify the Ed25519 signatureVerification is failing.
Retry and endpoint outagesYou want to know the behaviour when an endpoint goes down.
Migrating from your current providerYou are moving off an existing system and need to run both in parallel for a while.

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