2026-11-23

Crypto Underpayments and Overpayments: How to Handle Them

With a card, the amount is never wrong. The customer confirms a total and the processor charges exactly that. A crypto payment is a transfer the customer types or pastes, and people get it wrong. They send the price without the network fee. They pay from an exchange that takes its withdrawal fee out of the amount. They add a zero.

Most of it is fixable. But your system has to know what to do with 48.75 USDC on a 49.01 charge before the first customer sends it.


How Klap counts what arrived

Every charge has an amount and keeps a running total of what arrived, amountReceived. Transfers add up. A customer who sends 30 and then 19.01 has paid 49.01, and the charge confirms on the second transfer. They can even split it between tokens or networks the charge accepts, as long as the total gets there.

What the charge does with a short total depends on whether it's still open.

While the charge is open, a partial transfer moves it to partially_paid and fires charge.partially_paid. The charge keeps waiting for the rest until it expires. This is the moment to tell the customer, while they still have the checkout in front of them: you've sent 48.75, send 0.26 more.

When the charge expires short, it ends as underpaid and fires charge.underpaid. That's final. No more money will be counted toward it.

When the total reaches the amount, it's confirmed, whether that took one transfer or three, and whether the last one landed a few seconds after the expiry. Klap keeps checking recently expired charges for an hour, so a transfer that was already on its way when the timer ran out isn't lost.


Underpaid doesn't mean the money is gone

An underpaid charge still pays out. Whatever did arrive goes to your wallet through the charge's split, the same way a full payment would, and charge.settled fires when it lands. Klap never holds a balance, so there's nowhere for a partial payment to be stuck on our side.

What it leaves you with is a decision. The customer paid something, and you have to pick one:

  • Accept it. If the gap is small, fulfill the order and absorb the difference. 25 cents short on a $49 order usually isn't worth an email.
  • Ask for the rest. Create a new charge for the difference, with the same externalRef, and send the customer a link to it. Fulfill once both are paid.
  • Refund it. Send the partial amount back from your own wallet and cancel the order.

Whichever you choose, put it in code, so the same case gets the same answer every time.


Overpayments

A charge that receives more than its amount confirms like any other, with isOverpaid: true and an extra charge.overpaid event. The whole amount pays out to your wallet. You can list them with GET /v1/charges?isOverpaid=true.

Klap doesn't refund the difference automatically, and it can't. The money never passes through a Klap account. It goes from the customer to the charge's contract and from there to the recipients the charge was created with, and nothing in that contract knows how to send funds back to whoever paid. A refund has to come from your wallet, as a new transfer you decide to make.

That's usually the right call for small amounts, and worth a conversation for large ones. Someone who sends 490 instead of 49 will notice, and you'll want to send it back to the address they actually control, which isn't always the address the transfer came from. If they paid from an exchange, the sending address belongs to the exchange. Ask them for a return address instead of sending it back blind.


Transfers too small to count

Some transfers are too small to be a real attempt at paying. A live charge ignores any transfer under $0.10 unless that transfer completes the charge. It's recorded, but it doesn't count toward the total and doesn't move the charge to partially_paid. If a later transfer finishes the payment, the small one is counted too.

This keeps a stray 0.000001 USDC transfer, a common sight on public addresses, from turning a charge into partially_paid and making your checkout ask the customer for $48.99 more.

At the other end of the payout, a charge can receive so little that paying it out would cost more in gas than it's worth. If a payout can't cover its gas for 24 hours, its settlementStatus becomes below_minimum, charge.settlement_below_minimum fires, and the charge carries a selfSettlement object with what you'd need to run the payout yourself. In practice that only happens to very small underpaid charges. Does USDC have gas fees? explains what that gas pays for.


Writing the policy into the handler

Here's one way to turn the choices above into code. It accepts anything within 1% of the price, asks for the rest otherwise, and flags overpayments of more than a dollar for a person to look at:

const TOLERANCE = 0.01
const OVERPAY_REVIEW_USD = 1
switch (event.event) {
case 'charge.partially_paid':
await notifyCheckout(event.data.externalRef, {
remaining: event.data.amount - event.data.amountReceived,
})
break
case 'charge.underpaid': {
const order = await orders.get(event.data.externalRef)
const shortBy = event.data.amount - event.data.amountReceived
if (shortBy <= event.data.amount * TOLERANCE) {
await orders.fulfillOnce(order.id, event.data.id)
} else {
await orders.requestBalance(order.id, shortBy)
}
break
}
case 'charge.overpaid':
if (event.data.amountReceived - event.data.amount > OVERPAY_REVIEW_USD) {
await support.flagOverpayment(event.data.externalRef, event.data.id)
}
break
case 'charge.confirmed':
await orders.fulfillOnce(event.data.externalRef, event.data.id)
break
}

charge.overpaid and charge.confirmed both arrive for the same overpaid charge, so fulfillOnce has to be keyed on the charge, not the event, or an overpayment ships twice. Idempotent payment webhook handlers covers that. And for real money math, use amountReceivedExact, a decimal string, instead of subtracting JSON numbers. The plain numbers are fine for a display, but they can lose precision.

You can push a test charge into each of these states without sending anything, with klap.sandbox.underpay(), .overpay() and .expire(). How to test crypto payments without real funds walks through it.


Fewer wrong amounts to begin with

Most of these cases start at the checkout. Show the full amount, gas included, as the number to send, and make it easy to copy exactly. When the customer pays by connecting a wallet, as with @klappay/checkout-kit, the transaction is built with the right amount, so nobody types it.

And tell customers paying from an exchange that the exchange may take its fee out of what arrives. That one line covers a lot of the 48.75s.