# FX conversions

Convert money between two of your accounts in different currencies, at a rate you see and lock before any money moves. Use it to hold USD from NGN, or to fund a payout in a currency you don't hold yet.

In short:

1. **Get a quote:** ask for a rate to receive an amount in the target currency. The quote is valid for about 60 seconds.
2. **Lock it:** confirm the quote with the two accounts. The source account is debited and the target account credited, usually during the call.
3. **Confirm:** check the lock response's `status`, or wait for the `trade_completed` webhook.

| Term | Meaning |
|---|---|
| **Source currency / account** | What you convert **from**: the account that is debited. |
| **Target currency / account** | What you convert **to**: the account that is credited. |
| **Quote** | A rate Multigate offers you, with an ID, valid until `endTime`. |
| **Lock** | Accepting a quote. This is the step that moves money. |
| **Liquidity provider** | The bank or partner Multigate buys the currency from. |

## How it works

```mermaid
sequenceDiagram
    autonumber
    participant You as Your backend
    participant MG as Multigate
    You->>MG: GET /fx-rates/best-rate (source, target, amount to receive)
    MG-->>You: Quote id, buyRate, sellRate, endTime (about 60 seconds)
    You->>MG: POST /fx-rates/lock-rate (quote id, accounts, amount)
    Note over MG: Check balance, debit source account,<br/>book with the liquidity provider,<br/>credit target account
    MG-)You: trade / trade_completed (can arrive before the response)
    MG-->>You: Conversion: status completed (or processing)
    Note over MG,You: processing: trade_completed follows when the credit settles
```

Two calls, no separate fee: Multigate's margin is built into the rate.

## Before you start

- **`MG_BASE_URL`, `MG_API_KEY` and `MG_CLIENT_ID`** are set, as in the [Quickstart](../01-get-started/03-quickstart.md).
- **One active account in each currency**, both yours, or both the same customer's. You need their **`accountId`** values (not account numbers). Put them in variables:

```bash
curl -s "$MG_BASE_URL/client-service/api/v1/accounts?pageSize=50" \
  -H "mp-api-key: $MG_API_KEY" \
  | jq '.data[] | {accountId, currencyIso3Code, status}'
export MG_NGN_ACCOUNT_ID="accountId of your NGN account"
export MG_USD_ACCOUNT_ID="accountId of your USD account"
```

- **The source account funded** with more than the amount that will be debited. Check it with [Get an account balance](api:getAccountBalance) before you lock.
- **The `entityId` header** set to your **client ID** on every conversion request. Without it, the service uses the business your API key belongs to.
- **Permissions:** *lock rates* (`can_lock_rates`) to quote and lock; *view rates* (`can_view_rates`) or *lock rates* to read conversions.
- **For a customer:** pass `onBehalfOf` when you get the quote. See [Acting for customers](../02-concepts/06-acting-for-customers.md).

:::caution[Known issue: conversions for customers are refused]
Today, a quote or lock with `onBehalfOf` is refused with `400` "Invalid onBehalfOf id", even for your own active customers. Until Multigate fixes this, convert in your own business's accounts, or contact Multigate.
:::

