curl --request POST \
--url https://sandbox-api.sakawave.example/v1/transfer-recipients/{recipientId}/payment-details \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"routeId": "01K2Z3AB4M5N6P7Q8RSTVWXYZA",
"currency": "KES",
"country": "KE",
"accountHolderName": "Ada Okafor",
"fields": {
"bankCode": "001",
"accountNumber": "0123456789"
},
"isDefault": true
}
'const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
routeId: '01K2Z3AB4M5N6P7Q8RSTVWXYZA',
currency: 'KES',
country: 'KE',
accountHolderName: 'Ada Okafor',
fields: {bankCode: '001', accountNumber: '0123456789'},
isDefault: true
})
};
fetch('https://sandbox-api.sakawave.example/v1/transfer-recipients/{recipientId}/payment-details', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://sandbox-api.sakawave.example/v1/transfer-recipients/{recipientId}/payment-details"
payload = {
"routeId": "01K2Z3AB4M5N6P7Q8RSTVWXYZA",
"currency": "KES",
"country": "KE",
"accountHolderName": "Ada Okafor",
"fields": {
"bankCode": "001",
"accountNumber": "0123456789"
},
"isDefault": True
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"id": "01K2Z3AB4Q5R6S7T8VWXYZABCD",
"recipientId": "01K2Z3AB4G5H6J7K8MNPQRSTVW",
"routeId": "01K2Z3AB4M5N6P7Q8RSTVWXYZA",
"paymentMethod": "bank-transfer",
"currency": "KES",
"country": "KE",
"accountHolderName": "Ada Okafor",
"institutionName": "Example Bank",
"maskedIdentifier": "•••• 6789",
"isDefault": true,
"providerLocked": false,
"createdAt": "2026-08-14T09:06: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": 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."
}Add recipient payment detail
Adds routing to a recipient: which route, and the exact fields that route requires. Values are validated against the route’s own field descriptors, then encrypted. Only a masked identifier ever comes back.
curl --request POST \
--url https://sandbox-api.sakawave.example/v1/transfer-recipients/{recipientId}/payment-details \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"routeId": "01K2Z3AB4M5N6P7Q8RSTVWXYZA",
"currency": "KES",
"country": "KE",
"accountHolderName": "Ada Okafor",
"fields": {
"bankCode": "001",
"accountNumber": "0123456789"
},
"isDefault": true
}
'const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
routeId: '01K2Z3AB4M5N6P7Q8RSTVWXYZA',
currency: 'KES',
country: 'KE',
accountHolderName: 'Ada Okafor',
fields: {bankCode: '001', accountNumber: '0123456789'},
isDefault: true
})
};
fetch('https://sandbox-api.sakawave.example/v1/transfer-recipients/{recipientId}/payment-details', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://sandbox-api.sakawave.example/v1/transfer-recipients/{recipientId}/payment-details"
payload = {
"routeId": "01K2Z3AB4M5N6P7Q8RSTVWXYZA",
"currency": "KES",
"country": "KE",
"accountHolderName": "Ada Okafor",
"fields": {
"bankCode": "001",
"accountNumber": "0123456789"
},
"isDefault": True
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"id": "01K2Z3AB4Q5R6S7T8VWXYZABCD",
"recipientId": "01K2Z3AB4G5H6J7K8MNPQRSTVW",
"routeId": "01K2Z3AB4M5N6P7Q8RSTVWXYZA",
"paymentMethod": "bank-transfer",
"currency": "KES",
"country": "KE",
"accountHolderName": "Ada Okafor",
"institutionName": "Example Bank",
"maskedIdentifier": "•••• 6789",
"isDefault": true,
"providerLocked": false,
"createdAt": "2026-08-14T09:06: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": 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."
}fields is defined by the route, not by you
Read the route first — GET /v1/transfer-corridors or GET /v1/currencies/{code}/routes — and send exactly the keys its fieldDescriptors name. Each value is checked for presence, length, and pattern, and a key the route does not define is rejected rather than ignored.
Every failure comes back at once, in errors, each entry naming the field and why:
{
"statusCode": 422,
"message": "Payment detail validation failed.",
"code": "payment_detail_invalid",
"errors": [
{ "field": "accountNumber", "path": ["fields", "accountNumber"], "code": "too_small", "message": "accountNumber is too short." },
{ "field": "swiftBic", "path": ["fields", "swiftBic"], "code": "unrecognized_field", "message": "swiftBic is not accepted for this route." }
]
}
Defaults
The first detail added to a recipient becomes the default whateverisDefault says. Setting isDefault on a later one moves the default to it.
Institution names
For a route withinstitutionDirectoryEnabled, submit the institution code in fields and Sakawave resolves the canonical name. Sending institutionName yourself is pointless there.
Error codes
| Status | code | Cause |
|---|---|---|
400 | validation_failed | The recipient identifier is not a ULID. |
404 | transfer_route_not_found | No active route with that id delivers to that currency. |
422 | payment_detail_invalid | A routing field is missing, malformed, or not accepted; the route does not serve the country; or a bank-transfer route needs a country you did not send. |
401 | INVALID_API_KEY | The key is missing, invalid, inactive, expired, from the other environment, or blocked by its IP allowlist. |
404 | recipient_not_found | No such recipient for this business, or it has been deleted. |
429 | api_key_rate_limit_exceeded | The per-minute limit for this key was exceeded. All of these operations share a 120-per-minute allowance. |
Authorizations
A secret API key, sent as Authorization: Bearer sk_test_… in sandbox or Bearer sk_live_… in live.
Path Parameters
Saved recipient ULID. A 26-character ULID.
26^[0-7][0-9A-HJKMNP-TV-Z]{25}$"01K2Z3AB4C5D6E7F8G9HJKMNPQ"
Body
A route id from GET /v1/currencies/{code}/routes or GET /v1/transfer-corridors.
26^[0-7][0-9A-HJKMNP-TV-Z]{25}$"01K2Z3AB4C5D6E7F8G9HJKMNPQ"
NGN, USD, GBP, EUR, GHS, ZAR, UGX, CAD, MXN, AED, AUD, SGD, HKD, KES, TZS, BRL, CNY, XAF, PHP, USDC 2 - 200The routing values for this route, keyed exactly as the route’s fieldDescriptors name them. A key the route does not define is rejected.
Show child attributes
Show child attributes
Required for a bank-transfer route that is not already scoped to one country. Uppercased on the way in.
2Ignored for directory-backed routes, where Sakawave resolves the name from the institution code.
160The first detail on a recipient becomes the default whatever this says.
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"
bank-transfer, momo-transfer NGN, USD, GBP, EUR, GHS, ZAR, UGX, CAD, MXN, AED, AUD, SGD, HKD, KES, TZS, BRL, CNY, XAF, PHP, USDC 2The last four characters of the routing identifier, masked.
True when the provider holds the routing fields and they cannot be edited here.
"2026-08-14T09:00:00.000Z"