Migrate crypto payments from v.1
Payments v.3 API endpoints are in beta and may change as the product evolves.
If you already have an existing integration with /payment/v1/ endpoints, you can continue using it. There is currently no mandatory migration or sunset date for existing endpoints, allowing you to migrate each flow individually at your own pace.
BVNK is unifying its fiat and crypto payments APIs. Crypto payouts and crypto payment links (payins) move onto /payment/v3/*, using the same envelope, originator and beneficiary structure, and webhook taxonomy as fiat payments and Internal Transfers.
Version 3 introduces key changes: payments now have two named sides instead of a single wallet and direction flag, three currencies instead of one amount and display field, and a quote as a property of the payment rather than a separate resource. These changes drive most of the field mapping in this guide.
Benefits of migrating to v.3:
- One endpoint per direction:
/payment/v3/payoutsand/payment/v3/payinsreplace a single/api/v1/pay/summarythat switched behaviour on atypeflag. - A display currency you can price against: quote a payment in any currency, including ones BVNK does not settle in, and let BVNK derive the debit and the credit.
- Quotes without a second resource: create, reprice, and accept on the payment itself, instead of creating an estimate and converting it into a payment.
- Pricing you can show a customer: the rate before spread, the rate including spread, and the spread between them are disclosed separately.
- One webhook per resource: two status-change events replace a dozen v.1 event names, with
labels[]telling you why a payment is unusual. - Payments that link to each other:
relatedPayments[]ties a refund to the payin that caused it.
Migration scope
- Covered in this guide: crypto payouts and crypto payment links (payins).
- Fiat payouts: also supported on v.3, through the same
/payment/v3/payoutsendpoint. BVNK infers fiat versus crypto frombeneficiary.paymentInstrument.type. To move fiat payouts from v.2, see Migrate fiat payments from v.2. - Fiat pay-ins and refunds: not on v.3 yet. Keep using the current fiat endpoints.
- Not affected: orchestrations, wallets, and the ledger. Internal Transfers are managed through a separate v.3 endpoint and require no changes.
What changes from v.1
The concepts v.3 is built on are explained in Payments API v.3. These are the differences from v.1 that drive most of the field mapping in this guide:
- Two sides instead of a direction flag: v.1 described a payment from your point of view, with one
walletId, oneamount, and atypeofINorOUT. v.3 has an originator and a beneficiary, and direction follows from the endpoint you call.type: "IN"andtype: "OUT"are no longer accepted on requests and have no effect if included. Responses return the direction asdirection:OUTon payouts andINon payins.walletIdmoves underoriginatoron payouts and underbeneficiaryon payins. See Originator and beneficiary. - Three named currencies: v.1 only exposed the third currency through
displayCurrency. v.3 names the originator, beneficiary, and display currencies explicitly. See Currency types. - One fixed amount, against any of the three currencies: v.1 let you set the wallet amount or the paid amount, never both. v.3 applies the same rule across all three currencies. See Amounts.
- One payment instrument: v.1's
payOutDetailsand address objects are replaced bypaymentInstrument, discriminated bytype. Requests carry nomethod: BVNK infers fiat versus crypto frompaymentInstrument.typeand returnsmethodon responses. See Payment methods. - Quotes on the payment, not a separate estimate resource: see Payout lifecycle.
- Travel Rule data on
entity: v.1 sent party details incomplianceDetails.partyDetails[]. See Travel Rule. - Identifier naming: identifiers ending in
Referenceare yours, and identifiers ending inIdare issued by BVNK. This is whycustomerIdchanges meaning; see Breaking changes.
Endpoint mapping
Two v.1 endpoints currently have no v.3 equivalent: payout dry-run and address validation. Continue using these v.1 endpoints while migrating other payout flows to v.3.
Payout lifecycle
In v.1, an estimate was a resource in its own right. You created one, optionally refreshed it, accepted it, which turned it into a payment, then confirmed the payment to send it: four calls across two resources.
In v.3, what v.1 called an estimate is called a quote, and it is a property of the payout rather than a resource. POST /payment/v3/payouts is a single create whose result depends on how complete your payload is. See What you send decides what happens. A complete payout plus "confirmationRequired": true is held at PENDING_VERIFICATION and replaces the v.1 two-step payout.
There is no quote-only parameter. Withholding the destination address is what keeps a payout a quote, so including an address you happen to have will send live funds.
- v.1: estimate, accept, confirm
- v.3: quote, then accept
- v.3: one call
Two resources and up to four calls: an estimate that you refresh and accept, then a payment that you confirm.
Compared with v.1:
- An empty-body estimate update becomes a
REFRESH_QUOTEaction carrying the currentconversion.digest. - An estimate update with a new amount becomes a
PATCHon the payout, which reprices the quote. - Accepting the estimate and confirming the payment become one
ACCEPT_QUOTEaction. It requiresbeneficiary.entityorbeneficiary.contactIdto be set by then, either at create time or by aPATCH. There is no separate confirm call.
For how the digest and repricing work, see the Quote flow.
Endpoint changes
Several changes in this section are silent: existing integrations may continue to run but return incorrect values instead of errors. Start by addressing the two most critical changes.
Breaking changes
Response amounts move into objects. This is the highest-risk change in the release. Requests are unchanged: you still send a flat currency and amount, with the amount on one of originator, beneficiary, or checkout. In responses and webhooks, the paths change. v.1's amount (asked for) becomes required.amount, and v.1's actual (paid) becomes actual.amount, on originator, beneficiary, and checkout. Fees follow the same pattern with estimated and actual. The meaning is the same as in v.1, so code still reading a flat amount from a response finds nothing rather than the wrong figure. Review all uses of amount and actual before migrating any flows. See Amounts.
customerId changes owner. In v.1, customerId was your own reference for the customer. In v.3, customerId is issued by BVNK, and your reference becomes customerReference, following the Reference and Id rule above. Both fields are now at the top level of originator and beneficiary. If your v.1 value was a BVNK-issued ID, it remains unchanged. If it was your own reference, move it to customerReference.
Conventions and fields
| Change | v.1 | v.3 | Notes |
|---|---|---|---|
| Money amounts | Numbers, for example 9.99 | Strings, for example "9.99" | Applies to all monetary fields. |
| Amount objects | Per-currency objects with amount (asked for) and actual (paid): walletCurrency, paidCurrency, and displayCurrency. Fees as feeCurrency and networkFeeCurrency | Requests: unchanged, a flat currency and amount, with an amount on one of originator, beneficiary, or checkout.Responses and webhooks: originator, beneficiary, and checkout carry required and actual; processingFee and networkFee carry estimated and actual, and a payout's customerFee carries required and actual. Each is a {currency, amount} object. conversion.spread, trackingDetails[], and instructions[] keep a plain currency and amount | required or estimated, and actual, are always present, even when they match. On payouts, actual has an amount of 0.00 until money moves: originator.actual shows the debit once the quote is accepted, and the rest fill in at COMPLETED. On payins, actual is null until PROCESSING. If the customer sends the wrong coin, required shows the coin asked for and actual the coin that arrived. |
| Timestamps | Epoch milliseconds, for example 1741985003161 | ISO 8601, for example "2026-05-05T12:30:00Z" | Applies to all timestamp fields. |
| Direction | type: "IN" or "OUT" on the request | Inferred from the endpoint namespace. Responses return direction: OUT on payouts, IN on payins | Not accepted on requests. Ignored if included. |
amount and actual | amount is what you asked for,actual is what was paid | required.amount is what you asked for,actual.amount is what was paid | Highest-risk change in the release. The meaning is the same as in v.1, but the paths change: v.1 amount becomes required.amount, and v.1 actual becomes actual.amount. Code still reading a flat amount from a response finds nothing rather than the wrong figure. Requests are unchanged. See Breaking changes. |
customerId | Your own reference to your customer | The ID of a customer that BVNK issues. | Your own free-text reference becomes customerReference. See Breaking changes. |
complianceDetails wrapper | Top level on requests, with a partyDetails[] array | Removed. Its contents move onto beneficiary on a payout and originator on a payin | entityType becomes entity.type, countryCode becomes entity.address.country, and requesterIpAddress is retained on the party. |
| Travel Rule fields (EU crypto) | — | On entity: placeOfBirth (Pair A), or identification[] plus a full address (Pair B), for INDIVIDUAL originators; legalEntityIdentifier (optional) for COMPANY entities | INDIVIDUAL payin originators must send exactly one pair. COMPANY originators need a full address. Responses echo what was sent. |
| Statuses | A small enum shared by both directions | Expanded and normalised per direction | See the Status reference. |
| Network and protocol | Both protocol (token standards such as ERC20) and network (chain names such as ETHEREUM) are supported | network only, chain names only | If you already send network, the value is unchanged. Remap the values if you still send protocol. See Network and protocol values. |
| Payment instruments | An address object with an alternatives[] array | paymentInstrument: write-only on requests, read-only on responses | A request sets beneficiary.paymentInstrument.address, a single destination. A response returns beneficiary.paymentInstrument.addresses[] holding that one address, and originator.paymentInstrument.addresses[] holding one entry per address a payin customer sent from, across late or partial payments. The asymmetry is deliberate. On payins, the accepted networks appear as instructions[] instead, one entry per network, with no primary/alternative split. |
| Durations | expiryMinutes: 30 | expiryDuration: "PT30M" | Changed to ISO 8601 duration format. |
| Exchange rates | exchangeRate.{base, counter, rate}, plus a reciprocal displayRate | A conversion block: - baseRate before spread- allInRate including spread- conversion.spread: spread itself | v.1's single rate maps to allInRate, not baseRate. checkout.displayRate is a separate leg: the display-currency conversion, not the inverse of allInRate. |
| Quote validity | quoteExpiryDate and acceptanceExpiryDate, with a short acceptance window | One window: conversion.validityDuration (for example "PT60M") and conversion.expiresAt | Expired quotes are rejected. There is no separate price lock. |
| Pegged payments | A top-level pegged boolean | conversion.pegAdjustment object | Detect pegging by checking for the object. Omitted on non-pegged payments. |
| On-chain transactions | transactions[] | trackingDetails[], one entry per on-chain transaction, with hash, amount, currency, network, and confirmedAt | Sending addresses move to originator.paymentInstrument.addresses[]. |
| Status history | Not available | statusHistory[], an array of {status, timestamp} on every payment resource | New field. No migration required. |
metadata | Available | Free-form object, unchanged | No migration required. Use customerReference for customer references instead of metadata. |
labels[]relatedPayments[] | Not available | New. Classify why a payment exists, and link it to its counterpart | See Exceptions, refunds, and linked payments. |
Hawk authentication and the Idempotency-Key header function the same way in v.3 as in v.1. No authentication changes are required.
Network and protocol values
If you currently send protocol (a token standard) rather than network (a chain name), map the values before switching:
v.1 protocol | v.3 network |
|---|---|
ERC20 | ETHEREUM |
TRC20 | TRON |
BEP20 | BSC |
POLYGON | POLYGON |
SOL | SOLANA |
BTC | BITCOIN |
LTC | LITECOIN |
XRP | XRP |
ARBITRUM | ARBITRUM |
BASE | BASE |
Exceptions, refunds, and linked payments
v.1 gave a payment that went wrong its own webhook name. v.3 records it on the ordinary payment resource instead, with two fields that have no v.1 equivalent: labels[] classifies the payment, and relatedPayments[] links it to its counterpart, for example a refund payout to the overpaid payin it corrects. The event name no longer tells you what happened; the labels do. See Labels and related payments, and Consolidate webhooks for the event mapping.
Status reference
v.3 has more statuses than v.1, but several are conditional and only occur if the corresponding step applies, so not all of them appear in every flow. For what each v.3 status means and the payout status diagram, see Payment statuses.
| v.1 status | v.3 status | Notes |
|---|---|---|
PENDING (payout) | PROCESSING | A payout sent in one call starts at PROCESSING, or at PENDING_APPROVAL if your account requires approvals. |
PENDING (payment link) | PENDING_CUSTOMER_PAYMENT | The payin is priced and waiting for your customer to send funds. |
PENDING with quoteStatus: "TEMPLATE" | PENDING_QUOTE_ACCEPTANCE | An unpriced payout, or a quote not yet accepted. v.3 has no quoteStatus. |
PENDING with quoteStatus: "ACCEPTED" on a two-step payout | PENDING_VERIFICATION | Fully priced, quote accepted, and held until your customer confirms it. |
PROCESSING | PROCESSING | Unchanged. |
COMPLETE | COMPLETED | Renamed. |
UNDERPAID | COMPLETED with labels: ["UNDER"] | An underpayment is a label, not a status. |
EXPIRED | EXPIRED | Unchanged. A payin that arrives late and still completes moves on to PROCESSING and COMPLETED, with labels: ["LATE"]. |
Migrate payouts
The simplest payout is the one where you already know the destination: a beneficiary currency, a crypto address, and an amount in exactly one of the three currencies. In v.1 that was one call to a shared endpoint with type: "OUT"; in v.3 it is one call to a dedicated one, with the merchant's details on originator and the payee's on beneficiary.
- v.1
- v.3
{
"walletId": "a:25022613287255:zmHs0pg:1",
"type": "OUT",
"amount": 10,
"currency": "USD",
"expiryMinutes": 30,
"reference": "REF46730",
"returnUrl": "https://merchant.com/status",
"payOutDetails": {
"code": "crypto",
"currency": "USDT",
"network": "ETHEREUM",
"address": "0x02ae6765C6991813a3EAa86fe63ebBCA1c9EC156",
"tag": ""
},
"customerId": "9420b652-c6e9",
"complianceDetails": {
"requesterIpAddress": "77.71.188.87",
"partyDetails": [
{
"type": "BENEFICIARY",
"entityType": "INDIVIDUAL",
"firstName": "John",
"lastName": "Mirra",
"dateOfBirth": "1984-06-30",
"relationshipType": "THIRD_PARTY",
"countryCode": "DE"
}
]
}
}
Request field changes:
- With details
- Without details
- Quote flow
- Pending verification
All destination fields are present. The payout is created and sent in one call.
| v.1 field | v.3 field | Notes |
|---|---|---|
walletId | originator.walletId | Moved into the originator block. The wallet determines originator.currency automatically. |
type: "OUT" | ❌ | Direction is inferred from the /payouts namespace. |
amount: number | originator.amount, beneficiary.amount, or checkout.amount: "string" | The display amount. Set exactly one anchor. Numbers become strings. |
currency | checkout.currency | The display currency. Can be a currency BVNK does not settle in. |
displayCurrency | checkout.currency | Maps to the same field as currency. v.1 provided two ways to set the display currency. |
expiryMinutes: 30 | expiryDuration: "PT30M" | Changed to ISO 8601 duration format. |
reference | reference | Unchanged. |
returnUrl | checkout.returnUrl | Destination for a customer hosted page, if used. |
payOutDetails.code: "crypto" | beneficiary.paymentInstrument.type: "CRYPTO_ADDRESS" | Replaced by the payment instrument type. Required because v.3 uses the same endpoint for fiat and crypto and has no request-side method: fiat versus crypto is inferred from this field. method is returned on responses. |
payOutDetails.currency | beneficiary.currency | The currency the payee receives. |
payOutDetails.network or payOutDetails.protocol | beneficiary.paymentInstrument.network | v.1 accepted both network and protocol. If you send network, the value is unchanged. If you send protocol, map it first. See Network and protocol values. |
payOutDetails.address | beneficiary.paymentInstrument.address | Moved. One destination per payout. Write-only: responses return it in addresses[]. |
payOutDetails.tag | beneficiary.paymentInstrument.tag | Moved. v.1 used "" for absent; v.3 uses null. |
customerId | beneficiary.customerId | The BVNK-issued ID of the customer. Your own references become beneficiary.customerReference. See Breaking changes. |
embeddedCustomerDetails.reference | ❌ | Identifies the intermediary wallet for flow: "EMBEDDED_CRYPTO" payout routing, a different concept from the beneficiary. No v.3 equivalent yet. |
contactId | beneficiary.contactId | References a stored contact instead of an inline entity. Send contactId or entity, not both. |
complianceDetails (wrapper) | ❌ | Removed. Contents moved onto beneficiary. |
complianceDetails.requesterIpAddress | beneficiary.requesterIpAddress | Moved onto the beneficiary. |
partyDetails[].type: "BENEFICIARY" | ❌ | Redundant. Position under beneficiary conveys the meaning. |
partyDetails[].entityType | beneficiary.entity.type | Renamed. Values are still INDIVIDUAL or COMPANY. |
...firstNamelastNamedateOfBirth | beneficiary.entity.firstName.lastName.dateOfBirth | Moved. INDIVIDUAL entities only. |
...legalNameregistrationNumber | beneficiary.entity.legalName.registrationNumber | Moved. COMPANY entities only. |
...relationshipType | beneficiary.entity.relationshipType | Moved. Values unchanged (SELF_OWNED, THIRD_PARTY). Affects fee pricing on crypto. |
...countryCode: "DE" | beneficiary.entity.address.country: "DE" | Renamed and nested deeper. v.3 accepts a full structured address; v.1 accepted a country code only. |
| ❌ | fees.customerFee | New. Optional. Your markup, deducted from the payee's amount. |
Response field changes:
- With details
- Without details
- Quote flow
- Pending verification
| v.1 field | v.3 field | Notes |
|---|---|---|
uuid | id | Renamed. Still a UUID. |
merchantId | ❌ | Derived from auth context. |
merchantDisplayName | ❌ | Replaced by the wallet name. |
walletId | originator.walletId | Moved into the originator block. |
type: "OUT" | type: "payment:payout"direction: "OUT" | Split into two fields. type is now a resource discriminator; direction carries what v.1's type conveyed. |
subType: "merchantPayOut" | ❌ | Covered by type. |
| ❌ | method: "CRYPTO" | Returned on responses and webhooks, so you can query and filter payments by payment method. Inferred from beneficiary.paymentInstrument.type; not sent on requests. |
status: "PENDING" | status: "PROCESSING" | Status values changed. On payment links, PENDING becomes PENDING_CUSTOMER_PAYMENT. See Status reference. |
dateCreated (epoch ms) | createdAt (ISO 8601) | Renamed and reformatted. |
lastUpdated (epoch ms) | updatedAt (ISO 8601) | Renamed and reformatted. |
expiryDate (epoch ms) | expiresAt (ISO 8601) | Renamed and reformatted. |
quoteExpiryDate | conversion.expiresAt | BVNK accepts the quote at creation on this flow. conversion.expiresAt matches the payout's own expiresAt. |
acceptanceExpiryDate | ❌ | No separate acceptance window. The rate is locked for conversion.priceLockDuration, 30 seconds, until conversion.priceLockExpiresAt. |
quoteStatus: "ACCEPTED" | ❌ | Inferred from status. |
displayCurrency.currency | checkout.required.currencycheckout.actual.currency | The display currency, now inside each amount object. |
displayCurrency.amount (asked for) | checkout.required.amount | Same meaning as v.1, new path. |
displayCurrency.actual (paid) | checkout.actual.amount | Same meaning as v.1, new path. checkout.actual has an amount of 0.00 until the payout reaches COMPLETED. |
walletCurrency.{currency, amount, actual} | originator.required.{currency, amount}originator.actual.{currency, amount} | Moved into the originator block. Numbers to strings. v.1 amount becomes required.amount, and v.1 actual becomes actual.amount. Fees are not folded in; see totalWalletAmount below. |
paidCurrency.{currency, amount, actual} | beneficiary.required.{currency, amount}beneficiary.actual.{currency, amount} | Moved into the beneficiary block, with the same path change. v.1's "paid" prefix referred to the payee. |
feeCurrency.{currency, amount} | fees.processingFee.estimated.{currency, amount}fees.processingFee.actual.{currency, amount} | Moved into the fees block, as quoted and as charged. Charged as a separate wallet line item. |
networkFeeCurrency.{currency, amount} | fees.networkFee.estimated.{currency, amount}fees.networkFee.actual.{currency, amount} | Moved into the fees block, as quoted and as charged. Charged as a separate wallet line item. |
totalWalletAmount | ❌ | Calculate by summing originator.actual.amount and each fee's actual.amount, or required and estimated before the payout completes. v.1 excluded the network fee. |
| ❌ | fees.customerFee | New. Echoed from the request as required, with actual at 0.00 until it's deducted; null when not supplied. Deducted from the payee's amount. |
displayRate.{base, counter, rate} | checkout.displayRate.{from, to, rate} | Restructured with named fields. This is the display-currency leg, not the inverse of allInRate. |
exchangeRate.{base, counter, rate} | conversion.baseRateconversion.allInRate | v.1's single rate maps to allInRate. baseRate is new: the rate before spread. |
| ❌ | conversion.spread.{percentage, amount, currency} | New. Spread disclosed separately rather than embedded in the rate. |
| ❌ | conversion.type: "QUOTE" | New. QUOTE on this flow too: BVNK accepts the quote for you and sends the payout immediately. |
| ❌ | conversion.pegAdjustment.* | New. Present on pegged payments only. Omitted otherwise. |
address.{address, tag, network} | beneficiary.paymentInstrument.{addresses[], tag, network} | Moved onto the beneficiary's payment instrument. addresses[] holds the one destination. |
address.uri | ❌ | Payment URIs apply to collections, not payouts. |
address.alternatives[] | ❌ | Payouts target a single network and address. |
returnUrl | checkout.returnUrl | Moved into the checkout block. |
redirectUrl | checkout.redirectUrl | Moved into the checkout block. Always points to the BVNK hosted payments page. |
transactions[] | trackingDetails[] | Renamed. One entry per on-chain transaction. Populated as the payout settles. |
transactions[].dateCreated.dateConfirmed | trackingDetails[].createdAt.confirmedAt | Renamed and reformatted. |
transactions[].hash | trackingDetails[].hash | Unchanged. |
transactions[].amount | trackingDetails[].amount | Numbers to strings. |
| ❌ | trackingDetails[].network.currency | New fields on each entry. |
transactions[].networkFeeCurrency.networkFeeAmount | ❌ | Only the aggregate fees.networkFee is returned, not per transaction. v.1 reported native gas units per transaction; v.3 reports the wallet currency. |
transactions[].risk.displayRate.exchangeRate | ❌ | Rates are no longer duplicated per transaction. |
transactions[].sources[] | ❌ | Payin-only. On payins, these surface as originator.paymentInstrument.addresses[]. |
refundrefunds[] | ❌ | Replaced by labels: ["REFUND"] and relatedPayments[] on the payout. See Exceptions, refunds, and linked payments. |
currencyOptions[] | checkout.currencyOptions[] | Empty on this flow. The currency is already selected. |
| ❌ | statusHistory[] | New. Array of {status, timestamp} on the payment resource. |
| ❌ | labels[]relatedPayments[] | New. Populated when this payout relates to another payment, for example a refund. See Exceptions, refunds, and linked payments. |
For worked examples of each flow, see Send stablecoin payouts.
Migrate payins
On a payin the sides are reversed. Your customer is the originator, who pays, and you are the beneficiary, whose wallet is credited. Everything that described the paying side in v.1, including the pay-in currency, the network, and the compliance party, moves to originator, and walletId moves to beneficiary.
- v.1
- v.3
{
"walletId": "a:24092328494070:G5i4XZ9:1",
"type": "IN",
"amount": 223.05,
"currency": "EUR",
"expiryMinutes": 20,
"reference": "myUniqueRef333",
"returnUrl": "https://my-shop.com/payment-complete?ref=xyz",
"payInDetails": {
"currency": "ETH",
"network": "ETHEREUM"
},
"customerId": "d063635e-0f83-4e47-a1f3-fc9484df1509",
"complianceDetails": {
"requesterIpAddress": "172.16.254.1",
"partyDetails": [
{
"type": "BENEFICIARY",
"entityType": "COMPANY",
"legalName": "BVNK LTD",
"registrationNumber": "5493001KJTIIGC8Y1R12",
"relationshipType": "SELF_OWNED",
"countryCode": "GB"
}
]
}
}
Request field changes:
| v.1 field | v.3 field | Notes |
|---|---|---|
walletId | beneficiary.walletId | Moved to the beneficiary. On a payin, you are the party being credited. |
payInDetails.currency.network | originator.currencyoriginator.paymentInstrument.network | Moved to the originator. Your customer pays, so their currency belongs on their side. |
customerId | originator.customerId | Moved to the originator. Your own references become originator.customerReference. |
partyDetails[].type: "BENEFICIARY" | Removed | v.1 labelled the paying customer as BENEFICIARY. v.3 places them under originator, making the type redundant. |
currencyOptions[] | checkout.currencyOptions[] | Optional. Restricts which currencies your customer can pay in. Omit to allow all supported currencies. |
Response field changes:
| v.1 field | v.3 field | Notes |
|---|---|---|
type: "IN" | type: "payment:payin"direction: "IN" | Split into two fields. type is now a resource discriminator; direction carries what v.1's type conveyed. |
status: "PENDING" | status: "PENDING_CUSTOMER_PAYMENT" | v.1's PENDING becomes an explicit state: created, priced, and awaiting customer payment. |
address.{address, tag, network, uri} | instructions[0].{address, tag, network, uri} | Changed from a single object to an array, one entry per accepted network. |
address.alternatives[] | Additional instructions[] entries | Each accepted network is a flat entry. No primary/alternative distinction. |
transactions[] | trackingDetails[] | Renamed. One entry per on-chain transaction with hash, amount, network, and confirmation time. |
transactions[].sources[] | originator.paymentInstrument.addresses[] | Moved from the transaction onto the originator. An array because a customer may send from multiple addresses, across late or partial payments. |
| — | labels[] | New. Values: OVER, UNDER, or LATE when the payment did not arrive as expected. Empty on exact, on-time payments. |
For the full request and response details, see Receive stablecoins via Payment Links. For how labels[] replaces the separate overpayment, underpayment, and late-payment webhooks, see Handle exception scenarios.
Consolidate webhooks
v.1 assigned a unique event name to almost every notable moment, requiring payin integrations to subscribe to detection, confirmation, settlement, lateness, and holds separately. v.3 uses one status-change event per resource. The status field indicates what happened, and labels[] explains any unusual circumstances. Most handlers now branch on these two fields instead of switching on the event name.
| v.1 event | v.3 event | Notes |
|---|---|---|
bvnk:payment:crypto:status-change (OUT) | payment:v3:payout:status-change | All payout transitions use one event. Read the progression from statusHistory[]. |
bvnk:payment:crypto:refund-initiated | payment:v3:payout:status-change | Refunds are payouts with labels: ["REFUND"] and relatedPayments[] linking to the originating payin. |
bvnk:payment:crypto:status-change (IN)bvnk:payment:crypto:transaction-detectedbvnk:payment:crypto:transaction-confirmed | payment:v3:payin:status-change | Detection, confirmation, and completion are one event at different status values. |
bvnk:payment:crypto:transaction-late | payment:v3:payin:status-change | Detected via labels: ["LATE"], not a distinct event. statusHistory[] shows EXPIRED, then PROCESSING, then COMPLETED. |
bvnk:payment:crypto:status-change (status UNDERPAID), and the overpayment status change | payment:v3:payin:status-change | Detected via labels: ["UNDER"] or labels: ["OVER"]. |
bvnk:payment:crypto:transaction-on-hold | payment:v3:payin:status-change | Indicated by status: "HELD", not a distinct event. |
bvnk:payment:crypto:conversion-completed | payment:v3:payin:conversion-completed | Provisional. May change before release. |
bvnk:payment:crypto:transaction-settled | — | No direct equivalent. Covered by payment:v3:payin:status-change with status: "COMPLETED". |
Full event schemas and example payloads live in the Payments v.3 webhooks reference.
Migration checklist
Follow these steps in order. The first two steps help prevent data inconsistencies. You can complete the remaining steps for each flow individually.
- Audit the two breaking changes. Find every place your integration reads
amountoractualfrom a payment response or webhook, and readrequired.amountoractual.amountinstead. The meaning is unchanged, and your request code needs no change. Find every place you write or readcustomerIdand move your own references tocustomerReference. - Update money and timestamp parsing. Every amount is now a string, and every timestamp is ISO 8601 rather than epoch milliseconds.
- Update payout creation. Replace
POST /api/v1/pay/summary(type: "OUT") withPOST /payment/v3/payouts.- Move
walletIdintooriginator.walletId, and the destination details intobeneficiary. - Remove
methodfrom the request. Send the destination asbeneficiary.paymentInstrumentwithtype: "CRYPTO_ADDRESS", and read it back frombeneficiary.paymentInstrument.addresses[]on responses. - Choose exactly one amount anchor:
originator.amount,beneficiary.amount, orcheckout.amount. - Flatten
complianceDetails.partyDetails[]intobeneficiary.entity, or reference a stored contact withbeneficiary.contactId.
- Move
- Replace the estimate flow. There is no estimate resource. Create an incomplete payout to get a quote,
PATCHit to add details, and send anACCEPT_QUOTEaction to release it. Use aREFRESH_QUOTEaction carrying the currentconversion.digestin place of an empty-body estimate update. - Update two-step payouts. Replace
"twoStep": "true"with"confirmationRequired": true, and replacePUT /api/v1/pay/{uuid}/confirm/summarywith anACCEPT_QUOTEaction onPOST /payment/v3/payouts/{id}/action. - Update payin creation. Replace
POST /api/v1/pay/summary(type: "IN") withPOST /payment/v3/payins. MovewalletIdtobeneficiary.walletIdand everything describing the paying customer tooriginator. Read deposit instructions frominstructions[]rather thanaddressandalternatives[]. - Consolidate your webhook handlers. Subscribe to
payment:v3:payout:status-changeandpayment:v3:payin:status-changein place of the individual v.1 events, and branch onstatusandlabels[]instead of the event name. - Leave your v.1 integration in place until each flow is cut over. Nothing is being switched off, so v.1 traffic keeps working while you migrate.
Related guides:
- Payments API v.3: the concepts, payment methods, and statuses v.3 is built on.
- Send stablecoin payouts: the full v.3 payout sequence, including the quote flows and pending verification.
- Receive stablecoins via Payment Links: the full v.3 payin integration.
- Handle exception scenarios: how
labels[]andrelatedPayments[]replace the v.1 overpayment, underpayment, late-payment, and refund webhooks.