Billium
Concepts

Payment matching

How Billium links on-chain transactions to the invoices they settle.

When a customer pays a Billium invoice, the on-chain transaction arrives with no invoiceId field — just an amount and a destination address. Billium has to figure out which invoice that transaction settles. With an xpub wallet this is trivial (each invoice gets its own address), but with direct wallets — which is the only option on EVM chains and TRON — many invoices share one address. That's where payment matching comes in.

You don't need to do anything to use matching — it runs automatically the moment a transaction hits your wallet. What follows is what you should know to design invoices that settle cleanly and handle the edge cases.

What counts as a match

Billium auto-matches a transaction to an invoice when the amount is close enough to the invoice's expected amount and no other invoice is a better fit. "Close enough" means:

  • Within −2% to +5% of the expected amount. This range exists to absorb the small slippage that happens in real payments — gas fees taken from the transfer, exchange rounding, withdrawal fees from a custodial wallet. A customer who owes $100 and sends $99.80 will still get their invoice marked paid; a customer who sends $50 will not.
  • Unique enough to beat the runner-up. If two live invoices on the same address have identical expected amounts, Billium won't auto-match either of them until one becomes distinguishable. See designing invoices for clean matching below.

A transaction that doesn't auto-match isn't lost — it's parked in your dashboard's unmatched-payments view for manual review. An operator can review and link it to the right invoice.

Payment outcomes

Depending on the received amount, a matched transaction settles the invoice in one of four states:

StatusWhenWhat fires
PAIDReceived amount equals the expected amount (or within the tight auto-match tolerance).invoice.paid — fulfill the order.
UNDERPAIDConfirmed amount is less than expected and below the auto-match range.invoice.underpaid — decide whether to accept the partial, wait for a top-up, or refund manually.
OVERPAIDConfirmed amount exceeds expected and above the auto-match range.invoice.overpaid — fulfill the order. The difference can be refunded out-of-band.
EXPIREDNo matching transaction arrived before expiresAt.invoice.expired — no action needed.

All four are terminal. An UNDERPAID invoice doesn't automatically become PAID if a second transaction arrives — the first settlement wins.

Designing invoices for clean matching

If you're using direct wallets (i.e. anything other than BTC/LTC with xpub), follow these practices to keep matching unambiguous:

  1. Avoid identical amounts in flight simultaneously. Two live $49.00 invoices on the same address are hard to tell apart from a single $49.00 transaction. Billium handles this internally, but keeping distinct amounts removes the ambiguity before it reaches the matcher.
  2. Use tight expiry windows. A 15- or 30-minute expiry reduces the number of live candidates at any moment and shrinks the window for collisions.
  3. For high volume on BTC/LTC, use an xpub wallet. It removes matching from the equation entirely: each invoice has its own address, and mis-matching becomes architecturally impossible.

Reorg handling

Matching fires on the first detected transaction, but that transaction isn't final until it has accumulated the wallet's requiredConfirmations. In the window between detection and finality, the chain can reorg — a block is replaced by a longer fork and the transaction vanishes. Billium handles this automatically:

  • A background monitor checks every payment that's been waiting in PENDING_CONFIRMATION longer than expected and re-queries the chain for the transaction.
  • If the transaction is gone, the invoice reverts to AWAITING_PAYMENT and a fresh invoice.updated webhook fires so your UI can update. Matching then starts over when a new transaction arrives.
  • If the transaction is still there but confirmation counts are lagging, Billium keeps waiting.
  • Works on every supported chain family: EVM (Ethereum, Polygon, BNB, Cronos), TRON, and UTXO (Bitcoin, Litecoin).

Payments stuck in PENDING_CONFIRMATION for more than 48 hours are expired automatically. At that point the transaction is almost certainly abandoned (dropped from mempool, replaced via RBF, or on a deep fork) and holding the invoice open indefinitely just blocks you from creating a new one.

Because reorgs can revert a payment after you've seen invoice.paid, make any fulfillment action idempotent and ideally reversible. In practice: deliver digital goods immediately on invoice.paid, but hold off on physical shipping or irreversible actions until the invoice has been settled for at least the network's finality window.

What you'd see in the webhooks

A clean auto-match produces the standard event sequence:

  1. payment.created (best-effort) — customer opened the checkout page.
  2. payment.detected (durable) — transaction seen on-chain, matched to an invoice. Your UI can show "payment detected, waiting for confirmation".
  3. payment.confirmed (durable) — fires on every new confirmation, until the wallet's threshold is reached.
  4. payment.paid / payment.underpaid / payment.overpaid (durable) — terminal payment state.
  5. invoice.paid / invoice.underpaid / invoice.overpaid (durable) — terminal invoice state. This is the one you fulfill from.

If a reorg fires in step 3, you'll see the invoice status revert via invoice.updated (best-effort), and matching starts over when a new transaction arrives. Because durable events are delivered at least once, always dedupe on event.id — see Webhooks → Delivery guarantees.

On this page