Skip to main content

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

OperationLegacy Quote APITransfers v.3
CreatePOST /api/v1/quotePOST /payment/v3/transfers
Preview (estimate)POST /api/v1/quote?estimate=truePOST /payment/v3/transfers/dry-run
AcceptPUT /api/v1/quote/accept/{uuid}POST /payment/v3/transfers/{transferId}/actions
GetGET /api/v1/quote/{uuid}GET /payment/v3/transfers/{transferId}
Historical quotes remain accessible

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.

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.

Request: POST /api/v1/quote
{
"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 fieldv.3 fieldNotes
fromoriginator.currencyCurrency to convert from.
tobeneficiary.currencyCurrency to convert to.
fromWalletLsidoriginator.walletIdWallet being debited.
toWalletLsidbeneficiary.walletIdWallet being credited.
amountInoriginator.amountFixed-debit. With QUOTE you may instead set beneficiary.amount for a fixed-credit conversion — specify exactly one side.
useMinimum, useMaximumRemoved.
payInMethod, payOutMethodRemoved. v3 transfers are always BOOK (internal wallet) transfers.
referenceNow required. A unique reference for the transfer.
conversion.typeQUOTE or AUTO_CONVERSION. Omit the whole conversion object for same-currency transfers.
conversion.validityDurationOptional. PT20S or PT60M. Applies to QUOTE only.
metadataOptional key-value map for your own tracking data.
Idempotency-Key headerRequired. A client-generated UUID, reused on retries.
Estimate mode on v.3

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:

  • QUOTE presents 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_CONVERSION executes immediately at the prevailing market rate with no acceptance step. Only fixed-debit is supported (set originator.amount).
  • Same-currency when both wallets hold the same currency, omit the conversion object 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.

Request: PUT /api/v1/quote/accept/{uuid}
{
"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:

Request: POST /payment/v3/transfers/{transferId}/actions
{
"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.

OutcomeLegacy quoteStatus / paymentStatusv.3 status
Just created (non-quote)PENDING / PENDINGPROCESSING
Awaiting quote acceptancePENDING / PENDINGPENDING_QUOTE_ACCEPTANCE
Accepted, settlingACCEPTED / PENDINGPROCESSING
CompletedPAYMENT_OUT_PROCESSED / SUCCESSCOMPLETED
Conversion failedCONVERSION_FAILED / FAILEDFAILED
Pay-out failedPAYMENT_OUT_FAILED / FAILEDFAILED
Pay-in failedPAYMENT_IN_FAILED / FAILEDFAILED
Quote validity elapsedEXPIRED
EXPIRED is a distinct terminal state

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 fieldv.3 fieldNotes
uuididUUID identifier of the transfer.
amountInoriginator.amountNow nested.
amountOutbeneficiary.amountNow nested.
fromoriginator.currencyPlain currency code in both.
tobeneficiary.currencyPlain currency code in both.
quoteStatus + paymentStatusstatusMerged into a single field.
priceconversion.allInRateEffective rate including spread.
fee, feesfees.processingFeeBVNK processing fee applied to the debiting wallet.
acceptanceExpiryDateconversion.expiresAtWhen the quoted rate expires. Now ISO-8601 instead of epoch milliseconds. See the note below on the two expiry fields.
dateCreatedcreatedAtNow ISO-8601.
lastUpdatedupdatedAtNow ISO-8601.
payInMethod, payOutMethodmethod: "BOOK"Always BOOK for Transfers v.3.
conversion.baseRateNew. Mid-market rate before spread.
conversion.spreadNew. Nested percentage, amount, and currency.
conversion.digestNew. Required to accept the quote.
Two expiry fields in v.3

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 a REFRESH_QUOTE action.
  • 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:

Response: POST /payment/v3/transfers
{
"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 fieldReplaces (legacy)Description
conversion.typeQUOTE or AUTO_CONVERSION.
conversion.allInRatepriceEffective rate including spread (originator → beneficiary).
conversion.baseRateBase market rate before spread.
conversion.spread.percentageSpread as a decimal fraction (for example, 0.01 = 1%).
conversion.spread.amountSpread amount in the spread currency.
conversion.spread.currencyCurrency of the spread amount.
conversion.validityDurationQuote validity window. QUOTE only.
conversion.expiresAtacceptanceExpiryDateWhen the quoted rate expires. QUOTE only.
conversion.digestOpaque 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:

  • OUT is delivered to the originator of the transfer.
  • IN is 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

  1. Idempotency is now mandatory. The legacy quote create had no idempotency mechanism. In v.3, every POST /payment/v3/transfers call requires a unique Idempotency-Key header, a client-generated UUID. Reuse the same key when retrying after a network failure so the transfer is not created twice.

  2. Acceptance uses a digest, not a redirect. The legacy PUT /api/v1/quote/accept/{uuid} took a successUrl and completed the conversion through a redirect. In v.3, you accept by sending an ACCEPT_QUOTE action carrying the digest from the create (or GET) response. Store the digest and pass it back at acceptance time. This ties the acceptance to the exact rate the client was shown, with no redirect involved.

  3. 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.validityDuration to PT20S or PT60M. If omitted, the server applies a default.

  4. Fixed-credit amounts are now possible, but only with QUOTE. The legacy quote request accepted only a fixed debit (amountIn). In v.3, AUTO_CONVERSION remains fixed-debit only (set originator.amount). QUOTE additionally lets you fix the credit side by setting beneficiary.amount instead. Specify exactly one side.

  5. The estimate mode is now a dedicated dry-run endpoint. The legacy API supported POST /api/v1/quote?estimate=true to get an indicative rate without persisting a quote. Send POST /payment/v3/transfers/dry-run instead. 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 both QUOTE and AUTO_CONVERSION conversions but not for same-currency transfers, where there is nothing to preview. See Preview a transfer.

  6. 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 with GET /payment/v3/transfers/{transferId} instead.

Migration checklist

  1. Update quote creation: replace POST /api/v1/quote with POST /payment/v3/transfers.

    • Generate and send an Idempotency-Key header on every request.
    • Map from/tooriginator.currency/beneficiary.currency and fromWalletLsid/toWalletLsidoriginator.walletId/beneficiary.walletId.
    • Move amountIn to originator.amount; add a required reference.
    • Add conversion: { type: "QUOTE" } for firm-rate flows, AUTO_CONVERSION for immediate execution, or omit conversion for same-currency transfers.
  2. Update acceptance: replace PUT /api/v1/quote/accept/{uuid} with POST /payment/v3/transfers/{transferId}/actions.

    • Store conversion.digest from the create response and send { "type": "ACCEPT_QUOTE", "digest": "<digest>" }.
    • Handle expired quotes by sending a REFRESH_QUOTE action to get a new digest, then re-accepting.
  3. Update status handling: replace the dual quoteStatus + paymentStatus checks with the single status field.

Treat EXPIRED as a distinct terminal state and PENDING_QUOTE_ACCEPTANCE as a non-terminal state that requires client action.

  1. Update response parsing: read amounts from originator.amount / beneficiary.amount, the rate from conversion.allInRate, the fee from fees.processingFee, and switch timestamp parsing from Unix epoch to ISO-8601 (createdAt, updatedAt, conversion.expiresAt).

  2. Update status retrieval: replace GET /api/v1/quote/{uuid} polling with GET /payment/v3/transfers/{transferId}, and optionally subscribe to the Transfer webhook to eliminate polling.

  3. Keep the Quote API for historical records: continue using GET /api/v1/quote/{uuid} to retrieve quotes created before migration.


Related guides:

Was this page helpful?