Migrate from the Quote API to Transfers v.3
The Transfers v.3 API replaces the legacy Quote API for wallet-to-wallet currency conversion. Instead of creating a standalone quote and accepting it through a redirect flow, currency conversion is now a property of a transfer. Migrating to v.3 provides a single-field status model, digest-based acceptance that ties a client to the exact rate shown, mandatory idempotency, and the option to replace polling with a webhook. This guide walks you through the required changes for a successful migration.
The core conceptual shift is that a quote is no longer a separate entity you convert. You create a transfer, and if the two wallets hold different currencies, you attach a conversion to it.
Endpoint mapping
| Operation | Legacy Quote API | Transfers v.3 |
|---|---|---|
| Create | POST /api/v1/quote | POST /payment/v3/transfers |
| Preview (estimate) | POST /api/v1/quote?estimate=true | POST /payment/v3/transfers/dry-run |
| Accept | PUT /api/v1/quote/accept/{uuid} | POST /payment/v3/transfers/{transferId}/actions |
| Get | GET /api/v1/quote/{uuid} | GET /payment/v3/transfers/{transferId} |
The Quote API endpoints stay available for retrieving quotes created before you migrate. Query them with GET /api/v1/quote/{uuid} as before. New conversions should be created through Transfers v.3.
Flow comparison
The legacy flow was always create, accept, then poll two status fields. In v.3, the flow depends on the conversion type you choose, and status is a single field. Select a tab to compare the shapes before mapping fields.
- Legacy
- v.3 QUOTE
- v.3 AUTO_CONVERSION
- v.3 Same-currency
Create, accept, then poll paymentStatus until the conversion settles.
What changed
Create quote
The legacy request described a conversion between two wallets with a flat set of fields. In v.3, the request describes a transfer with an originator and a beneficiary, and conversion is an optional nested object. Every v.3 create also requires an Idempotency-Key header.
- Legacy Quote API
- Transfers v.3
{
"from": "EUR",
"to": "USDC",
"fromWalletLsid": "a:25052137356562:qjOSsJf:1",
"toWalletLsid": "a:24092328494070:G5i4XZ9:2",
"amountIn": 500,
"useMinimum": false,
"useMaximum": false,
"payInMethod": "wallet",
"payOutMethod": "wallet"
}
Field changes:
| Legacy field | v.3 field | Notes |
|---|---|---|
from | originator.currency | Currency to convert from. |
to | beneficiary.currency | Currency to convert to. |
fromWalletLsid | originator.walletId | Wallet being debited. |
toWalletLsid | beneficiary.walletId | Wallet being credited. |
amountIn | originator.amount | Fixed-debit. With QUOTE you may instead set beneficiary.amount for a fixed-credit conversion — specify exactly one side. |
useMinimum, useMaximum | — | Removed. |
payInMethod, payOutMethod | — | Removed. v3 transfers are always BOOK (internal wallet) transfers. |
| — | reference | Now required. A unique reference for the transfer. |
| — | conversion.type | QUOTE or AUTO_CONVERSION. Omit the whole conversion object for same-currency transfers. |
| — | conversion.validityDuration | Optional. PT20S or PT60M. Applies to QUOTE only. |
| — | metadata | Optional key-value map for your own tracking data. |
| — | Idempotency-Key header | Required. A client-generated UUID, reused on retries. |
The legacy ?estimate=true query parameter maps to a dedicated preview endpoint on v.3: POST /payment/v3/transfers/dry-run. It returns the rate, spread, fees, and converted amounts without persisting a transfer or moving funds. See Preview a transfer.
Choose a conversion type
The legacy API had one conversion path. v.3 gives you three, chosen by the conversion object:
QUOTEpresents a firm rate that your customer must accept before the transfer settles. This is the closest analogue to the legacy create-then-accept flow.AUTO_CONVERSIONexecutes immediately at the prevailing market rate with no acceptance step. Only fixed-debit is supported (setoriginator.amount).- Same-currency when both wallets hold the same currency, omit the
conversionobject entirely and the transfer settles directly.
For the full request and response shapes of each type, see Create an internal transfer (v.3).
Accept the quote
In the legacy API, acceptance was a redirect-oriented step: you sent a successUrl and the conversion executed. In v3, you accept by sending an ACCEPT_QUOTE action carrying the digest from the Create endpoint's response. The digest is a fingerprint of the quoted rate. Passing it back proves the client agreed to the specific rate shown. There is no redirect.
- Legacy Quote API
- Transfers v.3
{
"successUrl": "https://app.sandbox.bvnk.com/wallets/convert/complete/{{quote_id}}"
}
If the quote expires before you accept it, send a REFRESH_QUOTE action to obtain a new rate and digest, then accept:
{
"type": "REFRESH_QUOTE"
}
Status model
The legacy API required you to interpret two fields, quoteStatus and paymentStatus, to determine an outcome. v.3 consolidates these into a single status field, which is the source of truth.
| Outcome | Legacy quoteStatus / paymentStatus | v.3 status |
|---|---|---|
| Just created (non-quote) | PENDING / PENDING | PROCESSING |
| Awaiting quote acceptance | PENDING / PENDING | PENDING_QUOTE_ACCEPTANCE |
| Accepted, settling | ACCEPTED / PENDING | PROCESSING |
| Completed | PAYMENT_OUT_PROCESSED / SUCCESS | COMPLETED |
| Conversion failed | CONVERSION_FAILED / FAILED | FAILED |
| Pay-out failed | PAYMENT_OUT_FAILED / FAILED | FAILED |
| Pay-in failed | PAYMENT_IN_FAILED / FAILED | FAILED |
| Quote validity elapsed | — | EXPIRED |
In the legacy API, an unaccepted quote surfaced as a failure. In v.3, a transfer whose quote is not accepted in time transitions to EXPIRED, separate from FAILED. Update any logic that treats FAILED as the only non-success terminal state.
Response and conversion details
The response is now nested by participant, timestamps are ISO-8601 instead of Unix epoch, and rate and spread details live under the conversion object.
| Legacy field | v.3 field | Notes |
|---|---|---|
uuid | id | UUID identifier of the transfer. |
amountIn | originator.amount | Now nested. |
amountOut | beneficiary.amount | Now nested. |
from | originator.currency | Plain currency code in both. |
to | beneficiary.currency | Plain currency code in both. |
quoteStatus + paymentStatus | status | Merged into a single field. |
price | conversion.allInRate | Effective rate including spread. |
fee, fees | fees.processingFee | BVNK processing fee applied to the debiting wallet. |
acceptanceExpiryDate | conversion.expiresAt | When the quoted rate expires. Now ISO-8601 instead of epoch milliseconds. See the note below on the two expiry fields. |
dateCreated | createdAt | Now ISO-8601. |
lastUpdated | updatedAt | Now ISO-8601. |
payInMethod, payOutMethod | method: "BOOK" | Always BOOK for Transfers v.3. |
| — | conversion.baseRate | New. Mid-market rate before spread. |
| — | conversion.spread | New. Nested percentage, amount, and currency. |
| — | conversion.digest | New. Required to accept the quote. |
The legacy acceptanceExpiryDate maps to conversion.expiresAt, not the top-level expiresAt. v.3 exposes both:
conversion.expiresAt— when the quoted rate expires. Accept the quote before this time, or refresh it with aREFRESH_QUOTEaction.expiresAt(top level) — when the transfer itself expires. Unaccepted quote transfers expire 24 hours after creation, regardless of the shorter quote validity window.
A QUOTE create response returns the conversion details you need for acceptance:
{
"id": "d4a904e0-b040-4132-a6d3-ed7d03cdfce7",
"reference": "treasury-123",
"type": "payment:transfer",
"status": "PENDING_QUOTE_ACCEPTANCE",
"fees": {
"processingFee": {
"amount": 0.5,
"currency": "EUR"
}
},
"originator": {
"amount": 500,
"currency": "EUR",
"walletId": "a:25052137356562:qjOSsJf:1"
},
"beneficiary": {
"amount": 537.10,
"currency": "USDC",
"walletId": "a:24092328494070:G5i4XZ9:2"
},
"conversion": {
"type": "QUOTE",
"spread": {
"percentage": 0.01,
"amount": 5.00,
"currency": "EUR"
},
"allInRate": 1.0742,
"baseRate": 1.0850,
"validityDuration": "PT20S",
"expiresAt": "2025-07-30T10:30:20Z",
"digest": "a1b2c3d4e5f6"
},
"expiresAt": "2025-07-31T10:00:00Z",
"createdAt": "2025-07-30T10:00:00Z",
"updatedAt": "2025-07-30T10:00:00Z",
"method": "BOOK"
}
The conversion object is present in the response only for cross-currency transfers. Its fields, and the legacy quote fields they replace, are:
v.3 conversion field | Replaces (legacy) | Description |
|---|---|---|
conversion.type | — | QUOTE or AUTO_CONVERSION. |
conversion.allInRate | price | Effective rate including spread (originator → beneficiary). |
conversion.baseRate | — | Base market rate before spread. |
conversion.spread.percentage | — | Spread as a decimal fraction (for example, 0.01 = 1%). |
conversion.spread.amount | — | Spread amount in the spread currency. |
conversion.spread.currency | — | Currency of the spread amount. |
conversion.validityDuration | — | Quote validity window. QUOTE only. |
conversion.expiresAt | acceptanceExpiryDate | When the quoted rate expires. QUOTE only. |
conversion.digest | — | Opaque value passed back in the ACCEPT_QUOTE action. QUOTE only. |
The legacy fee field is not part of the conversion object in v.3; the BVNK processing fee is reported separately under fees.processingFee.
Notifications (new in v.3)
The legacy quote flow had no outbound webhooks. You polled GET /api/v1/quote/{uuid} for status updates.
Transfers v.3 supports the Transfer webhook (payment:v3:transfer:status-change) as an alternative to polling. Adopting it is recommended but not mandatory; polling GET /payment/v3/transfers/{transferId} remains available. The webhook fires on each status change, and its payload mirrors the GET /payment/v3/transfers/{transferId} response, including the conversion object for cross-currency transfers.
Each event carries a direction so you know which side of the transfer it concerns:
OUTis delivered to the originator of the transfer.INis delivered to the beneficiary of the transfer.
When the originator and beneficiary are the same wallet, both an IN and an OUT event are delivered.
Flow differences to be aware of
-
Idempotency is now mandatory. The legacy quote create had no idempotency mechanism. In v.3, every
POST /payment/v3/transferscall requires a uniqueIdempotency-Keyheader, a client-generated UUID. Reuse the same key when retrying after a network failure so the transfer is not created twice. -
Acceptance uses a digest, not a redirect. The legacy
PUT /api/v1/quote/accept/{uuid}took asuccessUrland completed the conversion through a redirect. In v.3, you accept by sending anACCEPT_QUOTEaction carrying thedigestfrom the create (or GET) response. Store thedigestand pass it back at acceptance time. This ties the acceptance to the exact rate the client was shown, with no redirect involved. -
Quote validity is now a fixed set of durations. The legacy API set the acceptance window server-side with no way to request a duration. In v.3, you can optionally set
conversion.validityDurationtoPT20SorPT60M. If omitted, the server applies a default. -
Fixed-credit amounts are now possible, but only with
QUOTE. The legacy quote request accepted only a fixed debit (amountIn). In v.3,AUTO_CONVERSIONremains fixed-debit only (setoriginator.amount).QUOTEadditionally lets you fix the credit side by settingbeneficiary.amountinstead. Specify exactly one side. -
The estimate mode is now a dedicated dry-run endpoint. The legacy API supported
POST /api/v1/quote?estimate=trueto get an indicative rate without persisting a quote. SendPOST /payment/v3/transfers/dry-runinstead. It takes the same request body as the create call and returns the rate, spread, fees, and converted amounts without persisting a transfer or moving funds. It works for bothQUOTEandAUTO_CONVERSIONconversions but not for same-currency transfers, where there is nothing to preview. See Preview a transfer. -
The Quote API stays available for historical records. The legacy
GET /api/v1/quote/{uuid}is not deprecated. Quotes created through the Quote API before you migrate remain retrievable through it. Transfers created via v.3 must be queried withGET /payment/v3/transfers/{transferId}instead.
Migration checklist
-
Update quote creation: replace
POST /api/v1/quotewithPOST /payment/v3/transfers.- Generate and send an
Idempotency-Keyheader on every request. - Map
from/to→originator.currency/beneficiary.currencyandfromWalletLsid/toWalletLsid→originator.walletId/beneficiary.walletId. - Move
amountIntooriginator.amount; add a requiredreference. - Add
conversion: { type: "QUOTE" }for firm-rate flows,AUTO_CONVERSIONfor immediate execution, or omitconversionfor same-currency transfers.
- Generate and send an
-
Update acceptance: replace
PUT /api/v1/quote/accept/{uuid}withPOST /payment/v3/transfers/{transferId}/actions.- Store
conversion.digestfrom the create response and send{ "type": "ACCEPT_QUOTE", "digest": "<digest>" }. - Handle expired quotes by sending a
REFRESH_QUOTEaction to get a new digest, then re-accepting.
- Store
-
Update status handling: replace the dual
quoteStatus+paymentStatuschecks with the singlestatusfield.
Treat EXPIRED as a distinct terminal state and PENDING_QUOTE_ACCEPTANCE as a non-terminal state that requires client action.
-
Update response parsing: read amounts from
originator.amount/beneficiary.amount, the rate fromconversion.allInRate, the fee fromfees.processingFee, and switch timestamp parsing from Unix epoch to ISO-8601 (createdAt,updatedAt,conversion.expiresAt). -
Update status retrieval: replace
GET /api/v1/quote/{uuid}polling withGET /payment/v3/transfers/{transferId}, and optionally subscribe to the Transfer webhook to eliminate polling. -
Keep the Quote API for historical records: continue using
GET /api/v1/quote/{uuid}to retrieve quotes created before migration.
Related guides:
- Create an internal transfer (v.3): full v.3 integration guide covering all three conversion types.