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 amountLog 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:

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.