Stop asking the customer to go acquire your coin. Show them the rails you accept and let them pay with the balance already in their wallet.
A customer lands on your checkout with $49 to spend. In their wallet that $49 is TRC-20 USDT. Your invoice demands USDC on Base. So before they can pay you, they have to bridge, swap, eat a fee, and wait — and a meaningful slice of them just close the tab instead. You didn't lose that sale to price or to trust. You lost it to a network mismatch you chose for them.
A multi-currency invoice is the fix, and it is less exotic than it sounds. It is the same invoice object as everything else — same lifecycle, same rate lock, same underpaid and overpaid handling. The only difference is what the customer is allowed to choose at pay time. This post is about what that actually looks like once real customers start paying it, because the idea is simple and the operational details are where teams trip.
One invoice, several payable options
The shape that makes this work is a fiat-anchored invoice. You bill a fiat amount — amount_fiat and fiat_currency — and you don't name a coin. There is no separate "multi-currency endpoint" to learn. You create the same invoice you always do, and the assets halfin supports become the customer's menu when the invoice is presented.
{
"amount_fiat": "49.00",
"fiat_currency": "USD",
"deferred": true,
"description": "Pro plan, monthly",
"idempotency_key": "sub-8842-2026-04"
}
Two request headers — X-API-Key and Content-Type — and nothing else. The idempotency_key is a field in the body, not an Idempotency-Key header, so a retried create returns the same invoice instead of minting a second one. (The why is in idempotency key.)
That single $49 invoice is now payable in any rail halfin runs a gate for: USDT on Tron, Ethereum, or Solana; USDC on Ethereum, Base, or Solana; native BTC; ETH and ERC-20 tokens; SOL and SPL; XRP. The customer picks the one they already hold. You did not have to decide for them, and you did not have to stand up seven separate invoices to offer seven rails.
The anchor is the number that doesn't move
Here is the part that matters for your books, and the part new teams second-guess: the customer's asset choice does not re-price the invoice. The $49 is fixed at creation. When the invoice activates, halfin locks the conversion rate and pins a concrete payable amount per option — this much USDT, or this much BTC, or this much SOL — and every one of those amounts resolves back to the same $49.
So the customer is choosing a rail, not a price. Whether they settle in Tron USDT or in BTC from cold storage, the value that lands on your balance is the figure you billed. Your reconciliation does not branch by asset. A paid multi-currency invoice maps to $49 exactly the way a single-asset one would; the asset and network the payment arrived on are recorded for accounting, not as a second, re-valued total you have to chase.
A multi-currency invoice widens who can pay you. It does not widen what you reconcile. The asset is the customer's problem; the fiat figure stays yours.
This is the whole pitch. You get the acceptance surface of "we take every major rail" without the operational tax of running a different invoice, a different rate, and a different month-end column per rail.
What it looks like the moment the customer commits
An invoice presents many options, but a payment lands on exactly one network. The customer selects USDC on Base, say, and sends funds. From there the path is identical to any single-asset invoice:
- halfin matches the deposit on that chain against the invoice.
- It waits for the per-chain confirmation threshold — BTC's threshold is not Solana's, and you don't want it to be.
- Crediting is reorg-aware, so a transaction unwound by a chain reorganization is reflected rather than silently counted as paid.
Your backend learns about all of it through the same signed webhook events as any invoice: invoice.confirming as confirmations accrue, then invoice.paid. Verify the HMAC signature over the raw bytes before you act on any of them — that discipline is non-negotiable and we wrote a whole post on why webhooks have to survive everything. Once verified, read which asset and network settled if your accounting needs it.
One nuance worth saying out loud, because it surprises people: there is no invoice.activated event. "When the invoice activates" describes the lifecycle moment the rate locks and the per-asset amounts pin — it is real, but it is not a webhook you subscribe to. The events you wire are invoice.confirming, invoice.paid, invoice.overpaid, invoice.underpaid, invoice.expired, and the late-deposit and reversal events. Build your state machine around those, not around an activation hook that doesn't exist.
Underpaid and overpaid, on the rail they actually used
Multi-currency does not change how off-amounts work — it just means the off-amount is measured in whatever the customer chose. Someone pays slightly under the pinned figure for their asset, usually because an exchange withdrawal fee came off the top or their quote went stale by a few seconds. The invoice records that shortfall in the asset that arrived and fires invoice.underpaid with the real number. You decide the policy: request a top-up, settle partially, credit it and move on. Overpayment is symmetrical — invoice.overpaid fires, recorded in the chosen asset.
The gap is denominated in the coin the customer used, but it still resolves against the one fiat anchor. You are never holding two competing definitions of "how much is this invoice worth." There is one, in fiat, and the per-asset arithmetic hangs off it. We walk through the policy choices in more depth in handling underpaid and overpaid invoices — the short version is that multi-currency doesn't add a new failure mode, it just makes the existing ones land on different chains.
Scope the menu when "everything" is wrong
Offering every rail is the default, not a mandate. You can scope an invoice to a subset when that fits the business. The most common one we see: stablecoins only. A subscription team that books predictable monthly revenue often doesn't want to take BTC or ETH for a $20 plan, because then they're sitting on volatile balances they have to convert. Restricting that invoice to USDT and USDC keeps the inbound side denominated in something that doesn't move.
That's a product decision, not a technical one. The fiat anchor and the lifecycle are identical whether the menu is two assets or all of them. Scope it to match how you actually want to hold value — and remember that whatever comes in across several rails can be swept or rebalanced afterward with balance conversion, so a wide menu doesn't have to mean a messy treasury.
Where multi-currency sits in the rest of checkout
You usually don't render the asset picker yourself. A multi-currency invoice is exactly what a hosted checkout draws as the menu — the customer chooses the rail in a page you didn't have to build, scans a QR for the option they picked, and pays. If you do build your own surface, the per-asset payable amounts and the settling asset are all in the invoicing API response and in the @halfin/sdk-merchant types; you render the menu, halfin owns the rate, the matching, and the crediting.
The mental model I'd leave you with: a single-asset invoice is a bet on what the customer holds, and you lose the bet every time you guess wrong. A multi-currency invoice doesn't bet. It shows the customer the rails you accept, takes the one they brought, and hands your finance team the same fiat number it was always going to reconcile. If you've been hardcoding USDT_TRC20 because the docs example did, this is the upgrade — and it's one call.
L. Tanaka, halfin product team