← halfin journalMar 31, 2026 · 10 min read
Engineering

After the window closes: late deposits, reversals, and the handler that doesn't double-credit

An invoice that already expired can still receive money, and a credit you already booked can still be unwound by a reorg. invoice.late_deposit and invoice.deposit_reversed are the two events that make those facts arrive as data instead of as a finance ticket — if your handler is idempotent enough to trust them.

RA
R. AdeyemiPayments Engineering
engineering · cover

A blockchain does not know your invoice expired. It will happily confirm a transfer to that address an hour after you closed the order, and it is your move to make.

The most expensive bug I have watched a merchant ship was not a crash. It was a +=. Their reconciliation job credited a customer's balance from a deposit, ran again the next morning, and credited it a second time — because the line that added money never asked whether it had added that money before. Nobody noticed until the customer withdrew twice. That is the whole hazard of this post in one anecdote: the late parts of an invoice's life are where money moves after you stopped paying attention, and a handler that isn't built for it leaks in both directions.

This is the sequel to the off-quote events. That piece was about the amount being wrong. This one is about the timing being wrong — the deposit that lands after the window closed, and the credit that gets taken back after you booked it. halfin gives each of those its own event so they arrive as a fact you switch on, not a mystery you reconstruct from a block explorer.

Two events, two uncomfortable truths

invoice.late_deposit fires when a confirmed payment arrives for an invoice that has already expired. The customer paid — really paid, the funds are on-chain and confirmed — they were just slow, or their wallet was, or the network was congested at exactly the wrong moment. The invoice's payment window is over, so this did not become invoice.paid. But the money is real and it is now sitting on your balance against an order you may have already cancelled.

invoice.deposit_reversed fires when a deposit you were previously told about gets unwound by a chain reorganization before it reached the confirmation threshold that makes a credit final. The block that carried the transaction was orphaned, the canonical chain no longer contains it, and the credit has to be walked back. If you acted on the earlier, optimistic signal as settled, you are now holding an order — or a withdrawal — backed by money that no longer exists.

These are not the same shape of problem, and the instinct to handle them together is the first mistake.

EventWhat actually happenedThe danger if you ignore it
invoice.late_depositConfirmed payment after expiry; funds are real and yoursStranded money, a cancelled order, a confused customer who did pay
invoice.deposit_reversedA pre-final credit was orphaned by a chain reorganizationYou shipped or paid out against money that vanished
invoice lifecycle · the tail end
The two events that fire after you'd normally stop watching an invoice.

Why late deposits are not your customer's fault

It is tempting to treat an expired invoice as a closed door and a late payment as the customer's problem. Resist it. The expiry window on a halfin invoice exists so you can stop holding a rate quote open indefinitely — it is a risk control on your side, not a deadline the customer agreed to honor to the minute. From their seat, they scanned a QR code and sent the amount; the fact that their exchange batched the withdrawal or the mempool was full is invisible to them.

So when invoice.late_deposit arrives, the money is unambiguously theirs-turned-yours, and the only real question is what they get for it. The decision mirrors the underpayment policy from the off-quote post, because the situation rhymes: a real, confirmed payment that doesn't cleanly map to a fulfilled order.

  • Re-quote and fulfil when the goods are still available and the rate hasn't moved enough to hurt. The payment landed; honor it.
  • Credit the balance when the order is gone but the relationship isn't — park the funds on the customer's account for next time. The friendliest default for accounts-based products.
  • Refund when you can't fulfil and don't run balances. Refunds are a first-class halfin primitive, so this is one call, not a treasury exercise.

The one option that is not on the table is do nothing. A late deposit you don't handle is confirmed money sitting against a cancelled order, and it surfaces eventually — as a support ticket or a reconciliation mismatch someone in finance spends an afternoon on. Spend that afternoon now, in a switch statement, exactly once.

Why reversals are the genuinely dangerous one

Late deposits cost you an afternoon. A mishandled reversal costs you the deposit. The asymmetry is the whole reason to take invoice.deposit_reversed more seriously than its quiet name suggests.

