The amount on your invoice is a request. The amount on-chain is a fact. The gap between them is where every naive integration leaks money.
A merchant once asked us why their order queue had a slow trickle of orders stuck in "awaiting payment" that the dashboard clearly showed as paid for. The answer was three cents. Their customers were paying from exchanges that skim a withdrawal fee, the deposits were landing a hair short, and their backend only listened for invoice.paid. Every short payment fell into a void: the money was on their balance, the order never shipped, and a human eventually untangled each one by hand.
That is the entire problem in one sentence. The fiat amount on a fiat-anchored invoice is fixed, the payable asset amount is locked when the invoice activates, but the number that actually arrives is under the customer's control. Treat anything that isn't an exact match as a failure and you will strand real, confirmed money on-chain.
Why off-quote payments are the norm, not the edge case
Underpayment is not an attack and overpayment is not generosity. They're the texture of real crypto payments.
The most common shortfall comes from a customer paying out of an exchange or custodial wallet that deducts the network fee from the amount they typed rather than adding it on top. They entered the quoted figure in good faith; the platform sent that figure minus the fee. A stale quote paid a moment after expiry, or a plain typo, produces the same result. Overpayment is usually a rounded-up amount, a doubled send, or a wallet that padded a buffer onto the transfer.
None of these are recoverable with a "declined" message, because the funds have already moved and an on-chain deposit is irreversible. You can't bounce it back the way a card processor declines an authorization. The deposit is here, it's yours to account for, and the only question is what the customer gets for it.
This is why halfin treats both cases as first-class outcomes with their own dedicated events. The invoice tracks the amount expected against the amount actually received, and when the confirmed credit lands off the quote, you get told exactly how — and by how much.
The two events, and the recorded gap
invoice.underpaid fires when a real, confirmed payment arrives below the amount due. invoice.overpaid fires when the invoice settles with a confirmed surplus over it. Both carry the invoice object with the expected and received amounts already computed for you.
The word confirmed is load-bearing. The shortfall or excess you act on is measured against a reorg-aware credit that has cleared the chain's confirmation threshold — not an optimistic first-seen deposit that a reorganization could later unwind. You are reacting to a settled fact, which means it's safe to move money in response. (If you want the mechanics of why that's hard and how it works, reorg-aware crediting is the long version.)
The mental model is a small set of lifecycle states your handler has to recognize:
The trap that produced the stuck-order queue above is listening only for the second row. An endpoint that handles invoice.paid and nothing else is silently correct on the happy path and silently broken on every cent of variance.
Read the gap — don't recompute it
The point of these events is that the arithmetic is done. You do not screenshot a rate, multiply, and figure out what the deposit was worth. You read the expected, received, and difference amounts off the invoice object and decide.
Two rules keep this honest:
- Keep the amounts as strings end to end. Monetary values cross the wire as strings, never floats. A shortfall of
"0.42"stays a string until you hand it to a decimal library or compare it against your own ledger. Converting it to a JavaScriptnumberto doreceived - expectedis the classic way to inject a rounding error into a figure halfin already computed exactly. - Carry your own identifier on the invoice. Attach the order id (or subscription + cycle) at creation time so the off-quote event maps straight back to the thing it affects, with no side lookup table to keep in sync.
And before you read a single byte of that body: verify the signature. An off-quote path moves money, so a forged invoice.overpaid claiming a surplus you should "refund" is a direct theft vector. Recompute the HMAC over the raw request bytes — not the reserialized JSON, whose whitespace and key order will differ — compare it in constant time, and only then parse. We've written the long argument for this elsewhere (verify the signature before you act); for off-quote events it's not optional.
Your underpayment policy: top-up, partial, or refund
An underpaid invoice has received a real but insufficient payment. The money is on your balance; what the customer gets for it is a business decision, not a technical one. The discipline is to pick one policy per product line, encode it, and let the recorded shortfall drive it instead of improvising per ticket.
Three policies cover almost everything:
- Top-up — for indivisible goods. Hold the order, surface the exact shortfall to the customer, ask them to send the difference to the same address, and fulfil once the balance covers the amount due. This is what the stuck-queue merchant should have built.
- Partial settlement — for divisible value: account credit, metered usage, a donation. Grant value proportional to what arrived and record the remainder as owed (or simply accept the lesser amount). A credits product almost always wants this.
- Void and refund — when you'd rather the customer retry cleanly than chase a top-up. Don't fulfil; return the underpaid funds via a refund; let them start a fresh invoice.
The failure mode that actually hurts is fulfilling a $100 order against a $97 payment because the handler checked did money arrive and not was it enough. The invoice.underpaid event exists precisely so that case stays explicit: the order holds, the shortfall is visible to support and finance, and the customer gets a clear next action instead of a stuck order and a confused email a week later.
Your overpayment policy: refund or credit
Overpayment is the easier half. The amount due is fully covered, so the order can ship — the only open question is the surplus, and the invoice records it against the original quote so it's a visible, accountable figure rather than an unmatched deposit that vanishes into reconciliation.
- Refund the recorded excess back to the customer's address. Refunds are a first-class halfin primitive, so you fulfil on the
invoice.overpaidevent and issue a refund for the surplus. - Credit the excess to the customer's account or their next invoice. For subscriptions and small surpluses this is friendlier — a tiny on-chain refund can cost nearly as much in network fees as the surplus is worth.
State which one you do in your terms so nobody is surprised. The one thing to avoid is treating an overpayment as a clean invoice.paid and quietly pocketing the difference. The customer sent more than they owed; that excess is theirs until you deliberately decide otherwise.
Make the off-quote handler idempotent
halfin delivers each event at least once, not exactly once. A slow response, a transient 5xx, or a network blip triggers a redelivery, and the same invoice.underpaid or invoice.overpaid can legitimately arrive twice carrying the same stable event id.
On the happy path a duplicate is a harmless re-fulfil. On the off-quote path it's a double refund or a second "please top up" email — exactly the bugs at-least-once delivery surfaces. So build the handler around the event id: record processed ids, make the side effect a no-op the second time, acknowledge fast with a 2xx once you've verified the signature and durably recorded the event, then do the refund call, the email, and the ledger write on a background queue. Don't hold the delivery open on a slow downstream — that's what triggers the retry that becomes the duplicate.
Persist the invoice id, the recorded shortfall or excess, the policy you applied, and the resulting refund or top-up. When finance asks why an order with a $3 surplus shows a $3 refund, you point at the event and the policy instead of reconstructing it from a block explorer.
The operating rule
Off-quote payments are not exceptions to catch. They're a distribution you serve. The merchant who only handles invoice.paid hasn't built a payment integration — they've built one for a world where customers never make mistakes, and that world does not exist.
When you're ready to write the handler, the step-by-step guide to handling underpaid and overpaid invoices walks the signature check, the field reads, and the policy tables in code. And if you want the short, support-desk version to point a teammate at, what happens if an invoice is underpaid answers it in three paragraphs.
The gap between the request and the fact is where money leaks. Close it with a switch, not a support ticket.
R. Adeyemi, halfin payments engineering