> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sakawave.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Cross-currency transfers

> How a quote binds to a transfer, what has to line up, and the order of operations that works.

This is the transfers-specific integration guide. Converting currency adds one object and about six ways to get it wrong, all of which are avoidable by doing things in the right order.

## The rule

**If `sourceCurrency` and `destinationCurrency` differ, `fxQuoteId` is required.** There is no implicit conversion and no spot rate — omit the quote and you get `409 fx_quote_required`.

## Order of operations

```mermaid theme={null}
flowchart LR
    C["Check both currencies<br/>fxEnabled"] --> D["Resolve destination"]
    D --> Q["POST /v1/fx-quotes<br/>binds to the destination"]
    Q --> P["POST /v1/transfers/preview<br/>with fxQuoteId"]
    P --> T["POST /v1/transfers<br/>same fxQuoteId"]
```

The destination comes **before** the quote, because a quote is bound to a destination. Resolving a new destination invalidates the quote you already hold.

## What a quote binds to

Five things are fixed at quote time. Change any of them and the transfer is refused.

| Bound | Mismatch gives |
| - | - |
| `sourceAccountId` | `fx_quote_mismatch` |
| `destinationId` | `fx_quote_mismatch` |
| `sourceCurrency` | `fx_quote_mismatch` |
| `destinationCurrency` | `fx_quote_mismatch` |
| `amountSide` | `fx_quote_mismatch` |
| The amount on that side | `fx_quote_amount_mismatch` |

So a quote is not "a rate for NGN→USD". It is "this transfer, at this rate, once".

## Fixing either side

`amountSide` decides which amount you control and which the rate determines.

```jsonc theme={null}
// "Send exactly ₦25,000, whatever that becomes."
{ "amountSide": "source", "sourceAmount": "2500000" }

// "Deliver exactly $15.63, whatever that costs."
{ "amountSide": "destination", "destinationAmount": "1563" }
```

Send only the amount matching `amountSide`. Sending both, or the wrong one, is rejected — at the quote as a validation failure, at the transfer as `transfer_amount_invalid`.

Paying an invoice in the recipient's currency is almost always `destination`. Sweeping a fixed sum out of your own balance is `source`.

## Expiry comes from the provider

`expiresAt` is set by the FX provider, not by a fixed Sakawave window. Read it; do not assume a duration.

```ts theme={null}
const quote = await post('/v1/fx-quotes', { /* … */ });
const msLeft = Date.parse(quote.expiresAt) - Date.now();

if (msLeft < 30_000) {
  // Too little runway for a user to read a confirmation screen and click.
  return requote();
}
```

A quote is also single-use: `used` becomes true when a transfer spends it. Expired and spent both surface as `409 fx_quote_unavailable`, so the two are indistinguishable — which is another reason not to hold a quote across a user decision.

## Both currencies must allow FX

Before quoting, three things must be true, and only two of them are checkable in advance:

1. `fxEnabled` on the source currency.
2. `fxEnabled` on the destination currency.
3. An active FX corridor for that **ordered** pair.

The third is not exposed by any endpoint. NGN → USD being wired does not imply USD → NGN. You can precheck the first two from [`GET /v1/currencies`](/api-reference/currencies-and-countries/list-currencies); the corridor only reveals itself as `409 fx_corridor_unavailable`.

```ts theme={null}
const currencies = await cachedCurrencies();
const source = currencies.find((c) => c.code === from);
const destination = currencies.find((c) => c.code === to);

if (!source?.fxEnabled || !destination?.fxEnabled) throw new ConversionUnavailable();
// The corridor itself can still refuse. Handle fx_corridor_unavailable anyway.
```

## Rounding

`fx_quote_amount_too_small` means the converted amount rounds to nothing on the destination side. It is easy to hit when the destination currency has fewer minor units than the source — ₦ to UGX, say, where UGX has none at all.

Check the destination currency's `minorUnits` before offering an amount, and set a sensible floor in your own UI rather than letting the provider refuse it.

## A complete conversion

