Webhooks
inzi sends webhooks to your server when payment events occur. This is the primary way to know when a checkout is paid, expired, or underpaid.
Events
| Event | When | Recommended action |
|---|---|---|
checkout.completed | Paid in full — or the merchant accepted a partial payment as paid (accepted_as_paid: true) | Fulfill the order |
checkout.expired | The search window ended and nothing arrived | Cancel order / notify user |
checkout.underpaid | Received less than the price; the customer can still top up | Show “send remaining” or log for review |
checkout.partially_paid | The top-up window for this attempt closed with part of the price received. The checkout is not closed | Wait for the rest, or accept what arrived as paid |
Partial payments (top-up)
If a customer sends less than the required amount, inzi sends a checkout.underpaid webhook. The checkout stays active — the customer can send additional transfers to the same deposit address to cover the difference.
inzi accumulates all transfers automatically. When the total reaches the price, a checkout.completed webhook fires. There is no tolerance: 99.97 of 100 is a partial payment, not a payment. Only the merchant can decide to let the difference go (see Accept as paid below).
Customer sends 60 USDT (expected: 100 USDT)
→ checkout.underpaid webhook (amount_crypto: "60")
Customer sends 40 USDT to the same address
→ checkout.completed webhook (amount_crypto: "100")The amount_crypto field in the webhook always reflects the total received across all transfers, not just the latest one. Use this value for balance crediting.
Important for your handler:
- Do not credit the user on
checkout.underpaidorcheckout.partially_paid— wait forcheckout.completed - If you need to show progress, use
amount_cryptofrom the underpaid webhook, orpayment.received_amount/payment.remaining_amountfrom the partially-paid one - After
checkout.completed, no further webhooks are sent for that checkout
When the window closes with part of the price
Transfers are searched for until 30 minutes after expires_at. If part of the
price arrived by then, the attempt is not expired — money came in. inzi
sends checkout.partially_paid:
{
"event": "checkout.partially_paid",
"checkout_id": "chk_…",
"data": {
"status": "partially_paid",
"amount": "25.00",
"currency": "USD",
"payment": {
"received_amount": "20",
"remaining_amount": "5",
"token": "USDC",
"chain": "polygon",
"top_up_open": false
}
}
}The checkout stays open. If the customer opens it again, the new attempt asks for the remaining amount only, and the checkout completes when the total over all attempts reaches the price.
checkout.expired now means exactly one thing: nothing arrived at all.
Accept as paid
A merchant owner or admin can close a partially paid checkout as paid from the
dashboard — for example when the customer’s exchange withheld a few cents.
inzi then sends checkout.completed with:
{
"accepted_as_paid": true,
"received_amount": "24.70",
"shortfall_amount": "0.30"
}Your books receive what actually arrived; nothing is topped up.
Webhook payload
Every webhook is an HTTP POST to your webhook_url:
{
"event": "checkout.completed",
"checkout_id": "chk_live_abc123def456",
"merchant_id": "mrc_xxxxx",
"data": {
"status": "completed",
"amount": "25.00",
"currency": "USD",
"payment": {
"amount_crypto": "25.000000",
"crypto_currency": "USDT",
"network": "ton",
"tx_hash": "abc123...",
"sender_address": "UQ...",
"confirmed_at": "2026-04-04T12:05:00Z"
},
"metadata": {
"order_id": "uuid-xxx",
"user_id": "uuid-yyy"
}
},
"created_at": "2026-04-04T12:05:01Z"
}The metadata object is exactly what you passed when creating the checkout — inzi never reads or modifies it.
Signature verification
Every webhook includes these headers:
| Header | Description |
|---|---|
X-Inzi-Signature | sha256=<hex-encoded HMAC> |
X-Inzi-Webhook-Id | Unique webhook delivery ID (whk_xxxxx) |
X-Inzi-Timestamp | Unix timestamp (seconds) |
How to verify
- Build the signed payload:
{timestamp}.{raw_body} - Compute HMAC-SHA256 using your webhook secret
- Compare signatures (timing-safe)
- Reject if timestamp is older than 5 minutes
Node.js
import crypto from 'crypto'
function verifyWebhook(rawBody, headers, webhookSecret) {
const timestamp = headers['x-inzi-timestamp']
const signature = headers['x-inzi-signature'].replace('sha256=', '')
// 1. Build signed payload
const signedPayload = `${timestamp}.${rawBody}`
// 2. Compute expected signature
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(signedPayload)
.digest('hex')
// 3. Timing-safe comparison
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
throw new Error('Invalid webhook signature')
}
// 4. Check timestamp freshness
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
throw new Error('Webhook timestamp too old')
}
return JSON.parse(rawBody)
}Always verify the signature before processing a webhook. Never trust the payload without verification.
Retry policy
If your endpoint returns a non-2xx status code, inzi retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | 10 seconds |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
Each retry sends the same X-Inzi-Webhook-Id. Use this to deduplicate.
After 5 failed attempts, the webhook is marked as failed. You can manually retry from the merchant dashboard .
Idempotency
Your webhook handler must handle duplicate deliveries:
- Use
checkout_idas the idempotency key - If already processed, return
200 OKwithout re-processing - inzi may retry even after receiving a
200(network ambiguity)
app.post('/webhooks/inzi', async (req, res) => {
const event = verifyWebhook(req.rawBody, req.headers, WEBHOOK_SECRET)
// Deduplicate
if (await isAlreadyProcessed(event.checkout_id)) {
return res.status(200).json({ ok: true })
}
if (event.event === 'checkout.completed') {
await fulfillOrder(event.data.metadata.order_id)
await markAsProcessed(event.checkout_id)
}
res.status(200).json({ ok: true })
})Test mode webhooks
When using sk_test_ keys, a mock checkout.completed webhook fires 5 seconds after checkout creation. No real blockchain transaction occurs.
The test webhook payload includes "test": true and uses chk_test_ prefixed IDs.
This lets you test your entire integration flow without testnet tokens.
Polling fallback
If webhook delivery fails, you can poll for status:
curl https://api.inzilink.com/api/v1/checkouts/chk_live_abc123 \
-H "Authorization: Bearer sk_live_xxxxx"Poll every 3-5 seconds until status is completed or expired. See Get Checkout.