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.completedPaid in full — or the merchant accepted a partial payment as paid (accepted_as_paid: true)Fulfill the order
checkout.expiredThe search window ended and nothing arrivedCancel order / notify user
checkout.underpaidReceived less than the price; the customer can still top upShow “send remaining” or log for review
checkout.partially_paidThe top-up window for this attempt closed with part of the price received. The checkout is not closedWait 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.underpaid or checkout.partially_paid — wait for checkout.completed
  • If you need to show progress, use amount_crypto from the underpaid webhook, or payment.received_amount / payment.remaining_amount from 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:

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.

Last updated on