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
- Include a
callback_urlin yourPOST /v2/topupsrequest body. - When the transaction status changes, we send a
POSTrequest to your URL with the transaction data. - Your endpoint should return a
2xxstatus 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
| Event | Description |
|---|---|
topup.processing | The transaction has been accepted and delivery is in progress |
topup.completed | Airtime was successfully delivered to the recipient |
topup.failed | Delivery failed (check failure_reason in the payload) |
Webhook Headers
Every webhook request includes these headers:
| Header | Description |
|---|---|
X-Webhook-Event | Event type (e.g., topup.completed) |
X-Webhook-Signature | HMAC-SHA256 signature of the request body |
X-Webhook-Timestamp | Unix timestamp when the webhook was sent |
Content-Type | Always 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', 200function 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:
| Attempt | Delay |
|---|---|
| 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
2xxstatus, 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_urlto protect data in transit. - Log webhook payloads for debugging and audit purposes.
