AFA Portal AfaPortal ← Back to sign in
Public API v1

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

  1. Sign in to your AFA Portal account and open API Keys.
  2. Generate a key and copy it somewhere safe — it's shown only once.
  3. Send it as an X-API-KEY header 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
phoneYesThe number to check — 0241234567 or 233241234567
networkRecommendedThe 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 trueMTN confirms the number is verified (or it has received data from us before)
valid trueA valid Telecel or AirtelTigo number on the right network
checking nullMTN is still checking a new number. Call again after retry_after seconds (usually ready within 2 minutes)
not_verified falseMTN says the number is not verified — data may not be delivered to it
wrong_network falseThe number belongs to a different network (see detected_network)
invalid falseNot a valid Ghana mobile number, or MTN reports it as invalid
unknown trueWe 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_idYesAn id from the packages endpoint
phoneYesRecipient's Ghana number — 0201234567 or 233201234567
client_referenceRecommendedYour 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_urlNoHTTPS 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.