Error Handling
Best practices for handling errors, implementing retries, and building resilient integrations with the Click Airtime API.
Robust error handling is essential for any airtime top-up integration. This guide covers common error scenarios and proven patterns for building reliable systems.
Error Response Format
Version 2 API
All V2 error responses follow a consistent structure:
{
"success": false,
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "Your wallet does not have sufficient funds for this transaction",
"statusCode": 402
}
}
Version 1 API
V1 errors use a simpler format with string-based status codes:
{
"message": "Insufficient balance to complete this transaction",
"statusCode": 200,
"code": "0"
}
The V1 API returns HTTP 200 for many error conditions. Always check the code field ("0" = failure) rather than relying on the HTTP status code alone.
Error Categories
Client Errors (4xx)
These errors indicate a problem with the request. Do not retry these automatically.
| Status | Code | Description | Action |
|---|---|---|---|
| 400 | INVALID_REQUEST | Malformed request body or missing required fields | Fix the request parameters |
| 400 | INVALID_PHONE_NUMBER | Phone number format is invalid | Validate phone number format (E.164) |
| 401 | UNAUTHORIZED | Invalid or expired access token | Refresh the token and retry |
| 402 | INSUFFICIENT_BALANCE | Wallet balance is too low | Fund the wallet or reduce the amount |
| 403 | FORBIDDEN | Missing required permission | Check user role and permissions |
| 404 | NOT_FOUND | Resource does not exist | Verify the resource ID |
| 409 | DUPLICATE_TRANSACTION | Idempotency key already used | Use the existing transaction result |
| 429 | RATE_LIMITED | Too many requests | Back off and retry after Retry-After period |
Server Errors (5xx)
These errors indicate a problem on our side. These are safe to retry with exponential backoff.
| Status | Code | Description | Action |
|---|---|---|---|
| 500 | INTERNAL_ERROR | Unexpected server error | Retry with exponential backoff |
| 502 | BAD_GATEWAY | Upstream provider error | Retry with exponential backoff |
| 503 | SERVICE_UNAVAILABLE | Service is temporarily offline | Retry after Retry-After period |
| 504 | GATEWAY_TIMEOUT | Upstream provider timed out | Check transaction status before retrying |
Retry Strategy
Exponential Backoff with Jitter
For transient errors (5xx, network timeouts), implement exponential backoff with random jitter to avoid thundering herd problems.
async function retryWithBackoff(fn, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;
// Only retry on transient errors
if (error.status && error.status < 500 && error.status !== 429) {
throw error;
}
// Exponential backoff with jitter
const baseDelay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
const jitter = Math.random() * 1000;
const delay = baseDelay + jitter;
console.log(`Attempt ${attempt + 1} failed, retrying in ${Math.round(delay)}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Usage
const result = await retryWithBackoff(() => sendTopup({
phoneNumber: '+233541112259',
amount: 5,
}));import time
import random
def retry_with_backoff(fn, max_retries=3):
for attempt in range(max_retries + 1):
try:
return fn()
except Exception as error:
if attempt == max_retries:
raise
# Only retry on transient errors
status = getattr(error, 'status_code', 500)
if status < 500 and status != 429:
raise
# Exponential backoff with jitter
base_delay = (2 ** attempt) # 1s, 2s, 4s
jitter = random.uniform(0, 1)
delay = base_delay + jitter
print(f"Attempt {attempt + 1} failed, retrying in {delay:.1f}s...")
time.sleep(delay)
# Usage
result = retry_with_backoff(lambda: send_topup(
phone_number='+233541112259',
amount=5,
))Idempotency
Idempotency ensures that retrying a request does not cause duplicate transactions. This is critical for financial operations.
Version 2 API
Use the X-Idempotency-Key header with a unique value for each logical transaction:
const idempotencyKey = `topup_${customerId}_${Date.now()}`;
const response = await fetch(
'https://api.clickairtime.com/enterprise/airtime/transactions/create',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'X-Company-ID': companyId,
'X-Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(transactionData),
}
);
Version 1 API
Use the extRefId field in the request body:
const response = await fetch('https://api.clickairtime.com/adp/topup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Click-Airtime-Email': email,
'X-Click-Airtime-Token': token,
},
body: JSON.stringify({
msisdn: '233541112259',
amount: 5,
extRefId: 'unique_reference_12345',
}),
});
Never retry a top-up request without checking its status first. If your initial request timed out, the top-up may have already been delivered. Query the transaction status before retrying to avoid duplicate deliveries.
Timeout Handling
Network timeouts are the most dangerous error scenario for financial APIs because you cannot be certain whether the transaction was processed.
async function safeTopup(transactionData, idempotencyKey) {
try {
return await sendTopup(transactionData, idempotencyKey);
} catch (error) {
if (error.name === 'AbortError' || error.code === 'ETIMEDOUT') {
// Timeout occurred — check if the transaction was processed
console.log('Request timed out, checking transaction status...');
const existing = await checkTransactionStatus(idempotencyKey);
if (existing) {
console.log('Transaction was already processed:', existing.status);
return existing;
}
// Transaction was not processed — safe to retry
console.log('Transaction not found, retrying...');
return await sendTopup(transactionData, idempotencyKey);
}
throw error;
}
}
Token Refresh Pattern
For the V2 API, implement automatic token refresh when receiving 401 Unauthorized:
class ClickAirtimeClient {
constructor(email, password, companyId) {
this.email = email;
this.password = password;
this.companyId = companyId;
this.accessToken = null;
this.refreshToken = null;
}
async request(method, path, body = null) {
if (!this.accessToken) {
await this.login();
}
let response = await this.makeRequest(method, path, body);
// If token expired, refresh and retry once
if (response.status === 401 && this.refreshToken) {
await this.refresh();
response = await this.makeRequest(method, path, body);
}
return response.json();
}
async login() {
const res = await fetch('https://api.clickairtime.com/enterprise/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: this.email, password: this.password }),
}).then(r => r.json());
this.accessToken = res.data.accessToken;
this.refreshToken = res.data.refreshToken;
}
async refresh() {
const res = await fetch('https://api.clickairtime.com/enterprise/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: this.refreshToken }),
}).then(r => r.json());
this.accessToken = res.data.accessToken;
this.refreshToken = res.data.refreshToken;
}
async makeRequest(method, path, body) {
return fetch(`https://api.clickairtime.com${path}`, {
method,
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'X-Company-ID': String(this.companyId),
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
}
}
Best Practices Summary
-
Categorize errors -- Distinguish between client errors (do not retry) and server errors (safe to retry).
-
Use idempotency keys -- Always include an idempotency key for transaction creation requests to prevent duplicates.
-
Implement exponential backoff -- Start with a 1-second delay and double it with each retry, adding random jitter.
-
Check before retrying timeouts -- Query transaction status before retrying a timed-out top-up request.
-
Refresh tokens proactively -- Refresh JWT tokens before they expire rather than waiting for a 401 response.
-
Log everything -- Store the full request and response for every API call to assist with debugging and reconciliation.
-
Set reasonable timeouts -- Use a 30-second timeout for top-up requests and 10 seconds for read operations.
-
Monitor error rates -- Track your error rate by type and set up alerts for unusual spikes.
