Developers

Partner API Product guide

Airtime & data

MTN, Telecel and AT airtime top-ups and data bundles, with live bundle lookup per phone number.

Base URL http://fex.mojo-pay.com/v1
Open API tester

Overview

Everything you need to sell airtime & data through the FEX Partner API. All paths are relative to the /v1 base URL above. Shared concepts (sandbox vs live, rate limits) are on the Partner API overview.

Integration flow

  1. Get a Bearer token with POST /oauth/token.
  2. List products with GET /catalog/products?category=airtime or ?category=data.
  3. For data, look up the bundles available to the phone with POST /catalog/products/{id}/lookup.
  4. Create a quote — airtime needs phone + amount; data also needs the bundle from the lookup.
  5. Create an order from the quote and send the customer to payment.url.
  6. Wait for order.completed or order.failed.

Products

Product Name Required fields Lookup
PRD_HB_AT_AIRTIME
hb-at-airtime
AirtelTigo Airtime phone amount —
PRD_PPN_TOPUP
ppn-topup
International Airtime Top-up sku_id mobile amount Yes
PRD_HB_MTN_AIRTIME
hb-mtn-airtime
MTN Airtime phone amount —
PRD_HB_TELECEL_AIRTIME
hb-telecel-airtime
Telecel Airtime phone amount —
PRD_HB_AT_DATA
hb-at-data
AirtelTigo Data Bundle phone bundle amount Yes
PRD_HB_MTN_DATA
hb-mtn-data
MTN Data Bundle phone bundle amount Yes
PRD_HB_TELECEL_DATA
hb-telecel-data
Telecel Data Bundle phone bundle amount Yes

Orientation only — your credentials see a partner-scoped subset. Always call GET /catalog/products and read each input_schema rather than hardcoding this table.

Authentication

Create a client_id / client_secret under Partner Portal → API credentials, then exchange them for a Bearer token (about 20 minutes). Send Authorization: Bearer <token> on every other call.

Global headers

Header Required Description
Authorization Yes* Bearer <token> (*except token endpoint)
Content-Type POST application/json
Accept Recommended application/json
X-FEX-Country No ISO country (e.g. GH). Defaults to partner profile.
Idempotency-Key Recommended On POST /orders — replay-safe create.
POST /oauth/token No auth

Issue a short-lived Bearer access token.

Send Authorization: Bearer <access_token> on all other routes.

Body parameters

client_id required
string
From Partner Portal → API credentials.
client_secret required
string
Shown once when you generate or rotate credentials in the Partner Portal.
grant_type optional
string
Optional. Must be client_credentials when sent.
POST http://fex.mojo-pay.com/v1/oauth/token
Request
{
  "grant_type": "client_credentials",
  "client_id": "fex_…",
  "client_secret": "…"
}
Response 200
{
  "access_token": "…",
  "token_type": "Bearer",
  "expires_in": 1200
}

Products

Airtime is under the airtime category and data bundles under data, one product per network (MTN, Telecel, AT).

Rules

  • Airtime (hb-mtn-airtime, hb-telecel-airtime, hb-at-airtime): phone + amount, from GHS 0.50 up to GHS 100 per order.
  • Data (hb-mtn-data, hb-telecel-data, hb-at-data): phone + bundle + amount, where the bundle and amount come from the lookup.
  • Phone numbers may be local (0246912184) or international (233246912184).
  • Amounts allow at most 2 decimals.
GET /catalog/products Bearer

List airtime or data products available to your partner.

Query parameters

country optional
string
ISO country code.
category optional
string
Category code from list categories.
GET http://fex.mojo-pay.com/v1/catalog/products
Request
GET /v1/catalog/products?category=data
Response 200
{
  "object": "list",
  "data": [
    {
      "id": "PRD_HB_ECG",
      "object": "product",
      "code": "hb-ecg-bill",
      "name": "ECG Prepaid & Postpaid",
      "category": "bills",
      "country": "GH",
      "currency": "GHS",
      "status": "sandbox",
      "input_schema": {
        "type": "object",
        "required": ["phone", "meter_number", "amount"],
        "properties": {
          "phone": { "type": "string" },
          "meter_number": { "type": "string" },
          "amount": { "type": "number" }
        }
      },
      "min_amount": "1.00",
      "max_amount": null,
      "provider": { "id": "PRV_…", "code": "…", "name": "…" },
      "lookup_url": "https://…/v1/catalog/products/PRD_HB_ECG/lookup"
    }
  ]
}
GET /catalog/products/{id} Bearer

