Skip to content

Webhooks — overview and envelope ​

Webhooks are the main channel for learning how a deposit or withdrawal is progressing. Polling is only a fallback.

This page answers three questions: what the envelope looks like, how many endpoints you need, and what your endpoint must do to count as "received".

Three principles ​

1. The payload is complete — do not call back ​

Every event carries data.object as the full object as it stood at the moment the event was created, not just an id and a status.

That means your handler can be written like this: receive the event → write it to your database with a single UPDATE → release the goods. No GET /v1/pay/deposits/{id} step in between.

❌ Don't do this:  webhook arrives → GET to read state → then decide
✅ Correct:        webhook arrives → verify → UPDATE ... WHERE version < new → decide from the UPDATE result

Why we designed it this way: a read-back GET creates a second race (you may get state that is newer than the event you are currently processing, and then overwrite it), and it burns your pay_read rate limit exactly when traffic peaks.

2. event.type tells you "what happened" — data.object.status is the truth ​

Decide money questions from data.object.status only

Only status = 'settled' means you may credit. slip_processing and under_review mean the money has already been transferred but verification is not finished — do not release goods.

Writing switch (event.type) and releasing goods inside case 'deposit.slip_received' is a bug that costs real money.

3. Order is not guaranteed — version is what stops stale data overwriting fresh data ​

We guarantee only that data.object.version increases in the order the state actually changed in our database, and that no two events for the same object ever carry the same version.

We do not guarantee they reach you in that order. Read Order is not guaranteed before you write the first line of your handler.

The envelope (present on every event, no exceptions) ​

This envelope is an immutable business event. We store the raw JSON when the event is created and send the same body bytes on every attempt and manual resend. Per-delivery metadata lives only in X-PG-* headers, so retries never mutate the body.

json
{
  "id": "evt_01K3M8QW7Z4N2VYB6H0RSTA9CD",
  "object": "event",
  "type": "deposit.settled",
  "api_version": "2026-08-01",
  "livemode": true,
  "created_at": "2026-08-21T04:12:33.881Z",
  "sequence": 88214,
  "account": {
    "merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
    "white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
  },
  "request": {
    "id": "req_01K3M8QW7Y2VNRT4HB0XKDF9CS",
    "idempotency_key": "order-9981-topup"
  },
  "data": {
    "object": { "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B", "object": "deposit_order", "version": 9 },
    "previous_attributes": { "version": 8, "status": "slip_processing" }
  }
}

data.object is truncated above so the envelope is visible. In real traffic data.object is the full object — see the complete bodies in the event catalogue.

Every field in the envelope ​

fieldtypewhat it guarantees
idstring evt_* (ULID)Never reused. Identical on every attempt and on every manual resend ⇒ use it as the unique key of your dedupe table.
objectstringAlways "event".
typestringEvent name from the catalogue · 🔴 open enum — every switch needs a default:.
api_versionYYYY-MM-DDRendered with the version you had pinned when the event was created. Retries of an old event keep the original version.
livemodebooleanfalse = sandbox · 🔴 a sandbox event can never reach a production endpoint, and vice versa.
created_atRFC3339 UTCWhen the event was created — unchanged by retries. Always 3-digit milliseconds, always ends in Z.
sequenceint64Always increasing per merchant, ordered by creation time, not delivery time. Use it to detect gaps and as the cursor for GET /v1/pay/events.
account.merchant_idstringThe merchant account this event belongs to.
account.white_label_idstringThe provider that issued your keys.
request.idstringId of the API call that caused this event · present only when the event came from an API call (the key is absent entirely when we generated it ourselves, e.g. an expiry).
request.idempotency_keystringThe key you sent when creating the order ⇒ you can map the event back to your own order even before you have stored the deposit_id.
data.objectobjectThe full business object at that moment · every object carries version except immutable ones.
data.previous_attributesobject | nullOnly the keys that actually changed, plus the previous version · null for *.created events · the key is absent entirely for balance_transaction.

