Webhooks
Webhooks are how ZaroPay tells your backend a payment happened. They are the authoritative signal for fulfilment — always prefer them over the customer's browser redirect.
Events
| Event | Subscribable | Delivered today |
|---|---|---|
deposit.confirmed | ✅ (default) | ✅ — the main payment event |
deposit.late | ✅ | reserved (same payload shape, not yet emitted) |
deposit.detected | ✅ | not emitted yet |
withdrawal.confirmed | ✅ | not emitted yet |
withdrawal.failed | ✅ | not emitted yet |
Today, build against deposit.confirmed. If you subscribe with no eventTypes, you get
deposit.confirmed by default.
Payload — deposit.confirmed
This is the exact JSON body POSTed to your URL. The example is a client fee mode order of 100 USDT at a 1% fee (the customer pays the fee on top): the customer was asked for 101 and sent it, ZaroPay kept the 1 fee, and 100 reached the merchant's balance.
{
"id": "5f1c9d2e-7a4b-4c0a-9e21-3b8f0a1d2c34",
"event": "deposit.confirmed",
"createdAt": "2026-06-27T10:15:42.123Z",
"data": {
"depositId": "a1b2c3d4-…",
"merchantId": "m1m2m3m4-…",
"chain": "tron",
"currency": "USDT",
"source": "deposit",
"netAmount": "100",
"feeAmount": "1",
"grossAmount": "101",
"orderAmount": "100",
"paymentState": "exact",
"feeMode": "client",
"amount": "100",
"fiatCurrency": null,
"fiatAmount": null,
"quoteRate": null,
"fiatNetAmount": null,
"amountUnits": "101000000",
"txHash": "0xabc…",
"outputIndex": 0,
"depositAddress": "TXyz…",
"fromAddress": "TAbC…",
"blockNumber": "64512345",
"confirmations": 20,
"required": 19,
"confirmedAt": "2026-06-27T10:15:40.000Z"
}
}
| Field | Type | Notes |
|---|---|---|
id | string (uuid) | Delivery id — your dedupe key. Also in the x-zaropay-delivery-id header. |
event | string | deposit.confirmed (or webhook.test for test probes) |
createdAt | string (ISO-8601) | When the payload was built |
data.depositId | string (uuid) | The deposit; matches reference_id in delivery logs |
data.source | string | deposit (paid to one of your deposit addresses) or external (a direct transfer to your settlement wallet — credited in full, feeAmount "0", feeMode / paymentState / orderAmount null) |
data.netAmount | string | null | What you receive from this payment: grossAmount − feeAmount, i.e. the credit net of ZaroPay's platform fee. (If your account is on merchant gas mode, the sweep's network gas is debited from your balance separately, later — it is not part of this figure.) null only if ZaroPay had not finished crediting when the webhook was built (rare) — fetch the deposit via the API; never fulfil on null |
data.feeAmount | string | null | The ZaroPay platform fee charged on this payment ("0" when none) |
data.grossAmount | string | What arrived on-chain, before the fee (always present) |
data.orderAmount | string | null | The order amount you created the charge for (USDT), or null for an open address |
data.paymentState | string | null | exact, underpaid or overpaid — the on-chain amount vs what the customer was asked to send; null when no amount was quoted (open address) |
data.feeMode | string | null | Who bore the fee: client (customer paid it on top) or merchant (deducted from the order); null for a direct transfer to your settlement wallet |
data.amount | string | Legacy alias kept for older integrations: the order amount when one exists, else the on-chain gross. Prefer netAmount |
data.fiatCurrency | string | null | USD if the order was priced in fiat (see Pricing in USD); else null |
data.fiatAmount | string | null | The original fiat amount (e.g. "100"); null for USDT-priced orders |
data.quoteRate | string | null | The locked USDT-per-fiat rate at order creation; null otherwise |
data.fiatNetAmount | string | null | For fiat-priced orders: netAmount in the order's currency at the locked rate, rounded to cents (netAmount ÷ quoteRate). On an exact client-mode payment it equals fiatAmount (in merchant mode the fee is deducted: a $100 order at 1% → 99); on an under- or overpayment it is the fiat value of what you received. null for USDT-priced orders or until credited |
data.txHash | string | null | On-chain tx hash |
data.depositAddress / data.fromAddress | string | null | To / from addresses |
data.blockNumber | string | null | Block height (serialized as a decimal string) |
data.confirmations / data.required | number | null | Confirmations at finalization |
data.confirmedAt | string | null | When it reached CONFIRMED |
Two different questions, two different fields:
- What did the customer pay for? Fulfil the order for
orderAmountwhenpaymentStateisexactoroverpaid(for an open address with no order, usegrossAmount). - What do I receive? Book
netAmountas your revenue — the credit net of ZaroPay's platform fee.
| Fee mode | Customer asked to send | Arrived (grossAmount) | feeAmount | Fulfil for (orderAmount) | You receive (netAmount) |
|---|---|---|---|---|---|
client (fee on top) | 101 | 101 | 1 | 100 | 100 |
merchant (fee deducted) | 100 | 100 | 1 | 100 | 99 |
In client mode the two are the same number because the customer paid the fee. Never credit the customer
for grossAmount on a client-mode order — that books the customer's fee as their money. Match the payment
to your order via data.orderAmount plus the order_ref you set when creating the charge. For a
USD-priced order, netAmount / grossAmount / orderAmount are still USDT: fulfil for
data.fiatAmount (the order in dollars) when paymentState is exact or overpaid, and book
data.fiatNetAmount — what you received, already converted to dollars at the locked rate (a $100 order
paid with 50 USDT at 1.002 USDT/USD → netAmount "49.5", fiatNetAmount "49.4").
Underpaid and overpaid payments
paymentState compares grossAmount with the amount the customer was asked to send (client mode: the
order plus the fee; merchant mode: the order). A customer who sends the quoted amount in full is charged
exactly the quoted fee; otherwise ZaroPay charges the fee on the amount that arrived (a 1% order of 100
paid with 50 → fee 0.5, netAmount 49.5; paid with 200 → fee 2, netAmount 198). On an overpayment the
fee is charged on the full amount received but capped at the surplus above the order (grossAmount − orderAmount), so you always receive at least orderAmount. ZaroPay credits whatever arrived and never
refunds automatically — your integration decides. Rely on paymentState, not on comparing netAmount
with orderAmount (in merchant mode an exact payment nets orderAmount − feeAmount, so netAmount < orderAmount does not mean the customer underpaid).
paymentState | What happened | Suggested handling |
|---|---|---|
exact | The customer sent what was quoted | Fulfil for orderAmount; book netAmount |
underpaid | Less arrived than quoted | Do not fulfil in full — book netAmount as a partial payment and ask the customer for the difference (a new charge), or refund it from your balance |
overpaid | More arrived than quoted | Fulfil for orderAmount; book netAmount — the surplus (what arrived above the quote, net of the fee on it) is in your balance; refund it to the customer yourself if your policy requires |
null | An open address with no amount quoted | Fulfil for grossAmount; book netAmount |
Request headers on every delivery
| Header | Value |
|---|---|
content-type | application/json |
user-agent | ZaroPay-Webhooks/1 |
x-zaropay-signature | t=<unixSeconds>,v1=<hex> — verify this |
x-zaropay-event | the event name |
x-zaropay-delivery-id | the delivery id (= payload id) |
Delivery semantics
- Respond
2xxto acknowledge. Any other status, a timeout, or a redirect counts as failure. - Timeout: 10 seconds.
- Retries: up to 5 attempts with backoff 60s → 300s → 1800s → 7200s; after the last failure
the delivery is marked
EXHAUSTED. - At-least-once: the same event can arrive more than once — dedupe on the payload
id. - Redirects are not followed (anti-SSRF), so your endpoint must be the final URL.
- Respond fast: acknowledge with
2xxfirst, then do slow work asynchronously.
Best practices
- Verify the signature before trusting anything — see Signature verification.
- Dedupe on
id; make fulfilment idempotent. - Acknowledge quickly (
2xx), process in the background. - Use the delivery logs to debug from your side.