Click Airtime

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.

StatusCodeDescriptionAction
400INVALID_REQUESTMalformed request body or missing required fieldsFix the request parameters
400INVALID_PHONE_NUMBERPhone number format is invalidValidate phone number format (E.164)
401UNAUTHORIZEDInvalid or expired access tokenRefresh the token and retry
402INSUFFICIENT_BALANCEWallet balance is too lowFund the wallet or reduce the amount
403FORBIDDENMissing required permissionCheck user role and permissions
404NOT_FOUNDResource does not existVerify the resource ID
409DUPLICATE_TRANSACTIONIdempotency key already usedUse the existing transaction result
429RATE_LIMITEDToo many requestsBack 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.

StatusCodeDescriptionAction
500INTERNAL_ERRORUnexpected server errorRetry with exponential backoff
502BAD_GATEWAYUpstream provider errorRetry with exponential backoff
503SERVICE_UNAVAILABLEService is temporarily offlineRetry after Retry-After period
504GATEWAY_TIMEOUTUpstream provider timed outCheck 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

  1. Categorize errors -- Distinguish between client errors (do not retry) and server errors (safe to retry).

  2. Use idempotency keys -- Always include an idempotency key for transaction creation requests to prevent duplicates.

  3. Implement exponential backoff -- Start with a 1-second delay and double it with each retry, adding random jitter.

  4. Check before retrying timeouts -- Query transaction status before retrying a timed-out top-up request.

  5. Refresh tokens proactively -- Refresh JWT tokens before they expire rather than waiting for a 401 response.

  6. Log everything -- Store the full request and response for every API call to assist with debugging and reconciliation.

  7. Set reasonable timeouts -- Use a 30-second timeout for top-up requests and 10 seconds for read operations.

  8. Monitor error rates -- Track your error rate by type and set up alerts for unusual spikes.