curl --request POST \
--url https://api.pay.aptahq.com/v1/collections/otp \
--header 'Content-Type: application/json' \
--header 'X-AptaPay-Signature: <api-key>' \
--data '
{
"msisdn": "0772123456",
"country": "UG"
}
'const options = {
method: 'POST',
headers: {'X-AptaPay-Signature': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({msisdn: '0772123456', country: 'UG'})
};
fetch('https://api.pay.aptahq.com/v1/collections/otp', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://api.pay.aptahq.com/v1/collections/otp"
payload = {
"msisdn": "0772123456",
"country": "UG"
}
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": "<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": {}
}
}Request a collection OTP
Trigger an SMS one-time PIN for a momo collection.
You almost certainly do not need this. This account is whitelisted for phone verification, so no corridor requires an OTP — POST /v1/collections charges on one call and the customer approves on their handset. The endpoint is kept for the case where a corridor is configured to demand a code, and for a deployment on an un-whitelisted account.
When you do need it: call this first, show the user an “enter the code we texted you” field, then pass what they type back on the charge. It returns a pin_id; send it with the code as otp: {pin_id, pin} — on POST /v1/collections for a fresh charge, or on POST /v1/collections/{reference}/authorize for one already created and sitting at requires_action.
No money moves, so no Idempotency-Key. Calling it again simply sends another code.
curl --request POST \
--url https://api.pay.aptahq.com/v1/collections/otp \
--header 'Content-Type: application/json' \
--header 'X-AptaPay-Signature: <api-key>' \
--data '
{
"msisdn": "0772123456",
"country": "UG"
}
'const options = {
method: 'POST',
headers: {'X-AptaPay-Signature': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({msisdn: '0772123456', country: 'UG'})
};
fetch('https://api.pay.aptahq.com/v1/collections/otp', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://api.pay.aptahq.com/v1/collections/otp"
payload = {
"msisdn": "0772123456",
"country": "UG"
}
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": "<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
- Option 1
- Option 2
msisdn is required. Supply country OR currency so the corridor can be resolved — the phone number is normalised against it before the provider is called.
Any accepted form — 0772123456, 256772123456, +256772123456 or the bare national 772123456. Normalised to E.164 with the leading +.
"0772123456"
"UG"
Alternative to country when the corridor is unambiguous for that currency.
"UGX"
Response
OTP dispatched.
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