Handle late payments
The Payments v.3 API endpoints are in beta and may change as we enhance the product. See Migrate crypto payments from v.1 for background on this project.
This type of payment is specific for Merchant Payment Services / Payment Links only.
Late payments occur when cryptocurrency arrives after a payin has already expired. This situation can arise if:
- The blockchain confirmation is delayed.
- The customer sends funds late.
- They attempt to top up a payin that has already expired.
Funds arriving late is just another transition on the same payin, reported on the same payment:v3:payin:status-change webhook. The payin's statusHistory[] shows the full trail, PENDING_CUSTOMER_PAYMENT then EXPIRED then PROCESSING then COMPLETED, and the final resource carries labels: ["LATE"] so you can tell it apart from a payin that arrived on time.
Why and when do payments expire?
We don't want to leave payins in the PENDING_CUSTOMER_PAYMENT state indefinitely if a customer never sends any funds. Therefore, each payin has an expiry window:
- If you specify
expiryDurationand the payin is still in aPENDING_CUSTOMER_PAYMENTorPROCESSINGstate after that time, it will expire once that duration elapses without reachingCOMPLETED. - If you don't specify
expiryDurationin the payin request, your default expiration period, as configured in your merchant settings, will be used. - The default expiry time of 60 minutes will be used for payins that require an FX conversion between the currency being paid and the currency of your MID wallet, with no
expiryDurationspecified and no default expiration setting configured. This is the same window exposed on the payin asconversion.validityDuration: "PT60M": the exchange rate is locked for that period, so if the customer pays later, BVNK does not guarantee the rate will be the same as the one quoted when the payin was created.
Example: the customer wants to pay 223.05 EUR from their ETH wallet. BVNK quotes 0.13275995 ETH at a rate of 1 ETH = 1680.1 EUR, locked for
conversion.validityDuration: "PT60M". If the customer pays after that window closes, the rate may have moved by the time the funds are detected, so the payment settles at the new rate instead of the one quoted. The customer still sends 0.13275995 ETH, but if the rate has since moved to 1 ETH = 1750 EUR, the merchant's wallet is credited 232.33 EUR instead of 223.05 EUR. And if the rate drops to 1 ETH = 1600 EUR, the merchant's wallet is credited 212.42 EUR instead.
How do I know the payment has expired?
Once a payin expires, you receive a payment:v3:payin:status-change webhook with data.status showing EXPIRED, and a matching entry appended to data.statusHistory[].
How do I know a payment is a late payment?
There is no separate late-payment event. You receive the same payment:v3:payin:status-change webhook again once the late funds are detected, and again once they're confirmed. Check data.labels[] for "LATE", and compare beneficiary.actual (what was actually credited) to beneficiary.required (what was asked for) to see the amount in your wallet currency.
{
"id": "9E17B5C3-04AF-4D68-A9E1-7B2C6F350D8A",
"reference": "myUniqueRef336",
"type": "payment:payin",
"direction": "IN",
"status": "COMPLETED",
"method": "CRYPTO",
"fees": {
"customerFee": null,
"processingFee": { "estimated": { "currency": "EUR", "amount": "1.12" }, "actual": { "currency": "EUR", "amount": "1.12" } },
"networkFee": null
},
"originator": {
"required": { "currency": "ETH", "amount": "0.13275995" },
"actual": { "currency": "ETH", "amount": "0.13275995" },
"customerId": "d063635e-0f83-4e47-a1f3-fc9484df1509",
"customerReference": null,
"requesterIpAddress": "172.16.254.1",
"entity": {
"type": "COMPANY",
"legalName": "BVNK LTD",
"registrationNumber": "5493001KJTIIGC8Y1R12",
"relationshipType": "SELF_OWNED",
"address": { "country": "GB" }
},
"paymentInstrument": {
"type": "CRYPTO_ADDRESS",
"network": "ETHEREUM",
"addresses": ["0x3f5ce5fbfe3e9af3971dd833d26ba9b5c936f0be"],
"tag": null
}
},
"beneficiary": {
"required": { "currency": "EUR", "amount": "223.05" },
"actual": { "currency": "EUR", "amount": "223.05" },
"walletId": "a:24092328494070:G5i4XZ9:1"
},
"conversion": {
"type": "QUOTE",
"baseRate": "1688.54",
"allInRate": "1680.1",
"spread": { "percentage": "0.005", "amount": "1.12", "currency": "EUR" },
"validityDuration": "PT60M",
"expiresAt": "2026-03-14T21:43:23Z"
},
"instructions": [
{
"type": "CRYPTO",
"network": "ETHEREUM",
"currency": "ETH",
"amount": "0.13275995",
"address": "0x5a3d19f8c04b7e26d1908af53bc7e40921d6b8f4",
"tag": null,
"uri": "ethereum:0x5a3d19f8c04b7e26d1908af53bc7e40921d6b8f4?value=1.3276e17"
}
],
"checkout": {
"required": { "currency": "EUR", "amount": "223.05" },
"actual": { "currency": "EUR", "amount": "223.05" },
"displayRate": { "from": "ETH", "to": "EUR", "rate": "1680.1" },
"redirectUrl": "https://pay.bvnk.com/payin/9E17B5C3-04AF-4D68-A9E1-7B2C6F350D8A",
"returnUrl": "https://my-shop.com/payment-complete?ref=xyz"
},
"trackingDetails": [
{
"amount": "0.13275995",
"currency": "ETH",
"hash": "0xe208d5f37b91c4a6802d1f5e9b3c7a04812f6d9e5b0a3c7f19d4e82b6a0c5f31",
"network": "ETHEREUM",
"createdAt": "2026-03-14T21:12:44Z",
"confirmedAt": "2026-03-14T21:13:51Z"
}
],
"labels": ["LATE"],
"relatedPayments": [],
"statusHistory": [
{ "status": "PENDING_CUSTOMER_PAYMENT", "timestamp": "2026-03-14T20:43:23Z" },
{ "status": "EXPIRED", "timestamp": "2026-03-14T21:03:23Z" },
{ "status": "PROCESSING", "timestamp": "2026-03-14T21:12:44Z" },
{ "status": "COMPLETED", "timestamp": "2026-03-14T21:13:51Z" }
],
"metadata": {},
"expiryDuration": "PT20M",
"expiresAt": "2026-03-14T21:03:23Z",
"updatedAt": "2026-03-14T21:13:51Z",
"createdAt": "2026-03-14T20:43:23Z"
}
The customer sent exactly the required 0.13275995 ETH, so there's no OVER or UNDER label here, only LATE. The payin expired at 21:03:23Z, twenty minutes after creation as set by expiryDuration: "PT20M", but the funds were detected at 21:12:44Z and confirmed a little over a minute later. Because the funds eventually arrived and matched the required amount, the payin still completes; it does not stay stuck in EXPIRED.
📘 Payment states
A late payment in v.3 always ends up
COMPLETED(or, if the merchant's over/underpayment configuration applies, stillCOMPLETEDbut also carryingOVERorUNDERalongsideLATE).EXPIREDonly appears as an intermediate entry instatusHistory[], not as the final state, as long as funds eventually arrive.
Late payments exception flows
The summary of the flow for three different outcomes is given below:
| Configuration | Balance impact | Payin status | Labels |
|---|---|---|---|
| Settle Merchant Wallet | Received amount is credited to your MID wallet in your wallet currency. No further action required. For more information on a MID wallet, refer to the merchant setup guide. | COMPLETED | ["LATE"] |
| Handle Manually | Received amount is deposited into the merchant cryptocurrency wallet. This allows you to refund these funds to customers if you choose to do so. You can also convert this cryptocurrency amount into your MID wallet using a spot FX rate on the BVNK portal. | COMPLETED | ["LATE"] |
| Auto Refund | Late payment amount is attributed to a new refund payout, auto-created for your MID wallet and linked back via relatedPayments[]. | Payin COMPLETED; refund payout PENDING_VERIFICATION | Payin ["LATE"]; payout ["REFUND"] |
{
"id": "f6032d8a-1b47-4e95-8c3f-5a9d2e7b0c14",
"reference": "myUniqueRef336-refund",
"type": "payment:payout",
"status": "PENDING_VERIFICATION",
"direction": "OUT",
"method": "CRYPTO",
"fees": {
"customerFee": null,
"processingFee": null,
"networkFee": null
},
"originator": {
"required": { "currency": "ETH", "amount": "0.13275995" },
"actual": { "currency": "ETH", "amount": "0.00" },
"walletId": "a:24092328494070:K2mPfLt:1"
},
"beneficiary": {
"required": { "currency": "ETH", "amount": "0.13275995" },
"actual": { "currency": "ETH", "amount": "0.00" },
"customerId": "d063635e-0f83-4e47-a1f3-fc9484df1509",
"customerReference": null,
"entity": {
"type": "COMPANY",
"legalName": "BVNK LTD",
"registrationNumber": "5493001KJTIIGC8Y1R12",
"relationshipType": "SELF_OWNED",
"address": { "country": "GB" }
},
"paymentInstrument": { "type": "CRYPTO_ADDRESS", "network": null, "addresses": [], "tag": null }
},
"conversion": null,
"checkout": {
"required": { "currency": "ETH", "amount": "0.13275995" },
"actual": { "currency": "ETH", "amount": "0.00" },
"redirectUrl": "https://pay.bvnk.com/refund/f6032d8a-1b47-4e95-8c3f-5a9d2e7b0c14",
"returnUrl": null
},
"instructions": [],
"trackingDetails": [],
"labels": ["REFUND"],
"relatedPayments": [
{ "type": "payment:payin", "paymentId": "9E17B5C3-04AF-4D68-A9E1-7B2C6F350D8A" }
],
"statusHistory": [
{ "status": "PENDING_VERIFICATION", "timestamp": "2026-03-14T21:13:58Z" }
],
"flow": "default",
"metadata": {},
"expiryDuration": "P3M",
"expiresAt": "2026-06-14T21:13:58Z",
"updatedAt": "2026-03-14T21:13:58Z",
"createdAt": "2026-03-14T21:13:58Z"
}
relatedPayments[] on this payout points back at 9E17B5C3-04AF-4D68-A9E1-7B2C6F350D8A, the late payin above, and the amount matches the full 0.13275995 ETH received, since the entire late payment is returned, not a partial difference.
To change your configuration or learn more, contact your Account or Integration Manager.
Late payments webhooks
To get notified when the status of a payin changes, including a late arrival, listen to the payment:v3:payin:status-change webhook and branch on data.status and data.labels[]. If Auto Refund is configured, listen to the payment:v3:payout:status-change webhook for the resulting refund payout, identified by labels: ["REFUND"].