Guide

How to handle underpaid and overpaid crypto invoices

A customer pays your invoice from an exchange that skims a withdrawal fee, or fat-fingers the amount, and the deposit lands a few cents short. Another rounds up and overpays. A naive integration treats anything that is not an exact match as a failure and strands real money on-chain. halfin treats both as first-class outcomes: the invoice records the exact shortfall or excess, and an HMAC-signed webhook tells your backend the moment it happens. This guide shows how to consume the invoice.underpaid and invoice.overpaid events, read the recorded gap, and turn an ad-hoc support fire-drill into a policy your code applies the same way every time.

01

Why under- and overpayment happen on every chain

Before you handle them, it helps to know why these cases are normal rather than rare. The fiat amount on the invoice is fixed and the payable asset amount is locked at activation, but the number that actually arrives on-chain is under the customer's control, not yours. Several ordinary things push it off the quote.

Underpayment most often comes from a customer paying out of an exchange or a custodial wallet that deducts the network fee from the amount they entered — they typed the quoted figure, the platform sent that minus the fee, and the deposit lands short. A stale quote that the customer paid just after expiry, or a plain typo, produce the same result. Overpayment is usually a rounded-up amount, a doubled send, or a wallet that added a buffer. None of these are attacks; they are the texture of real payments.

halfin tracks the amount expected against the amount actually received for every invoice, across all supported chains, with reorg-aware crediting and per-chain confirmation thresholds. So the shortfall or excess you act on is measured against a confirmed on-chain credit, not an optimistic first-seen deposit that a reorg could later unwind. That is the foundation the rest of this guide builds on: the gap is a recorded fact, and you decide what to do with it.

02

Step 1 — Subscribe to the underpaid and overpaid events

The two events you need are invoice.underpaid and invoice.overpaid. Register a webhook endpoint and its signing secret from the dashboard or the API, and make sure these two event types are in the set your endpoint receives alongside the ones you already consume for a clean payment. A storefront that only listens for invoice.paid will silently miss every short and every surplus payment — the order sits unfulfilled while the customer's money is already on your balance.

Think of the invoice lifecycle as a small set of terminal-ish states your handler has to recognise. The table below is the slice that matters for this guide. invoice.confirming is the live, waiting state; invoice.paid is the clean exact-match result; invoice.underpaid and invoice.overpaid are the two off-quote outcomes this guide is about; invoice.expired closes the window with no sufficient payment.

EventWhat it meansWhat your handler does
invoice.confirmingInvoice is live with a locked quote and an expiry; a deposit may be confirmingShow the address, amount, and countdown — do not act yet
invoice.paidThe expected amount confirmed under the chain's thresholdFulfil the order (idempotently)
invoice.underpaidA real, confirmed payment arrived but is below the amount dueRead the shortfall; apply your top-up or partial policy
invoice.overpaidThe invoice settled with a confirmed surplus over the amount dueFulfil; read the excess; refund or credit per policy
invoice.expiredThe payment window closed before a sufficient payment arrivedCancel the order; let the customer start a fresh invoice
03

Step 2 — Verify the signature before you read the amount

Every halfin webhook is an HMAC-signed JSON envelope, and the underpaid and overpaid events are no different. Your endpoint URL is public the moment you register it, so anyone can POST forged JSON claiming a shortfall of zero or a surplus you should refund. The signature is the only thing that separates a real halfin event from a fake one, and the rule is absolute: recompute the HMAC over the exact raw request bytes, compare it to the signature header in constant time, and only then parse the body and act.

Compute the HMAC over the bytes you received, before any framework middleware reserializes the JSON — a re-encoded body changes whitespace and key order and will fail an otherwise-valid signature. In most stacks that means reading the raw request buffer and disabling automatic body parsing for the webhook route. The handler below does exactly that, then branches on the event type. It is deliberately strict on the under/overpaid path because that path moves money: a forged invoice.overpaid could otherwise trick you into refunding funds that never arrived.

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
const SIGNING_SECRET = process.env.HALFIN_WEBHOOK_SECRET!;

// HMAC must be computed over the EXACT bytes received — capture the raw body.
app.post(
  "/webhooks/halfin",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("x-halfin-signature") ?? "";
    const expected = createHmac("sha256", SIGNING_SECRET)
      .update(req.body) // req.body is a Buffer here
      .digest("hex");

    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      return res.status(401).send("invalid signature");
    }

    // Only now is it safe to parse and act on the amounts.
    const event = JSON.parse(req.body.toString("utf8"));
    switch (event.type) {
      case "invoice.underpaid":
        // event.data carries the expected vs. received amounts — see Step 3
        handleUnderpaid(event.data);
        break;
      case "invoice.overpaid":
        handleOverpaid(event.data);
        break;
      case "invoice.paid":
        fulfilOrder(event.data.id);
        break;
    }

    // Acknowledge fast; defer slow work to a queue.
    return res.status(200).send("ok");
  },
);

// Field names and the full envelope shape are defined at docs.thehalfin.com
// and in the @halfin/sdk-merchant types.
04

Step 3 — Read the recorded shortfall or excess

The point of these events is that the gap is measured for you. The invoice tracks the amount expected against the amount actually received on-chain, so an invoice.underpaid event tells you the shortfall and an invoice.overpaid event tells you the excess. You do not recompute anything from rate screenshots or guess what a deposit was worth — you read the recorded figures off the invoice object on the event and decide.

