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 | Payment confirmed on-chain | Fulfill the order |
checkout.expired | Checkout expired without payment | Cancel order / notify user |
checkout.underpaid | Received less than required amount | Log for manual review |
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.