Integrate AFA Registrations & Data Bundles
Submit AFA registrations and sell MTN, MTN Instant, Telecel and AirtelTigo data bundles directly from your own website, app, or backend using a personal API key.
Getting Started
- Sign in to your AFA Portal account and open API Keys.
- Generate a key and copy it somewhere safe — it's shown only once.
- Send it as an
X-API-KEYheader on every request below.
Authentication
Every request must include your API key in an X-API-KEY header.
Requests are billed to the wallet of the account that owns the key — AFA registrations at that account's tier fee and data bundles at the listed package price, exactly like using the portal.
X-API-KEY: YOUR_API_KEY
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/afa/fee | Get your current registration fee and tier |
| POST | /api/v1/afa/register | Submit a new AFA registration |
| GET | /api/v1/afa/registrations | Paginated list of your submitted registrations |
| GET | /api/v1/data/packages | Data bundles you can buy right now (MTN, MTN Instant, Telecel, AirtelTigo) |
| POST | /api/v1/data/verify-number | Check a recipient number (valid, right network, MTN verified) before buying |
| POST | /api/v1/data/purchase | Buy a data bundle for any Ghana number |
| GET | /api/v1/data/orders | Paginated list of your data orders |
| GET | /api/v1/data/orders/{reference} | One data order by our reference or your client_reference |
Create a Registration
POST /api/v1/afa/register — debits your wallet at your tier's fee and creates a pending registration for admin review.
curl -X POST https://afaportal.com/api/v1/afa/register \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"full_name": "Kwame Mensah",
"phone": "0241234567",
"id_card_number": "GHA-123456789-0",
"location": "Accra, Madina"
}'<?php
$apiKey = 'YOUR_API_KEY';
$ch = curl_init('https://afaportal.com/api/v1/afa/register');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'X-API-KEY: ' . $apiKey,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'full_name' => 'Kwame Mensah',
'phone' => '0241234567',
'id_card_number' => 'GHA-123456789-0',
'location' => 'Accra, Madina',
]),
]);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($statusCode === 201) {
echo "Registration submitted: #{$data['data']['id']}";
} else {
echo "Error: {$data['message']}";
}const response = await fetch('https://afaportal.com/api/v1/afa/register', {
method: 'POST',
headers: {
'X-API-KEY': 'YOUR_API_KEY',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
full_name: 'Kwame Mensah',
phone: '0241234567',
id_card_number: 'GHA-123456789-0',
location: 'Accra, Madina',
}),
});
const data = await response.json();
if (response.ok) {
console.log('Registration submitted:', data.data.id);
} else {
console.error('Error:', data.message);
}// Node.js 18+ (built-in fetch, no extra dependencies)
async function submitAfaRegistration() {
const response = await fetch('https://afaportal.com/api/v1/afa/register', {
method: 'POST',
headers: {
'X-API-KEY': process.env.AFA_API_KEY,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
full_name: 'Kwame Mensah',
phone: '0241234567',
id_card_number: 'GHA-123456789-0',
location: 'Accra, Madina',
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'AFA registration failed');
}
return data.data;
}
submitAfaRegistration()
.then((registration) => console.log('Submitted:', registration.id))
.catch((err) => console.error(err.message));import requests
API_KEY = 'YOUR_API_KEY'
BASE_URL = 'https://afaportal.com'
response = requests.post(
f'{BASE_URL}/api/v1/afa/register',
headers={
'X-API-KEY': API_KEY,
'Accept': 'application/json',
},
json={
'full_name': 'Kwame Mensah',
'phone': '0241234567',
'id_card_number': 'GHA-123456789-0',
'location': 'Accra, Madina',
},
)
data = response.json()
if response.status_code == 201:
print(f"Registration submitted: #{data['data']['id']}")
else:
print(f"Error: {data['message']}")Example response — 201 Created
{
"data": {
"id": 123,
"status": "pending",
"status_label": "Pending",
"fee_paid": "15.00",
"full_name": "Kwame Mensah",
"phone": "0241234567",
"id_card_number": "GHA-123456789-0",
"location": "Accra, Madina",
"submitter_tier": "agent",
"admin_notes": null,
"created_at": "2026-09-11T09:00:00+00:00",
"updated_at": "2026-09-11T09:00:00+00:00"
},
"message": "Registration submitted. GHS 15.00 was deducted from your wallet.",
"reference": "AFA-42-123"
}
Fee & History
Get your current fee before submitting:
curl https://afaportal.com/api/v1/afa/fee \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Accept: application/json"
List your submitted registrations, paginated:
curl "https://afaportal.com/api/v1/afa/registrations?page=1&per_page=15" \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Accept: application/json"
Data: Packages
GET /api/v1/data/packages — every bundle you can buy right now, with the price your wallet will be charged.
Filter with ?network=mtn, ?network=mtn_instant, ?network=telecel or ?network=airteltigo.
Availability and prices can change, so fetch this list regularly rather than hard-coding package IDs or prices.
# All buyable bundles
curl "https://afaportal.com/api/v1/data/packages" \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Accept: application/json"
# Filter by network: mtn | mtn_instant | telecel | airteltigo
curl "https://afaportal.com/api/v1/data/packages?network=mtn_instant" \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Accept: application/json"
Example response — 200 OK
{
"data": [
{
"id": 24,
"network": "telecel",
"network_label": "Telecel",
"name": "10GB",
"capacity_mb": 10000,
"price": "36.00"
},
{
"id": 41,
"network": "mtn_instant",
"network_label": "MTN Instant",
"name": "890MB",
"capacity_mb": 890,
"price": "6.50"
},
{
"id": 42,
"network": "mtn_instant",
"network_label": "MTN Instant",
"name": "1GB",
"capacity_mb": 1000,
"price": "6.50"
}
]
}
Data: Verify a Number
POST /api/v1/data/verify-number — check a recipient number before you buy, so you don't send data to a wrong or unusable number.
It confirms the number is a real Ghana mobile number, that it belongs to the network you're buying for, and for MTN, whether MTN reports the number as verified.
Checking is free; nothing is charged to your wallet.
| Field | Required | Description |
|---|---|---|
| phone | Yes | The number to check — 0241234567 or 233241234567 |
| network | Recommended | The network you plan to buy for: mtn, mtn_instant, telecel or airteltigo. If you leave it out we use the network the number belongs to. |
curl -X POST "https://www.afaportal.com/api/v1/data/verify-number" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"phone": "0241234567", "network": "mtn"}'
Example response — 200 OK (good to go)
{
"data": {
"phone": "0241234567",
"network": "mtn",
"detected_network": "mtn",
"status": "verified",
"can_buy": true,
"message": "0241234567 is a verified MTN number. Good to go!"
}
}
Example response — 200 OK (wrong network)
{
"data": {
"phone": "0201234567",
"network": "mtn",
"detected_network": "telecel",
"status": "wrong_network",
"can_buy": false,
"message": "0201234567 looks like a Telecel number, but you're buying MTN data."
}
}
Example response — 200 OK (MTN still checking)
{
"data": {
"phone": "0551234567",
"network": "mtn",
"detected_network": "mtn",
"status": "checking",
"can_buy": null,
"message": "Checking 0551234567 with MTN… this can take up to 2 minutes. Please wait.",
"retry_after": 30
}
}
The status is one of:
| status | can_buy | Meaning |
|---|---|---|
| verified | true | MTN confirms the number is verified (or it has received data from us before) |
| valid | true | A valid Telecel or AirtelTigo number on the right network |
| checking | null | MTN is still checking a new number. Call again after retry_after seconds (usually ready within 2 minutes) |
| not_verified | false | MTN says the number is not verified — data may not be delivered to it |
| wrong_network | false | The number belongs to a different network (see detected_network) |
| invalid | false | Not a valid Ghana mobile number, or MTN reports it as invalid |
| unknown | true | We couldn't reach MTN to check right now. You can still buy, but double-check the number |
Tip: call this endpoint as your customer types or confirms the number, then only call
POST /api/v1/data/purchase when can_buy is true. Results are cached, so checking the same number again is instant.
Number checks are limited to 20 per minute per API key.
Data: Buy a Bundle
POST /api/v1/data/purchase — debits your wallet at the package price and sends the bundle for delivery straight away.
| Field | Required | Description |
|---|---|---|
| package_id | Yes | An id from the packages endpoint |
| phone | Yes | Recipient's Ghana number — 0201234567 or 233201234567 |
| client_reference | Recommended | Your own order ID (max 64 chars), unique per account. Sending the same value again never charges twice — you get the original order back with a 409, so it is safe to retry after a timeout. |
| callback_url | No | HTTPS URL we POST to when the order is delivered or fails — see Callbacks |
curl -X POST https://afaportal.com/api/v1/data/purchase \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"package_id": 24,
"phone": "0201234567",
"client_reference": "ORDER-10045",
"callback_url": "https://your-site.com/webhooks/data"
}'<?php
$apiKey = 'YOUR_API_KEY';
$ch = curl_init('https://afaportal.com/api/v1/data/purchase');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'X-API-KEY: ' . $apiKey,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'package_id' => 24,
'phone' => '0201234567',
'client_reference' => 'ORDER-10045', // your own order ID — safe to retry
'callback_url' => 'https://your-site.com/webhooks/data',
]),
]);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($statusCode === 201) {
echo "Order {$data['data']['reference']} is {$data['data']['status']}";
} else {
echo "Error ({$statusCode}): {$data['message']}";
}// Call this from your server — never expose your API key in browser code.
const response = await fetch('https://afaportal.com/api/v1/data/purchase', {
method: 'POST',
headers: {
'X-API-KEY': 'YOUR_API_KEY',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
package_id: 24,
phone: '0201234567',
client_reference: 'ORDER-10045',
callback_url: 'https://your-site.com/webhooks/data',
}),
});
const data = await response.json();
if (response.status === 201) {
console.log('Order placed:', data.data.reference, data.data.status);
} else {
console.error('Error:', data.message);
}// Node.js 18+ (built-in fetch, no extra dependencies)
async function buyData(packageId, phone, orderId) {
const response = await fetch('https://afaportal.com/api/v1/data/purchase', {
method: 'POST',
headers: {
'X-API-KEY': process.env.AFA_API_KEY,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
package_id: packageId,
phone,
client_reference: orderId,
callback_url: 'https://your-site.com/webhooks/data',
}),
});
const data = await response.json();
if (response.status === 409) {
return data.data; // already placed earlier with this client_reference
}
if (!response.ok) {
throw new Error(data.message || 'Data purchase failed');
}
return data.data;
}
buyData(24, '0201234567', 'ORDER-10045')
.then((order) => console.log(order.reference, order.status))
.catch((err) => console.error(err.message));import requests
API_KEY = 'YOUR_API_KEY'
BASE_URL = 'https://afaportal.com'
response = requests.post(
f'{BASE_URL}/api/v1/data/purchase',
headers={
'X-API-KEY': API_KEY,
'Accept': 'application/json',
},
json={
'package_id': 24,
'phone': '0201234567',
'client_reference': 'ORDER-10045',
'callback_url': 'https://your-site.com/webhooks/data',
},
)
data = response.json()
if response.status_code == 201:
print(f"Order {data['data']['reference']} is {data['data']['status']}")
else:
print(f"Error ({response.status_code}): {data['message']}")Example response — 201 Created
{
"data": {
"reference": "DT-K2M9QX7ZP4LA",
"client_reference": "ORDER-10045",
"status": "processing",
"status_label": "Processing",
"network": "telecel",
"package": "10GB",
"package_id": 24,
"capacity_mb": 10000,
"recipient_phone": "0201234567",
"amount": "36.00",
"refunded": false,
"failure_reason": null,
"delivered_at": null,
"created_at": "2026-10-11T09:00:00+00:00",
"updated_at": "2026-10-11T09:00:01+00:00"
},
"message": "Order received. GHS 36.00 was deducted from your wallet."
}
The status of an order is one of:
| pending | Paid and queued — we are still handing it to the network. Retried automatically. |
| processing | Accepted by the network and on its way to the recipient |
| completed | Delivered to the recipient |
| failed | Could not be delivered — the amount is refunded to your wallet automatically (refunded: true) and failure_reason explains why |
Data: Order Status
Look up one order by the reference we returned or by your own client_reference, or page through your history.
If you are not using callbacks, poll an order no more than once a minute until it is completed or failed.
# By our reference or by your client_reference
curl https://afaportal.com/api/v1/data/orders/ORDER-10045 \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Accept: application/json"
# Your order history (optional ?status=pending|processing|completed|failed)
curl "https://afaportal.com/api/v1/data/orders?page=1&per_page=15" \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Accept: application/json"
Data: Callbacks
If you send a callback_url, we POST the final order to it once, when the order becomes
completed (data.order.delivered) or
failed (data.order.failed). Respond with any 2xx status.
POST https://your-site.com/webhooks/data
Content-Type: application/json
{
"event": "data.order.delivered",
"data": {
"reference": "DT-K2M9QX7ZP4LA",
"client_reference": "ORDER-10045",
"status": "completed",
"status_label": "Delivered",
"network": "telecel",
"package": "10GB",
"recipient_phone": "0201234567",
"amount": "36.00",
"refunded": false,
"delivered_at": "2026-10-11T09:02:13+00:00",
...
}
}
Callbacks are not signed and are not retried. Treat one as a prompt to re-fetch the order from
GET /api/v1/data/orders/{reference} with your API key before you act on it, and keep polling as a fallback.
Errors
| Status | Meaning |
|---|---|
| 401 | Missing, invalid, or revoked API key |
| 400 | Validation failed — check the errors object in the response |
| 402 | Insufficient wallet balance — top up before retrying |
| 404 | Data order not found on your account |
| 409 | AFA: an active registration already exists for that phone or ID number. Data: an order with that client_reference already exists — the original order is returned in data and you were not charged again |
| 422 | Data: the network refused the bundle — the order is returned with status: failed and the amount was refunded to your wallet |
| 503 | Data: that package is not available right now — refresh the packages list and try another |
| 429 | Rate limit exceeded — see below. Number checks return a retry_after (seconds) |
Rate Limits
Each API key is limited to 60 requests per minute. Exceeding this returns a 429 response. Number checks (/data/verify-number) are also limited to 20 per minute.