Skip to Content

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

EventWhenRecommended action
checkout.completedPayment confirmed on-chainFulfill the order
checkout.expiredCheckout expired without paymentCancel order / notify user
checkout.underpaidReceived less than required amountShow “send remaining” or log for review

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 expected amount (within tolerance), a checkout.completed webhook fires.

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.underpaid — wait for checkout.completed
  • If you need to show progress, use the amount_crypto field from the underpaid webhook
  • The checkout still expires at expires_at — partial payments must be completed before then
  • After checkout.completed, no further webhooks are sent for that checkout

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:

HeaderDescription
X-Inzi-Signaturesha256=<hex-encoded HMAC>
X-Inzi-Webhook-IdUnique webhook delivery ID (whk_xxxxx)
X-Inzi-TimestampUnix timestamp (seconds)

How to verify

  1. Build the signed payload: {timestamp}.{raw_body}
  2. Compute HMAC-SHA256 using your webhook secret
  3. Compare signatures (timing-safe)
  4. Reject if timestamp is older than 5 minutes
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:

AttemptDelay
110 seconds
230 seconds
32 minutes
410 minutes
51 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_id as the idempotency key
  • If already processed, return 200 OK without 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.