curl --request POST \
--url https://sandbox-api.sakawave.example/v1/fx-quotes \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "USD",
"amountSide": "source",
"sourceAmount": "2500000"
}
'const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
sourceAccountId: '01K2Z3AB4D5E6F7G8H9JKMNPQR',
destinationId: '01K2Z3AB4F5G6H7J8KMNPQRSTV',
sourceCurrency: 'NGN',
destinationCurrency: 'USD',
amountSide: 'source',
sourceAmount: '2500000'
})
};
fetch('https://sandbox-api.sakawave.example/v1/fx-quotes', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://sandbox-api.sakawave.example/v1/fx-quotes"
payload = {
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "USD",
"amountSide": "source",
"sourceAmount": "2500000"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"id": "01K2Z3AB4H5J6K7M8NPQRSTVWX",
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "USD",
"amountSide": "source",
"sourceAmount": "25000.00",
"destinationAmount": "15.63",
"rate": "0.000625",
"expiresAt": "2026-08-14T09:20:00.000Z",
"used": false
}{
"statusCode": 400,
"message": "Must be a valid identifier.",
"code": "validation_failed",
"errors": [
{
"message": "Must be a valid identifier.",
"field": "accountId",
"code": "custom",
"path": [
"accountId"
]
}
]
}{
"statusCode": 401,
"code": "INVALID_API_KEY",
"message": "Invalid or inactive API credentials."
}{
"statusCode": 404,
"code": "resource_not_found",
"message": "Resource not found."
}{
"statusCode": 409,
"code": "resource_conflict",
"message": "The request conflicts with the current resource state."
}{
"statusCode": 429,
"message": "Too many API requests. Try again later.",
"code": "api_key_rate_limit_exceeded",
"retryAfterSeconds": 17
}{
"statusCode": 500,
"code": "internal_server_error",
"message": "An unexpected error occurred."
}{
"statusCode": 503,
"code": "provider_unavailable",
"message": "We could not issue an account number just now. Try again shortly."
}Create an FX quote
Locks a rate for one cross-currency transfer. A quote is bound to the account, destination, side, and amount it was created for, expires at a time the provider sets, and can be spent once.
curl --request POST \
--url https://sandbox-api.sakawave.example/v1/fx-quotes \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "USD",
"amountSide": "source",
"sourceAmount": "2500000"
}
'const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
sourceAccountId: '01K2Z3AB4D5E6F7G8H9JKMNPQR',
destinationId: '01K2Z3AB4F5G6H7J8KMNPQRSTV',
sourceCurrency: 'NGN',
destinationCurrency: 'USD',
amountSide: 'source',
sourceAmount: '2500000'
})
};
fetch('https://sandbox-api.sakawave.example/v1/fx-quotes', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://sandbox-api.sakawave.example/v1/fx-quotes"
payload = {
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "USD",
"amountSide": "source",
"sourceAmount": "2500000"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"id": "01K2Z3AB4H5J6K7M8NPQRSTVWX",
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "USD",
"amountSide": "source",
"sourceAmount": "25000.00",
"destinationAmount": "15.63",
"rate": "0.000625",
"expiresAt": "2026-08-14T09:20:00.000Z",
"used": false
}{
"statusCode": 400,
"message": "Must be a valid identifier.",
"code": "validation_failed",
"errors": [
{
"message": "Must be a valid identifier.",
"field": "accountId",
"code": "custom",
"path": [
"accountId"
]
}
]
}{
"statusCode": 401,
"code": "INVALID_API_KEY",
"message": "Invalid or inactive API credentials."
}{
"statusCode": 404,
"code": "resource_not_found",
"message": "Resource not found."
}{
"statusCode": 409,
"code": "resource_conflict",
"message": "The request conflicts with the current resource state."
}{
"statusCode": 429,
"message": "Too many API requests. Try again later.",
"code": "api_key_rate_limit_exceeded",
"retryAfterSeconds": 17
}{
"statusCode": 500,
"code": "internal_server_error",
"message": "An unexpected error occurred."
}{
"statusCode": 503,
"code": "provider_unavailable",
"message": "We could not issue an account number just now. Try again shortly."
}Minor units in, decimals out
You sendsourceAmount as a positive integer of minor units — "2500000" for ₦25,000 — and the response returns "25000.00". The request and the response are not in the same form. Never feed a response amount back into a request.
A quote is bound to one transfer
Five things are fixed when the quote is created:sourceAccountId, destinationId, sourceCurrency, destinationCurrency, and amountSide together with its amount. Spending it on a transfer that differs in any of them fails with fx_quote_mismatch or fx_quote_amount_mismatch.
Quote, preview, and transfer in one sequence, and do not cache a quote across user decisions.
Expiry
expiresAt comes from the FX provider, not from a fixed Sakawave window — read it rather than assuming a duration. A quote is also single-use: used becomes true when a transfer spends it.
Error codes
| Status | code | Cause |
|---|---|---|
400 | validation_failed | A field failed validation, both amounts were sent, neither was, or the two currencies are the same. |
409 | transfer_source_account_ineligible | The source account is missing, inactive, not debitable, a customer wallet, or in a different currency. |
409 | destination_currency_mismatch | The destination was resolved for a different currency. |
409 | transfer_destination_expired | The destination has expired or has already been spent. |
409 | currency_capability_unavailable | One of the currencies is closed for this kind of transfer or for FX. |
409 | fx_corridor_unavailable | No active corridor exists for this ordered currency pair. |
409 | fx_quote_amount_out_of_range | The amount is outside the supported range. |
409 | fx_quote_amount_too_small | The converted amount rounds to nothing on the destination side. |
409 | conversion_amount_not_whole_cents | A USDC amount finer than a cent, converting to or from USD, which converts one for one in whole cents. |
503 | fx_provider_unavailable | The FX provider could not be reached, or returned a quote that had already expired. |
401 | INVALID_API_KEY | The key is missing, invalid, inactive, expired, from the other environment, or blocked by its IP allowlist. |
429 | api_key_rate_limit_exceeded | The per-minute limit for this key was exceeded. Transfers, quotes, destinations, and recipients share one 120-per-minute allowance. |
Authorizations
A secret API key, sent as Authorization: Bearer sk_test_… in sandbox or Bearer sk_live_… in live.
Body
- Option 1
- Option 2
Provide only the amount matching amountSide. The currencies must differ.
The account the money leaves. Must be active, debitable, and in sourceCurrency.
26^[0-7][0-9A-HJKMNP-TV-Z]{25}$"01K2Z3AB4C5D6E7F8G9HJKMNPQ"
An unexpired, unspent destination. Its currency must equal destinationCurrency.
26^[0-7][0-9A-HJKMNP-TV-Z]{25}$"01K2Z3AB4C5D6E7F8G9HJKMNPQ"
NGN, USD, GBP, EUR, GHS, ZAR, UGX, CAD, MXN, AED NGN, USD, GBP, EUR, GHS, ZAR, UGX, CAD, MXN, AED, AUD, SGD, HKD, KES, TZS, BRL, CNY, XAF, PHP, USDC Which side you are fixing. Send the matching amount and only that one.
source, destination A positive integer number of minor units.
^[1-9]\d*$"2500000"
A positive integer number of minor units.
^[1-9]\d*$"2500000"
Response
Successful response.
A 26-character ULID.
26^[0-7][0-9A-HJKMNP-TV-Z]{25}$"01K2Z3AB4C5D6E7F8G9HJKMNPQ"
A 26-character ULID.
26^[0-7][0-9A-HJKMNP-TV-Z]{25}$"01K2Z3AB4C5D6E7F8G9HJKMNPQ"
A 26-character ULID.
26^[0-7][0-9A-HJKMNP-TV-Z]{25}$"01K2Z3AB4C5D6E7F8G9HJKMNPQ"
NGN, USD, GBP, EUR, GHS, ZAR, UGX, CAD, MXN, AED NGN, USD, GBP, EUR, GHS, ZAR, UGX, CAD, MXN, AED, AUD, SGD, HKD, KES, TZS, BRL, CNY, XAF, PHP, USDC source, destination A decimal display amount returned by transfer APIs. Preserve it as a string.
^-?\d+(?:\.\d+)?$"25000.00"
A decimal display amount returned by transfer APIs. Preserve it as a string.
^-?\d+(?:\.\d+)?$"25000.00"
"2026-08-14T09:00:00.000Z"