Overview
REST over HTTPS at api.kudivend.com/v1, JSON only, amounts in kobo as integers (₦510 is 51000). The public API is for reseller and API clients only. The web app, consoles and storefronts use the session endpoints under App endpoints. Live keys need business verification (KYB); sandbox keys work without it.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/balance | available_kobo, on_hold_kobo, currency: "NGN" |
| GET | /v1/products?category=data&network=mtn | Catalogue with this account's prices and "changed today" markers |
| POST | /v1/orders | Buy airtime, data, cable, exam pin or electricity; 202 with status: "processing" |
| POST | /v1/orders/bulk | Up to 1,000 rows; each row gets request_id bulk-{batch_id}-{row} |
| GET | /v1/orders/{id} or ?request_id= | Order status: processing, pending, success, failed, reversed |
| GET | /v1/orders/{id}/vouchers | Tokens and pins for a successful order; signed, audited, repeatable |
| POST | /v1/orders/{id}/requery | Ask for a requery; once per minute per order |
| GET | /v1/orders | Paginated history (cursor) |
| POST | /v1/orders/export | CSV export; over 10,000 rows it is emailed |
| POST | /v1/verify/meter | name, address, meter_type (prepaid, postpaid), min_vend_kobo |
| POST | /v1/verify/smartcard | name, current_bouquet, due_date, amount_due_kobo, provider |
| POST | /v1/verify/jamb | Candidate name for a JAMB profile code |
| GET | /v1/funding/accounts | This account's virtual account numbers |
| POST | /v1/webhooks | Register an endpoint and its events; returns whsec_… once |
Authentication
Every request carries your API key, a timestamp and an HMAC-SHA256 signature made with the key's signing secret. The signing string is timestamp + "." + METHOD + "." + path + "." + rawBody: the method in capitals, the path including /v1 and the query string, and the exact body bytes (rawBody is empty for GET). The timestamp must be within ±300 seconds of our clock.
| Header | Value |
|---|---|
| X-Api-Key | Your key: kv_live_<prefix>… (live) or kv_test_… (sandbox) |
| X-Timestamp | Unix seconds when you signed; rejected if more than ±300 s off |
| X-Signature | Lowercase hex of HMAC_SHA256(secret, timestamp + "." + METHOD + "." + path + "." + rawBody) |
// Node.jsconst ts = Math.floor(Date.now() / 1000).toString(); const path = "/v1/orders"; // include any query string const payload = `${ts}.POST.${path}.${rawBody}`; // rawBody = "" for GET const sig = crypto.createHmac("sha256", SECRET) .update(payload).digest("hex"); headers["X-Api-Key"] = "kv_test_3Lm8…"; headers["X-Timestamp"] = ts; headers["X-Signature"] = sig;
Optional IP allowlist per key: up to 20 entries, CIDR blocks no wider than /24. A call from elsewhere gets 401 ip_not_allowed. A bad key, signature or timestamp gets 401 invalid_signature. Signing secrets are encrypted at rest and shown once when created; we can't show them again.
Idempotency
Send your own request_id with every order. It is unique per account forever. The same request_id and body returns the original order with 200, never a second charge; a different body returns 409 idempotency_conflict.
Rate limits
| Scope | Limit |
|---|---|
| All endpoints | 60 requests per second per key (sliding window) |
POST /v1/verify/* | 60 requests per minute per key, because they reveal customer names |
POST /v1/orders/{id}/requery | Once per minute per order |
POST /v1/orders/bulk | Up to 1,000 rows per request |
Over a limit you get 429 rate_limited with a Retry-After header in seconds.
Orders
POST /v1/orders buys airtime, data, cable, an exam pin or electricity and returns 202 Accepted with status processing. The result arrives by webhook, or poll GET /v1/orders/{id}. amount_kobo is required for airtime and electricity and ignored for fixed-price products. max_price_kobo is optional; the order is refused if the price is higher.
POST /v1/ordersX-Api-Key: kv_live_9f2c… X-Timestamp: 1792741332 X-Signature: 5d0e… // hex HMAC of "1792741332.POST./v1/orders.<rawBody>" { "request_id": "rs-20261023-000184", "product_code": "MTN-DATA-SME-2GB-30D", "recipient": "08031234567", "amount_kobo": null, "max_price_kobo": 51000, "meta": { "customer_ref": "abc" } }
202 Accepted{ "id": "ord_01JB7R2M8K", "request_id": "rs-20261023-000184", "status": "processing", "charged_kobo": 51000, "balance_after_kobo": 18381000, "created_at": "2026-10-23T07:42:12Z" }
Example: Bisi Gadgets & Data (Silver, wallet ₦184,320.00) buys MTN SME 2GB at the Silver price ₦510. Order objects never carry tokens or pins; fetch them from the vouchers endpoint.
Get an order
GET /v1/orders/{id} or /v1/orders?request_id=rs-20261023-000184 returns the order with its status: processing, pending, success, failed or reversed.
List orders
GET /v1/orders?limit=50&cursor=… is your paginated order history, newest first. Pass the returned next_cursor to get the next page; it is null on the last page.
200 OK{ "data": [ { "id": "ord_01JB7R2M8K", "status": "pending", "product_code": "MTN-DATA-SME-2GB-30D", "price_kobo": 51000 } ], "next_cursor": "c_9f2a71" }
Export orders
POST /v1/orders/export builds a CSV export of your orders. Over 10,000 rows it is emailed to the account owner instead of returned.
Order statuses
| status | Meaning | Money |
|---|---|---|
| processing | Accepted, being sent | On hold, not spent |
| pending | Sent, awaiting confirmation. We keep checking | On hold, not spent |
| success | Delivered | Spent |
| failed | Never delivered | Money back in your balance |
| reversed | Was success, refunded after investigation | Credited back |
A late delivery after failed or reversed returns the order to success and claws the money back. You get an order.success webhook for it.
Vouchers
GET /v1/orders/{id}/vouchers returns tokens and pins for a successful order. It is signed, audited and repeatable. Webhooks and order objects never contain voucher values. Exam pins are non-refundable once revealed.
200 OK{ "data": [ { "kind": "token", "value": "4523 8890 1123 6654 2201", "serial": null } ] }
Bulk and requery
POST /v1/orders/bulk takes up to 1,000 rows. Each row gets request_id bulk-{batch_id}-{row}, so resending a batch can't charge twice. POST /v1/orders/{id}/requery asks for a requery, once per minute per order.
Products
GET /v1/products?category=data&network=mtn is the catalogue with this account's prices and “changed today” markers. Prices change when costs change, with no notice period: subscribe to price.changed.
200 OK{ "data": [ { "code": "MTN-DATA-SME-2GB-30D", "category": "data", "network": "mtn", "plan_type": "sme", "name": "MTN SME 2GB", "price_kobo": 51000, "validity": "30 days", "status": "active", "changed_today": false } ] }
Balance
GET /v1/balance
200 OK{ "available_kobo": 18432000, "on_hold_kobo": 199000, "currency": "NGN" }
Funding accounts
GET /v1/funding/accounts returns this account's virtual account numbers. Transfers to them credit your wallet; a wallet.funded webhook follows.
Verify
Check a meter, smartcard or JAMB profile before you buy and show the returned name to your customer. Limited to 60 a minute per key, because they reveal customer names.
| Endpoint | Send | Returns |
|---|---|---|
| POST /v1/verify/meter | disco, meter_number, meter_type | name, address, meter_type (prepaid, postpaid), min_vend_kobo |
| POST /v1/verify/smartcard | provider, smartcard_number | name, current_bouquet, due_date, amount_due_kobo, provider |
| POST /v1/verify/jamb | profile_code | Candidate name for the JAMB profile code |
Webhooks
We POST JSON to your HTTPS endpoint through our egress proxy. Each endpoint has one whsec_… secret, encrypted at rest and shown once; after rotation both secrets sign for 24 hours. Return 2xx within 10 s and deduplicate on event_id.
| Header | Value |
|---|---|
| X-KudiVend-Event-Id | evt_…, the same on every retry and replay |
| X-KudiVend-Timestamp | Unix seconds of this delivery |
| X-KudiVend-Signature | hex(HMAC_SHA256(whsec, timestamp + "." + rawBody)) |
During the 24 hours after a secret rotation, X-KudiVend-Signature contains two comma-separated signatures (new secret first, then the previous one); accept the webhook if any one of them matches your own HMAC.
// envelope{ "event_id": "evt_01JB8D0Q4R", "type": "order.success", "created_at": "2026-10-23T07:42:19Z", "data": { "id": "ord_01JB7R2M8K", "request_id": "rs-20261023-000184", "status": "success", // full order object without voucher values } }
- order.pending
- order.success
- order.failed
- order.reversed
- wallet.funded
- wallet.refund.paid
- wallet.refund.failed
- price.changed
processing is not an event. Vouchers are never in a webhook; fetch them with GET /v1/orders/{id}/vouchers.
Retries after a non-2xx or timeout: 1 m, 5 m, 30 m, 2 h, 6 h, then every 6 h; 10 attempts total. After the 10th failure the delivery goes to dead-letter, the endpoint is disabled and we email you; replay from the dashboard. Delivery logs are kept 30 days; payloads are purged after 30 days.
Register a webhook
POST /v1/webhooks registers an HTTPS endpoint and its events and returns its whsec_… secret once.
POST /v1/webhooks{ "url": "https://bisigadgets.ng/hooks/kudivend", "events": ["order.success", "order.failed", "price.changed"] }
Errors
Errors return { "error": { "code": "…", "message": "…" } }.
| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_request | Validation failed; fields lists which |
| 401 | invalid_signature | Key, signature or timestamp wrong |
| 401 | ip_not_allowed | Caller IP not in the key's allowlist |
| 402 | insufficient_balance | Wallet cannot cover the price; body carries available_kobo, on_hold_kobo, currency |
| 403 | limit_exceeded | Daily or KYC limit reached |
| 404 | product_unavailable | Product paused, disabled or not priced for this account |
| 409 | idempotency_conflict | request_id reused with a different body |
| 422 | recipient_invalid | Number, meter, smartcard or JAMB code failed validation |
| 429 | rate_limited | Slow down; Retry-After header in seconds |
| 503 | upstream_unavailable | Every route for this product is switched off at intake; nothing charged. Later failures arrive as order.failed |
Sandbox
Use kv_test_… keys at https://sandbox.api.kudivend.com/v1. The sandbox is a separate deployment and database, excluded from the production ledger, reconciliation and solvency. No KYB needed, no real money. The recipient decides the outcome:
Pass all three before you go live. Live keys need approved KYB and an authenticator app.