Kati for Business: API Reference
v1
Open kati.ng →

Introduction

REST API for wholesale resale of airtime, data bundles, prepaid electricity, and TV subscriptions, through one wallet-funded integration.

Base URL https://kati.ng/api/business/v1

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").

All timestamps are ISO 8601 UTC. Nigerian phone numbers are accepted in local format (0801...) or E.164 (+234801...), and the API normalises them automatically.
Track every purchase to a terminal state. Poll GET /transactions/:id or, preferably, receive webhooks for transaction.success and transaction.failed. Treat pending and processing as non-terminal, never as a failure.

Authentication

Bearer token authentication using a long-lived server-to-server API key.

Kati for Business keys are issued by the Kati team when your business is onboarded; there is no self-serve signup. Contact info@kati.ng to get started. Keys are prefixed kb_live_ and can be rotated or revoked on request. Include your key in every API request using the Authorization header:

HTTP Header
Authorization: Bearer kb_live_<hex>
Your API key grants full spending access to your wallet. Keep it server-side only; never embed it in mobile apps, browser JavaScript, or public repositories. If a key is ever exposed, contact info@kati.ng immediately to rotate it; the old key is invalidated as soon as the new one is issued.
Requests without a valid key return 401 unauthenticated. If your account has been suspended, all requests return 403 account_suspended. Requests over your per-minute limit return 429 rate_limited with a Retry-After header (see Rate Limits), and an unexpected server error returns 500 internal. Every authenticated response carries X-RateLimit-Limit and X-RateLimit-Remaining headers.

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.

Worked example: ₦5,000.00 airtime at a 2.5% rate
"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.

A failed or cancelled purchase refunds exactly what was debited (totalDebited), so commission never needs to be clawed back.

Account

Inspect your wallet balance.

GET /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();
Response
200 OK
{
  "balance": "142650.00",
  "currency": "NGN"
}
Funding your wallet: the wallet is funded by bank transfer from the Kati account tied to your business phone number. Sign in at kati.ng to view your dedicated account details. For enterprise settlement arrangements, contact info@kati.ng.

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.

GET /services

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();
Response
200 OK
{
  "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"] }
}
GET /plans/data

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.

Query Parameters
ParameterTypeDescription
networkstringOne 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();
Response
200 OK
{
  "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"
    }
  ]
}
GET /plans/tv

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.

