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.

MethodPathPurpose
GET/v1/balanceavailable_kobo, on_hold_kobo, currency: "NGN"
GET/v1/products?category=data&network=mtnCatalogue with this account's prices and "changed today" markers
POST/v1/ordersBuy airtime, data, cable, exam pin or electricity; 202 with status: "processing"
POST/v1/orders/bulkUp 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}/vouchersTokens and pins for a successful order; signed, audited, repeatable
POST/v1/orders/{id}/requeryAsk for a requery; once per minute per order
GET/v1/ordersPaginated history (cursor)
POST/v1/orders/exportCSV export; over 10,000 rows it is emailed
POST/v1/verify/metername, address, meter_type (prepaid, postpaid), min_vend_kobo
POST/v1/verify/smartcardname, current_bouquet, due_date, amount_due_kobo, provider
POST/v1/verify/jambCandidate name for a JAMB profile code
GET/v1/funding/accountsThis account's virtual account numbers
POST/v1/webhooksRegister 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.

HeaderValue
X-Api-KeyYour key: kv_live_<prefix>… (live) or kv_test_… (sandbox)
X-TimestampUnix seconds when you signed; rejected if more than ±300 s off
X-SignatureLowercase hex of HMAC_SHA256(secret, timestamp + "." + METHOD + "." + path + "." + rawBody)
// Node.js
const 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.

Never make a new request_id for a retry after a timeout. Resend the same request and you get the original order back.

Rate limits

ScopeLimit
All endpoints60 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}/requeryOnce per minute per order
POST /v1/orders/bulkUp 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/orders
X-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

statusMeaningMoney
processingAccepted, being sentOn hold, not spent
pendingSent, awaiting confirmation. We keep checkingOn hold, not spent
successDeliveredSpent
failedNever deliveredMoney back in your balance
reversedWas success, refunded after investigationCredited 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.

EndpointSendReturns
POST /v1/verify/meterdisco, meter_number, meter_typename, address, meter_type (prepaid, postpaid), min_vend_kobo
POST /v1/verify/smartcardprovider, smartcard_numbername, current_bouquet, due_date, amount_due_kobo, provider
POST /v1/verify/jambprofile_codeCandidate 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.

HeaderValue
X-KudiVend-Event-Idevt_…, the same on every retry and replay
X-KudiVend-TimestampUnix seconds of this delivery
X-KudiVend-Signaturehex(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 } }
Events
  • 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": "…" } }.

HTTPcodeMeaning
400invalid_requestValidation failed; fields lists which
401invalid_signatureKey, signature or timestamp wrong
401ip_not_allowedCaller IP not in the key's allowlist
402insufficient_balanceWallet cannot cover the price; body carries available_kobo, on_hold_kobo, currency
403limit_exceededDaily or KYC limit reached
404product_unavailableProduct paused, disabled or not priced for this account
409idempotency_conflictrequest_id reused with a different body
422recipient_invalidNumber, meter, smartcard or JAMB code failed validation
429rate_limitedSlow down; Retry-After header in seconds
503upstream_unavailableEvery 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:

Success
08000000001
Failed
08000000002
Pending, then success after 2 minutes
08000000003

Pass all three before you go live. Live keys need approved KYB and an authenticator app.