curl --request POST \
--url https://sandbox-api.sakawave.example/v1/transfers/preview \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "NGN",
"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: 'NGN',
amountSide: 'source',
sourceAmount: '2500000'
})
};
fetch('https://sandbox-api.sakawave.example/v1/transfers/preview', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://sandbox-api.sakawave.example/v1/transfers/preview"
payload = {
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "NGN",
"amountSide": "source",
"sourceAmount": "2500000"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"sourceCurrency": "NGN",
"destinationCurrency": "NGN",
"sourceAmount": "25000.00",
"destinationAmount": "25000.00",
"fee": "50.00",
"totalDebit": "25050.00",
"routeId": null,
"scheme": "internal",
"fxQuoteId": null,
"quoteExpiresAt": null
}{
"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."
}Preview a transfer
Runs every check a transfer runs, and moves no money. Returns the amounts, the fee, the total debit, and the route that would be used. Same request body as creating a transfer, minus externalReference and narration.
curl --request POST \
--url https://sandbox-api.sakawave.example/v1/transfers/preview \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "NGN",
"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: 'NGN',
amountSide: 'source',
sourceAmount: '2500000'
})
};
fetch('https://sandbox-api.sakawave.example/v1/transfers/preview', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://sandbox-api.sakawave.example/v1/transfers/preview"
payload = {
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "NGN",
"amountSide": "source",
"sourceAmount": "2500000"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"sourceCurrency": "NGN",
"destinationCurrency": "NGN",
"sourceAmount": "25000.00",
"destinationAmount": "25000.00",
"fee": "50.00",
"totalDebit": "25050.00",
"routeId": null,
"scheme": "internal",
"fxQuoteId": null,
"quoteExpiresAt": null
}{
"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."
}totalDebit is what actually leaves the account — sourceAmount plus fee — and it is the figure a payer should be asked to approve.
insufficient_funds a moment later.Error codes
| Status | code | Cause |
|---|---|---|
400 | validation_failed | A field failed validation, or an unknown field was sent. |
409 | transfer_source_account_ineligible | The source account is missing, inactive, not debitable, a customer wallet, or in a different currency. |
409 | transfer_destination_expired | The destination has expired or has already been spent. |
409 | destination_currency_mismatch | The destination was resolved for a different currency. |
409 | same_transfer_account | Source and destination resolve to the same account. |
409 | accounts_held_separately | The USDC account cannot convert, or two USDC accounts hold their balances apart. |
409 | transfer_amount_invalid | The wrong amount was sent for amountSide, or both were. |
409 | fx_quote_required | The currencies differ and no fxQuoteId was supplied. |
409 | fx_quote_mismatch | The quote was created for a different account, destination, or side. |
409 | fx_quote_amount_mismatch | The quote was created for a different amount. |
409 | fx_quote_unavailable | The quote has expired or has already been spent. |
409 | transfer_amount_below_route_minimum | Below the route minimum, which is returned in minimumAmount. |
409 | transfer_amount_above_route_maximum | Above the route maximum, which is returned in maximumAmount. |
409 | currency_capability_unavailable | A currency is closed for this kind of transfer. |
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
Amounts are positive integers of minor units, as strings. Send exactly the one matching amountSide for a same-currency transfer; for a cross-currency one the amounts come from the quote.
Must be active, debitable, in sourceCurrency, and not a customer wallet.
26^[0-7][0-9A-HJKMNP-TV-Z]{25}$"01K2Z3AB4C5D6E7F8G9HJKMNPQ"
An unexpired, unspent destination.
26^[0-7][0-9A-HJKMNP-TV-Z]{25}$"01K2Z3AB4C5D6E7F8G9HJKMNPQ"
NGN, USD, GBP, EUR, GHS, ZAR, UGX, CAD, MXN, AED, USDC NGN, USD, GBP, EUR, GHS, ZAR, UGX, CAD, MXN, AED, AUD, SGD, HKD, KES, TZS, BRL, CNY, XAF, PHP, USDC source, destination A positive integer number of minor units.
^[1-9]\d*$"2500000"
A positive integer number of minor units.
^[1-9]\d*$"2500000"
Required when the currencies differ, and rejected as a mismatch if it was quoted for a different account, destination, side, or amount.
26^[0-7][0-9A-HJKMNP-TV-Z]{25}$"01K2Z3AB4C5D6E7F8G9HJKMNPQ"
Response
Successful response.
NGN, USD, GBP, EUR, GHS, ZAR, UGX, CAD, MXN, AED, USDC NGN, USD, GBP, EUR, GHS, ZAR, UGX, CAD, MXN, AED, AUD, SGD, HKD, KES, TZS, BRL, CNY, XAF, PHP, USDC 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"
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"
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"
"2026-08-14T09:00:00.000Z"