curl --request POST \
--url https://api.pay.aptahq.com/v1/fx/exchange \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: <idempotency-key>' \
--header 'X-AptaPay-Signature: <api-key>' \
--data '
{
"from": "UGX",
"to": "KES",
"amount": "1000"
}
'const options = {
method: 'POST',
headers: {
'Idempotency-Key': '<idempotency-key>',
'X-AptaPay-Signature': '<api-key>',
'Content-Type': 'application/json'
},
body: JSON.stringify({from: 'UGX', to: 'KES', amount: '1000'})
};
fetch('https://api.pay.aptahq.com/v1/fx/exchange', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://api.pay.aptahq.com/v1/fx/exchange"
payload = {
"from": "UGX",
"to": "KES",
"amount": "1000"
}
headers = {
"Idempotency-Key": "<idempotency-key>",
"X-AptaPay-Signature": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"code": 200,
"message": "OK",
"data": {
"source_currency": "UGX",
"source_amount_minor": 1000,
"destination_currency": "KES",
"destination_amount_minor": 3100,
"rate": 0.031
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}Convert between your own wallets — money moves
Actually convert money between two of your own wallets. The use case is funding a corridor you are short on: you hold UGX, you need to pay out in KES, so you exchange first and then send. Also how a treasury process rebalances float across currencies.
To be clear about what this is NOT: it moves money between your own wallets only. It never sends money to a customer — that is POST /v1/payouts, which handles its own cross-currency conversion via amount_type. You do not need to call this before a cross-currency payout.
Converts amount of from into to between wallets you already hold. This moves money, so Idempotency-Key is REQUIRED; a missing one is a 400 and the server never invents one.
ONE call. The provider’s quote-then-execute handshake happens INTERNALLY and that two-call shape never leaks to you — which is also what keeps the ~60s quote window from being your problem. You do not need to call /v1/fx/quote first; that endpoint exists to show a price in a UI.
Replaying a completed key returns the original response verbatim with no second conversion. The same key with different parameters is a 422.
rate in the response is the rate the executed quote encoded — the one actually applied — not a figure recomputed from the returned amounts.
The source wallet must be funded. The provider prices a conversion it cannot fund but issues no token to execute it, which surfaces here as a 409 quote_not_executable. It is terminal: retrying will not help until the wallet holds enough of from. /v1/fx/quote still returns the rate in that state, so a quote succeeding is not evidence that an exchange will.
curl --request POST \
--url https://api.pay.aptahq.com/v1/fx/exchange \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: <idempotency-key>' \
--header 'X-AptaPay-Signature: <api-key>' \
--data '
{
"from": "UGX",
"to": "KES",
"amount": "1000"
}
'const options = {
method: 'POST',
headers: {
'Idempotency-Key': '<idempotency-key>',
'X-AptaPay-Signature': '<api-key>',
'Content-Type': 'application/json'
},
body: JSON.stringify({from: 'UGX', to: 'KES', amount: '1000'})
};
fetch('https://api.pay.aptahq.com/v1/fx/exchange', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://api.pay.aptahq.com/v1/fx/exchange"
payload = {
"from": "UGX",
"to": "KES",
"amount": "1000"
}
headers = {
"Idempotency-Key": "<idempotency-key>",
"X-AptaPay-Signature": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"code": 200,
"message": "OK",
"data": {
"source_currency": "UGX",
"source_amount_minor": 1000,
"destination_currency": "KES",
"destination_amount_minor": 3100,
"rate": 0.031
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}{
"code": 200,
"message": "OK",
"data": "<unknown>",
"error": {
"code": "payout_rejected_by_provider",
"message": "<string>",
"terminal": true,
"retriable": true,
"ambiguous": true,
"details": {}
}
}Authorizations
v1={hex HMAC-SHA256} over the ten-field canonical string. Sent alongside X-AptaPay-Key, X-AptaPay-Timestamp and X-AptaPay-Nonce — all four are required. OpenAPI can only model one header per scheme, so the other three are described in the Authentication section above.
Headers
Required. A missing key is a 400 — never a server-generated one.
255Body
Source currency. Must differ from to.
UGX, RWF, XAF, XOF, KES, GHS, NGN, TZS, ZMW, USD, EUR, GBP Destination currency.
UGX, RWF, XAF, XOF, KES, GHS, NGN, TZS, ZMW, USD, EUR, GBP Major units as a decimal STRING. See /v1/fx/quote.
"1000"
Response
The conversion that was applied.
The single response envelope, used by EVERY endpoint so a consuming app parses one shape everywhere.
200
"OK"
Present on success. Shape varies per endpoint.
Machine-readable error classification. EXACTLY ONE of terminal/retriable/ ambiguous is true. laces_api inferred this from the HTTP status and got it wrong, stranding real money twice (2026-08-15, 2026-08-16). Branch on these booleans, never on the status code:
terminal -> the provider refused. Reverse the debit and tell the user. retriable -> safe to retry with the SAME Idempotency-Key. ambiguous -> the outcome is UNKNOWN. Do NOT reverse. Poll instead.
Show child attributes
Show child attributes