Skip to main content

Under- and overpayments

On some payment methods the payer decides how much to send. A crypto payer sends coins from their own wallet, and what arrives can be worth less or more than the amount you asked for - the rate moved, a network fee came out of it, or the payer simply sent the wrong amount. When that happens the provider does not settle the payment. It holds the funds and waits for your decision: accept what arrived, or send it back to the payer.

Today this applies to crypto pay-ins, on a direct payment or a hosted checkout session alike.

How it works​

  1. The payer sends a different amount. The provider holds it, and you receive payment.underpaid or payment.overpaid. The payment stays processing, and the order now carries amount_mismatch: true and the received_amount.
  2. You decide. Call accept or decline with the payment's order_id, transaction_id or merchant_reference. Both calls only submit the decision; the provider acts on it asynchronously.
  3. The provider reports the outcome.
DecisionWhat the provider doesWebhookPayment endsOrder ends
AcceptSettles the amount that arrived.payment.succeeded, on a new transaction_id for received_amountcompleted (the new transaction)succeeded
DeclineSends everything that arrived back to the payer.payment.returned, on the held paymentreturnedreturned

A decision taken in the provider's own dashboard instead of through this API ends the same way, and is recorded on the order with amount_mismatch_decided_by: "provider".

The webhook​

payment.underpaid and payment.overpaid carry the requested charge in amount and what arrived in received_amount, both in currency:

{
"type": "payment.underpaid",
"data": {
"transaction_id": "txn_9f8e7d6c",
"order_id": "ord_5b4a3c2d",
"merchant_reference": "order-1001",
"status": "processing",
"amount": 100.00,
"amount_mismatch": true,
"received_amount": 95.00,
"currency": "EUR",
"payment_method": "crypto"
}
}

Each new amount the provider reports fires a new event, so a payer who tops up to an amount that still differs triggers another payment.underpaid or payment.overpaid. Base your decision on the latest one. See Webhook events for the full payload.

Accepting​

POST /v1/payments/amount-mismatch/accept. Identify the held payment with exactly one of order_id, transaction_id, or merchant_reference. There is no amount - you accept what the provider holds:

REQ_PATH="/v1/payments/amount-mismatch/accept"
BODY='{
"transaction_id": "txn_9f8e7d6c"
}'
TS=$(date +%s)
SIG=$(printf '%s' "${TS}${REQ_PATH}${BODY}" \
| openssl dgst -sha256 -hmac "$UPPROVE_SIGNING_SECRET" | sed 's/^.*= //')

curl -sS -X POST "https://api.sandbox.upprove.com${REQ_PATH}" \
-H "Authorization: Bearer $UPPROVE_SECRET_KEY" \
-H "X-Signature: t=${TS}, v1=${SIG}" \
-H "Content-Type: application/json" \
--data-raw "$BODY"

The response is the held payment as it stands - still processing, because the provider has not settled it yet:

{
"order_id": "ord_5b4a3c2d",
"transaction_id": "txn_9f8e7d6c",
"merchant_reference": "order-1001",
"status": "processing",
"amount": 100.00,
"received_amount": 95.00,
"currency": "EUR"
}

When the provider settles, the payment completes on a new transaction for the amount that arrived:

  • payment.succeeded fires with the new transaction_id, amount equal to the received amount, and parent_transaction_id naming the held payment (example).
  • The held transaction ends canceled. It fires no payment.canceled - the money settled on its replacement.
  • The order becomes succeeded. Its amount stays the amount you requested; received_amount is what you were paid.

To refund the payment later, target the new transaction (or the order).

Declining​

POST /v1/payments/amount-mismatch/decline, with the same one-of target. Everything that arrived goes back, so there is no amount.

Some providers can only send the funds back to a wallet address you supply. For those, pass the payer's address in refund_destination; a decline without it is refused with 400 amount_mismatch_decision_invalid. Providers that return the funds on their own ignore it.