:::danger[The sandbox doesn't check your balance]
In production, locking a quote the source account can't cover returns `402` "Insufficient balance", and a failed debit returns `402` "The settlement has failed due to an issue with our banking partners." In the sandbox, **neither stops the conversion**: an unfunded lock succeeds. Handle `402` in your code anyway. See [Sandbox vs production](../01-get-started/05-sandbox-vs-production.md#fx-conversions).
:::

## Convert

<Steps>
<Step title="Get a quote">

`amount` is what you want to **receive**, in the target currency: here, 1,000 USD paid for in NGN. Save the quote's `id` in `MG_QUOTE_ID`:

```bash
QUOTE=$(curl -s "$MG_BASE_URL/trade-service/api/v1/fx-rates/best-rate?sourceCurrency=NGN&targetCurrency=USD&amount=1000" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "entityId: $MG_CLIENT_ID")
echo "$QUOTE"
export MG_QUOTE_ID=$(echo "$QUOTE" | jq -r .id)
```

```json
{
  "message": "The FX rate has been successfully fetched.",
  "id": "FX-261010-150000482913",
  "sourceCurrency": "NGN",
  "targetCurrency": "USD",
  "buyRate": 1530.5,
  "sellRate": 0.0006533812479581836,
  "validityPeriod": 60,
  "startTime": "2026-10-10T15:00:00.000Z",
  "endTime": "2026-10-10T15:01:00.000Z",
  "createdAt": "2026-10-10T15:00:00.482Z",
  "updatedAt": "2026-10-10T15:00:00.482Z"
}
```

- `buyRate`: source units per one target unit (here, NGN per USD), to 6 decimal places. Show this one to your user.
- `sellRate`: target units per one source unit (`1 / buyRate`).
- The quote expires at `endTime`, usually about 60 seconds after `startTime`. Show a countdown and lock straight after the user confirms.
- Every call creates a new quote. Getting another one doesn't cancel the first.

</Step>
<Step title="Lock it">

Store the quote ID in your database first: it is your retry key, and the webhook can arrive before the response. Then send the quote `id`, the two accounts and the **same `amount`** you quoted, before `endTime`. Add a `reference`, unique across Multigate, that you can look the conversion up by.

```bash
curl -s -X POST "$MG_BASE_URL/trade-service/api/v1/fx-rates/lock-rate" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "entityId: $MG_CLIENT_ID" \
  -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "id": "$MG_QUOTE_ID",
  "sourceAccountId": "$MG_NGN_ACCOUNT_ID",
  "targetAccountId": "$MG_USD_ACCOUNT_ID",
  "amount": 1000,
  "reference": "acme_conv_$(date +%s)"
}
EOF
```

```json
{
  "message": "The FX rate has been successfully locked.",
  "id": "FX-261010-150000482913",
  "status": "completed",
  "sourceCurrency": "NGN",
  "sourceAmount": "1530500",
  "targetCurrency": "USD",
  "targetAmount": "1000",
  "rate": "0.0006533812479581836",
  "tradeReference": "acme_conv_1791640820",
  "sourceAccountNumber": "1029384756",
  "targetAccountNumber": "4410023391",
  "valueDate": "2026-10-10T15:00:20.114Z",
  "createdAt": "2026-10-10T15:00:20.114Z",
  "updatedAt": "2026-10-10T15:00:21.902Z"
}
```

- The source account is debited `sourceAmount` (2 decimal places); the target account receives `targetAmount`. Record `sourceAmount`: it is close to `amount × buyRate` but not always equal.
- `rate` is the quote's `sellRate`.
- Amounts may be strings or numbers: see [Amounts and currencies](../02-concepts/07-amounts-and-currencies.md).
- `sourceAccountNumber`, `targetAccountNumber` and `valueDate` appear only in this response. Store them if you need them.

</Step>
<Step title="Wait for settlement if needed">

If `status` is `completed`, the target account is already credited. You may already have received `trade` / `trade_completed` for it.

If it's `processing`, wait for [`trade_completed`](webhook:trade.trade_completed), or check with [Get a conversion](api:getFxTrade) using the quote ID or your `reference`.

</Step>
</Steps>

## Conversion statuses

```mermaid
stateDiagram-v2
    direction LR
    [*] --> completed: credited straight away
    [*] --> processing: debited, credit waiting for settlement
    [*] --> awaiting_balance: post-funded, short of funds
    [*] --> failed: provider refused at lock time
    awaiting_balance --> processing: account funded and debited
    processing --> completed: settled and credited
    processing --> failed: provider failed (pairs priced through USD)
    completed --> [*]
    failed --> [*]
```

| Status | Final | Meaning |
|---|---|---|
| `completed` | Yes | The target account is credited. `trade_completed` is sent. |
| `processing` | No | The source is debited; the credit is waiting for the liquidity provider or Multigate's settlement. `trade_completed` follows. |
| `awaiting_balance` | No | Only for post-funded accounts (agreed with Multigate): the account was short of funds, so the debit is deferred. Fund the account. |
| `failed` | Yes | The liquidity provider refused the conversion, usually when you locked it (the lock call returned `400` "Failed to execute trade, please reach out to support"). The source debit is reversed. |

:::warning[Confirm before releasing funds]
Today, if the liquidity provider fails a `processing` conversion later, Multigate reverses the source debit but the conversion can still end as `completed` (or stay `processing`), and `trade_completed` can still be sent. [Get a conversion](api:getFxTrade) then shows the same status. Before you release money to your own customers based on a conversion that was `processing`, check that the target account received `targetAmount` (for example with [Get an account balance](api:getAccountBalance) or its statement).
:::

## Handling `trade_completed`

- Match the event to your conversion by `id`, which is the **quote ID**. `tradeReference` is your `reference` (or one Multigate generated).
- Store the quote ID **before** you call lock: the event can arrive before the lock response.
- Read the amounts from your lock response or [Get a conversion](api:getFxTrade). For pairs priced through USD, an event sent after a `processing` conversion settles can carry the amounts of one leg (to or from USD) and a Multigate-generated `tradeReference`.
- Deduplicate by `Idempotency-Key` **and** by quote ID: the same conversion can be notified more than once.

## Retrying safely

Lock has no idempotency key, but a quote can be locked **only once**. That makes the quote ID your retry key.

If a lock call times out, the connection drops, or you get a `5xx`:

1. Call [Get a conversion](api:getFxTrade) with the quote ID. If it's found, that is your conversion: follow its status.
2. If it isn't found, send the **same** lock request again (same quote ID, same `reference`):
   - `409` "Rate already locked": the first attempt is recorded or still running. Wait a few seconds and call Get a conversion again.
   - `400` "Rate expired": no conversion was recorded for this quote. Check the source account balance for a debit you can't explain before you get a new quote, and contact support with the quote ID if you find one.
   - `200`: converted.
3. Never lock a **new** quote for the same purpose until you've confirmed the first one didn't go through.

`503` "Failed to execute trade, please reach out to support" means Multigate couldn't reserve the quote: nothing was debited, so retry the same request shortly. The same message with `400` means the provider refused and the conversion failed, so always check the HTTP status, not just the message.

:::warning[Conversions for customers]
[Get a conversion](api:getFxTrade) and [List conversions](api:listFxTrades) only return conversions made for your own business. A conversion locked for a customer (`onBehalfOf`) returns `404`. Track customer conversions with the `trade_completed` webhook (it comes to you) and the customer's account balances.
:::

Use a new `reference` for every conversion, including after a failed one: a reused value returns `409` "Duplicate reference". References must be unique across Multigate, so add your own prefix or use a UUID.

## Which pairs you can convert

Pairs depend on the liquidity Multigate has configured. Get a quote to check a pair: an unavailable pair returns `404` (or `400`) "Rates for this currency pair are currently unavailable."

- Pairs that include USD are quoted directly. Other pairs are priced through USD.
- **USD to NGN, USD to GHS and USD to ETB** are quoted for information only. Locking them returns `404` "The rate is not currently tradeable". They exist because many treasury systems expect a buy and a sell rate for every pair: the rate is derived from the opposite direction (for example NGN to USD) with a margin, so you can display it, not trade on it.

## Errors

| Message | Status | Call | Fix |
|---|---|---|---|
| Validation error body | `400` | Both | Fix the field named in `errors`. |
| Rates for this currency pair are currently unavailable. | `400`, `404` | Quote | The pair isn't available right now. Try later or ask Multigate. |
| Invalid onBehalfOf id | `400`, `404` | Both | `onBehalfOf` isn't your customer, doesn't exist, or doesn't match the quote's customer. On lock, leave it out or send the quote's customer. |
| Unauthorized access | `403` | Both | Your API user lacks the permission, or the `entityId` header isn't one of your businesses. |
| The rate could not be found. | `404` | Lock | Unknown quote ID. |
| The rate is not currently tradeable | `404` | Lock | The pair is quoted for information only. |
| Rate already locked | `409` | Lock | The quote was already used, or a lock is in progress. Look it up with Get a conversion. |
| Rate expired | `400` | Lock | Get a new quote and lock it before `endTime`. |
| Duplicate reference | `409` | Lock | Send a new `reference`. |
| The customer viban could not be found. | `400` | Lock | `sourceAccountId` or `targetAccountId` doesn't exist. Use `accountId` values. |
| Invalid source or target account | `400` | Lock | Accounts must be yours (or the quote's customer's) and match the quote currencies. |
| The multi-gate viban could not be found. | `400` | Lock | The pair isn't set up on Multigate's side. Contact support. |
| Insufficient balance | `402` | Lock | Production only. The available balance must be **more** than the debit. Fund the account. Nothing was converted. |
| The settlement has failed due to an issue with our banking partners. | `402` | Lock | Production only. The debit didn't go through. Check the source balance before retrying: in rare cases a debit that timed out completes later. |
| Failed to execute trade, please reach out to support | `400` | Lock | The liquidity provider refused. The conversion is `failed` and the debit was reversed. Get a new quote. |
| Reversal failed. Please try again later. | `417` | Lock | The provider refused and the automatic refund failed: the money is still debited. Contact support with the quote ID. Don't retry. |
| Failed to execute trade, please reach out to support | `503` | Lock | Multigate couldn't reserve the quote. Nothing was debited. Retry the same request. |

## Next steps

- [Payouts](05-payouts.md): pay out from the target account.
- [Event catalogue](../03-webhooks-and-emails/02-event-catalogue.md): the `trade_completed` payload.
- [Balances and statements](10-balances-and-statements.md): reconcile the debit and the credit.
- [Cross-border payouts](../05-use-cases/01-cross-border-payouts.md): convert, then pay a supplier.