```bash theme={null}
# 1. Destination first — the quote binds to it.
DESTINATION_ID=$(curl --silent --request POST \
  --url "$SAKAWAVE_BASE_URL/v1/transfer-destinations/recipients/$RECIPIENT_ID" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
  --header "Content-Type: application/json" --data '{}' | jq --raw-output '.id')

# 2. Quote. Minor units of the side you are fixing.
QUOTE=$(curl --silent --request POST \
  --url "$SAKAWAVE_BASE_URL/v1/fx-quotes" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{
    \"sourceAccountId\": \"$SOURCE_ACCOUNT_ID\",
    \"destinationId\": \"$DESTINATION_ID\",
    \"sourceCurrency\": \"NGN\",
    \"destinationCurrency\": \"USD\",
    \"amountSide\": \"source\",
    \"sourceAmount\": \"2500000\"
  }")

QUOTE_ID=$(printf '%s' "$QUOTE" | jq --raw-output '.id')

# 3. Preview, then transfer, with the same quote.
curl --request POST \
  --url "$SAKAWAVE_BASE_URL/v1/transfers" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{
    \"sourceAccountId\": \"$SOURCE_ACCOUNT_ID\",
    \"destinationId\": \"$DESTINATION_ID\",
    \"sourceCurrency\": \"NGN\",
    \"destinationCurrency\": \"USD\",
    \"amountSide\": \"source\",
    \"sourceAmount\": \"2500000\",
    \"fxQuoteId\": \"$QUOTE_ID\",
    \"externalReference\": \"invoice_4471\"
  }"
```

The quote response tells you what the other side will be:

```json theme={null}
{
  "id": "01K2Z3AB4H5J6K7M8NPQRSTVWX",
  "sourceAmount": "25000.00",
  "destinationAmount": "15.63",
  "rate": "0.000625",
  "expiresAt": "2026-08-14T09:20:00.000Z",
  "used": false
}
```

Note the form: you sent `"2500000"`, you got back `"25000.00"`. Show `destinationAmount` and `rate` on the confirmation screen, and do not send either of them back.

## USD and USDC

Dollars and USDC convert into each other between your own accounts, one for one. It is the same quote and transfer as any other conversion, with three differences:

* **The rate is always `1`, and nothing is charged for it.** Any fee is the one for an own-account transfer, shown in the preview.
* **USDC converts in whole cents.** `1.23` USDC converts and `1.234` does not: an amount finer than a cent has no dollar equivalent, and is refused with `409 conversion_amount_not_whole_cents` rather than rounded. Dollars always convert exactly.
* **Only between your own accounts**, and only with a USDC account whose deposit addresses Sakawave issued. Any other USDC account is refused at preview with `409 accounts_held_separately`. Paying a USDC address straight from a dollar balance is not a conversion; convert first, then send a [wallet payout](/services/transfers/wallet-payouts).

Converting into USDC needs the tier that may receive crypto deposits, and converting out of it the tier that may withdraw crypto; below either, the transfer is refused with `403`, `reason: "feature_not_available"`.

The quote still carries `expiresAt`. Read it as you would any other.

## Things that go wrong, and why

| Error | Cause |
| - | - |
| `fx_quote_required` | Currencies differ, no quote sent |
| `fx_quote_mismatch` | The quote was for a different account, destination, or side |
| `fx_quote_amount_mismatch` | The quote was for a different amount |
| `fx_quote_unavailable` | Expired, or already spent |
| `fx_corridor_unavailable` | No corridor for this ordered pair |
| `currency_capability_unavailable` | One currency has `fxEnabled` off |
| `fx_quote_amount_too_small` | Rounds to nothing on the destination side |
| `fx_provider_unavailable` | The provider could not be reached |
| `conversion_amount_not_whole_cents` | A USDC amount finer than a cent, converting to or from USD |
| `accounts_held_separately` | A USDC account that cannot convert, or two USDC accounts held apart |

Almost all of them come from holding a quote too long or reusing one. Quote, preview, and transfer in a single sequence, and requote rather than reusing.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.