Retrieve a single product by public id (PRD_…).

Path parameters

id required
string
Product public id.
GET http://fex.mojo-pay.com/v1/catalog/products/{id}
Request
GET /catalog/products/{id}
Authorization: Bearer <token>
Response 200
{
  "id": "PRD_HB_ECG",
  "object": "product",
  "code": "hb-ecg-bill",
  "name": "ECG Prepaid & Postpaid",
  "category": "bills",
  "input_schema": {
    "type": "object",
    "required": ["phone", "meter_number", "amount"],
    "properties": {
      "phone": { "type": "string", "title": "Phone linked to the meter" },
      "meter_number": { "type": "string", "title": "Meter / account number" },
      "amount": { "type": "number", "title": "Amount (GHS)" }
    }
  },
  "provider": { "id": "PRV_…", "code": "…", "name": "…" },
  "lookup_url": "https://…/v1/catalog/products/PRD_HB_ECG/lookup"
}

Bundle lookup

Data bundles differ per phone number and change often — look them up right before quoting.

POST /catalog/products/{id}/lookup Bearer

Resolve account details or purchasable options before quoting — data bundles for a phone, the account name / amount due for DSTV, GOtv, StarTimes, Ghana Water and Telecel Broadband, or the meters linked to a phone for ECG. Only products that return a lookup_url support this; others answer 422 lookup_unsupported.

For data bundles, quote with fields.bundle = the item value and fields.amount = its amount — the pair must match (some values such as flexi_data_bundle repeat with different amounts). Account lookups return type: account with an account map (e.g. name, amount_due). Bundle lists change — always look up before quoting.

Path parameters

id required
string
Product public id.

Body parameters

fields required
object
Lookup destination: phone (data bundles, ECG), account_number (DSTV, GOtv, StarTimes), landline_number (Telecel Broadband), or meter_number + phone (Ghana Water).
POST http://fex.mojo-pay.com/v1/catalog/products/{id}/lookup
Request (MTN data bundles)
{
  "fields": {
    "phone": "0246912184"
  }
}
Response 200
{
  "object": "lookup",
  "product_id": "PRD_HB_MTN_DATA",
  "type": "bundles",
  "label": "Successful",
  "items": [
    { "display": "349.24MB", "value": "data_bundle_3", "amount": "3.00" },
    { "display": "1.19GB", "value": "flexi_data_bundle", "amount": "20.00" }
  ],
  "account": null
}

Quotes

A quote locks the price for the fields you send. Every field must satisfy the product input_schema.

POST /quotes Bearer

Price a product for the submitted fields. Quotes expire — create the order before expires_at.

Airtime quotes only need phone and amount, e.g. {"product_id": "PRD_HB_MTN_AIRTIME", "fields": {"phone": "0246912184", "amount": 10}}. A data quote fails with validation_failed if the bundle/amount pair is not in the current lookup.

Body parameters

product_id required
string
Product public id from the catalog.
customer_id optional
string
Optional FEX customer id — provider-specific fields are merged from linked profiles. For SecondsTax, api_user defaults to the customer’s email or phone (same identity used on provider-funds / provider-portfolios).
fields required
object
Must satisfy the product input_schema. Explicit fields override merged customer/profile values (e.g. fields.api_user).
POST http://fex.mojo-pay.com/v1/quotes
Request (MTN data bundle)
{
  "product_id": "PRD_HB_MTN_DATA",
  "fields": {
    "phone": "0246912184",
    "bundle": "data_bundle_3",
    "amount": 3
  }
}
Response 201
{
  "id": "QTE_…",
  "object": "quote",
  "product_id": "PRD_HB_MTN_DATA",
  "currency": "GHS",
  "amount": "3.00",
  "amount_payable": "3.00",
  "input_fields": {
    "phone": "0246912184",
    "bundle": "data_bundle_3",
    "amount": 3
  },
  "expires_at": "2026-08-30T15:00:00+00:00"
}
GET /quotes/{id} Bearer

Retrieve a quote owned by the authenticated partner.

Path parameters

id required
string
Quote public id.
GET http://fex.mojo-pay.com/v1/quotes/{id}
Request
GET /quotes/{id}
Authorization: Bearer <token>