Keep these amounts as strings end to end. Monetary values cross the wire as strings, never as floating-point, and a shortfall of "0.42" should stay a string until you hand it to a decimal library or compare it against your own ledger. Converting it to a JavaScript number to do `received - expected` is the classic way to introduce a rounding error into the exact figure halfin already computed correctly. The exact field names for the expected, received, and difference amounts live in the API reference at docs.thehalfin.com; treat that as the source of truth and the snippet below as the shape of the decision, not the schema.

Carry your own identifier on the invoice when you create it — the order id, the subscription and cycle, whatever you reconcile against — so the under/overpaid event maps straight back to the thing it affects without a lookup table you have to keep in sync. Then the handler's job is mechanical: identify the order, read the recorded gap, and route it to the policy you defined once.

  • Read the expected, received, and difference amounts off the event's invoice data — do not recompute them.
  • Keep every amount as a string; use a decimal library if you must compare or add.
  • Map the event to your order via an identifier you attached at invoice creation.
  • The shortfall/excess is measured against a confirmed, reorg-aware credit — it is safe to act on.
05

Step 4 — Decide your underpayment policy: top-up or partial

An underpaid invoice has received a real but insufficient confirmed payment. The money is on your balance; the question is what the customer gets for it. There is no single correct answer — it depends on what you sell — so the discipline is to pick one policy, encode it, and let the recorded shortfall drive it rather than improvising per ticket.

Three policies cover almost every case. A top-up policy holds the order, surfaces the shortfall to the customer, and asks them to send the difference; you keep the invoice associated with the order and mark it fulfilled once the balance covers the original amount due. A partial-settlement policy is for divisible goods — account credit, a metered top-up, a donation — where you grant value proportional to what arrived and record the rest as owed or simply accept the lesser amount. A void-and-refund policy treats the shortfall as a failed payment: you do not fulfil, and you return the underpaid funds to the customer through a refund so they can retry cleanly. Choose per product line; a physical-goods store usually wants top-up, a credits-based product usually wants partial.

Whatever you choose, do not silently extend the order on an underpayment. The failure mode that hurts is fulfilling a $100 order against a $97 payment because the handler only checked "did money arrive" and not "was it enough". The invoice.underpaid event exists precisely so that case is explicit: the order stays on hold, 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 thread a week later.

PolicyWhen it fitsWhat your handler does on invoice.underpaid
Top-upIndivisible goods/services where the full amount is requiredHold the order; tell the customer the exact shortfall; fulfil once covered
Partial settlementDivisible value — credits, metered usage, donationsGrant value proportional to the received amount; record any remainder
Void and refundYou would rather the customer retry than chase a top-upDo not fulfil; refund the underpaid funds; let them start a fresh invoice
06

Step 5 — Decide your overpayment policy: refund or credit

An overpaid invoice is the easier of the two: the amount due is fully covered, so the order can be fulfilled, and the only open question is the surplus. The invoice records the excess against the original quote, so it is a visible, accountable figure rather than an unmatched deposit that disappears into a reconciliation report. Two policies cover it.

A refund policy returns the surplus to the customer. Refunds are a first-class halfin primitive, so you fulfil the order on the invoice.overpaid event and issue a refund for the recorded excess back to the customer's address. A credit policy keeps the surplus on the customer's account as store credit or applies it to their next invoice — common for subscriptions, where crediting the excess to the next cycle is friendlier than a small on-chain refund whose network fee might rival the surplus itself. State which one you do in your terms so the customer is not surprised either way.

The one thing to avoid is treating an overpayment as a clean paid event and discarding the surplus. The customer sent more than they owed; that excess is theirs until you deliberately decide otherwise. Reading the recorded excess off the event and routing it to a refund or a credit is what keeps the books honest and the support queue quiet.

  • Fulfil the order — the amount due is fully covered on an overpayment.
  • Refund the recorded excess to the customer, or credit it to their account / next invoice.
  • For small surpluses, crediting often beats a refund whose network fee approaches the surplus.
  • Never discard the excess as if it were a clean exact payment.
07

Step 6 — Make the handler idempotent and reconcilable

halfin delivers each event at least once, not exactly once. A slow response, a transient 5xx, or a network blip causes a redelivery, and the same invoice.underpaid or invoice.overpaid can legitimately arrive more than once with the same stable event id. On an off-quote path this matters more than on a clean one, because the side effect often moves money: issuing the same refund twice, or asking the customer for the same top-up twice, are exactly the bugs at-least-once delivery surfaces.

Build the handler around the stable event id. Record processed event ids, and make the side effect a no-op the second time you see one — a redelivered overpaid event must not fire a second refund, and a redelivered underpaid event must not send a second "please top up" email or grant a second partial credit. Acknowledge fast with a 2xx once you have verified the signature and durably recorded the event, then do the refund call, the email, and the ledger write on a background queue so a slow downstream does not hold the delivery open and trigger a retry.

Finally, make every off-quote outcome reconcilable. Persist the invoice id, the recorded shortfall or excess, the policy you applied, and the resulting refund or top-up against your order. When finance asks why an order with a $3 surplus shows a $3 refund, or why a held order is waiting on a $3 top-up, you point at the event and the policy instead of reconstructing it from chain explorers. The under/overpaid events plus a few stored fields turn the messy middle of crypto payments into an audit trail.