curl --request POST \
--url https://sandbox-api.sakawave.example/v1/transfers \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "NGN",
"amountSide": "source",
"sourceAmount": "2500000",
"externalReference": "payout_20260814_001",
"narration": "Sandbox tutorial transfer"
}
'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',
externalReference: 'payout_20260814_001',
narration: 'Sandbox tutorial transfer'
})
};
fetch('https://sandbox-api.sakawave.example/v1/transfers', 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"
payload = {
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "NGN",
"amountSide": "source",
"sourceAmount": "2500000",
"externalReference": "payout_20260814_001",
"narration": "Sandbox tutorial transfer"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"id": "01K2Z3AB4J5K6M7N8PQRSTVWXY",
"externalReference": "payout_20260814_001",
"transferReference": "SKWV-TRF-01K2Z3AB4J5K6M7N8PQRSTVWXY",
"destinationType": "own_account",
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "NGN",
"amountSide": "source",
"sourceAmount": "25000.00",
"destinationAmount": "25000.00",
"fee": "50.00",
"totalDebit": "25050.00",
"routeId": null,
"scheme": "internal",
"fxQuoteId": null,
"destination": {
"displayName": "Treasury NGN",
"institutionName": null,
"accountNumberLast4": null
},
"status": "completed",
"narration": "Sandbox tutorial transfer",
"failureCode": null,
"failureReason": null,
"initiatedByUserId": null,
"initiatedByApiKeyId": "01K2Z3AB4N5P6Q7R8STVWXYZAB",
"approvedByUserId": null,
"approvedAt": null,
"completedAt": "2026-08-14T09:08:00.000Z",
"createdAt": "2026-08-14T09:07:58.000Z",
"updatedAt": "2026-08-14T09:08:00.000Z"
}{
"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": 403,
"message": "International wallets for your customers are not available yet. Your own approval cannot be used for a customer wallet.",
"code": "international_customer_wallet_not_supported",
"currency": "USD"
}{
"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": 422,
"message": "Payment detail validation failed.",
"code": "payment_detail_invalid",
"errors": [
{
"message": "Account number is required.",
"field": "accountNumber",
"code": "required",
"path": [
"fields",
"accountNumber"
]
}
]
}{
"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 a transfer
Spends a destination and moves money. Returns as soon as the transfer has a status, which for most transfers means pending rather than completed — settlement finishes asynchronously and is reported by webhook.
curl --request POST \
--url https://sandbox-api.sakawave.example/v1/transfers \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "NGN",
"amountSide": "source",
"sourceAmount": "2500000",
"externalReference": "payout_20260814_001",
"narration": "Sandbox tutorial transfer"
}
'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',
externalReference: 'payout_20260814_001',
narration: 'Sandbox tutorial transfer'
})
};
fetch('https://sandbox-api.sakawave.example/v1/transfers', 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"
payload = {
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "NGN",
"amountSide": "source",
"sourceAmount": "2500000",
"externalReference": "payout_20260814_001",
"narration": "Sandbox tutorial transfer"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"id": "01K2Z3AB4J5K6M7N8PQRSTVWXY",
"externalReference": "payout_20260814_001",
"transferReference": "SKWV-TRF-01K2Z3AB4J5K6M7N8PQRSTVWXY",
"destinationType": "own_account",
"sourceAccountId": "01K2Z3AB4D5E6F7G8H9JKMNPQR",
"destinationId": "01K2Z3AB4F5G6H7J8KMNPQRSTV",
"sourceCurrency": "NGN",
"destinationCurrency": "NGN",
"amountSide": "source",
"sourceAmount": "25000.00",
"destinationAmount": "25000.00",
"fee": "50.00",
"totalDebit": "25050.00",
"routeId": null,
"scheme": "internal",
"fxQuoteId": null,
"destination": {
"displayName": "Treasury NGN",
"institutionName": null,
"accountNumberLast4": null
},
"status": "completed",
"narration": "Sandbox tutorial transfer",
"failureCode": null,
"failureReason": null,
"initiatedByUserId": null,
"initiatedByApiKeyId": "01K2Z3AB4N5P6Q7R8STVWXYZAB",
"approvedByUserId": null,
"approvedAt": null,
"completedAt": "2026-08-14T09:08:00.000Z",
"createdAt": "2026-08-14T09:07:58.000Z",
"updatedAt": "2026-08-14T09:08:00.000Z"
}{
"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": 403,
"message": "International wallets for your customers are not available yet. Your own approval cannot be used for a customer wallet.",
"code": "international_customer_wallet_not_supported",
"currency": "USD"
}{
"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": 422,
"message": "Payment detail validation failed.",
"code": "payment_detail_invalid",
"errors": [
{
"message": "Account number is required.",
"field": "accountNumber",
"code": "required",
"path": [
"fields",
"accountNumber"
]
}
]
}{
"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."
}Idempotency
externalReference is unique within your business and is the idempotency key. Persist it before sending the request, and reuse it on every retry of the same intended payment.
Reusing it returns the existing transfer as it stands, without comparing the rest of the payload. Two consequences to design around:
- A different payment sent under a reference you have already used returns the old transfer and moves no money. Never reuse a reference for a different payment.
- A transfer that failed keeps its reference. Retrying with the same reference returns the failed transfer, not a new attempt. To genuinely retry a failed payment, mint a new reference — and resolve a fresh destination for it.
insufficient_funds — the response is an error, and a transfer row exists in failed status holding that externalReference.What comes back
| Status | Meaning |
|---|---|
pending | Accepted and submitted. Settlement is asynchronous; wait for a webhook. |
completed | Already settled, typical for an internal book transfer. |
awaiting_approval | Your business requires a human approval for this transfer. |
submitting status is never published to a webhook, and you will rarely see it in a response.
Amounts
You send minor units as strings —"2500000" — and get back display decimals — "25000.00". totalDebit is sourceAmount plus fee, and is what actually leaves the account.
Error codes
| Status | code | Cause |
|---|---|---|
400 | validation_failed | A field failed validation, or an unknown field was sent. |
409 | insufficient_funds | The account does not hold totalDebit. A transfer row is created in failed status. |
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. Resolve a fresh one. |
409 | transfer_destination_unavailable | The destination was spent between preview and execution. |
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_unavailable | The quote expired or was spent before the transfer ran. |
409 | fx_quote_mismatch | The quote does not match this transfer. |
409 | transfer_amount_below_route_minimum | Below the route minimum, returned in minimumAmount. |
409 | transfer_amount_above_route_maximum | Above the route maximum, returned in maximumAmount. |
403 | — (reason: currency_not_available) | The source account is not NGN and your tier is below 3. Carries currentTier and requiredTier, and no code. |
422 | — (reason: daily_outflow_limit_exceeded) | The tier daily outflow allowance for this currency is spent. Carries limit, spentToday, and remaining in minor units, and no code. |
503 | provider_unavailable | The settlement provider could not be reached. |
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
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 1 - 120A 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"
500Response
Successful response.
A 26-character ULID.
26^[0-7][0-9A-HJKMNP-TV-Z]{25}$"01K2Z3AB4C5D6E7F8G9HJKMNPQ"
own_account, sakawave_tag, external 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, USDC 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"
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"
Show child attributes
Show child attributes
awaiting_approval, submitting, pending, completed, failed, reversed, rejected 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"
"2026-08-14T09:00:00.000Z"
"2026-08-14T09:00:00.000Z"
"2026-08-14T09:00:00.000Z"
"2026-08-14T09:00:00.000Z"