A reorg unwinds a deposit only while it is still pre-final — before it has cleared the per-chain confirmation threshold that halfin treats as settled. This is the entire point of reorg-aware crediting: the platform does not treat a first-seen, single-confirmation deposit as money you can stand on. The deeper a transaction is buried under later blocks, the more absurd it becomes to reorg it out, which is why chains carry different thresholds — the glossary entry on chain reorganizations is worth five minutes if the concept is fuzzy.

Here is the trap. If you only ever act on a settled invoice.paid, a reversal mostly can't hurt you — the credit you'd be unwinding was never finalized, so you never shipped against it. The danger lives entirely in the merchants who wired their fulfilment to an earlier, optimistic deposit signal to "save thirty seconds of latency." They ship, the block orphans, invoice.deposit_reversed arrives, and the event is telling them about money that left while the goods are gone.

So the reversal handler has exactly one job: unwind, idempotently, whatever you booked from the deposit being reversed — and nothing else. Reverse the balance credit. Flag the order for hold or recall. Don't refund (there is nothing to refund; the money was never finally yours), and don't assume you can re-collect it. Treat it as the credit-side counterpart of a clawback, applied to one specific deposit, traceable to one specific event.

The double-credit problem, and the shape that beats it

Everything above collapses into one engineering property: your handler has to be idempotent, and not in the hand-wavy "we retry sometimes" sense. halfin webhooks deliver every event at least once. A slow response, a transient 5xx, a timeout you didn't cause — any of them earns a redelivery, and the redelivered event carries the same stable event id as the first. On the late-deposit and reversal paths, a duplicate isn't a harmless re-render. It is a second credit, or a second clawback, against a balance that should only have moved once.

The fix is the same discipline the webhook reliability writeup hammers on, applied to money movement:

  1. Verify the signature over the raw request bytes before you parse a thing. Both events move money; a forged invoice.late_deposit is a free-money request and a forged invoice.deposit_reversed is a way to make you claw back a legitimate customer. Recompute the HMAC over the exact bytes you received — not the reserialized JSON, whose whitespace and key order will differ — compare in constant time, then parse. The full argument lives in verify the signature before acting; on these two paths it is not optional.

  2. Make the event id the unit of work. Record every event id you have durably processed. When one arrives, check the ledger first: if you've seen it, acknowledge 2xx and no-op. The credit or reversal happens exactly once because the decision to apply it is guarded by a uniqueness constraint, not by hoping the event arrives once.

  3. Make the ledger write the source of truth, not the trigger. The += from the opening anecdote is dangerous because it's an imperative mutation. A row keyed on (invoice_id, deposit_reference, event_id) with a unique index turns a double-credit into a duplicate-key error the database refuses for you — exactly what you want a database to do with money.

  4. Acknowledge fast, then do the slow work on a queue. Persist the event, return 2xx, and only then run the refund call, the balance adjustment, the order-state change, the email. Holding the connection open while a downstream service is slow is precisely what triggers the retry that becomes the duplicate.

The mental test I use: imagine the same invoice.late_deposit arriving three times and the same invoice.deposit_reversed twice, interleaved in the worst order you can picture. If your customer's balance lands on the same number every time, the handler is real. If the answer depends on arrival order or count, you have shipped the +=.

What to persist, so finance never has to guess

When a balance shows a credit that was added and then removed two hours later, someone will ask why. The answer should be a row you can point at, not a forensic dig through a block explorer. For each event, record the invoice id, the deposit reference, the triggering event id, the amount, the action you took, and the resulting ledger entry — so a late deposit you credited and a reversal that walked it back read as two linked, explainable lines. This is also where balance.credited earns its keep: when a late deposit lands on a customer's balance, that credit fires its own event, so the late-deposit and reversal events thread together with the credit into a connected chain of records rather than a set of disconnected balance jumps.

The operating rule

An invoice's life does not end when the window closes. The chain can still deliver money into it, and the chain can still take money back out of it, and both of those happen on the chain's schedule, not yours. invoice.late_deposit and invoice.deposit_reversed exist so those two facts arrive as events you designed for instead of incidents you discover.

Build the credit path as a guarded, idempotent ledger write — and the late, slow, reorg-y tail of the invoice stops being the place your money leaks. It becomes two more rows in the switch.

R. Adeyemi, halfin payments engineering

↳ end of articlehalfin journal · Mar 31, 2026