Webhooks reference
The full event catalogue with exact payloads, delivery headers and semantics, the 1m→12h retry ladder and consumer idempotency rules.
Everything about receiving ZeroKYC Pay events: the event catalogue, exact payload shapes, delivery headers, the retry ladder and idempotency rules.
Webhook endpoints are configured in the console under Webhooks: a URL plus a per-endpoint secret (whsec_…). Every active endpoint receives every event; an invoice can also override the URL ad hoc via the webhook_url field at creation.
Events
| Event | Fires when |
|---|---|
payment.detected | A transaction matching one of the invoice options is seen on chain (mempool or early confirmations) |
payment.confirmed | The required confirmations are reached — the "ship the product" signal |
payment.underpaid | A payment arrived below the accepted range; the invoice becomes underpaid and needs your decision |
invoice.expired | TTL elapsed without a valid payment |
invoice.canceled | The invoice was canceled via POST /v1/invoices/{id}/cancel before payment |
webhook.test | You pressed Send test event in the console — a signed smoke-check for your handler |
invoice.created exists only in the event journal (GET /v1/events), it is not delivered to webhooks.
Multi-variant invoices (payment_currency=any) confirm on the first valid payment; the remaining options close automatically.
Payload
Every delivery is a JSON envelope: event id, type, created_at and the event-specific data object:
{
"id": "evt_5c1a...",
"type": "payment.confirmed",
"created_at": "2026-09-06T14:02:33Z",
"data": {
"invoice_id": "inv_9f2k...",
"order_id": "order-1042",
"amount": "49.00",
"base_currency": "USD",
"option": {
"asset": "TON",
"network": "ton",
"amount_crypto": "9.81",
"paid_amount": "9.81"
},
"tx": {
"hash": "9d24...",
"confirmations": 1,
"explorer_url": "https://tonviewer.com/transaction/9d24...",
"output_index": 0
},
"metadata": { "customer_id": "cust_8821" }
}
}
payment.detected, payment.confirmed and payment.underpaid share this exact shape. Field notes:
idis globally unique per event — store it and deduplicate (delivery is at-least-once).order_idandmetadataecho what you sent at invoice creation;order_idis your join key.amountis the invoice total inbase_currency; the crypto figures live inoption(amount_crypto= due,paid_amount= actually observed on chain).tx.explorer_urllinks the transaction on the asset's block explorer;output_indexdisambiguates UTXO/log outputs.- Overpayments confirm normally:
option.paid_amountis the observed truth.
The lifecycle events are smaller:
{
"id": "evt_7b2c...",
"type": "invoice.expired",
"created_at": "2026-09-06T20:20:11Z",
"data": {
"invoice_id": "inv_9f2k...",
"order_id": "order-1042",
"amount": "49.00",
"base_currency": "USD"
}
}
invoice.canceled carries only invoice_id and order_id; webhook.test carries endpoint_id, url and a message string.
Delivery headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | ZeroKYC-Pay-Webhooks/1.0 |
X-ZKP-Signature | t=<unix seconds>,v1=<hex hmac-sha256> |
X-ZKP-Event-Id | Same as id in the payload — cheap dedup before parsing |
X-ZKP-Event-Type | Same as type — route without parsing the body |
The signature is HMAC-SHA256 over {timestamp}.{raw_body} with the endpoint secret; the timestamp window is ±300 seconds. Verify before trusting — full Node/PHP/Python snippets in HMAC verification.
Delivery semantics and retries
- Success = any
2xxresponse within 10 seconds. Anything else —4xx,5xx, timeout, TLS failure — schedules a retry. - Redirects are not followed. A
3xxcounts as a failure: point the endpoint at its final URL, not at a redirect. - Retry ladder:
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours → then failed |
- When the ladder exhausts, the delivery is marked
failedand a notification appears in the console. Manual retry (Webhooks → delivery log, or per-invoice) resets the attempt counter and fires immediately. - Rotate the endpoint secret and URL any time in Webhooks; the Send test event button delivers a signed
webhook.testso you can verify the new config end-to-end.
Reconciliation
Your handlers should be idempotent, and the API gives you two ways to cross-check:
GET /v1/invoices/{id}always returns the authoritative invoice state — treat webhooks as hints, not as the source of truth.GET /v1/events?limit=100replays the event journal (including journal-only types likeinvoice.created), withtypeandcreated_afterfilters and cursor pagination — use it to backfill after downtime.
A rescan job re-checks all open invoices every 3 minutes, so chain events missed in real time self-heal: a payment.confirmed can arrive noticeably later than the transaction itself.
Consumer checklist
- Verify the
X-ZKP-SignatureHMAC and the ±300 s window before touching the payload. - Deduplicate on
event.id(orX-ZKP-Event-Id) — the same event may arrive more than once. - Fulfil on
payment.confirmedonly; usepayment.detectedfor early buyer feedback andpayment.underpaidfor manual review. - Respond
2xxfast; do heavy work asynchronously.