Delivery metadata is not part of the JSON body. Read X-PG-Delivery-Id, X-PG-Endpoint-Id, X-PG-Attempt, and X-PG-Resend from the headers after signature verification. See the complete list in signature verification.

"optional" means the key may be missing entirely — not null

These docs use one rule throughout: a field documented as "always present" is a guarantee, and an optional field may be missing key and all. Code written as if (event.request.idempotency_key) breaks the moment request is absent — use optional chaining.

Objects without version ​

balance_transaction (btxn_*) is append-only and immutable ⇒ no version, no previous_attributes.

⇒ Dedupe by id and then accumulate, never overwrite. Storage pattern: ordering.

api_version — what counts as breaking ​

Upgrades are opt-in. Breaking changes are announced 90 days ahead. Old versions are supported for 12 months. An X-PG-Deprecation header warns you.

Not breaking changes (can happen at any time without notice):

  • Adding a new field to a payload
  • Adding a new value to an enum declared open: event type · review.reasons[].code · rejection.code · balance_transaction.type · fee_breakdown[].component

Every switch needs a default:

An open enum means that one day you will receive a value your code does not know. A switch without default: fails silently — it does not throw, and you find out at month-end reconciliation.

The only closed enums (where adding a value would be a breaking change) are the 9 values of deposit_orders.status and the 7 values of withdrawal_orders.status.

You can split endpoints by group ​

fieldrequired?which events it receives
deposit_url✅ requiredDeposit-side events · also the fallback for every group that has no separate URL.
withdrawal_urloptionalWithdrawal-side events · unset → goes to deposit_url.
account_urloptionalbalance.* and webhook.* · unset → goes to deposit_url.

Requirements for every URL:

  • Must be https:// · TLS ≥ 1.2 · a valid certificate (self-signed = fail)
  • We add no query parameters and send no data in the query string at all — data is always in the body
  • 🔴 We do not follow redirects — a 3xx response counts as a failure (blocks SSRF and keeps the signature off other hosts)
  • 🔴 We have no fixed source IP — do not design around an IP allowlist. The Ed25519 signature is the only gate.

How many endpoints should you configure?

If one team owns everything, a single deposit_url is enough. Split when different teams own different sides, or when you want withdrawal traffic (one order can produce 7 deliveries) to stay out of the deposit queue.

What your endpoint has to do ​

The correct order inside the handler — do not rearrange:

  1. Read the raw body (bytes) — do not let the framework parse JSON first
  2. Verify the Ed25519 signature over the raw bytes → fail, respond 401
  3. Check the timestamp abs(now − t) ≤ 300 seconds → outside the window, respond 400
  4. Parse the JSON (only now) and check that event.id matches the header and livemode matches your environment
  5. Transactional inbox — in one transaction, insert/dedupe event.id with the raw event and create an outbox row for downstream work
  6. Respond 2xx only after that transaction commits — duplicates answer 200; an outbox relay hands heavy work to a worker later

Never commit markSeen() and enqueue in a second operation: a crash between them loses the event permanently. Complete correct and deliberately broken examples are in signature verification.

What counts as "received" ​

criterionvalue
status codes that count as success200 201 202 204 only
recommended response time3 seconds
total request timeout (hard limit)15 seconds
size of the body we send≤ 256 KB (guaranteed)
the body you returnWe read up to 8 KB for logs · content has no effect on success — an empty body is fine.

Heavy work belongs outside the request

Sending email, syncing to your ERP, rebuilding reports — do those in a queue, not on the line that blocks res.send().

If your handler takes more than 15 seconds we count it as a failure and retry. A handler that repeats work gets slower, then times out again, and the loop tips over.

Five events you cannot unsubscribe from ​

Every endpoint must subscribe to these five. It is enforced in the database, not a recommendation:

