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 resultWhy 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.
{
"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.objectis truncated above so the envelope is visible. In real trafficdata.objectis the full object — see the complete bodies in the event catalogue.
Every field in the envelope
| field | type | what it guarantees |
|---|---|---|
id | string evt_* (ULID) | Never reused. Identical on every attempt and on every manual resend ⇒ use it as the unique key of your dedupe table. |
object | string | Always "event". |
type | string | Event name from the catalogue · 🔴 open enum — every switch needs a default:. |
api_version | YYYY-MM-DD | Rendered with the version you had pinned when the event was created. Retries of an old event keep the original version. |
livemode | boolean | false = sandbox · 🔴 a sandbox event can never reach a production endpoint, and vice versa. |
created_at | RFC3339 UTC | When the event was created — unchanged by retries. Always 3-digit milliseconds, always ends in Z. |
sequence | int64 | Always 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_id | string | The merchant account this event belongs to. |
account.white_label_id | string | The provider that issued your keys. |
request.id | string | Id 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_key | string | The 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.object | object | The full business object at that moment · every object carries version except immutable ones. |
data.previous_attributes | object | null | Only 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
| field | required? | which events it receives |
|---|---|---|
deposit_url | ✅ required | Deposit-side events · also the fallback for every group that has no separate URL. |
withdrawal_url | optional | Withdrawal-side events · unset → goes to deposit_url. |
account_url | optional | balance.* 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
3xxresponse 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:
- Read the raw body (bytes) — do not let the framework parse JSON first
- Verify the Ed25519 signature over the raw bytes → fail, respond
401 - Check the timestamp
abs(now − t) ≤ 300seconds → outside the window, respond400 - Parse the JSON (only now) and check that
event.idmatches the header andlivemodematches your environment - Transactional inbox — in one transaction, insert/dedupe
event.idwith the raw event and create an outbox row for downstream work - Respond
2xxonly after that transaction commits — duplicates answer200; 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"
| criterion | value |
|---|---|
| status codes that count as success | 200 201 202 204 only |
| recommended response time | 3 seconds |
| total request timeout (hard limit) | 15 seconds |
| size of the body we send | ≤ 256 KB (guaranteed) |
| the body you return | We 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:
| event | why it cannot be turned off |
|---|---|
deposit.reversed | Money is pulled back out of your wallet. |
withdrawal.fill_reversed | Money already paid out is reversed. |
balance.debited | Every debit against your wallet. |
webhook.key.rotating | The signing key is about to change — miss it and every verification fails on switchover day. |
webhook.endpoint.disabled | One 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)
| topic | other gateways you have integrated | us | what you must add |
|---|---|---|---|
| Callback signature | HMAC with a shared secret, or nothing at all | Ed25519 — we hold the private key, you hold the public key | Fetch the public key from GET /v1/webhooks/signing-keys (no auth) and verify over raw bytes. |
| Payload completeness | id + status, then you call back for details | The full object plus version | Drop the mid-flight GET and use a version guard instead. |
| Ordering | Many providers deliver in order | Not guaranteed — the retry window is ~92 hours | You need a version guard. Without one, under_review overwrites settled. |
| One withdrawal | 1 callback at the end | withdrawal.filled multiple times per order | Close the order only when status='settled' and remaining_minor = 0. |
| Duplicate-slip result | 409 duplicate_slip immediately on upload | 🔴 successful durable intake answers 202 | The 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 credited | One number | Four separate core amounts, X/V/A/E, plus fee/wallet credit | Credit 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:
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 next | when |
|---|---|
| Full event catalogue | You want to know which events exist and what each body contains. |
| Verify the Ed25519 signature | You 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 outages | You want to know what happens after you answer 500. |
| Testing before production | Your handler is written. |
| Deposit statuses | You want to know which status means you may credit. |
