All posts

Underpaid, paid in parts, or overpaid — what happens to the order

The most common "wrong amount" situations in stablecoin payments, and how running totals, the grace period, the underpaid status and webhook events handle each of them.

  • Confirmation
  • Callbacks
  • Best practices

On-chain payments do not work like card charges. You cannot pull an exact amount. The customer types the number, the exchange deducts a withdrawal fee, they misread a decimal, they send twice — and what lands on the address is often not what you asked for. This post explains what happens to an order in each case and what your system should do about it.

One rule to remember

CNIXPay looks at one thing: has the total received for this order reached the amount due?

  • Yes → the order becomes paid, is settled to your balance, and order.paid is sent.
  • No → the order stays pending and keeps adding up; each payment sends an order.partially_paid.
  • Still short at expiry → there is a grace period (60 minutes by default). If it is still short when the grace period ends, the amount received is credited, the order becomes underpaid, and order.underpaid is sent.

There are only four statuses, and for you there is only one rule: wait on pending, act on everything else.

status What to do
pending Wait; if paidAmount > 0 you may ask the customer to top up
paid Fulfil
underpaid Handle manually: partial fulfilment, ask for the rest, or refund
closed Void the order

Scenario 1: underpaid

The order is for 100 USD and the customer sends 99.5 USDT.

The order stays pending and you receive an order.partially_paid:

{
  "event": "order.partially_paid",
  "data": {
    "orderNo": "20261017105142a1b2c3d4e5f6",
    "status": "pending",
    "amount": "100.00",
    "paidAmount": "99.50",
    "version": 2
  }
}

The order has not failed or closed — it is waiting for the remaining 0.5.

  • If the customer sends 0.5 more before expiry or within the grace period, the total reaches 100, the order becomes paid and you receive order.paid.
  • If it is still 99.5 when the grace period ends, the order becomes underpaid, the 99.5 is credited to your balance (minus the fee), and you receive order.underpaid.

What to do: while pending, you can show “0.5 USDT still due” (amount − paidAmount), but do not fulfil. On underpaid, decide whether to fulfil partially, ask for the rest, or refund.

Scenario 2: paid in several transfers

The order is for 10 and the customer sends 3, then 4, then 3. You receive, in order:

{ "event": "order.partially_paid", "data": { "status": "pending", "paidAmount": "3.00",  "txCount": 1, "version": 2 } }
{ "event": "order.partially_paid", "data": { "status": "pending", "paidAmount": "7.00",  "txCount": 2, "version": 3 } }
{ "event": "order.paid",           "data": { "status": "paid",    "paidAmount": "10.00", "txCount": 3, "version": 4 } }

Three things to note:

  1. paidAmount is a running total, not the size of the last transfer; each transfer is listed in transactions[].
  2. version goes up by 1 on every change. Webhooks may arrive out of order — keep the highest version.
  3. The final transfer produces order.paid, not a third order.partially_paid.

What to do: fulfil only when status == "paid". order.partially_paid is good for a progress bar or a top-up reminder.

Scenario 3: overpaid

The order is for 100 and the customer sends 120.

120 ≥ 100, so the order becomes paid straight away with paidAmount of "120.00". The extra 20 is credited to your merchant balance as well — what gets settled is what actually arrived.

If the customer pays again after the order is paid, that payment is credited too: the status stays paid, paidAmount and version update, but no further webhook is sent.

What to do: apply your own rules on whether to refund the difference (paidAmount − amount). The CNIXPay merchant API has no refund endpoint; send refunds from your own wallet.

Scenario 4: the payment arrives after expiry

Every order has an expiry (expireTime, 30 minutes by default). On-chain transfers take time, so a customer can hit send before the countdown ends and have the block confirm after the order has expired.

That is what the grace period is for: for a while after expiry the order is still pending, and payments and top-ups count as usual. You never need to work out “has it expired yet” — as long as it is pending, keep waiting.

When the grace period ends:

  • nothing received → the order becomes closed (closeReason = timeout) and order.closed is sent;
  • some payment received → the order becomes underpaid and order.underpaid is sent.

Funds that arrive after the grace period are not credited automatically; the platform handles them manually — refunding the payer, or crediting them after review. A manual credit re-evaluates the order on what was received, so it may go from closed to paid, with order.paid sent.

What to do: if you need to release stock earlier you can act on expireTime yourself, but do not delete the order outright — you still need to find it if order.paid arrives.

Putting it in code

A robust handler looks roughly like this (for signature verification and deduplication see Callback verification and idempotency):

async function onOrderChanged(order) {
  // keep only the newest version: UPDATE ... WHERE order_no = ? AND version < ?
  if (!(await orders.saveIfNewer(order))) return

  switch (order.status) {
    case 'pending':
      // optional: if paidAmount > 0, tell the customer amount − paidAmount is still due
      break
    case 'paid':
      await fulfillOnce(order.orderNo) // fulfil, idempotent per order
      break
    case 'underpaid':
      await createManualReview(order.orderNo, order.paidAmount)
      break
    case 'closed':
      await voidOrder(order.orderNo)
      break
  }
}

Webhooks and queries return the same order object, so this function serves both. Add a periodic batch query of open orders by merchantOrderNo as a fallback and no order slips through.

Summary

Scenario Order status Events What you do
Underpaid, then topped up pending → paid partially_paid → paid Wait, fulfil on paid
Underpaid, never topped up pending → underpaid partially_paid → underpaid Handle manually
Paid in parts pending → paid several partially_paid, then paid Fulfil only on paid
Overpaid paid paid, paidAmount is the actual amount Handle the difference per your rules
Paid within the grace period pending → paid paid Don’t give up too early
Never paid closed (timeout) closed Void

Full status and event details are in the integration guide · Order statuses.