curl --request POST \
--url https://api.pay.aptahq.com/v1/fx/quote \
--header 'Content-Type: application/json' \
--header 'X-AptaPay-Signature: <api-key>' \
--data '
{
"from": "UGX",
"to": "KES",
"amount": "1000"
}
'const options = {
method: 'POST',
headers: {'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/quote', 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/quote"
payload = {
"from": "UGX",
"to": "KES",
"amount": "1000"
}
headers = {
"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": {
"quote_id": "evfx_01J8Z2K9",
"source_currency": "UGX",
"source_amount_minor": 1000,
"destination_currency": "KES",
"destination_amount_minor": 3100,
"rate": 0.031,
"expires_at": "2026-08-25T13:41:53.000Z"
}
}{
"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": {}
}
}Price an exchange without executing it
Show a user what an exchange rate looks like before anything moves. The confirm-screen endpoint for currency conversion: “1,000 UGX becomes 31 KES at 0.031”. Also what a treasury dashboard polls to display the current rate between two of your wallets.
A read-only price check between two currencies you hold wallets in. No money moves and no Idempotency-Key is needed.
Available to every tenant. FX is a baseline capability, not one you are provisioned for: this gateway serves apps across several countries and every provider it routes to supports currency exchange. It needs no entry in your tenant’s allowed_capabilities.
Optional. POST /v1/fx/exchange re-quotes internally, so you never need to call this first — it exists purely for display.
amount is a decimal STRING, not a number. "100.00" survives the round trip; the JSON number 100.00 does not always, and an FX rate applied to a drifted amount is a real money bug. A numeric amount is refused with a 400 rather than silently coerced.
The returned quote expires in about SIXTY SECONDS — far shorter than the 30-minute payout quotation, and expires_at is the provider’s own absolute timestamp rather than a TTL this gateway assumes. Treat it as a display price: call POST /v1/fx/exchange to actually convert, which re-quotes internally so you never have to race the window.
The provider’s exchange token never appears in this response. A caller holding it could convert directly against the provider, bypassing this gateway’s limits, idempotency and records.
curl --request POST \
--url https://api.pay.aptahq.com/v1/fx/quote \
--header 'Content-Type: application/json' \
--header 'X-AptaPay-Signature: <api-key>' \
--data '
{
"from": "UGX",
"to": "KES",
"amount": "1000"
}
'const options = {
method: 'POST',
headers: {'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/quote', 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/quote"
payload = {
"from": "UGX",
"to": "KES",
"amount": "1000"
}
headers = {
"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": {
"quote_id": "evfx_01J8Z2K9",
"source_currency": "UGX",
"source_amount_minor": 1000,
"destination_currency": "KES",
"destination_amount_minor": 3100,
"rate": 0.031,
"expires_at": "2026-08-25T13:41:53.000Z"
}
}{
"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.
Body
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, up to 4 decimal places. Must be greater than zero.
"1000"
Response
A price, valid until expires_at.
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