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
- The payer sends a different amount. The provider holds it, and you receive
payment.underpaidorpayment.overpaid. The payment staysprocessing, and the order now carriesamount_mismatch: trueand thereceived_amount. - You decide. Call accept or decline with the payment's
order_id,transaction_idormerchant_reference. Both calls only submit the decision; the provider acts on it asynchronously. - The provider reports the outcome.
| Decision | What the provider does | Webhook | Payment ends | Order ends |
|---|---|---|---|---|
| Accept | Settles the amount that arrived. | payment.succeeded, on a new transaction_id for received_amount | completed (the new transaction) | succeeded |
| Decline | Sends everything that arrived back to the payer. | payment.returned, on the held payment | returned | returned |
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.succeededfires with the newtransaction_id,amountequal to the received amount, andparent_transaction_idnaming the held payment (example).- The held transaction ends
canceled. It fires nopayment.canceled- the money settled on its replacement. - The order becomes
succeeded. Itsamountstays the amount you requested;received_amountis 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"
}
| Field | Required | Description |
|---|---|---|
refund_destination.address | yes, when you send refund_destination | The payer's wallet address. Up to 128 characters. |
refund_destination.network | no | The network the address is on (e.g. ERC20, TRC20). Omit to use the network the payer paid on. |
refund_destination.memo | no | The destination tag or memo some ledgers require (XRP, XLM, EOS). |
refund_fee_bearer | no | Who 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, andpayment.returnedfires on it withreturned_amountandreturn_transaction_id(example). - The order ends
returned, withreturned_atset. Unlikecanceled, 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_conflictwhile 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_unavailableleaves 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
completedandcheckout.session.completedfires alongsidepayment.succeeded. The session'samountis still the amount you requested; readreceived_amountonpayment.succeededor on the order for what was paid. - Declined: the session also becomes
completed, andcheckout.session.completedfires just beforepayment.returned- although nothing was paid.
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}):
| Field | Description |
|---|---|
status | returned once the funds were sent back. Final. |
amount_mismatch | true once the payer sent a different amount. Stays true after the decision; never false. |
received_amount | What the payer actually sent, in currency. |
amount_mismatch_decision | accepted or declined, once submitted and taken by the provider. |
amount_mismatch_decided_at | When the decision was recorded. |
amount_mismatch_decided_by | apikey:<key id> for a decision made through this API, or provider for one taken at the provider. |
returned_at | When 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
code | HTTP | When it happens |
|---|---|---|
ambiguous_original_transaction_target | 400 | Not exactly one of order_id, transaction_id, merchant_reference was supplied. |
original_transaction_not_found | 404 | merchant_reference matches no payment awaiting a decision - including one the provider already settled or returned. |
original_transaction_not_eligible | 409 | The payment is not held for a decision - it never had a mismatch, or the provider already settled or returned it. |
payment_unexpected_state | 409 | The payment left processing while the decision was being made. |
amount_mismatch_decision_conflict | 409 | The opposite decision was already submitted. |
amount_mismatch_decision_in_progress | 409 | Another decision on this payment is being submitted right now. |
amount_mismatch_decision_invalid | 400 | The 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.overpaidandpayment.returnedpayloads. - Outcomes - the status model, including
returned. - Errors - the complete error-code reference.