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)
| Status | Meaning | Terminal? |
|---|---|---|
pending | Created, waiting for a payment | no |
detecting | A matching transaction is seen; waiting for confirmations | no |
confirmed | Required confirmations reached — ship it | yes |
underpaid | A payment arrived below the accepted range | yes |
expired | TTL elapsed, no matching transaction ever seen | yes |
canceled | Canceled via API before payment | yes |
Two rules worth internalizing:
- Mempool beats TTL. A transaction seen in the mempool before
expires_atstays valid even if its confirmations land after expiry — such invoices movepending → detectingand confirm normally; they never flip toexpired. - 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
| Transition | Webhook |
|---|---|
tx first seen (pending → detecting) | payment.detected |
confirmations reached (→ confirmed) | payment.confirmed |
| payment below accepted range | payment.underpaid (invoice becomes underpaid) |
| TTL out, nothing seen | invoice.expired |
POST /v1/invoices/{id}/cancel | invoice.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_cryptodue, therateused, and its own status:open→detected(tx seen on this option) →confirmed; when another option confirms, the rest becomeclosed.observations[]— raw on-chain sightings:tx_hash,output_index,amount_paid, currentconfirmationsandseen_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.