Orders

Lifecycle: pending_payment → paid → fulfilling → completed (or failed / cancelled / expired). After payment the order moves to fulfilling while the biller or network confirms. Most complete within seconds; some take a few minutes. FEX retries status checks automatically, so do not re-order — wait for the webhook or poll the order.

POST /orders Bearer

Create an order from a quote and start payment.

Open payment.url. Sandbox-tier partners use FEX hosted sandbox pay; live-tier partners use MojoPay Omni when configured. Then poll or wait for webhooks. Configure your webhook URL under Partner Portal → Webhooks.

Headers

Idempotency-Key required
UUID recommended. Same key returns the same order.

Body parameters

quote_id required
string
From POST /quotes.
partner_reference required
string
Your reconciliation key.
payment_method optional
string
checkout (default) or collection.
success_url optional
url
Return URL after payment UX.
cancel_url optional
url
Cancel / abandon URL.
customer optional
object
Optional customer details.
metadata optional
object
Arbitrary partner metadata.
POST http://fex.mojo-pay.com/v1/orders
Request
{
  "quote_id": "QTE_…",
  "partner_reference": "INV-1001",
  "payment_method": "checkout",
  "success_url": "https://partner.example/success",
  "cancel_url": "https://partner.example/cancel",
  "customer": { "email": "customer@example.com" },
  "metadata": { "channel": "mobile_app" }
}
Response 201
{
  "id": "ORD_…",
  "object": "order",
  "status": "pending_payment",
  "partner_reference": "INV-1001",
  "currency": "GHS",
  "amount": "50.00",
  "amount_payable": "50.00",
  "product_id": "PRD_…",
  "payment": {
    "id": "CS_…",
    "method": "checkout",
    "status": "processing",
    "url": "https://omni.mojo-pay.com/pay/CS_…",
    "merchant_reference": "FEX-ORD_…-1"
  },
  "failure": null,
  "created_at": "…",
  "completed_at": null
}
GET /orders/{id} Bearer

Retrieve an order by public id.

Path parameters

id required
string
Order public id.
GET http://fex.mojo-pay.com/v1/orders/{id}
Request
GET /orders/{id}
Authorization: Bearer <token>
GET /orders Bearer

List up to 50 most recent orders for the partner.

Query parameters

status optional
string
Filter by order status.
GET http://fex.mojo-pay.com/v1/orders
Request
GET /orders
Authorization: Bearer <token>
Response 200
{
  "object": "list",
  "data": [
    { "id": "ORD_…", "object": "order", "status": "completed" }
  ]
}
POST /orders/{id}/cancel Bearer

Cancel an order when still cancellable (typically before payment completes).

Path parameters

id required
string
Order public id.
POST http://fex.mojo-pay.com/v1/orders/{id}/cancel
Request
POST /orders/{id}/cancel
Authorization: Bearer <token>

Webhooks

Configure a webhook URL per API client under Partner Portal → API & webhooks. FEX signs every delivery with FEX-Signature (HMAC-SHA256). Treat order.completed / order.failed as the source of truth — not the payment redirect.

Verify signature

Header format:

FEX-Signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>

Signed payload: {timestamp}.{raw_body} using your webhook_secret.

Event envelope
{
  "id": "EVT_…",
  "type": "order.completed",
  "api_version": "2026-06-01",
  "created": "2026-08-25T14:30:00+00:00",
  "data": {
    "object": {
      "id": "ORD_…",
      "object": "order",
      "status": "completed"
    }
  }
}

Event types

Type When
order.pending_payment Order created; customer should pay
order.paid Payment confirmed
order.fulfilling Provider fulfillment started
order.completed Terminal success
order.failed Terminal failure
order.cancelled Cancelled by partner or system

Respond with 2xx quickly. Delivery is at-least-once — dedupe on event id.

Errors

Failed responses use the standard envelope. Use error.code for handling and request_id when contacting support. Provider codes (provider_*) can also appear on a failed order as failure.code.

Error envelope

{
  "error": {
    "code": "validation_failed",
    "message": "Product fields failed validation.",
    "details": { "errors": { "amount": ["required"] } },
    "request_id": "req_…"
  }
}

Common codes

unauthorized not_found product_unavailable validation_failed quote_unavailable quote_expired invalid_state lookup_unsupported provider_not_found provider_rejected provider_timeout