{
"transaction_id": "txn_9f8e7d6c",
"refund_destination": {
"address": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F",
"network": "ERC20"
},
"refund_fee_bearer": "merchant"
}
FieldRequiredDescription
refund_destination.addressyes, when you send refund_destinationThe payer's wallet address. Up to 128 characters.
refund_destination.networknoThe network the address is on (e.g. ERC20, TRC20). Omit to use the network the payer paid on.
refund_destination.memonoThe destination tag or memo some ledgers require (XRP, XLM, EOS).
refund_fee_bearernoWho pays the cost of sending the funds back, network fee included: merchant (default) or customer.

The response is the return transaction the decline recorded - type: return, against the held payment, on the payment's own order:

{
"transaction_id": "txn_6e5f4a3b",
"parent_transaction_id": "txn_9f8e7d6c",
"order_id": "ord_5b4a3c2d",
"status": "processing",
"amount": 95.00,
"currency": "EUR",
"provider": "crypto_provider",
"payment_method": "crypto"
}

When the provider reports the funds back with the payer:

  • The return transaction becomes completed.
  • The held payment ends returned, and payment.returned fires on it with returned_amount and return_transaction_id (example).
  • The order ends returned, with returned_at set. Unlike canceled, money moved both ways.

If the provider settles the payment instead - say the decision was changed in its dashboard - the return ends failed and the payment completes as if accepted.

One decision per payment​

  • Repeating the same decision is safe. It returns the current state - the payment for an accept, the recorded return for a decline - without submitting anything new.
  • The opposite decision is refused with 409 amount_mismatch_decision_conflict while the provider still holds the funds.
  • Two decisions at once on the same payment: the second gets 409 amount_mismatch_decision_in_progress. Retry it after a moment.
  • A timeout or provider_unavailable leaves you unsure whether the provider took the decision. Send the same decision again; it is not duplicated.
  • Once the provider has settled or returned the funds, there is nothing left to decide and the call is refused - see Errors.

Hosted checkout​

On a checkout session, the session does not complete while the provider holds the funds. Once the provider acts on your decision:

  • Accepted: the session becomes completed and checkout.session.completed fires alongside payment.succeeded. The session's amount is still the amount you requested; read received_amount on payment.succeeded or on the order for what was paid.
  • Declined: the session also becomes completed, and checkout.session.completed fires just before payment.returned - although nothing was paid.
Don't fulfil a returned payment

If you fulfil on checkout.session.completed, check for a mismatch first. Once an order has had a payment.underpaid or payment.overpaid, fulfil on payment.succeeded instead, or read the order and fulfil only when its status is succeeded.

Decide while the session is still live - before its expires_at.

Fields​

On the order (GET /v1/orders/{id}):

FieldDescription
statusreturned once the funds were sent back. Final.
amount_mismatchtrue once the payer sent a different amount. Stays true after the decision; never false.
received_amountWhat the payer actually sent, in currency.
amount_mismatch_decisionaccepted or declined, once submitted and taken by the provider.
amount_mismatch_decided_atWhen the decision was recorded.
amount_mismatch_decided_byapikey:<key id> for a decision made through this API, or provider for one taken at the provider.
returned_atWhen the order ended returned.

On transactions (GET /v1/transactions/{id}): the held payment ends returned (with returned_at) or canceled; the return has type: return and direction: out; and both the return and the replacement payment of an accept carry parent_transaction_id pointing at the held payment.

Errors​

codeHTTPWhen it happens
ambiguous_original_transaction_target400Not exactly one of order_id, transaction_id, merchant_reference was supplied.
original_transaction_not_found404merchant_reference matches no payment awaiting a decision - including one the provider already settled or returned.
original_transaction_not_eligible409The payment is not held for a decision - it never had a mismatch, or the provider already settled or returned it.
payment_unexpected_state409The payment left processing while the decision was being made.
amount_mismatch_decision_conflict409The opposite decision was already submitted.
amount_mismatch_decision_in_progress409Another decision on this payment is being submitted right now.
amount_mismatch_decision_invalid400The provider cannot take the decision as sent - most often a decline without refund_destination.address on a provider that needs one.

If the provider itself refuses the decision, the error's code comes from the decline vocabulary, for example provider_rejected_request.

Next steps​

  • Webhook events - the full payment.underpaid, payment.overpaid and payment.returned payloads.
  • Outcomes - the status model, including returned.
  • Errors - the complete error-code reference.