Introduction
REST API for wholesale resale of airtime, data bundles, prepaid electricity, and TV subscriptions, through one wallet-funded integration.
Kati for Business lets fintechs, banks, retail chains, and resellers vend recharge products at scale. You fund a single Naira wallet, and every purchase is debited from it. Purchases are executed with intelligent routing across redundant delivery channels, so a single integration gives you resilient coverage of every supported network, disco, and TV provider.
Every Recharge, One Wallet
Airtime, data bundles, prepaid electricity, and TV subscriptions are all debited from the same prefunded wallet. Check the balance any time via GET /balance.
Asynchronous Purchases
Purchase endpoints return 201 immediately with a transaction in status pending. It then moves through processing to a terminal success, failed, or cancelled. A failed purchase is automatically refunded in full to your wallet.
Commission Up Front
Your resale margin is applied as a discount at purchase time: the wallet is debited amount + fee − commission. Every transaction reports commissionEarned and totalDebited. See Commission.
Request Format
All request bodies are JSON. Set Content-Type: application/json. Amounts are Naira values sent as strings or numbers and always returned as strings with two decimals (e.g. "500.00").
Authentication
Bearer token authentication using a long-lived server-to-server API key.
Apply at kati.ng/business/get-started; once approved, you issue and manage your own keys from the Kati Business console. Keys are prefixed kb_live_ and can be rotated or revoked in the console at any time. Include your key in every API request using the Authorization header:
Authorization: Bearer kb_live_<hex>
Commission
Your resale margin, applied up front as a discount at purchase time.
Commission on Kati for Business is not a balance you accrue and later withdraw. It is applied the moment you purchase: your wallet is debited amount + fee − commission. The margin stays in your wallet from the moment you fund it; there is no separate commission balance, no settlement cycle, and no transfer to wait for.
"amount": "5000.00" "fee": "0.00" "commissionEarned": "125.00" 2.5% of 5000.00 "totalDebited": "4875.00" amount + fee − commissionEarned
Rates are percentages set per service and per provider: per telco for airtime and data, per disco for electricity, and per TV provider for TV subscriptions. Negotiated per-partner deals are available; contact info@kati.ng. Every transaction reports the exact commissionEarned and totalDebited, so your ledger reconciles to the kobo.
Account
Inspect your wallet balance.
Returns your current wallet balance. This one balance funds all purchases. Because commission is applied as an upfront discount at purchase time, it is already reflected here; there is no separate commission balance to track.
curl https://kati.ng/api/business/v1/balance \ -H "Authorization: Bearer kb_live_..."
const res = await fetch('https://kati.ng/api/business/v1/balance', { headers: { 'Authorization': `Bearer ${KATI_API_KEY}` } }); const { balance } = await res.json();
{
"balance": "142650.00",
"currency": "NGN"
}
Catalog
Discover the live service catalog: supported networks, discos, TV providers, and priced plans. Plan codes and prices can change, so refresh the catalog periodically rather than hard-coding it.
Returns the full service catalog: airtime and data networks, electricity distribution companies (discos), and TV providers currently available for vending.
curl https://kati.ng/api/business/v1/services \ -H "Authorization: Bearer kb_live_..."
const res = await fetch('https://kati.ng/api/business/v1/services', { headers: { 'Authorization': `Bearer ${KATI_API_KEY}` } }); const catalog = await res.json();
{
"airtime": { "networks": ["mtn", "glo", "airtel", "9mobile"] },
"data": { "networks": ["mtn", "glo", "airtel", "9mobile", "smile"] },
"electricity": {
"discos": [
{ "code": "aedc", "name": "Abuja" },
{ "code": "ikedc", "name": "Ikeja" },
{ "code": "eko", "name": "Eko" },
{ "code": "ibedc", "name": "Ibadan" },
{ "code": "phed", "name": "Port Harcourt" },
{ "code": "eedc", "name": "Enugu" },
{ "code": "kedco", "name": "Kano" },
{ "code": "kaedco", "name": "Kaduna" },
{ "code": "jed", "name": "Jos" },
{ "code": "bedc", "name": "Benin" },
{ "code": "aba", "name": "Aba" },
{ "code": "yedc", "name": "Yola" }
]
},
"tv": { "providers": ["dstv", "gotv", "startimes"] }
}
Returns the current data bundle catalog for a network. Use the returned code as the planCode in POST /data. Prices are fixed server-side from this catalog.
Plans come from the live catalog and codes can change, so always fetch fresh plans shortly before purchase rather than caching them. If the catalog is briefly down the endpoint returns 503 service_unavailable; retry shortly.
| Parameter | Type | Description |
|---|---|---|
| network | string | One of mtn, glo, airtel, 9mobile, smile required |
curl "https://kati.ng/api/business/v1/plans/data?network=mtn" \ -H "Authorization: Bearer kb_live_..."
const res = await fetch('https://kati.ng/api/business/v1/plans/data?network=mtn', { headers: { 'Authorization': `Bearer ${KATI_API_KEY}` } }); const { plans } = await res.json();
{
"plans": [
{
"code": "mtn-1gb-1d",
"network": "mtn",
"label": "1GB",
"dataMb": 1024,
"validityDays": 1,
"price": "350.00"
},
{
"code": "mtn-2gb-30d",
"network": "mtn",
"label": "2GB",
"dataMb": 2048,
"validityDays": 30,
"price": "1200.00"
}
]
}
Returns the current bouquet catalog for a TV provider. Use the returned code as the planCode in POST /tv. Prices are fixed server-side from this catalog.
Bouquets come from the live catalog and codes can change, so fetch fresh plans shortly before purchase. If the catalog is briefly down the endpoint returns 503 service_unavailable; retry shortly.
| Parameter | Type | Description |
|---|---|---|
| provider | string | One of dstv, gotv, startimes required |
curl "https://kati.ng/api/business/v1/plans/tv?provider=dstv" \ -H "Authorization: Bearer kb_live_..."
const res = await fetch('https://kati.ng/api/business/v1/plans/tv?provider=dstv', { headers: { 'Authorization': `Bearer ${KATI_API_KEY}` } }); const { plans } = await res.json();
{
"plans": [
{
"code": "dstv-padi",
"provider": "dstv",
"label": "DStv Padi",
"price": "4400.00"
},
{
"code": "dstv-compact",
"provider": "dstv",
"label": "DStv Compact",
"price": "19000.00"
}
]
}
Validation
Look up customer details for a meter or smartcard before vending. Validation is strongly recommended; it prevents crediting the wrong account.
Validate a meter number against a disco and return the registered customer details, where the disco supports lookup. Always validate before vending: show the returned name and address to your customer for confirmation.
| Field | Type | Description |
|---|---|---|
| disco | string | Disco code from GET /services, e.g. ikedc required |
| meterNumber | string | Customer meter number required |
curl -X POST https://kati.ng/api/business/v1/electricity/validate \ -H "Authorization: Bearer kb_live_..." \ -H "Content-Type: application/json" \ -d '{ "disco": "ikedc", "meterNumber": "45021234567" }'
const res = await fetch('https://kati.ng/api/business/v1/electricity/validate', { method: 'POST', headers: { 'Authorization': `Bearer ${KATI_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ disco: 'ikedc', meterNumber: '45021234567' }) }); const { valid, customer } = await res.json();
{
"valid": true,
"customer": {
"name": "ADEBAYO OGUNDIMU",
"address": "12 Allen Avenue, Ikeja"
}
}
{
"valid": false,
"customer": null
}
Validate a smartcard / IUC number against a TV provider and return the registered customer details. Validate before renewing a subscription.
| Field | Type | Description |
|---|---|---|
| provider | string | One of dstv, gotv, startimes required |
| smartcardNumber | string | Decoder smartcard / IUC number required |
curl -X POST https://kati.ng/api/business/v1/tv/validate \ -H "Authorization: Bearer kb_live_..." \ -H "Content-Type: application/json" \ -d '{ "provider": "dstv", "smartcardNumber": "7025123456" }'
const res = await fetch('https://kati.ng/api/business/v1/tv/validate', { method: 'POST', headers: { 'Authorization': `Bearer ${KATI_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'dstv', smartcardNumber: '7025123456' }) }); const { valid, customer } = await res.json();
{
"valid": true,
"customer": {
"name": "ADEBAYO OGUNDIMU"
}
}
{
"valid": false,
"customer": null
}
Purchases
Vend airtime, data, electricity, and TV subscriptions. All purchase endpoints share the same asynchronous model, idempotency behaviour, and commission mechanics.
Vend airtime to any Nigerian phone number. The recipient receives the full amount; your wallet is debited amount − commissionEarned.
| Field | Type | Description |
|---|---|---|
| network | string | One of mtn, glo, airtel, 9mobile required |
| phone | string | Recipient phone (0801... or +234801...) required |
| amount | string | Naira amount between 50.00 and 200000.00 required |
| reference | string | Your idempotency key, 6–64 chars [A-Za-z0-9._-] optional |
curl -X POST https://kati.ng/api/business/v1/airtime \ -H "Authorization: Bearer kb_live_..." \ -H "Content-Type: application/json" \ -d '{ "network": "mtn", "phone": "+2348031234567", "amount": "500.00", "reference": "INV-2026-00042" }'
const res = await fetch('https://kati.ng/api/business/v1/airtime', { method: 'POST', headers: { 'Authorization': `Bearer ${KATI_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ network: 'mtn', phone: '+2348031234567', amount: '500.00', reference: 'INV-2026-00042' }) }); const { transaction, duplicate } = await res.json();
{
"transaction": {
"id": "9b2f61c4-3d0a-4e8b-a1c2-7f5e90d4b823",
"reference": "INV-2026-00042",
"service": "airtime",
"network": "mtn",
"planCode": null,
"beneficiary": "+2348031234567",
"amount": "500.00",
"fee": "0.00",
"commissionEarned": "12.50",
"totalDebited": "487.50",
"status": "pending",
"failureCode": null,
"failureMessage": null,
"token": null,
"units": null,
"createdAt": "2026-07-16T10:15:02.000Z",
"completedAt": null
},
"duplicate": false
}
{
"transaction": {
"id": "9b2f61c4-3d0a-4e8b-a1c2-7f5e90d4b823",
"reference": "INV-2026-00042",
"service": "airtime",
"network": "mtn",
"planCode": null,
"beneficiary": "+2348031234567",
"amount": "500.00",
"fee": "0.00",
"commissionEarned": "12.50",
"totalDebited": "487.50",
"status": "processing",
"failureCode": null,
"failureMessage": null,
"token": null,
"units": null,
"createdAt": "2026-07-16T10:15:02.000Z",
"completedAt": null
},
"duplicate": true
}
Vend a data bundle to any Nigerian phone number. Select the bundle by planCode from GET /plans/data.
There is no amount field; the price is fixed server-side from the live plan catalog at the moment of purchase, so a stale client can never under- or over-pay. Plan codes change, so fetch plans fresh before purchase; an unrecognised code returns 400 unknown_plan.
| Field | Type | Description |
|---|---|---|
| network | string | One of mtn, glo, airtel, 9mobile, smile required |
| phone | string | Recipient phone (0801... or +234801...) required |
| planCode | string | Plan code from the data catalog, e.g. mtn-1gb-1d required |
| reference | string | Your idempotency key, 6–64 chars [A-Za-z0-9._-] optional |
curl -X POST https://kati.ng/api/business/v1/data \ -H "Authorization: Bearer kb_live_..." \ -H "Content-Type: application/json" \ -d '{ "network": "mtn", "phone": "+2348031234567", "planCode": "mtn-1gb-1d", "reference": "INV-2026-00043" }'
const res = await fetch('https://kati.ng/api/business/v1/data', { method: 'POST', headers: { 'Authorization': `Bearer ${KATI_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ network: 'mtn', phone: '+2348031234567', planCode: 'mtn-1gb-1d', reference: 'INV-2026-00043' }) }); const { transaction, duplicate } = await res.json();
{
"transaction": {
"id": "c4d7a90e-52b1-4f6d-9e38-0a61b2c8f745",
"reference": "INV-2026-00043",
"service": "data",
"network": "mtn",
"planCode": "mtn-1gb-1d",
"beneficiary": "+2348031234567",
"amount": "350.00",
"fee": "0.00",
"commissionEarned": "7.00",
"totalDebited": "343.00",
"status": "pending",
"failureCode": null,
"failureMessage": null,
"token": null,
"units": null,
"createdAt": "2026-07-16T10:16:41.000Z",
"completedAt": null
},
"duplicate": false
}
Vend prepaid or postpaid electricity to a meter. A small flat service fee is added on top of the vend amount and shown as fee on the transaction; the meter is credited with the full amount.
For prepaid meters, the recharge token and units are returned on the completed transaction (via GET /transactions/:id and the webhook). If customerPhone is provided, the token is also delivered to that number by SMS.
| Field | Type | Description |
|---|---|---|
| disco | string | Disco code from GET /services, e.g. ikedc required |
| meterNumber | string | Customer meter number required |
| amount | string | Naira vend amount between 500.00 and 1000000.00 (flat service fee added on top) required |
| customerPhone | string | Phone to receive the prepaid token by SMS optional |
| reference | string | Your idempotency key, 6–64 chars [A-Za-z0-9._-] optional |
curl -X POST https://kati.ng/api/business/v1/electricity \ -H "Authorization: Bearer kb_live_..." \ -H "Content-Type: application/json" \ -d '{ "disco": "ikedc", "meterNumber": "45021234567", "amount": "5000.00", "customerPhone": "+2348031234567", "reference": "INV-2026-00044" }'
const res = await fetch('https://kati.ng/api/business/v1/electricity', { method: 'POST', headers: { 'Authorization': `Bearer ${KATI_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ disco: 'ikedc', meterNumber: '45021234567', amount: '5000.00', customerPhone: '+2348031234567', reference: 'INV-2026-00044' }) }); const { transaction, duplicate } = await res.json();
{
"transaction": {
"id": "e8a13f76-9c40-4b2d-8f15-3d72c609a481",
"reference": "INV-2026-00044",
"service": "electricity",
"network": "ikedc",
"planCode": null,
"beneficiary": "45021234567",
"amount": "5000.00",
"fee": "60.00",
"commissionEarned": "25.00",
"totalDebited": "5035.00",
"status": "pending",
"failureCode": null,
"failureMessage": null,
"token": null,
"units": null,
"createdAt": "2026-07-16T10:18:22.000Z",
"completedAt": null
},
"duplicate": false
}
Renew or purchase a TV subscription for a smartcard. Select the bouquet by planCode from GET /plans/tv.
There is no amount field; the price is fixed server-side from the live bouquet catalog at the moment of purchase. Bouquet codes change, so fetch plans fresh before purchase; an unrecognised code returns 400 unknown_plan.
| Field | Type | Description |
|---|---|---|
| provider | string | One of dstv, gotv, startimes required |
| smartcardNumber | string | Decoder smartcard / IUC number required |
| planCode | string | Bouquet code from the TV catalog, e.g. gotv-max required |
| reference | string | Your idempotency key, 6–64 chars [A-Za-z0-9._-] optional |
curl -X POST https://kati.ng/api/business/v1/tv \ -H "Authorization: Bearer kb_live_..." \ -H "Content-Type: application/json" \ -d '{ "provider": "gotv", "smartcardNumber": "7025123456", "planCode": "gotv-max", "reference": "INV-2026-00045" }'
const res = await fetch('https://kati.ng/api/business/v1/tv', { method: 'POST', headers: { 'Authorization': `Bearer ${KATI_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'gotv', smartcardNumber: '7025123456', planCode: 'gotv-max', reference: 'INV-2026-00045' }) }); const { transaction, duplicate } = await res.json();
{
"transaction": {
"id": "f2b58c17-6da3-4e09-b7c4-91e05a83d266",
"reference": "INV-2026-00045",
"service": "tv",
"network": "gotv",
"planCode": "gotv-max",
"beneficiary": "7025123456",
"amount": "8600.00",
"fee": "0.00",
"commissionEarned": "86.00",
"totalDebited": "8514.00",
"status": "pending",
"failureCode": null,
"failureMessage": null,
"token": null,
"units": null,
"createdAt": "2026-07-16T10:20:05.000Z",
"completedAt": null
},
"duplicate": false
}
Transactions
List, inspect, and cancel purchases. Lifecycle: pending → processing → success | failed | cancelled. A failed purchase is auto-refunded; refunded can also appear after an initial success in rare reconciliation cases.
List your transactions, newest first, with filtering and offset pagination.
| Parameter | Type | Description |
|---|---|---|
| status | string | Filter: pending, processing, success, failed, refunded, cancelled optional |
| limit | integer | Results per page, default 50, max 100 optional |
| offset | integer | Number of rows to skip, default 0 optional |
curl "https://kati.ng/api/business/v1/transactions?status=success&limit=50&offset=0" \ -H "Authorization: Bearer kb_live_..."
const res = await fetch( 'https://kati.ng/api/business/v1/transactions?status=success&limit=50&offset=0', { headers: { 'Authorization': `Bearer ${KATI_API_KEY}` } } ); const { transactions } = await res.json();
{
"transactions": [
{
"id": "9b2f61c4-3d0a-4e8b-a1c2-7f5e90d4b823",
"reference": "INV-2026-00042",
"service": "airtime",
"network": "mtn",
"planCode": null,
"beneficiary": "+2348031234567",
"amount": "500.00",
"fee": "0.00",
"commissionEarned": "12.50",
"totalDebited": "487.50",
"status": "success",
"failureCode": null,
"failureMessage": null,
"token": null,
"units": null,
"createdAt": "2026-07-16T10:15:02.000Z",
"completedAt": "2026-07-16T10:15:09.412Z"
}
],
"limit": 50,
"offset": 0
}
Fetch full details for a single transaction. The :id segment accepts either your own reference or the Kati transaction id; the lookup tries your reference first, then falls back to the Kati id.
token and units are populated only for successful electricity purchases that produce a prepaid token. failureCode and failureMessage are populated only for unsuccessful transactions.
| Parameter | Type | Description |
|---|---|---|
| id | string | Kati transaction id (UUID) or your reference required |
# by your own reference, or by Kati transaction id curl https://kati.ng/api/business/v1/transactions/INV-2026-00044 \ -H "Authorization: Bearer kb_live_..."
const res = await fetch( 'https://kati.ng/api/business/v1/transactions/INV-2026-00044', { headers: { 'Authorization': `Bearer ${KATI_API_KEY}` } } ); const { transaction } = await res.json();
{
"transaction": {
"id": "e8a13f76-9c40-4b2d-8f15-3d72c609a481",
"reference": "INV-2026-00044",
"service": "electricity",
"network": "ikedc",
"planCode": null,
"beneficiary": "45021234567",
"amount": "5000.00",
"fee": "60.00",
"commissionEarned": "25.00",
"totalDebited": "5035.00",
"status": "success",
"failureCode": null,
"failureMessage": null,
"token": "1234-5678-9012-3456-7890",
"units": "142.3",
"createdAt": "2026-07-16T10:18:22.000Z",
"completedAt": "2026-07-16T10:18:31.412Z"
}
}
Cancel a purchase while it is still pending. The refund is exactly what was debited (totalDebited). Once a transaction has moved to processing it is already with the biller and returns 409 cannot_cancel; it will resolve to success or an automatic refund on failure.
A cancellation is a terminal state: it also fires a transaction.failed webhook with data.status = "cancelled".
| Parameter | Type | Description |
|---|---|---|
| id | string | Kati transaction id (UUID) or your reference required |
curl -X POST https://kati.ng/api/business/v1/transactions/INV-2026-00045/cancel \ -H "Authorization: Bearer kb_live_..."
const res = await fetch( 'https://kati.ng/api/business/v1/transactions/INV-2026-00045/cancel', { method: 'POST', headers: { 'Authorization': `Bearer ${KATI_API_KEY}` } } ); const { status } = await res.json();
{
"status": "cancelled"
}
{
"error": "cannot_cancel",
"message": "This purchase is already with the provider and can't be cancelled now. It'll resolve in a few moments."
}
Webhooks
Kati pushes real-time transaction outcomes to your server, the recommended way to learn a purchase's final state instead of polling.
Kati sends this request to your registered webhook URL when a transaction reaches a terminal state. Respond with any 2xx status quickly; do your processing after acknowledging. Failed deliveries are retried up to 8 times with exponential backoff over roughly 50 minutes.
| Header | Type | Description |
|---|---|---|
| X-Kati-Event | string | The event name, e.g. transaction.success |
| X-Kati-Signature | string | sha256=<HMAC-SHA256 of the raw request body using your webhook secret> |
| Event | Type | Description |
|---|---|---|
| transaction.success | string | The purchase completed successfully. For prepaid electricity, data.token and data.units are included. |
| transaction.failed | string | The purchase did not complete: failed, refunded, and cancelled transactions all deliver this event. Check data.status to distinguish them. Whatever was debited has been returned to your wallet. |
| Field | Type | Description |
|---|---|---|
| event | string | transaction.success or transaction.failed |
| eventId | string | Stable across retries of the same finalization. Deduplicate on this field alone. |
| sentAt | string | Delivery timestamp, inside the signed body. Reject deliveries older than 5 minutes to block replays. |
| data | object | The full transaction, same fields as GET /transactions/:id. |
{
"event": "transaction.success",
"eventId": "evt_9f3ab27c41d05e88b6a2f014",
"sentAt": "2026-07-16T10:18:32.104Z",
"data": {
"id": "e8a13f76-9c40-4b2d-8f15-3d72c609a481",
"reference": "INV-2026-00044",
"service": "electricity",
"network": "ikedc",
"planCode": null,
"beneficiary": "45021234567",
"amount": "5000.00",
"fee": "60.00",
"commissionEarned": "25.00",
"totalDebited": "5035.00",
"status": "success",
"failureCode": null,
"failureMessage": null,
"token": "1234-5678-9012-3456-7890",
"units": "142.3",
"createdAt": "2026-07-16T10:18:22.000Z",
"completedAt": "2026-07-16T10:18:31.412Z"
}
}
Every delivery includes an X-Kati-Signature header: sha256= followed by the hex HMAC-SHA256 of the request body, keyed with your webhook secret. Compute the HMAC over the raw body bytes (before any JSON parsing) and compare with a timing-safe comparison. Because sentAt lives inside the signed body, checking its freshness also defeats replayed deliveries.
X-Kati-Signature: sha256=a3f8c2d1e4b7...
const crypto = require('crypto'); function verifyWebhook(rawBody, signature, secret) { const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(rawBody) .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(signature || ''); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express example app.post('/webhooks/kati', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.headers['x-kati-signature']; if (!verifyWebhook(req.body, sig, process.env.KATI_WEBHOOK_SECRET)) { return res.status(401).end(); } const event = JSON.parse(req.body); // Replay window: sentAt is inside the signed body if (Date.now() - Date.parse(event.sentAt) > 5 * 60 * 1000) { return res.status(400).end(); } res.sendStatus(200); // acknowledge first, then process if (alreadySeen(event.eventId)) return; // dedupe on eventId // handle event.event === 'transaction.success' / 'transaction.failed' });
import hmac, hashlib def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool: expected = 'sha256=' + hmac.new( secret.encode(), raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature) # Flask example @app.route('/webhooks/kati', methods=['POST']) def kati_webhook(): sig = request.headers.get('X-Kati-Signature', '') if not verify_webhook(request.data, sig, KATI_WEBHOOK_SECRET): return '', 401 event = request.get_json() # reject if event['sentAt'] is older than 5 minutes (replay window), # dedupe on event['eventId'], then handle event['event'] return '', 200
Error Codes
All error responses follow a consistent shape with a machine-readable error code and a human-readable message.
{
"error": "insufficient_funds",
"message": "Top up your wallet to continue."
}
| Status | Error Code | Meaning | What To Do |
|---|---|---|---|
| 400 | invalid_input | The request body failed validation: a missing field, malformed phone number, an amount outside the allowed bounds, or a reference outside 6–64 chars of [A-Za-z0-9._-]. | Fix the request. Do not retry unchanged; the message names the problem. |
| 400 | unknown_disco | The supplied disco code is not recognised. | Fetch the supported list from GET /services. |
| 400 | unknown_plan | The supplied planCode is not in the live catalog. Plan codes change. | Refresh GET /plans/data or GET /plans/tv and retry with a fresh code. |
| 401 | unauthenticated | Missing, malformed, unknown, or revoked API key in the Authorization header. | Send Authorization: Bearer kb_live_.... If the key was revoked, use its replacement. |
| 403 | account_suspended | Your business account is suspended; every request is refused. | Contact info@kati.ng. |
| 404 | not_found | No transaction matches that reference or id under your account. | Check the reference or id; you can only see your own transactions. |
| 409 | insufficient_funds | Your wallet balance is below amount + fee − commission for the purchase. Nothing was debited. | Top up your wallet, then retry. |
| 409 | cannot_cancel | The transaction is no longer pending; it is already with the biller or already resolved. | Wait for the webhook or poll; it resolves to success or is automatically refunded. |
| 429 | rate_limited | You exceeded your per-minute request limit, which applies across all your keys. | Honor the Retry-After header (seconds) before retrying. |
| 500 | internal | An unexpected server error occurred. | Safe to retry with the same reference; idempotency protects you from a double vend. If it persists, contact support. |
| 503 | coming_soon | Purchasing is temporarily switched off platform-wide. | Retry later. If unexpected, contact info@kati.ng. |
| 503 | no_supplier | No delivery channel is currently available for this product. Nothing was charged. | Transient; retry after a short delay. |
| 503 | service_unavailable | A catalog, validation, or purchase path is briefly unavailable upstream. | Transient; retry after a short delay. |
Rate Limits
All API endpoints are rate-limited per business, not per key: your limit is shared across all API keys on the account. If you exceed it, the API returns HTTP 429 rate_limited.
Every authenticated response carries X-RateLimit-Limit and X-RateLimit-Remaining headers. When rate-limited, the response also includes a Retry-After header indicating how many seconds to wait before retrying.
X-RateLimit-Limit: 120 X-RateLimit-Remaining: 17 Retry-After: 26
Go-Live Checklist
Work through this list before switching real customer traffic onto your integration.
- 1 Fund your wallet. Transfer by bank from the Kati account tied to your business phone number, then confirm the credit via GET /balance. Keep a float that covers your peak vending volume; insufficient_funds failures are entirely avoidable.
- 2 Validate meters and smartcards before vending. Call POST /electricity/validate and POST /tv/validate and show the returned customer name to your user for confirmation. This is the single biggest protection against mis-vends.
- 3 Fetch plans fresh before every purchase. Data and TV prices are server-derived from the live catalog and plan codes change; never hard-code them. Handle 400 unknown_plan by refreshing the catalog and retrying.
- 4 Use your own reference on every purchase. It makes every request safely retryable, including after timeouts and 500 responses (same reference = same transaction, never a double vend), and lets you look transactions up by your own identifiers.
- 5 Handle pending as non-terminal. A 201 response is an acceptance, not an outcome. Only success, failed, and cancelled are terminal; never tell your customer a purchase failed while it is still pending or processing.
- 6 Harden your webhook endpoint. Verify X-Kati-Signature over the raw body with a constant-time comparison, reject deliveries whose sentAt is older than 5 minutes, acknowledge with a 2xx quickly, and deduplicate on eventId.