The most expensive integration mistake we see is not a bad API call. It's reaching for the wrong primitive and then bending your backend around it for the next two years.
There are exactly two ways to receive money on halfin, and they answer two different questions. An invoice asks: did this customer pay this specific amount, and is it done? A static deposit address asks nothing — it just sits there and credits whatever lands on it. Most teams know this in the abstract and still pick wrong, usually because the demo flow they copied happened to use one or the other.
Here is the distinction the way it actually shows up in your code.
An invoice is a charge with a deadline
You create an invoice when a customer owes a known amount, once. A checkout order. A subscription renewal. A single bill. You hand halfin either a fixed crypto amount and currency, or a fiat-anchored figure (amount_fiat + fiat_currency + deferred) that gets quoted into whatever asset the customer chooses to pay in.
Three things follow from "known amount, once," and all three are load-bearing:
- The rate locks when the invoice activates. The customer sees exactly how much crypto clears the charge, and that number holds for the life of the quote.
- There's an expiry window. A locked rate can't hold forever, so an invoice has a deadline. Miss it and the invoice expires.
- Underpaid and overpaid are real states. Because there's an expected figure, halfin can compare what arrived against what was quoted and tell you when a payment came up short or ran over — instead of leaving you to diff amounts by hand.
That last point is the whole reason invoices exist as a separate primitive. If you don't have an expected amount, you can't have the concept of "wrong amount," and a surprising share of payment bugs are really just amount-matching done badly in application code.
A deposit address is an open mailbox
A static deposit address has no amount and no expiry. You request one for a specific customer on a specific network, halfin returns it once, and that address is now permanently theirs. The customer saves it — typically as text plus a QR — and sends to it whenever they want to add funds. First top-up, fiftieth top-up, same address.
The attribution model is the part people underestimate. The receiving address is the identifier. A deposit that lands on it belongs to the customer it was issued for. No memo to parse, no amount to match, no fragile correlation logic. The same lookup that attributes their first deposit attributes their twentieth, which means your reconciliation cost does not grow with deposit volume.
The one rule that keeps this clean: map one address to one customer, forever, and never share an address across users. Break that and you've thrown away the only thing that made attribution free.
If you find yourself minting a fresh invoice with a new address every time the same person adds money, you've picked the wrong primitive. That's a deposit address wearing an invoice costume.
Where it actually diverges: crediting and webhooks
Both primitives settle the same way underneath — reorg-aware crediting against per-chain confirmation thresholds, across the same chains and assets. What differs is the shape of the event stream your backend has to consume, and getting this wrong is where integrations quietly break.
An invoice emits a bounded lifecycle. You're tracking one charge from start to finish:
invoice.confirming— funds detected, gathering confirmationsinvoice.paid— cleared, the happy endinginvoice.underpaid/invoice.overpaid— arrived, but not the quoted amountinvoice.expired— the deadline passed before it clearedinvoice.late_deposit— money showed up after expiryinvoice.deposit_reversed— a credited deposit got unwound by a reorg
That's a state machine with terminal states. Your code follows a single object until it resolves, then stops listening.
A deposit address emits an open-ended stream. There's no charge to "complete," so there's no lifecycle to close. Each deposit is an independent event with its own transaction hash and confirmation count, and the signal you actually act on is balance.credited — funds are now spendable in the customer's account. There's no underpaid or overpaid, because nothing was ever promised. halfin credits exactly what arrived.
The mental model that keeps this straight:
One more thing both primitives share, and it's non-negotiable: verify the HMAC signature on the raw bytes before you act. Treat the first signal as optimistic — show a "deposit seen" state in your UI if you like — but only move money in your own ledger on the confirmation event, and only after the signature checks out. We wrote the long version of this in verify the signature before acting; the short version is that an unverified webhook is a suggestion, not a fact.
The honest answer is usually "both"
Teams ask us which one to standardize on, and the framing is wrong. It's not either/or for your business — it's per-flow.
- A marketplace invoices a buyer for a specific order and gives a recurring seller a deposit address to fund their settlement account.
- An exchange-style product invoices for a paid plan and issues each user a deposit address for ongoing balance top-ups.
- A SaaS app invoices each renewal and never needs a deposit address at all.
The question to ask, per flow, is exactly one sentence: is the amount known and one-time, or open-ended and repeated? Known and one-time is an invoice — you want the locked rate, the expiry, and the paid-or-not certainty. Open-ended and repeated is a deposit address — you want one durable address and credit-on-arrival, not a new charge per top-up.
If you're still deciding for a specific flow, the invoice vs deposit address FAQ walks the same decision with worked examples. But the rule above resolves most of them before you get there.
Pick the primitive that matches the question you're asking. The plumbing underneath is the same; the contract you write against is not.