Skip to content
ZeroKYC Pay

Invoice lifecycle

The invoice state machine: statuses, option statuses, observations, unique-amount windows and which webhook fires when.

An invoice is a small state machine. Knowing the exact states, who moves them and which webhook fires on each transition is what makes your fulfilment logic airtight.

States

pending ──► detecting ──► confirmed
   │             │
   │             └──► underpaid   (paid below the accepted range — terminal)
   ├──► expired                (TTL out, nothing arrived)
   └──► canceled               (you canceled it before payment)
StatusMeaningTerminal?
pendingCreated, waiting for a paymentno
detectingA matching transaction is seen; waiting for confirmationsno
confirmedRequired confirmations reached — ship ityes
underpaidA payment arrived below the accepted rangeyes
expiredTTL elapsed, no matching transaction ever seenyes
canceledCanceled via API before paymentyes

Two rules worth internalizing:

  • Mempool beats TTL. A transaction seen in the mempool before expires_at stays valid even if its confirmations land after expiry — such invoices move pending → detecting and confirm normally; they never flip to expired.
  • First payment wins. With payment_currency=any, the first valid payment on any option confirms the whole invoice; every other option closes at that moment.

What fires when

TransitionWebhook
tx first seen (pending → detecting)payment.detected
confirmations reached (→ confirmed)payment.confirmed
payment below accepted rangepayment.underpaid (invoice becomes underpaid)
TTL out, nothing seeninvoice.expired
POST /v1/invoices/{id}/cancelinvoice.canceled

A rescan worker re-checks every open invoice every 3 minutes, so late-arriving chain data self-heals — transitions (and their webhooks) can arrive minutes after the on-chain event.

Payment options and observations

The invoice response carries two arrays that tell you precisely where the money is:

  • options[] — one per enabled asset: address, amount_crypto due, the rate used, and its own status: open → detected (tx seen on this option) → confirmed; when another option confirms, the rest become closed.
  • observations[] — raw on-chain sightings: tx_hash, output_index, amount_paid, current confirmations and seen_at. Useful for support and for spotting partial or multiple transfers.

Unique-amount windows (window_low / window_high)

Chains where wallets can't derive fresh addresses per invoice (TRON, TON) reuse one static address and disambiguate by amount: each live invoice gets a private amount range. In the option it shows up as:

{
  "asset": "USDT",
  "network": "tron",
  "amount_crypto": "101.4",
  "window_low": "101.3",
  "window_high": "101.5"
}

Any payment inside [window_low, window_high] matches this invoice and nothing else. Consecutive invoices are spaced so the windows never overlap. On address-derived chains (EVM, BTC) the windows are null — matching is by address instead.

For TON-based options the option may also carry payment_url_extra — the memo/tag that must accompany the transfer. Wallets handle it via the payment URI and QR code; if you build your own payment flow, a transfer without the memo is unrecoverable.

TTL

ttl_minutes at creation: default 360, range 10–4320. On expiry with no sighting the invoice moves to expired (and later options to expired too). Underpaid is a payment outcome, not a TTL one: the money arrived, just not enough — it's your call to accept, top-up or refund; contact the customer via the metadata you attached.