Query Parameters
ParameterTypeDescription
providerstringOne 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();
Response
200 OK
{
  "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.

POST /electricity/validate

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.

Request Body
FieldTypeDescription
discostringDisco code from GET /services, e.g. ikedc required
meterNumberstringCustomer 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();
Response
200 OK, meter found
{
  "valid": true,
  "customer": {
    "name": "ADEBAYO OGUNDIMU",
    "address": "12 Allen Avenue, Ikeja"
  }
}
200 OK, no match
{
  "valid": false,
  "customer": null
}
address may be null where the disco does not return one. A meter that cannot be matched returns 200 with "valid": false; do not vend until your customer has re-confirmed the meter number.
POST /tv/validate

Validate a smartcard / IUC number against a TV provider and return the registered customer details. Validate before renewing a subscription.

Request Body
FieldTypeDescription
providerstringOne of dstv, gotv, startimes required
smartcardNumberstringDecoder 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();
Response
200 OK, smartcard found
{
  "valid": true,
  "customer": {
    "name": "ADEBAYO OGUNDIMU"
  }
}
200 OK, no match
{
  "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.

Asynchronous model: every purchase endpoint returns 201 Created immediately with the transaction in status pending. The transaction then moves pending → processing → success | failed | cancelled. A failed transaction is automatically refunded in full to your wallet; you never lose funds on a failed vend. Get the outcome via webhooks (preferred) or by polling GET /transactions/:id.
Idempotency with reference: pass your own reference (6–64 characters of letters, digits, dot, dash, or underscore; UUIDs are fine) on every purchase. Retrying a request with the same reference returns the original transaction with 200 and "duplicate": true, never a double vend. This makes timeouts, network errors, and 500 responses safe to retry.
Commission is automatic: your margin is applied as an upfront discount on every purchase, so the wallet is debited amount + fee − commissionEarned. There is nothing to configure per request. See Commission.
One response shape: every purchase response, whether a fresh 201 or an idempotent 200 replay, is { "transaction": { ... }, "duplicate": ... } with the full transaction object, identical to GET /transactions/:id.
POST /airtime

Vend airtime to any Nigerian phone number. The recipient receives the full amount; your wallet is debited amount − commissionEarned.

Request Body
FieldTypeDescription
networkstringOne of mtn, glo, airtel, 9mobile required
phonestringRecipient phone (0801... or +234801...) required
amountstringNaira amount between 50.00 and 200000.00 required
referencestringYour 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();
Response
201 Created
{
  "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
}
200 OK, same reference retried: the original transaction, same full shape
{
  "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
}
POST /data

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.

Request Body
FieldTypeDescription
networkstringOne of mtn, glo, airtel, 9mobile, smile required
phonestringRecipient phone (0801... or +234801...) required
planCodestringPlan code from the data catalog, e.g. mtn-1gb-1d required
referencestringYour 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();
Response
201 Created
{
  "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
}
POST /electricity

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.

Request Body
FieldTypeDescription
discostringDisco code from GET /services, e.g. ikedc required
meterNumberstringCustomer meter number required
amountstringNaira vend amount between 500.00 and 1000000.00 (flat service fee added on top) required
customerPhonestringPhone to receive the prepaid token by SMS optional
referencestringYour 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();
Response
201 Created
{
  "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
}
The prepaid token is not in the 201 response; the purchase completes asynchronously. Fetch the token from GET /transactions/:id once the status is success, or take it from the transaction.success webhook payload.
POST /tv

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.

Request Body
FieldTypeDescription
providerstringOne of dstv, gotv, startimes required
smartcardNumberstringDecoder smartcard / IUC number required
planCodestringBouquet code from the TV catalog, e.g. gotv-max required
referencestringYour 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();
Response
201 Created
{
  "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.

GET /transactions

List your transactions, newest first, with filtering and offset pagination.

Query Parameters
ParameterTypeDescription
statusstringFilter: pending, processing, success, failed, refunded, cancelled optional
limitintegerResults per page, default 50, max 100 optional
offsetintegerNumber 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();
Response
200 OK
{
  "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
}
GET /transactions/:id

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.

Path Parameters
ParameterTypeDescription
idstringKati 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();
Response
200 OK
{
  "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"
  }
}
POST /transactions/:id/cancel

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".

Path Parameters
ParameterTypeDescription
idstringKati 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();
Response
200 OK
{
  "status": "cancelled"
}
409 Conflict, already processing
{
  "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.

Your webhook URL and signing secret are configured in the Kati Business console, where you can also inspect a log of recent deliveries while debugging your integration.
POST {your_webhook_url}

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.

Headers
HeaderTypeDescription
X-Kati-EventstringThe event name, e.g. transaction.success
X-Kati-Signaturestringsha256=<HMAC-SHA256 of the raw request body using your webhook secret>
Event Types
EventTypeDescription
transaction.successstringThe purchase completed successfully. For prepaid electricity, data.token and data.units are included.
transaction.failedstringThe 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.
Payload Fields
FieldTypeDescription
eventstringtransaction.success or transaction.failed
eventIdstringStable across retries of the same finalization. Deduplicate on this field alone.
sentAtstringDelivery timestamp, inside the signed body. Reject deliveries older than 5 minutes to block replays.
dataobjectThe full transaction, same fields as GET /transactions/:id.
Outbound Webhook Payload
{
  "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"
  }
}
Signature Verification

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.

Signature Header
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
Always use constant-time comparison (timingSafeEqual / hmac.compare_digest) when verifying signatures. Standard string equality is vulnerable to timing attacks.
Deliveries can arrive more than once: retries of the same finalization carry the same eventId with a fresh sentAt and signature. Deduplicate on eventId before acting, and reject any delivery whose sentAt is older than 5 minutes.

Error Codes

All error responses follow a consistent shape with a machine-readable error code and a human-readable message.

Error Response Shape
{
  "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.

Default Rate Limit
120 requests / minute per business
Raised for enterprise integrations on request. Contact info@kati.ng with your use case and desired throughput.

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.

Rate Limit Response Headers
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.