eventwhy it cannot be turned off
deposit.reversedMoney is pulled back out of your wallet.
withdrawal.fill_reversedMoney already paid out is reversed.
balance.debitedEvery debit against your wallet.
webhook.key.rotatingThe signing key is about to change — miss it and every verification fails on switchover day.
webhook.endpoint.disabledOne of your endpoints was disabled automatically.

The first three share one reason: they take money out. If they could be turned off you would only ever see inflows, your books would not balance, and you would have room to argue that you were "never notified".

What will never appear in a payload ​

Designed from day one so merchants never see it — do not write code that waits for these values:

  • Any name, code, domain, or reference belonging to an external provider — you see only liquidity_source ∈ internal | external_gateway, and 🔴 you must not use that value to decide whether to credit
  • The counterparty's real name or full account number — masked values only
  • The id of the counterparty order — you see a leg_ref, and the two sides receive different values, so they cannot be correlated
  • Anything read off the actual slip (the sender's name or account number as our system read it) — you get only the six pure booleans in verification.*
  • Support-queue internals and internal policy — you get only review.escalated, review.four_eyes_required, review.estimated_resolution_bucket

Where we differ from the rest of the market (and what you must do about it) ​

topicother gateways you have integrateduswhat you must add
Callback signatureHMAC with a shared secret, or nothing at allEd25519 — we hold the private key, you hold the public keyFetch the public key from GET /v1/webhooks/signing-keys (no auth) and verify over raw bytes.
Payload completenessid + status, then you call back for detailsThe full object plus versionDrop the mid-flight GET and use a version guard instead.
OrderingMany providers deliver in orderNot guaranteed — the retry window is ~92 hoursYou need a version guard. Without one, under_review overwrites settled.
One withdrawal1 callback at the endwithdrawal.filled multiple times per orderClose the order only when status='settled' and remaining_minor = 0.
Duplicate-slip result409 duplicate_slip immediately on upload🔴 successful durable intake answers 202The duplicate verdict arrives later, by webhook (deposit.under_review with code DUPLICATE_SLIP) or from the status endpoint — code that waits for a 409 will wait forever.
Amount creditedOne numberFour separate core amounts, X/V/A/E, plus fee/wallet creditCredit the end user with amount_received_minor (A) · the merchant wallet moves by credited_amount_minor · overpayment opens a claim for E, never an automatic credit/refund.

202 on slip upload — the quietest way legacy code breaks here

POST /v1/pay/deposits/{id}/slip answers 202 after capability auth/validation and confirmed storage + queue admission, even when that slip duplicates one already used. Unconfirmed intake returns a retryable 503.

We accept the file first (even while our database is struggling) and check it afterwards, because the user has genuinely already sent the money. Rejecting at the upload step leaves that money with no owner.

⇒ if (res.status !== 200) throw breaks the whole path · ⇒ the duplicate verdict will reach you by webhook only.

🔴 Webhooks are not the source of truth ​

Our ledger is the truth. Webhooks are notifications.

Even if your endpoint is down for four days and burns through the whole retry schedule, you can always reconcile afterwards:

bash
curl -s "https://api.example-wl.com/v1/pay/events?since_sequence=88214&limit=100" \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "PG-Signature: ..." -H "PG-Timestamp: ..." -H "PG-Nonce: ..." -H "PG-Version: 2026-08-01"

GET /v1/pay/events keeps 30 days of history and uses since_sequence as its only cursor.

A daily reconcile job is a hard requirement before go-live, not a nice-to-have.

Next ​

read nextwhen
Full event catalogueYou want to know which events exist and what each body contains.
Verify the Ed25519 signatureYou are about to write the first line of your handler.
Order is not guaranteed — version guard🔴 Read this before you release goods, always.
Retry and endpoint outagesYou want to know what happens after you answer 500.
Testing before productionYour handler is written.
Deposit statusesYou want to know which status means you may credit.

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