Click Airtime

Webhooks

Receive real-time notifications when topup transactions change status.

Webhooks

Receive real-time HTTP notifications when the status of a topup transaction changes. Instead of polling the /v2/topups/:id endpoint, provide a callback_url when creating a topup and we will send status updates to your server.

How It Works

  1. Include a callback_url in your POST /v2/topups request body.
  2. When the transaction status changes, we send a POST request to your URL with the transaction data.
  3. Your endpoint should return a 2xx status code to acknowledge receipt.
curl -X POST "https://api.clickairtime.com/v2/topups" \
  -H "Authorization: Bearer ce_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "+233541112259",
    "network_id": 42,
    "amount": { "value": 50, "currency": "GHS" },
    "callback_url": "https://your-app.com/webhooks/airtime"
  }'

Event Types

EventDescription
topup.processingThe transaction has been accepted and delivery is in progress
topup.completedAirtime was successfully delivered to the recipient
topup.failedDelivery failed (check failure_reason in the payload)

Webhook Headers

Every webhook request includes these headers:

HeaderDescription
X-Webhook-EventEvent type (e.g., topup.completed)
X-Webhook-SignatureHMAC-SHA256 signature of the request body
X-Webhook-TimestampUnix timestamp when the webhook was sent
Content-TypeAlways application/json

Verifying Signatures

To verify that a webhook was sent by Click Airtime and has not been tampered with, validate the X-Webhook-Signature header using your API key as the HMAC secret.

const crypto = require('crypto');

function verifyWebhookSignature(body, signature, timestamp, apiKey) {
  const payload = `${timestamp}.${body}`;
  const expected = crypto
    .createHmac('sha256', apiKey)
    .update(payload)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// In your webhook handler:
app.post('/webhooks/airtime', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const timestamp = req.headers['x-webhook-timestamp'];
  const body = JSON.stringify(req.body);

  if (!verifyWebhookSignature(body, signature, timestamp, process.env.API_KEY)) {
    return res.status(401).send('Invalid signature');
  }

  const event = req.headers['x-webhook-event'];
  // Handle the event...
  res.status(200).send('OK');
});
import hmac
import hashlib

def verify_webhook_signature(body: str, signature: str, timestamp: str, api_key: str) -> bool:
    payload = f"{timestamp}.{body}"
    expected = hmac.new(
        api_key.encode(),
        payload.encode(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

# In your webhook handler (Flask example):
@app.route('/webhooks/airtime', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Webhook-Signature')
    timestamp = request.headers.get('X-Webhook-Timestamp')
    body = request.get_data(as_text=True)

    if not verify_webhook_signature(body, signature, timestamp, API_KEY):
        return 'Invalid signature', 401

    event = request.headers.get('X-Webhook-Event')
    # Handle the event...
    return 'OK', 200
function verifyWebhookSignature(string $body, string $signature, string $timestamp, string $apiKey): bool {
    $payload = "{$timestamp}.{$body}";
    $expected = hash_hmac('sha256', $payload, $apiKey);
    return hash_equals($expected, $signature);
}

// In your webhook handler:
$body = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';

if (!verifyWebhookSignature($body, $signature, $timestamp, $apiKey)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = $_SERVER['HTTP_X_WEBHOOK_EVENT'] ?? '';
$payload = json_decode($body, true);
// Handle the event...
http_response_code(200);

Webhook Payload

The webhook body uses the same response envelope as the V2 API. The data object is identical to the response from GET /v2/topups/:id.

topup.processing

{
  "success": true,
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "status": "processing",
    "phone_number": "+233541112259",
    "network": {
      "id": 42,
      "name": "MTN Ghana",
      "country": "Ghana",
      "country_code": "GH"
    },
    "amount": {
      "value": 50,
      "currency": "GHS"
    },
    "cost": {
      "value": 4.05,
      "currency": "USD"
    },
    "product_id": null,
    "product_name": null,
    "reference": "invoice-12345",
    "provider_reference": null,
    "failure_reason": null,
    "metadata": {
      "customer_id": "cust_001"
    },
    "created_at": "2024-01-15T10:30:00.000Z",
    "completed_at": null
  },
  "meta": {
    "request_id": "req_w1h2k3e4v5t6",
    "timestamp": "2024-01-15T10:30:01.000Z"
  }
}

topup.completed

{
  "success": true,
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "status": "completed",
    "phone_number": "+233541112259",
    "network": {
      "id": 42,
      "name": "MTN Ghana",
      "country": "Ghana",
      "country_code": "GH"
    },
    "amount": {
      "value": 50,
      "currency": "GHS"
    },
    "cost": {
      "value": 4.05,
      "currency": "USD"
    },
    "product_id": null,
    "product_name": null,
    "reference": "invoice-12345",
    "provider_reference": "DT-123456789",
    "failure_reason": null,
    "metadata": {
      "customer_id": "cust_001"
    },
    "created_at": "2024-01-15T10:30:00.000Z",
    "completed_at": "2024-01-15T10:30:02.500Z"
  },
  "meta": {
    "request_id": "req_w1h2k3e4v5t6",
    "timestamp": "2024-01-15T10:30:02.500Z"
  }
}

topup.failed

{
  "success": true,
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "status": "failed",
    "phone_number": "+233541112259",
    "network": {
      "id": 42,
      "name": "MTN Ghana",
      "country": "Ghana",
      "country_code": "GH"
    },
    "amount": {
      "value": 50,
      "currency": "GHS"
    },
    "cost": {
      "value": 0,
      "currency": "USD"
    },
    "product_id": null,
    "product_name": null,
    "reference": "invoice-12345",
    "provider_reference": null,
    "failure_reason": "Provider temporarily unavailable. Please retry.",
    "metadata": {
      "customer_id": "cust_001"
    },
    "created_at": "2024-01-15T10:30:00.000Z",
    "completed_at": null
  },
  "meta": {
    "request_id": "req_w1h2k3e4v5t6",
    "timestamp": "2024-01-15T10:30:03.000Z"
  }
}

Retry Behavior

If your endpoint does not return a 2xx status code (or is unreachable), we retry delivery with exponential backoff:

AttemptDelay
1st retry~1 minute
2nd retry~5 minutes
3rd retry~30 minutes

After 3 failed retries, the webhook is abandoned. You can always check the final transaction status via GET /v2/topups/:id.

Your webhook endpoint should return a 2xx response within 10 seconds. Long-running processing should be handled asynchronously after acknowledging receipt.

Best Practices

  • Verify signatures on every request to ensure authenticity.
  • Respond quickly with a 2xx status, then process the event asynchronously.
  • Handle duplicates gracefully -- the same event may be delivered more than once due to retries.
  • Use HTTPS for your callback_url to protect data in transit.
  • Log webhook payloads for debugging and audit purposes.