Here is a pattern we watch teams build, struggle with, and then quietly rip out: a fresh invoice every time the same customer wants to add funds to their balance. Wallet top-up? New invoice. Re-fund the trading account? New invoice. Each one with its own amount, its own expiry, its own address, its own little state machine to babysit.
It works on day one. By month three you have a table full of expired invoices the customer never paid, a support queue asking "where do I send the money," and reconciliation logic that gets slower every week. The primitive was wrong. Account funding is not a charge. It's a static deposit address.
Funding is open-ended; an invoice is not
An invoice answers a closed question: did this customer pay this specific amount, this once? It has a fixed (or fiat-anchored) amount, a locked rate, an expiry, and the notion of underpaid/overpaid — because there was an expected figure to compare against.
Account funding has none of those properties. The customer decides how much to add, and when. There is no "correct" amount, so there is no underpaid and no overpaid. There is no deadline, so an expiry window is just a way to make the customer fail. And there is no one-time charge to close out — the relationship is the whole point. Bending an invoice around that means you spend your time inventing an expected amount the customer never agreed to, then handling all the ways reality fails to match it.
A deposit address is the shape that fits. You request one for a customer on a specific network, halfin returns it once, and that address is now permanently theirs. First top-up, fiftieth top-up — same address. The customer saves it as text plus a QR and sends to it whenever they like.
If you find yourself minting a new invoice with a new address every time the same person adds money, you've picked the wrong primitive. That's a funding flow wearing an invoice costume.
The receiving address is the identifier
This is the part that pays off at scale, and it's worth being precise about. With an invoice, attribution rides on the invoice ID — you created the charge, so you know who it belongs to. With a deposit address, the address itself is the identifier. Anything that lands on it belongs to the customer it was issued for. No memo to parse, no amount to match, no fragile correlation between an on-chain transfer and a row in your database.
That has a direct consequence for cost: the lookup that attributes a customer's first deposit is the same lookup that attributes their two-hundredth. Reconciliation does not grow with deposit volume. A funding flow built on invoices has the opposite property — every top-up is a new object to create, track, expire, and clean up, so your operational load scales with how successful you are. The more your customers fund, the more invoices you sweep.
There is exactly one rule that keeps this clean, and it is non-negotiable: map one address to one customer, on one network, forever. Never recycle an address between users, never share one. The moment two customers can land on the same address, you've thrown away the one thing that made attribution free, and you're back to diffing amounts by hand. (The what-is-a-static-deposit-address FAQ covers the persistence and one-customer-per-address guarantees in more detail.)
What your backend actually consumes: balance.credited
The biggest difference between the two primitives isn't the API call to create them — it's the event stream your backend has to consume afterward.
An invoice emits a bounded lifecycle: invoice.confirming, then a terminal state — invoice.paid, invoice.underpaid, invoice.overpaid, or invoice.expired. You follow one object until it resolves, then you stop listening. That's a state machine with an ending.
A deposit address emits an open-ended stream. There is no charge to complete, so there is no lifecycle to close and no terminal state. Each deposit is an independent event with its own transaction hash and confirmation count, and the one signal you act on is:
balance.credited
Funds are now spendable in the customer's account. That's it — no underpaid, no overpaid, no expired, because nothing was ever promised. halfin credits exactly what arrived, reorg-aware, once it clears the per-chain confirmation threshold. Waiting for that threshold is the whole point: a deposit that a reorg unwinds before it settles never leaves a phantom balance behind. If a deposit that had already begun confirming gets orphaned by a chain reorganization before it clears, you get invoice.deposit_reversed — the same reorg-aware safety net applies whether the money came in against an invoice or a bare address.
Three implications for how you write the receiver:
- Credit on
balance.credited, not on first sight. You may show a "deposit seen" state in your UI the instant funds appear — that's friendly. But only move money in your own ledger when the credit event lands, because that's the event that survives a reorg. - Verify the signature on the raw bytes before you act. Every webhook carries an HMAC signature over the raw request body. Check it before you trust the payload — an unverified webhook is a suggestion, not a fact. (The long version is in verify the signature before acting.)
- Make the handler idempotent. Networks retry, queues replay, and you will see the same
balance.creditedmore than once. Key your credit on the event's identity so a duplicate is a no-op. Crediting a balance twice because you trusted at-most-once delivery is the most expensive bug in this whole flow.
The request headers, by the way, are the same minimal set you use everywhere on halfin — X-API-Key and Content-Type, nothing exotic. There's no Idempotency-Key header to manage here; address creation is naturally idempotent per customer because you only mint the address once and reuse it forever.
What this looks like in practice
The funding pattern shows up across very different products, and it's always the same shape underneath:
- An exchange-style product issues every user a deposit address per network and credits balances on arrival. Users top up whenever; the address never changes.
- A trader-funding or prop product gives each account a persistent address so re-funding after a drawdown is just "send to your usual address," not "request a new invoice and race its expiry."
- A marketplace invoices buyers for specific orders — that's a charge, keep the invoice — but hands each seller a durable deposit address to fund their settlement account.
Notice the marketplace does both. That's the honest answer most of the time: invoice the one-time, known-amount charges; use a deposit address for the open-ended funding. The two primitives settle over the same chains and assets, with the same reorg-aware crediting underneath. What differs is the contract your code writes against.
If you're still deciding which one a given flow wants, the sibling piece static deposit addresses vs invoices walks the full decision with a side-by-side table. But for account funding specifically, the question resolves in one sentence: is the customer adding money to a balance they'll keep adding to? If yes, give them one address and listen for balance.credited. Don't make them — or your reconciliation — pay the invoice tax on every top-up.
L. Tanaka, halfin product team