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
$100and sends$99.80will still get their invoice marked paid; a customer who sends$50will 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:
| Status | When | What fires |
|---|---|---|
PAID | Received amount equals the expected amount (or within the tight auto-match tolerance). | invoice.paid — fulfill the order. |
UNDERPAID | Confirmed 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. |
OVERPAID | Confirmed amount exceeds expected and above the auto-match range. | invoice.overpaid — fulfill the order. The difference can be refunded out-of-band. |
EXPIRED | No 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:
- Avoid identical amounts in flight simultaneously. Two live
$49.00invoices on the same address are hard to tell apart from a single$49.00transaction. Billium handles this internally, but keeping distinct amounts removes the ambiguity before it reaches the matcher. - 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.
- 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_CONFIRMATIONlonger than expected and re-queries the chain for the transaction. - If the transaction is gone, the invoice reverts to
AWAITING_PAYMENTand a freshinvoice.updatedwebhook 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:
payment.created(best-effort) — customer opened the checkout page.payment.detected(durable) — transaction seen on-chain, matched to an invoice. Your UI can show "payment detected, waiting for confirmation".payment.confirmed(durable) — fires on every new confirmation, until the wallet's threshold is reached.payment.paid/payment.underpaid/payment.overpaid(durable) — terminal payment state.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.