# KDA API — Full Specification > Complete machine-readable reference for the KDA Virtual Top-Up (VTU) and bill-payment API. Covers every endpoint, payload, response, error, webhook, and guide. Concise version: /llms.txt — Human docs: /docs. ## 1. Base URLs and environments ```text https:///api/services https:///api/profile ``` - HTTPS only. Request/response bodies are JSON. - Every request needs `Authorization: Bearer ` plus `Content-Type: application/json` on POST. - One host for both environments. The key prefix selects it: - `kds_test_*` → TEST (sandbox simulator, simulated success, no debit). - `kds_live_*` → LIVE (real carriers/utilities, real wallet debit). - Confirm with `GET /api/services/test` or `GET /api/profile/test` (`"environment": "TEST"|"LIVE"`). ## 2. Authentication and scopes ```bash curl -X GET https:///api/profile/balance \ -H "Authorization: Bearer kds_test_YOUR_API_KEY" ``` - Keys are created in Dashboard → Settings → Dev Console. One scope per key: - `services` → `/api/services/*` (plans, purchases, verify, test). - `profile` → `/api/profile/*` (balance, transactions, withdraw, test). - Cross-scope calls fail: services key on profile routes (and vice versa) → `403 FORBIDDEN` + `keyStatus: "FORBIDDEN"`. The two `/test` routes accept either scope. - Auth failures → `401 UNAUTHORIZED` + `keyStatus` (`INVALID` | `NOT_FOUND` | `DISABLED`). No `Authorization` header → `401` without `keyStatus`. - Withdrawals additionally require **Allow Withdrawals** enabled on the key (Settings → Developer), else `412 PRECONDITION_FAILED`. - Rate limits per key: LIVE 10,000/hour, SANDBOX 500/hour. Over limit → `429 RATE_LIMITED` with `retryAfterMs` and `Retry-After` / `X-Retry-After` (seconds). Wait, do not spin. - Security: live keys server-side only, env/secrets manager, rotate periodically, test keys in dev/CI. Verify-key response (`GET /api/services/test` or `GET /api/profile/test`): ```json { "success": true, "message": "API Key is valid and authenticated.", "environment": "TEST", "user": { "id": "user_1234abcd", "email": "dev@example.com" }, "key": { "name": "My Sandbox Key", "prefix": "kds_test_9f2c", "type": "TEST", "scope": "services", "enabled": true, "remaining": 9000, "createdAt": "2026-01-15T10:00:00.000Z" } } ``` ## 3. Standard request/response Success envelope: ```json { "success": true, "message": "Data Purchase purchase successful!", "transactionId": "KDS-DAT-YRGH1BZA-SN", "data": {} } ``` Error envelope: ```json { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "One or more validation errors occurred.", "keyStatus": "INVALID", "transactionId": "KDS-...", "retryAfterMs": 1800000, "details": [{ "field": "number", "message": "Phone number is required" }] } } ``` Field presence: `details[]` only on validation errors; `transactionId` on `TRANSACTION_FAILED`/`CONFLICT`; `keyStatus` on `401`/`403` with a supplied key; `retryAfterMs` on `429`. ## 4. Quickstart (sandbox → live) ```bash # 1. Verify key curl -X GET https:///api/services/test \ -H "Authorization: Bearer kds_test_YOUR_API_KEY" # 2. Fetch plans curl -X GET https:///api/services/data/plans \ -H "Authorization: Bearer kds_test_YOUR_API_KEY" # 3. Purchase (new UUID per intent) curl -X POST https:///api/services/data \ -H "Authorization: Bearer kds_test_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "planId": "kds-data-mtn-sme-v1-003", "number": "08012345678", "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000", "isPorted": false }' ``` Going live: test all flows + `PENDING` handling + error catalog in sandbox → subscribe to Purchase webhooks and reconcile one success/failure → create `kds_live_*` → fund wallet → re-call `/test` (expect `LIVE`) → one low-value live purchase → confirm balance debit, transaction row, and `PURCHASE_SUCCESS` webhook → scale gradually. ## 5. Plans endpoints ```text GET /api/services/data/plans[?type=SME][&id=kds-data-...] GET /api/services/airtime/plans[?id=kds-airtime-...] GET /api/services/tv/plans[?type=Padi][&id=kds-tv-...] GET /api/services/electricity/plans[?id=kds-elec-...] GET /api/services/exam/plans[?id=kds-exam-...] GET /api/services/data-card/plans[?id=kds-data-card-...] GET /api/services/recharge-card/plans[?id=kds-pin-...] ``` - Method must be `GET`, else `405`. Scope must be `services`. - `?id=` returns one plan or `404 NOT_FOUND`. `?type=` filters by category (data: `SME`, `Gifting`, `Corporate Gifting`, `Gifting Promo`, …; tv: `Padi`, `Compact`, `Smallie`, …). Airtime/electricity/exam/cards accept `id` only. - Data/tv without filters group as `[{ type, plans[] }]` with `count` = total plans; filtered/single returns flat rows with `type` per row. - Shapes: - Data: `{ id, label, validity, salePrice, isNightPlan, isSocialPlan, provider }` (e.g. `kds-data-mtn-sme-v1-003`, `2GB`, `30days`, `899`, `MTN`). - Airtime: `{ id, name, airtimeType: "VTU"|"Share and Sell", salePercentage }` (e.g. `kds-airtime-mtn-vtu-v2`, `MTN`, `"3"` → ₦100 costs ₦97). - TV: `{ id, label, validity, salePrice, provider }` (e.g. `kds-tv-dstv-padi-v1-001`, `DStv Padi`, `1 Month`, `4400`). - Electricity: `{ id, name, saleFee, slug }` (e.g. `kds-elec-aedc-v1`, `Abuja Electricity`, `"5"`, `AEDC`). - Exam: `{ id, label, examName, salePrice }` (WAEC/NECO/NABTEB; v2 cheaper where present; NABTEB v1 only). - Data card: `{ id, label, validity, salePrice, provider }` (e.g. `kds-data-card-mtn-750mb-v1`). - Recharge card: `{ id, label, salePrice, provider }` (e.g. `kds-pin-mtn-100-v2`, `100`, `98`). - The `-v1-`/`-v2-` segment in every `kds-*` id selects the provider route (v1 primary, v2 secondary). v2 is usually cheaper for Data/Airtime and the only route with Social plans, Share-and-Sell, and the full recharge-card range; v1 is cheaper for Electricity/NECO/StarTimes Smart and wider for DStv combos/PROMO/NABTEB/StarTimes 1-day. Always re-fetch; never cache prices. ## 6. Purchase endpoints All are `POST` (else `405`), `services` scope, JSON body, `idempotencyKey` REQUIRED (UUID v4). ### 6.1 Data — POST /api/services/data ```json { "planId": "kds-data-mtn-sme-v1-003", "number": "08012345678", "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000", "isPorted": false } ``` - `planId` required (exact `kds-*` id). `number` required, min 10 chars. `isPorted` optional (default false) — true routes via MNP channels. `amount` optional and provider-ignored. - Validate prefix client-side (see /docs/reference/network-prefixes) but always allow manual override; set `isPorted: true` when the number moved networks. - Success `data`: `{ amount, planId, label, provider, number, status, description, transactionId, createdAt, validity, category, type: "DATA", idempotencyKey }`. ### 6.2 Airtime — POST /api/services/airtime ```json { "planId": "kds-airtime-mtn-vtu-v2", "number": "08012345678", "amount": 100, "idempotencyKey": "681227cc-2638-49fe-bbe3-d2500cf54767", "isPorted": false } ``` - `planId` is a channel id (`kds-airtime-{network}-{VTU|Share and Sell}-v{1|2}`). `amount` required (naira). - Limits by route: v1 min 50 max 500; v2 min 100 max 10,000. Below minimum → provider rejects, auto-refund with clear message. Above maximum → rejected before debit. - Success `data` includes `discountRate` and `type: "AIRTIME"`. ### 6.3 TV — POST /api/services/tv (verify first) Verify: ```bash curl -X POST https:///api/services/verify/tv \ -H "Authorization: Bearer kds_test_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "smartCardNumber": "2021234567", "providerId": "kds-tv-gotv" }' ``` → `{ success, data: { valid: true, name: "Sadiq Musa" } }` or `{ success: false, message: "INVALID IUC/SMARTCARD" }`. Purchase: ```json { "planId": "kds-tv-gotv-v1-001", "iucNumber": "2021234567", "idempotencyKey": "681227cc-2638-49fe-bbe3-d2500cf54767" } ``` - Success `data`: `{ amount, label, planId, provider, iucNumber, status, transactionId, createdAt, validity, category, type: "TV", idempotencyKey }`. ### 6.4 Electricity — POST /api/services/electricity (verify first) Verify (lowercase type; `/api/services/verify/bill` is an alias of `/api/services/verify/meter`): ```bash curl -X POST https:///api/services/verify/meter \ -H "Authorization: Bearer kds_test_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "discoSlug": "KEDCO", "meterNumber": "04123456789", "meterType": "prepaid" }' ``` → `{ success, data: { valid: true, name: "...", address: "..." } }` (`address` conditional) or `{ success: false, error: { code: "BAD_REQUEST", message: "INVALID METER NUMBER" } }`. Purchase (UPPERCASE type, amount ≥ 100): ```json { "providerId": "kds-elec-kedco-v1", "meterNumber": "04123456789", "amount": 5000, "meterType": "PREPAID", "idempotencyKey": "681227cc-2638-49fe-bbe3-d2500cf54767" } ``` - Success `data`: `{ amount, providerId, provider, meterNumber, status, transactionId, createdAt, meterType, fee, electricityToken, electricityUnits?, type: "ELECTRICITY", idempotencyKey }`. `electricityUnits` conditional; display `electricityToken` prominently for prepaid. ### 6.5 Exam — POST /api/services/exam ```json { "providerId": "kds-exam-waec-v1", "quantity": 1, "cardName": "Customer", "idempotencyKey": "681227cc-2638-49fe-bbe3-d2500cf54767" } ``` - `quantity` 1–3. `cardName` optional (default `"Customer"`). Result-checker PINs only. - Success `data`: `{ amount, providerId, provider, status, transactionId, createdAt, quantity, pin, cardName, type: "EXAM_PIN", idempotencyKey }`. `pin` may be `<=>`-joined PIN+serial; multiples are comma-separated. Store securely; non-recoverable. ### 6.6 Data card — POST /api/services/data-card ```json { "planId": "kds-data-card-mtn-750mb-v1", "quantity": 2, "cardName": "Eko Data Card Services", "idempotencyKey": "681227cc-2638-49fe-bbe3-d2500cf54767" } ``` - `quantity` 1–10. Success `data`: `{ amount, label, planId, provider, status, transactionId, createdAt, quantity, pin, serial, cardName, type: "DATA_PRINT", idempotencyKey }` with comma-separated `pin`/`serial`. ### 6.7 Recharge card — POST /api/services/recharge-card ```json { "planId": "kds-pin-mtn-100-v2", "quantity": 2, "cardName": "Eko Print Services", "idempotencyKey": "681227cc-2638-49fe-bbe3-d2500cf54767" } ``` - `quantity` 1–10. Success `data`: same shape as data card with `type: "PIN_PRINT"` plus `discountRate`. ## 7. Account endpoints (profile scope) ### 7.1 Balance — GET /api/profile/balance ```json { "success": true, "data": { "mode": "sandbox", "balance": 95000 } } ``` `mode` is `sandbox` (test key) or `live` (live key). Poll before large batches; shortfall → `402`. ### 7.2 Transactions — GET /api/profile/transactions ```text GET /api/profile/transactions?page=1&limit=10&type=DATA ``` - `page` 1-indexed (default 1). `limit` default 10, max 100. `type` optional: `DATA` | `AIRTIME` | `TV` | `ELECTRICITY` | `EXAM_PIN` | `DATA_PRINT` | `PIN_PRINT`. - Response: `{ success, pagination: { total, page, limit, totalPages }, transactions: [] }`. Rows carry common fields (`transactionId`, `type`, `status: SUCCESS|FAILED|PENDING`, `amount`, `provider`, `createdAt`, `description`, `idempotencyKey`) plus per-type fields (data: `planId/number/validity/category`; airtime: `+discountRate`; tv: `planId/iucNumber/validity/category`; electricity: `providerId/meterNumber/subscriptionType/address/electricityToken`; pins/cards: `quantity/pin/serial/cardName`). - Prefer webhooks over polling for status. ### 7.3 Withdraw — POST /api/profile/withdraw ```json { "amount": 5000, "bankCode": "011", "accountNumber": "0123456789", "narration": "Withdrawal to First Bank", "pin": "1234" } ``` - `amount` ≥ 1000. `bankCode` = CBN/NUBAN code (see /docs/reference/supported-banks). `accountNumber` 10-digit, verified before debit — invalid → `400 VALIDATION_ERROR`, no debit. `pin` = 4-digit transaction PIN (never log). `narration` ≤ 100 chars. - Accepted → `{ success, message: "Withdrawal request received!", transactionId, data: { amount, fee, total: amount+fee, status: "PENDING", bank: { bankCode, bankName, accountNumber, accountName }, narration, createdAt } }`. Terminal status arrives via `WITHDRAWAL_SUCCESS` / `WITHDRAWAL_FAILED` (failure refunds). Destination name must match key owner; not a customer-disbursement API. Test keys simulate only and fire no settlement webhook — use the dashboard Test button. ## 8. Errors | HTTP | code | Meaning / action | |---|---|---| | 400 | VALIDATION_ERROR | Fix fields in `details[]`. Includes invalid JSON body and unknown `bankCode`. | | 401 | UNAUTHORIZED | Fix key (`keyStatus`: `INVALID`/`NOT_FOUND`/`DISABLED`). | | 402 | INSUFFICIENT_BALANCE | Fund wallet. | | 403 | FORBIDDEN | Wrong scope (`keyStatus: FORBIDDEN`) or wrong PIN on withdraw. | | 404 | NOT_FOUND | Bad plan/provider/endpoint (`Plan with id '...' not found`). | | 405 | METHOD_NOT_ALLOWED | Plans = GET; purchases/verify/withdraw = POST; balance/transactions/test = GET. | | 409 | CONFLICT | `idempotencyKey` reused — return existing `transactionId`, do not retry. | | 412 | PRECONDITION_FAILED | KYC daily limit or withdrawals not enabled on key. | | 422 | TRANSACTION_FAILED | Upstream failed; refunded; support needs `transactionId`. | | 429 | RATE_LIMITED | Wait `retryAfterMs` / `Retry-After`. | | 500 | INTERNAL_ERROR | Retry with SAME `idempotencyKey` only if no transaction was created; else reconcile. | Handle in code: branch on `success`, then `error.code`; map `details[]` to field UI; treat `CONFLICT` as success-with-existing-id; never mint a new key for `PENDING`. ## 9. Idempotency - Required on every purchase. One UUID v4 per intent (`crypto.randomUUID()`, `uuid.uuid4()`). - First send processes; resend with same key → `409 CONFLICT` (`"This transaction has already been completed successfully."`), no second debit. - Retry with the SAME key on timeouts; a NEW key means a NEW charge. ## 10. Webhooks - Dashboard → Settings → Dev Console → Webhook Endpoints → Add (HTTPS URL + categories: Purchase and/or Withdrawal). Each endpoint gets a signing secret. - Every delivery carries `X-Webhook-Signature` = HMAC-SHA256 hex of the exact raw body with your secret. Verify against raw bytes BEFORE parsing; reject timestamp skew > 5 minutes; respond `200` within 10s. No automatic retries on non-200/timeout (logged only). - Category model: Purchase subscription delivers `PURCHASE_SUCCESS` + `PURCHASE_FAILED`; Withdrawal delivers `WITHDRAWAL_SUCCESS` + `WITHDRAWAL_FAILED`. Sandbox never fires withdrawal settlements. - Payloads: - Purchase: `{ event, timestamp, data: { transactionId, type, amount, status, providerRef?|errorMessage?, identifier } }`. - Withdrawal success: `{ event: "WITHDRAWAL_SUCCESS", timestamp, data: { transactionId, type: "WITHDRAWAL", amount, fee, total, status: "SUCCESS", bank: { bankCode, bankName, accountNumber, accountName }, narration, reference, disbursementRef, completedAt } }`. - Withdrawal failed: same with `status: "FAILED"`, `errorMessage`, `failedAt`; debit already refunded. - Manage from Dev Console: toggle active, Test (per-category success/failure), rotate secret (update server immediately), delete. ## 11. Networks, MNP, banks, pricing notes - Nigerian numbers are 11 digits (`0…`) or `+234/234…`; first 4 digits ≈ original network (MTN, GLO, Airtel, T2, VITEL `0712`, SMILE `0702`). Ported numbers keep old prefixes — pre-select by prefix but allow manual override and send `isPorted: true` for ported lines (data/airtime). - Supported banks: curated NG list keyed by `bankCode` (see /docs/reference/supported-banks). - Pricing: per-transaction, no subscriptions. `salePrice` = final ₦ debit; `salePercentage` = % off face; `saleFee` = flat ₦ add-on. Upstream rates change — re-fetch plans and follow the blog. ## 12. Guide index - /docs — overview + quickstart - /docs/getting-started/authentication — keys, scopes, keyStatus - /docs/getting-started/errors — codes + handling snippets - /docs/getting-started/idempotency — semantics + examples - /docs/getting-started/webhooks — setup + HMAC snippets + payloads - /docs/getting-started/going-live — sandbox→live checklist - /docs/reference/plan-versions — v1 vs v2 per service - /docs/reference/network-prefixes — prefixes + MNP snippet - /docs/reference/verify — key verification - /docs/reference/supported-banks — bank codes - /docs/pricing — live pricing table (developer role required) - /docs/services/data, /docs/services/airtime, /docs/services/tv, /docs/services/electricity, /docs/services/exam-pins, /docs/services/data-cards, /docs/services/recharge-cards - /docs/account/balance, /docs/account/transactions, /docs/account/withdraw