# InstaPayBill API > Programmable bill payments for Nigeria — airtime, data, electricity, cable TV, > education result-checker PINs and Startimes — behind one REST API. Base URL: `https://sandbox.instapaybill.com` · Prefix: `/api/v1` Auth: `Authorization: Bearer sk_live_…` (or `sk_test_…`). A `User-Agent` header is required. Most requests are `Content-Type: application/json`. --- # InstaPayBill API Source: https://developer.instapaybill.com/docs/index/ # InstaPayBill API **Programmable bill payments for Nigeria — airtime, data, electricity, cable TV, education result-checker PINs and Startimes — behind one REST API.** This is the reference for agents, developers and automated systems that integrate with the InstaPayBill payments backend. It documents every agent-facing endpoint, the exact request and response shapes, and how the backend actually works under the hood. | | | |---|---| | **Base URL** | `https://sandbox.instapaybill.com` | | **API prefix** | `/api/v1` | | **Sandbox base** | `https://sandbox.instapaybill.com/api/v1/sandbox` | | **Live base** | `https://api.instapaybill.com/api/v1` | | **Content type** | `application/json` | | **Live auth header** | `Authorization: Bearer sk_live_…` | | **Sandbox auth header** | `Authorization: Bearer sk_test_…` (sandbox routes only) | | **Currency** | Nigerian Naira (₦) | > **Test against the sandbox host.** `sandbox.instapaybill.com` is the > simulated environment for developer testing at `/api/v1/sandbox/*`. Use > `api.instapaybill.com/api/v1/*` with a Live key for real payments. > `staging-api.instapaybill.com` is reserved for internal QA. --- ## What you can do with it | Service | Endpoint | What it does | |---|---|---| | **Airtime** | [`POST /api/v1/airtime/buy`](api/airtime.md) | Top up any Nigerian mobile number on MTN, Glo, Airtel or 9mobile | | **Data** | [`POST /api/v1/data/buy`](api/data.md) | Activate a data bundle from the live plan catalogue | | **Electricity** | [`POST /api/v1/electricity/buy`](api/electricity.md) | Buy prepaid units and receive a meter token | | **Cable TV** | [`POST /api/v1/cables/buy`](api/cables.md) | Renew or subscribe to DStv, GOtv, ShowMax, Startimes | | **Education** | [`POST /api/v1/education/buy`](api/education.md) | Generate WAEC, JAMB and other result-checker PINs | | **Transactions** | [`GET /api/v1/transactions`](api/transactions.md) | List, resolve and reconcile your transactions | | **Wallet** | [`GET /api/v1/wallet/manage-wallet-balance`](api/wallet-keys.md) | Check spendable balance and commission | | **Webhooks** | [`x-webhook-signature`](api/webhooks.md) | Receive signed async settlement notifications | --- ## Start here
- :material-rocket-launch: **[Quickstart](getting-started.md)** Make your first purchase in five minutes. Sandbox key, `curl` call, response walkthrough. - :material-key: **[Authentication](authentication.md)** Sandbox and Live keys, session JWTs, and developer portal SSO. - :material-alert-circle: **[Errors & Status Codes](errors.md)** The `300` vs `400` distinction that decides whether you retry or stop.
--- ## Two ways to integrate ### 1. REST API (recommended) Authenticate with an API key and call the purchase endpoints directly. ```bash curl -X POST https://api.instapaybill.com/api/v1/airtime/buy \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "Content-Type: application/json" \ -H "User-Agent: my-integration/1.0" \ -d '{"phone":"08012345678","amount":500,"network":"MTN","apiRefId":"order-1001"}' ``` Production keys live in your server, are tied to an approved agent account, and draw down a real wallet balance. ### 2. Sandbox (free, no money moves) Every sandbox endpoint is a simulation — it never reaches a provider, never charges a wallet, and returns realistic response shapes so you can build against them. The sandbox key is available to every approved agent from the [developer portal](https://developer.instapaybill.com/keys). ```bash curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/airtime \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{"phone":"08012345678","amount":500,"network":"MTN","scenario":"success"}' ``` The sandbox lets you force outcomes with the `scenario` field (`success`, `failure`, `pending`) — see [Sandbox](api/sandbox.md). --- ## Conventions used in this documentation * **Field names** are shown exactly as they appear in the JSON payload. The casing is inconsistent in places (for example `apiRefId` on the request but `apiRefID` on the resolve query) — that is the real behaviour, not a typo. * **Status codes** follow the platform convention documented in [Errors & Status Codes](errors.md): `200` success, `300` retryable, `400` permanent failure, `403` not permitted. * **Source** — every claim here was read out of the running backend (`InstaPayBill-api`): `Routes/`, `Controllers/`, `Services/`, `Middlewares/`, `Models/` and `utils/`. Where behaviour is conditional on a live configuration flag (kill switches, feature flags, provider health), the condition is called out explicitly. * **Internal-only** endpoints (admin panels, CRM, cron triggers, risk scoring) are **not** part of the public integration surface. !!! note "Not an agent yet?" Production API keys are issued to approved InstaPayBill agents. Apply from your [profile settings](https://instapaybill.com/settings?tab=profile). Sandbox access requires an approved agent account too — the portal never creates standalone accounts. --- ## For AI agents Two machine-readable entry points are published alongside these docs: | File | What it is | |---|---| | [`/docs/llms.txt`](/docs/llms.txt) | A manifest of every page with a one-line summary. Fetch this first, then request only the pages you need. | | [`/docs/llms-full.txt`](/docs/llms-full.txt) | The entire reference concatenated into one file. Fetch this if you want everything in a single request. | The `llms.txt` format is described at [llmstxt.org](https://llmstxt.org). If you are driving the API from an agent, the shortest useful path is: 1. [`getting-started.md`](getting-started.md) — the auth header and the request shape. 2. [`errors.md`](errors.md) — the `300` vs `400` rule, which determines whether to retry. 3. The service page you need — [`api/airtime.md`](api/airtime.md), [`api/data.md`](api/data.md), [`api/electricity.md`](api/electricity.md), [`api/cables.md`](api/cables.md) or [`api/education.md`](api/education.md). 4. [`api/webhooks.md`](api/webhooks.md) — if you want the final outcome pushed to you. !!! tip "Two things to get right" * **Send a `User-Agent` header.** Without one the API returns HTTP 500. * **Never retry a `300` immediately.** It means the provider has not confirmed yet. Wait for the webhook, or poll [`resolve`](api/transactions.md#resolve-a-transaction) after about a minute. See [`errors.md`](errors.md). --- # getting-started Source: https://developer.instapaybill.com/docs/getting-started/ # Quickstart Make your first API call in about five minutes. --- ## 1. Get a key Open the [InstaPayBill developer portal](https://developer.instapaybill.com) and sign in with your InstaPayBill account. Access is granted to **active, approved agents with a verified email address** — there is no separate developer signup. From the portal: | Key | Where | Use it for | |---|---|---| | **Sandbox key** (`sk_test_…`) | [API Keys page](https://developer.instapaybill.com/keys) | Simulated transactions. Free, no wallet, no real providers. | | **Live key** (`sk_live_…`) | [API Keys page](https://developer.instapaybill.com/keys) | Real purchases against your real wallet balance. | The sandbox key is what the interactive playground and these examples use. The live key draws down your production wallet. !!! danger "Keep live keys on your server" A live key can spend real money from your wallet. Never ship it in a browser bundle, a mobile app, a public repo, or a client-side environment variable. Store it in a secret manager and read it at runtime. --- ## 2. Make a sandbox call ```bash export IPB_SANDBOX_KEY="sk_test_..." curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/airtime \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "08012345678", "amount": 500, "network": "MTN" }' ``` Response: ```json { "msg": "MTN Airtime Purchase of ₦500 for 08012345678 was successful", "data": { "code": 200, "reference": "SBTEST-ABCD1234EF", "description": "MTN Airtime Purchase", "message": "Top-up of ₦500 to 08012345678 delivered successfully.", "type": "airtime" }, "code": 200 } ``` The three fields that matter: * `code` — `200` succeeded, `300` still being verified by the provider, `400` failed. It always matches the HTTP status. * `data.reference` — your unique reference. Save it. * `data.message` — the human-readable outcome, safe to show a user. --- ## 3. Make a live call Swap the endpoint and the key. Two things change: * Use a `sk_live_…` key. * **Send a `User-Agent` header.** The backend rejects requests without one. Omitting it returns HTTP 500 with `"Server Error: user agent is missing"`. ```bash export IPB_LIVE_KEY="sk_live_..." curl -X POST https://api.instapaybill.com/api/v1/airtime/buy \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "Content-Type: application/json" \ -H "User-Agent: my-integration/1.0" \ -d '{ "phone": "08012345678", "amount": 500, "network": "MTN", "apiRefId": "order-1001" }' ``` `apiRefId` is **your** reference for the order. It is stored on the transaction only for API-channel requests, and you can look it up later with [`GET /api/v1/transactions/resolve?apiRefID=order-1001`](api/transactions.md#resolve-a-transaction). --- ## 4. Look up what happened ```bash curl -G https://api.instapaybill.com/api/v1/transactions/resolve \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" \ --data-urlencode "apiRefID=order-1001" ``` If the purchase settled in a provider callback (after the original response returned `300`), the `resolve` endpoint shows the final `code` — `200` for delivered, `400` for failed and refunded. --- ## 5. Handle the failure modes A purchase can end in three places, and your integration must treat them differently: | Outcome | HTTP / `code` | What happened | Your action | |---|---|---|---| | Success | `200` | Delivered | Fulfill the order. | | Pending | `300` | Provider has not confirmed yet | **Do not retry yet.** Wait for the webhook or poll `resolve`. The wallet is still debited. | | Failure | `400` | Provider declined | The wallet was refunded. Surface the message. Safe to retry. | The most common integration bug is treating `300` as a failure and retrying immediately. A `300` means *unknown*, and the platform is already polling the provider. Retrying risks a double charge. --- ## 6. Subscribe to webhooks Register a public HTTPS URL on the [API Keys page](https://developer.instapaybill.com/keys) and the platform will POST settlement results to it. ```bash curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/keys/webhook \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{"webhookUrl":"https://your-server.com/webhooks/instapaybill"}' ``` Live webhooks are signed — see [Webhooks](api/webhooks.md) for the payload shape and the HMAC verification snippet. --- ## Next steps * [Authentication](authentication.md) — the full auth model, including sessions and the developer portal SSO. * [Errors & Status Codes](errors.md) — the full error contract, including the validation-error shape and per-field error paths. * [Rate Limits](rate-limits.md) — what you can safely send, and how to back off. * [API Reference](api/overview.md) — every endpoint. --- # authentication Source: https://developer.instapaybill.com/docs/authentication/ # Authentication The developer portal offers two integration credentials: **Sandbox key** for simulations and **Live key** for real payments. App sessions and the WhatsApp agent channel use separate authentication mechanisms. | Mechanism | Header | Reaches | |---|---|---| | **Live API key** | `Authorization: Bearer sk_live_…` | All agent endpoints, real wallet | | **Sandbox key** | `Authorization: Bearer sk_test_…` or `X-API-Key` | Only `/api/v1/sandbox/*` | | **Session JWT** | `Authorization: ` | App/web endpoints, session wallet | | **Agent server key** | `x-agent-key: …` | Only `/api/v1/agent/*` (WhatsApp channel) | --- ## API keys Use a **Live key** (`sk_live_…`) with `https://api.instapaybill.com/api/v1/*`. It belongs to your approved agent account and uses your real wallet. Use a **Sandbox key** (`sk_test_…`) with `https://sandbox.instapaybill.com/api/v1/sandbox/*` for simulated payments. Copy each key from the [API Keys page](https://developer.instapaybill.com/keys). The keys are not interchangeable. The playground uses only the sandbox key. Internal QA at `staging-api.instapaybill.com` uses the `instapay_test` database; it is not selected by a developer key prefix. ```bash curl https://api.instapaybill.com/api/v1/wallet/manage-wallet-balance \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" ``` !!! note "Older integration test keys" The backend retains its older `test_key` field for compatibility, but the developer portal no longer displays that credential. It selects test wallet fields within the regular API and does not select the isolated sandbox or the `instapay_test` database. New integrations should use the Sandbox key for testing and the Live key for real payments. ### The header must be exactly `Bearer ` * The prefix is case-sensitive: `Bearer`, not `bearer`. * Exactly one space. `Bearer sk_live_x` (two spaces) splits into three parts and is rejected with `403`. * No extra segments — `Bearer sk_live_x extra` is rejected with `403`. ### Privilege requirement The key's owner must be an **agent** or an **admin**: ```js if (thisUser?.privilege !== 'agent' && !thisUser?.isAdmin) return res.status(403).json({ error: [{ msg: 'Your account is not yet upgraded as an agent', path: 'key' }], }); ``` A key belonging to a regular customer account is rejected with `403` even though the key itself is valid. ### `User-Agent` is mandatory Every authenticated request must carry a `User-Agent` header. A request without one fails with **HTTP 500**: ```json { "error": [{ "msg": "Server Error: user agent is missing" }] } ``` This affects non-browser clients (servers, CLI tools, agents). Send something identifiable: ``` User-Agent: my-integration/1.0 (+https://example.com) ``` The `User-Agent` is also part of the device fingerprint used by the blocklist guard. --- ## Sandbox keys Sandbox keys authenticate only the `/api/v1/sandbox/*` routes on `https://sandbox.instapaybill.com`. They cannot authenticate regular production API endpoints. ```bash curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/airtime \ -H "Authorization: Bearer sk_test_abcdef0123..." \ -H "Content-Type: application/json" \ -d '{"phone":"08012345678","amount":500,"network":"MTN"}' ``` The `X-API-Key` header works as an alternative: ```bash curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/airtime \ -H "X-API-Key: sk_test_abcdef0123..." \ -H "Content-Type: application/json" \ -d '{"phone":"08012345678","amount":500,"network":"MTN"}' ``` A sandbox key is bound to the agent identity, so it only works while the linked account is still an active, email-verified agent. If the agent account is suspended or downgraded, sandbox calls start returning `403`. Each sandbox wallet starts with **₦100,000 in virtual funds**. Successful sandbox API purchases deduct their simulated price. Failed and pending transactions leave the balance unchanged. The balance persists across sessions, and previously saved successful purchases count toward it. Real wallets and providers are never used. Browser-only previews do not save transactions or change the sandbox wallet. --- ## Session JWTs The app and website authenticate with a signed JWT: ``` Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ``` The backend verifies it with HS256 against `ACCESS_TOKEN_SECRET` and then checks that the session row still exists in the `jwttokens` collection: ```js user = jwt.verify(token, secret, { algorithms: ['HS256'] }); if (!(await hasActiveSession(user?.id, token, tokenAuth))) { return res.status(401).json({ error: [{ msg: 'Session expired. Please sign in again.', path: 'token' }] }); } ``` A JWT that is still cryptographically valid but has been revoked — by logout, by password change, or by role removal — returns `401` with `"Session expired. Please sign in again."` ### `frontend-source` header For session requests, the `frontend-source` header selects the channel and therefore the kill switch that applies: ``` frontend-source: web # or app | admin | ios-app | api | whatsapp-agent ``` | `frontend-source` | `req.userAuth` | Applies | |---|---|---| | `admin` | `admin` | `usecases.adminStatus` | | `web` | `web` | `usecases.webStatus` | | `app` | `app` | `usecases.appStatus` | | `ios-app` | `ios-app` | none specifically | | `api` | `api` | `usecases.apiStatus` | | `whatsapp-agent` | `whatsapp-agent` | none specifically | | *(absent or unknown)* | `auth` | none specifically | If the header is present but not in that list, the request is rejected with `300` and `"Invalid source header"`. !!! note "API keys ignore `frontend-source`" When you authenticate with `Authorization: Bearer sk_…`, the backend sets `req.userAuth = 'api'` itself. You do not need to send `frontend-source`, and sending it does not change the channel. --- ## Which endpoints accept which credential ### Agent/API-key accessible Use `Authorization: Bearer sk_live_…` on `https://api.instapaybill.com` for these endpoints. Use the separate sandbox endpoints when testing: | Method | Path | |---|---| | `POST` | `/api/v1/airtime/buy` | | `POST` | `/api/v1/data/buy` | | `POST` | `/api/v1/electricity/buy` | | `POST` | `/api/v1/electricity/validate` | | `POST` | `/api/v1/cables/buy` | | `POST` | `/api/v1/cables/validate` | | `POST` | `/api/v1/education/buy` | | `GET` | `/api/v1/data/agent-data` | | `GET` | `/api/v1/data/networks` | | `GET` | `/api/v1/data/networks/{network}/plans` | | `GET` | `/api/v1/data/` | | `GET` | `/api/v1/cables/types` | | `GET` | `/api/v1/electricity/` | | `GET` | `/api/v1/education/` | | `GET` | `/api/v1/network/` | | `GET` | `/api/v1/transactions/` | | `GET` | `/api/v1/transactions/resolve` | | `GET` | `/api/v1/transactions/success-rates` | | `GET` | `/api/v1/wallet/manage-wallet-balance` | | `GET` | `/api/v1/wallet/generate-wallet-reference` | | `GET` | `/api/v1/user/` | | `GET` | `/api/v1/notification/` | | `PUT` | `/api/v1/notification/` | | `POST` | `/api/v1/notification/` | | `GET` | `/api/v1/airtime/banks` | ### Public — no credential needed | Method | Path | |---|---| | `GET` | `/api/v1/cables/` | | `GET` | `/api/v1/startime/` | | `GET` | `/api/v1/category/` | | `GET` | `/api/v1/faqs/` | | `GET` | `/api/v1/settings/` | | `GET` | `/api/v1/usecase/` | | `GET` | `/api/v1/network/validate-number/{phoneNumber}` | | `GET` | `/api/v1/promo/active` | | `GET` | `/api/v1/giveaway/count` | ### Explicitly blocked for the API channel Routes marked `UserNonApiAuth` return `403` with `"This service is not available for api channel"`. This covers wallet transfers, key management, profile updates, admin and catalogue editing. ```js export const UserNonApiAuth = async (req, res, next) => { if (req?.userAuth === 'api' && !req?.header('InApp')) return res.status(403).json({ error: [{ msg: 'This service is not available for api channel', detail: 'Endpoint is available to direct auth alone', path: 'auth', }], }); next(); }; ``` !!! tip "The `InApp` header" `UserNonApiAuth` makes an exception when the request carries an `InApp` header. This exists for the first-party mobile app, which signs in with a production API key but must still reach app-only routes. Do not rely on it — it is not a supported integration path. --- ## The agent server key (`/api/v1/agent/*`) The `/api/v1/agent/*` routes are the **WhatsApp agent channel** used by the Halo bot. They are not a general-purpose integration surface — they authenticate a *server* with a shared secret and act on behalf of a customer identified by phone number. ``` x-agent-key: x-user-phone-number: 08012345678 ``` `x-agent-key` is compared in constant time against `process.env.IP_AGENT_KEY`. `x-user-phone-number` resolves the customer; the request then runs with that customer's identity, `userAuth = 'whatsapp-agent'`. Key differences from the API-key surface: * Wallet transfers are **blocked outright** — every `POST /api/v1/agent/wallet/*` returns `403` with *"Use the app or website for wallet transfers and balance moves"*. * Wallet PIN checks are skipped for agent-authenticated requests. * Promotional codes are not applied. * A purchase cap of ₦100,000/day applies per phone number when the WhatsApp channel is enabled. * A per-phone rate limit of 5 OTP requests per 10 minutes applies to `POST /api/v1/agent/otp/send`. See [Agent Channel](api/agent-channel.md) for the full surface. --- ## The developer portal session The [developer portal](https://developer.instapaybill.com) does not use any of the above. It uses a **PKCE + SSO handshake** that ends in a host-only cookie. ```text Portal ──► POST /api/v1/sandbox/auth/sso/authorize (customer origin) ◄── { code } one-time, 60 s TTL Portal ──► POST /api/v1/sandbox/auth/sso/exchange (developer origin) ◄── Set-Cookie: __Secure-ipb_developer ``` The resulting cookie is `Secure`, `HttpOnly`, `SameSite=Strict` and scoped to `path=/api/v1/sandbox`. It is never readable from JavaScript, and the website JWT is never forwarded to the portal or placed in a URL. The portal session is bound to the initiating browser tab. --- ## Authentication failure reference | HTTP | Body | Cause | |---|---|---| | `401` | `{"error":[{"msg":"Forbidden, Unauthorized User","path":"token"}]}` | No `Authorization` header on a session route, or a malformed token | | `401` | `{"error":[{"msg":"Session expired. Please sign in again.","path":"token"}]}` | JWT valid but the session was revoked | | `403` | `{"error":[{"msg":"Invalid Authentication, Unauthorized User","path":"token"}]}` | Unknown API key, or a `Bearer` header with more than two parts | | `403` | `{"error":[{"msg":"Your account is not yet upgraded as an agent","path":"key"}]}` | Key belongs to a non-agent account | | `403` | `{"error":[{"msg":"This service is not available for api channel","path":"auth"}]}` | Route requires a session, not an API key | | `403` | `{"error":[{"msg":"Use the developer sandbox website.","path":"origin"}]}` | Wrong `Origin` on a developer-portal route | | `403` | `{"error":[{"msg":"Become an approved agent to access the developer sandbox."}]}` | Not an approved agent | | `500` | `{"error":[{"msg":"Server Error: user agent is missing"}]}` | No `User-Agent` header | --- # errors Source: https://developer.instapaybill.com/docs/errors/ # Errors & Status Codes Every error response shares one envelope. Understanding it — particularly the difference between `300` and `400` — is the single most important thing for a reliable integration. --- ## The error envelope ```json { "code": 400, "error": [ { "msg": "Human readable message", "path": "field_name" } ] } ``` * `error` is **always an array**, even for a single problem. * `error[].msg` — human readable, safe to log. * `error[].path` — the field or subsystem that failed. Common values: `token`, `key`, `auth`, `user`, `phone`, `amount`, `network`, `planId`, `meterNo`, `smartCardNo`, `type`, `wallet`, `kyc`, `kyc_required`, `permission`, `biller`, `domain`, `server`, `agent`, `origin`, `reference`. * `code` — mirrors the HTTP status on service-thrown errors. See the caveat below. A validation failure adds the offending value and its location: ```json { "error": [ { "value": "", "msg": "Amount is required", "path": "amount", "location": "body" } ] } ``` --- ## Status codes | HTTP | `code` | Meaning | Retry? | Money | |---|---|---|---|---| | `200` | `200` | Success | — | Debited | | `300` | `300` | **Transient** — still being verified, or fixable by the caller | **Yes, after waiting** | Debited (held) | | `400` | `400` | **Permanent failure** — provider declined or request invalid | Safe to retry with a fix | Refunded | | `401` | — | Missing or invalid credential | No | Not debited | | `403` | — | Authenticated but not permitted | No | Not debited | | `404` | — | Not found | No | Not debited | | `500` | — | Unexpected server error | Yes, with backoff | Refunded (guaranteed) | | `503` | — | Dependency unavailable (Redis/SSO) | Yes, with backoff | Not debited | ### The `300` vs `400` rule This is the rule that matters: > **`300` means "not yet" — the outcome is unknown or the request needs > correcting. `400` means "no" — the transaction will not succeed as sent.** A `300` on a purchase means the provider did not confirm delivery *yet*. The platform is already polling the provider on your behalf. **Retrying immediately is the wrong move** — it risks a double charge, and the platform's duplicate guard will likely reject it anyway. A `400` on a purchase means the provider declined, or the request was structurally wrong. The wallet has been refunded. ### What actually produces which code **`300` — transient:** * Input validation failure (the `express-validator` chain). * Insufficient wallet balance. * Service or channel disabled by a kill switch. * Phone number belongs to a different network than the one you named. * KYC not completed (`path: "kyc"` or `path: "kyc_required"`). * Minimum or maximum amount exceeded. * A duplicate identical request was detected within the guard window. * The provider returned a pending/uncertain verdict. * The provider's network is in a bad state (but the purchase can be scheduled). * The biller is flagged unhealthy. **`400` — permanent:** * The provider declined the transaction. * The biller integration was retired (`"Provider integration removed; manual reconciliation required"`). * The wallet debit succeeded but the provider call failed in a way that indicates the transaction did not happen. **`403` — not permitted:** * Wrong credential, wrong role, or wrong channel for the route. * Suspended or inactive account. * Blocked IP, device fingerprint or user-agent. * Wrong `Origin` on a developer-portal route. * Not an approved agent. * Transaction blocked for a phone number on the telephone blacklist. **`500` — unexpected:** * Unhandled error. The purchase path guarantees a **wallet refund** on any unhandled exception, and the message explicitly says so. * Missing `User-Agent` (a client error surfaced as a server error — see [Authentication](authentication.md#user-agent-is-mandatory)). --- ## A caveat on the `code` field `code` is **not** always identical to the HTTP status. `ErrorResponse` — the helper used by most middleware — hardcodes `code: 400` regardless of the HTTP status it sets: ```js // Middlewares/ClassExports.cjs export const ErrorResponse = (res, status, msg, path, array, object) => { // ... return res.status(status).json({ code: 400, error: [{ msg, path }] }); }; ``` So a permission failure arrives as: ```json { "code": 400, "error": [{ "msg": "Permission denied", "path": "permission" }] } ``` …on an HTTP `300`. **The rule to follow: branch on the HTTP status, not on `code`.** The body `code` is only reliable on the purchase endpoints, where the six-argument `ErrorResponse` form is used and the body code is set to the real status: ```json { "code": 300, "reference": "IP|TN|A|123|20260918120000_ASDFG", "error": [{ "path": "airtime-merrybills", "msg": "..." }] } ``` `BaseController.sendError` does the same — it reflects the thrown status: ```js if (error?.status && Array.isArray(error?.errors)) { return res.status(error.status).json({ code: error.status, error: error.errors }); } ``` --- ## Purchase error responses carry a reference Purchase failures include the transaction reference, so you can look the attempt up: ```json { "code": 400, "reference": "IP|TN|A|123|20260918120000_ASDFG", "error": [ { "path": "airtime-merrybills", "msg": "Insufficient balance on your Merrybills account" } ] } ``` The `path` names the **provider** that produced the error, not a request field. Provider names in the path are internal routing details — do not parse them. --- ## Scheduling response When a network is in a bad state, the API offers to schedule the purchase instead of failing outright: ```json { "code": 300, "canSchedule": true, "action": "schedule", "error": [ { "path": "network_bad", "msg": "The network for MTN is currently in a bad state, but you can schedule the purchase and we will complete it once the network recovers." } ] } ``` `canSchedule: true` means the platform will deliver this purchase later without any further action from you — you do not need to resubmit it. --- ## Retry guidance | Situation | Retry? | Delay | |---|---|---| | `400` purchase failure | Yes, after fixing the cause | Immediate | | `300` pending purchase | **No** | Wait for the webhook, or poll `resolve` after 60 s | | `300` validation failure | Yes, after fixing the field | Immediate | | `300` insufficient funds | Yes, after funding | Immediate | | `300` duplicate guard | Yes | Wait for the stated window (typically 2–3 min) | | `403` | No | Fix the credential or role | | `401` | No | Refresh the credential | | `500` | Yes | Exponential backoff, 3 attempts max | | `503` | Yes | Backoff, then retry | The platform also enforces a **1 request per 5 seconds** purchase limit on session-authenticated channels, and a duplicate-request guard on the API channel. See [Rate Limits](rate-limits.md). --- ## Sanitised provider errors Provider messages are passed through a sanitiser before they reach you, so internal identifiers, credentials and stack traces are stripped. What you get is a message safe to display to a customer, plus a classification in `utils/classify-purchase-error.js`: | Class | Meaning | |---|---| | `network` | Connection refused / DNS failure | | `timeout` | The provider did not answer in time | | `db_unavailable` | Database unavailable | | `biller_5xx` | Provider returned a server error | | `biller_4xx` | Provider returned a client error | | `type_error` | The provider returned an unexpected payload | | `unknown` | Unclassified | A `500` on the purchase path also carries an explicit sentence telling the customer whether their wallet was refunded, so you can pass it through verbatim. --- ## Unhandled routes An unknown path returns HTTP `300`: ```json { "error": [ { "msg": "Route not found", "param": "server" } ] } ``` Note the key is `param`, not `path`, and there is no `code` field. This is the catch-all handler registered last in `Server.js`, after every route and after the MCP mount. --- # rate-limits Source: https://developer.instapaybill.com/docs/rate-limits/ # Rate Limits All limits are per IP address unless stated otherwise. The `RateLimit-*` headers are returned on every limited response, so you can read your remaining budget without guessing. --- ## Limits by endpoint group | Group | Limit | Window | |---|---|---| | `airtime`, `biller`, `data`, `user`, `agent`, `ai`, `debug` | **300** | 1 minute | | `biz`, `blacklist`, `blocklist`, `cables`, `cgwallet`, `education`, `electricity`, `network`, `product`, `transactions`, `wallet`, `admin`, `ip-allowlist`, `cron`, `user-context`, `schedule`, `crm`, `sandbox` | **120** | 1 minute | | `activity`, `auto-buy`, `category`, `promo`, `giveaway`, `faqs`, `feedback`, `file`, `gateway`, `keys`, `notification`, `permission`, `serviceicon`, `settings`, `signup-survey`, `slider`, `startime`, `upgrade`, `usecase`, `verify`, `voice-assistant`, `sim-gateway` | **60** | 1 minute | | `unsubscribe`, `webhooks` | unlimited | — | These are the group-level limiters applied in `utils/routes.js`. Individual routes layer stricter limits on top. --- ## The purchase limit — 1 request / 5 seconds A dedicated limiter caps purchase and wallet-transfer traffic: ```js const apiLimiterMaximumPurchase = rateLimit({ windowMs: 5 * 1000, max: 1, skip: (req) => req.method === 'OPTIONS', }); ``` **But it only applies to session-authenticated channels.** The dispatcher selects the limiter by `req.userAuth`: ```js const MaximumLimiterPurchase = (req, res, next) => { if (req.method === 'OPTIONS') return next(); let limiter = apiLimiter; switch (req?.userAuth) { case 'app': case 'auth': case 'web': limiter = apiLimiterMaximumPurchase; // 1 req / 5 s break; default: limiter = apiLimiter; // 60/min } limiter(req, res, next); }; ``` | `req.userAuth` | Effective purchase limit | |---|---| | `app` | 1 request / 5 seconds | | `auth` | 1 request / 5 seconds | | `web` | 1 request / 5 seconds | | **`api`** (API key) | **60 requests / minute** (group limiter) | | `whatsapp-agent` | 60 requests / minute (group limiter) | **API-key integrations are not throttled to one purchase every five seconds.** Your real ceiling is the group limiter: 300/min for airtime and data, 120/min for cables, electricity and education. This applies to: * `POST /api/v1/{airtime,data,electricity,cables,education}/buy` * `POST /api/v1/biz/verify` * `POST /api/v1/verify/verify` * `PUT /api/v1/wallet/manage-wallet` (transfer) * every `POST /api/v1/agent/*` buy and wallet route * every `POST /api/v1/sandbox/*` purchase --- ## Adaptive read limiters Some read endpoints use a dispatcher that raises the limit for the highest traffic app/web screens: ```js const MaximumLimiter = (req, res, next) => { let limiter = apiLimiter; // 60/min switch (req?.userAuth) { case 'app': case 'web': limiter = apiLimiterMaximum; break; // 120/min default: limiter = apiLimiter; break; // 60/min } limiter(req, res, next); }; ``` | Limiter | `app` / `web` | everyone else | |---|---|---| | `MaximumLimiter` (120/min for app/web) | 120/min | 60/min | | `MaximumTwentyLimiter` | 60/min | 60/min | | `MaximumLimiterPurchase` | **1 req / 5 s** | 60/min | Affected reads include `GET /api/v1/data/`, `/data/agent-data`, `/data/networks`, `/cables/types`, `/electricity/`, `/education/`, `/network/`, `/settings/`, `/slider/`, `/wallet/manage-wallet-balance`, `GET /api/v1/user/` and `GET /api/v1/usecase/`. --- ## Special-purpose limiters | Limiter | Limit | Window | Applies to | Keyed on | |---|---|---|---|---| | `authLimiter` | 50 | 15 min | `POST /api/v1/user/{login,register,forgot-password,otp,resend-otp,reset-pin,reset-password,validate-otp}` | IP | | `apiLimiterResetPassword` | 3 | 15 min | `PUT` + `POST /api/v1/user/reset-password` | IP | | `apiLimiterWebhook` | 240 | 1 min | Biller callback routes (AutoSyncNG, Ogdams, SES notifications) | IP | | `apiLimiterAgentOtp` | 5 | 10 min | `POST /api/v1/agent/otp/send` | **phone number** | | CRM limiter | 240 | 1 min | all `/api/v1/crm/*` | IP | `apiLimiterAgentOtp` is keyed on `req.body.phoneNumber`, not the IP, because every WhatsApp purchase originates from the single Halo server — an IP key would throttle all customers together. --- ## Rate limit response ```json { "error": [ { "msg": "Too many requests, please try again later" } ] } ``` Some limiters use a different shape: ```json { "status": "error", "msg": "Too many webhook callbacks from this IP." } ``` ```json { "status": "error", "msg": "Too many reset password attempts. Please try again later." } ``` !!! note "The status code varies" The default `express-rate-limit` response is HTTP `429`. Some of the custom limiters override the message but not the status, so `429` is the value to handle. Branch on `429`, and read `RateLimit-*` headers for the reset time. --- ## The duplicate-request guard Independent of the rate limiters, the platform actively blocks duplicate purchases. This is **not** a rate limiter — it is a correctness guard, and hitting it means your integration is retrying something that is already in flight. Three layers, all applied **before** any money moves: **1. Atomic Redis lock.** Keyed on `purchaselock::::[:]`, held for 180 seconds. Only the first request in a burst wins. Fails open if Redis is unreachable. **2. Committed-transaction check.** A database lookup for a matching transaction created in the last 180 seconds (excluding server-side retries). The message is precise: > "We just processed an identical request to 08012345678 42 seconds ago. If > that one's still pending, please give it 138 seconds to settle before > retrying." **3. Body hash.** An in-memory hash of the exact request body, 60-minute TTL. An identical body within the hour is rejected with *"Duplicate transaction detected"*. All three return HTTP `300`. **Practical guidance:** if you get a duplicate-guard error, do not retry. Poll [`GET /api/v1/transactions/resolve`](api/transactions.md#resolve-a-transaction) with your `apiRefId` to find out what happened to the first attempt. --- ## Practical budgets for an API-key integration | Activity | Safe rate | |---|---| | Airtime / data purchases | 300/min group limit — but respect the duplicate guard and settle `300`s first | | Electricity / cables / education purchases | 120/min | | Catalogue reads (plans, networks, discos) | 60–120/min | | Transaction list / resolve | 120/min | | Webhook endpoint (your server) | You control it — the platform retries failed deliveries up to 6 times | **Recommended production pattern:** a small bounded queue (a few workers, not hundreds) with a retry policy that only retries `500` and `503`, treats `300` as "wait", and never retries `400` without a change to the request. --- # overview Source: https://developer.instapaybill.com/docs/api/overview/ # API Overview Base URL: `https://api.instapaybill.com` Prefix: `/api/v1` Every request and response is `application/json` (UTF-8). Request bodies are capped at 10 MB. --- ## Endpoint map ### Purchases | Method | Path | Service | Auth | |---|---|---|---| | `POST` | [`/airtime/buy`](airtime.md#buy-airtime) | Airtime | API key | | `POST` | [`/data/buy`](data.md#buy-data) | Data bundle | API key | | `POST` | [`/electricity/validate`](electricity.md#validate-a-meter) | Meter validation | API key | | `POST` | [`/electricity/buy`](electricity.md#buy-electricity) | Prepaid electricity | API key | | `POST` | [`/cables/validate`](cables.md#validate-a-smart-card) | Smart card validation | API key | | `POST` | [`/cables/buy`](cables.md#buy-cable-tv) | Cable TV | API key | | `POST` | [`/education/buy`](education.md#generate-result-checker-pins) | Result-checker PINs | API key | ### Catalogue | Method | Path | Returns | Auth | |---|---|---|---| | `GET` | [`/data/agent-data`](data.md#agent-data-catalogue) | Full agent catalogue (data plans + cable plans) | API key | | `GET` | [`/data/networks`](data.md#available-networks) | Networks with active data plans | API key | | `GET` | [`/data/networks/{network}/plans`](data.md#plans-for-one-network) | Plans for one network | API key | | `GET` | [`/data/`](data.md#data-plans) | Data plans | API key | | `GET` | [`/cables/types`](cables.md#cable-types) | Cable types | API key | | `GET` | [`/cables/`](cables.md#cable-plans) | Cable plans | Public | | `GET` | [`/electricity/`](electricity.md#electricity-discos) | Electricity discos | API key | | `GET` | [`/education/`](education.md#education-plans) | Education exam plans | API key | | `GET` | [`/network/`](catalogue.md#networks) | Supported networks | API key | | `GET` | [`/startime/`](catalogue.md#startimes-plans) | Startimes plans | Public | | `GET` | [`/category/`](catalogue.md#categories) | Product categories | Public | | `GET` | [`/network/validate-number/{phoneNumber}`](catalogue.md#validate-a-phone-number) | Carrier lookup for a number | Public | ### Transactions and money | Method | Path | Auth | |---|---|---| | `GET` | [`/transactions/`](transactions.md#list-transactions) | API key | | `GET` | [`/transactions/resolve`](transactions.md#resolve-a-transaction) | API key | | `GET` | [`/transactions/success-rates`](transactions.md#success-rates) | API key | | `GET` | [`/wallet/manage-wallet-balance`](wallet-keys.md#wallet-balance) | API key | | `GET` | [`/wallet/generate-wallet-reference`](wallet-keys.md#generate-a-payment-reference) | API key | | `GET` | [`/user/`](wallet-keys.md#current-user) | API key | | `GET` | [`/notification/`](wallet-keys.md#notifications) | API key | ### Sandbox All under `/api/v1/sandbox` — see [Sandbox](sandbox.md). | Method | Path | |---|---| | `POST` | `/sandbox/airtime` | | `POST` | `/sandbox/data` | | `POST` | `/sandbox/electricity/validate` | | `POST` | `/sandbox/electricity` | | `POST` | `/sandbox/cables/validate` | | `POST` | `/sandbox/cables` | | `POST` | `/sandbox/education` | | `POST` | `/sandbox/startimes` | --- ## Common request fields Fields that apply to most purchase endpoints: | Field | Type | Notes | |---|---|---| | `apiRefId` | string | **Your** reference for the order. Stored **only** for API-channel requests. Look it up with `GET /api/v1/transactions/resolve?apiRefID=…`. | | `pin` | string | Wallet PIN. **Ignored for API-key requests** — only enforced on app/web channels. | | `promoCode` / `coupon` | string | Discount codes. **Not applied to API-channel requests.** | | `type` | string | Service-specific variant — `vtu` / `share&sell` / `awuf4u` for airtime, `prepaid` / `postpaid` for electricity. | `apiRefId` is the field most integrations get wrong. If you set it, you can always find the transaction later without scraping the transaction list. --- ## Common response envelope Every purchase endpoint returns the same shape: ```json { "msg": "MTN Airtime Purchase of ₦500 for 08012345678 was successful", "data": { "code": 200, "reference": "IP|TN|A|123|20260918120000_ASDFG", "description": "MTN Airtime Purchase", "message": "Top-up of ₦500 to 08012345678 delivered successfully.", "type": "airtime" }, "code": 200 } ``` | Field | Type | Notes | |---|---|---| | `msg` | string | Human-readable summary of the outcome. | | `data.code` | number | `200` / `300` / `400`. Mirrors the HTTP status. | | `data.reference` | string | **Your** reference for the transaction. Save it. | | `data.description` | string | What was bought, e.g. `"MTN Airtime Purchase"`. | | `data.message` | string | The outcome text. Safe to show a customer. | | `data.type` | string | Service discriminator: `airtime`, `data`, `electricity`, `cables`, `education`. | | `code` | number | Top-level business code. Same value as `data.code`. | ### Field availability by service Not every service returns every field. Check before you depend on one. | Field | Airtime | Data | Electricity | Cables | Education | |---|---|---|---|---|---| | `data.code` | ✅ | ✅ | ✅ | ✅ | ✅ | | `data.reference` | ✅ | ✅ | ✅ | ✅ | ✅ | | `data.description` | ✅ | ✅ | ✅ | ✅ | ✅ | | `data.message` | ✅ | ✅ | ✅ | ✅ | ✅ | | `data.msg` | ✅ | ✅ | ✅ | ❌ | ✅ | | `data.type` | ✅ | ✅ | ✅ | ✅ | ✅ | | `data.token` | ❌ | ❌ | ✅ | ❌ | ❌ | | `data.unit` | ❌ | ❌ | ✅ | ❌ | ❌ | | `data.data_pin` | ❌ | ❌ | ❌ | ❌ | ✅ | !!! warning "`data.reference` is *your* reference, not the provider's" The provider's own reference is overwritten with the platform's internal `item_id` before the response is sent. If you need the provider reference for reconciliation, read it from the transaction record via [`resolve`](transactions.md#resolve-a-transaction). ### The `data` shape differs by channel When you authenticate with an API key, `data` is the compact envelope above. When you authenticate with a session JWT, `data` is the **full transaction document** — every internal field, including wallet balances before and after and the profit margin. **Always code against the API-key shape.** If you need a field that only appears in the session shape, it is not part of the supported API contract. --- ## Reference values ### Networks `MTN`, `AIRTEL`, `GLO`, `T2MOBILE` Case-insensitive on input; the platform uppercases before use. `9MOBILE` is accepted as a legacy alias for `T2MOBILE` in some catalogue responses but `T2MOBILE` is the canonical value. ```json { "error": [{ "msg": "Invalid network", "path": "network", "suggesstions": ["MTN", "AIRTEL", "GLO", "T2MOBILE"] }] } ``` (The misspelled `suggesstions` key is the real field name.) ### Electricity meter type `PREPAID`, `POSTPAID` — case-insensitive. ### Cable type `DSTV`, `GOTV`, `STARTIMES`, `SHOWMAX` — uppercased before use. ### Data plan identifiers A data purchase needs the `planId` **and** the `network`: ```json { "planId": "MTN-1.5GB-30D", "network": "MTN", "phone": "08012345678" } ``` `planId` alone is not enough — the plan lookup is `{ planId, network: network.toUpperCase() }`. Omitting `network` produces *"Plan Id not found"*, which reads like a bad plan id but is a missing field. Retrieve valid identifiers from [`GET /api/v1/data/agent-data`](data.md#agent-data-catalogue). --- ## Product identifiers The `item_id` returned as `data.reference` follows a fixed pattern: ``` IP|TN||<3-digit>|_<5-char-suffix> ``` | Segment | Meaning | |---|---| | `IP` | InstaPayBill | | `TN` | Transaction | | `A` | Airtime (also `D` data, `E` electricity, `C` cables, `ED` education) | | `123` | Sequential counter | | `20260918120000` | Timestamp, 14 digits, 100 µs resolution | | `ASDFG` | Random suffix | You do not need to construct or parse these. Treat them as opaque strings. --- ## Health and status endpoints | Method | Path | Auth | Returns | |---|---|---|---| | `GET` | `/mcp/health` | None | `{ status, service, version, totalTools }` | | `GET` | `/mcp/tools` | None | Array of `{ name, description }` | | `GET` | `/api/v1/settings/` | None | Public settings (commission rates, limits) | | `GET` | `/api/v1/usecase/` | None | Feature flags and channel status | | `GET` | `/api/v1/voice-assistant/health` | None | Voice service health | `GET /api/v1/usecase/` is the fastest way to see which features are currently enabled for a channel — including the kill switches that can make a purchase fail with `300` *"Service is currently unavailable"*. --- ## Making requests from a browser CORS is restricted to an allowlist of known origins. The current allowlist includes `https://instapaybill.com`, `https://www.instapaybill.com`, `https://admin.instapaybill.com`, `https://crm.instapaybill.com` and `https://developer.instapaybill.com`. **Requests with no `Origin` header are always allowed.** That covers server-to- server calls, CLI tools, and `curl` — you do not need to do anything special. Browser calls from an origin outside the allowlist are blocked, and the CORS headers are omitted from the response. For browser-based integrations, proxy through your own backend rather than calling the API directly from the client — otherwise your live key would be exposed. --- ## Related pages * [Authentication](../authentication.md) — credentials and which endpoints accept them * [Errors & Status Codes](../errors.md) — the error contract * [Rate Limits](../rate-limits.md) — budgets and the duplicate guard * [Webhooks](webhooks.md) — async settlement notifications --- # airtime Source: https://developer.instapaybill.com/docs/api/airtime/ # Airtime Top up any Nigerian mobile number on MTN, Glo, Airtel or 9mobile. --- ## Buy airtime
POST/api/v1/airtime/buy
Purchases airtime for a phone number and debits the wallet. **Auth:** API key (live or test) or session. ```bash curl -X POST https://api.instapaybill.com/api/v1/airtime/buy \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "Content-Type: application/json" \ -H "User-Agent: my-integration/1.0" \ -d '{ "phone": "08012345678", "amount": 500, "network": "MTN", "apiRefId": "order-1001" }' ``` ### Request body | Field | Type | Required | Description | |---|---|---|---| | `phone` | string | ✅ | Nigerian mobile number. Validated as an `en-NG` mobile number. | | `amount` | integer | ✅ | Face value in naira, minimum 1. | | `network` | string | ✅ | `MTN`, `AIRTEL`, `GLO`, `T2MOBILE`. Case-insensitive. | | `apiRefId` | string | — | Your order reference. Stored for API-channel requests only. | | `type` | string | — | VTU variant: `vtu` (default), `share&sell`, `awuf4u`. | | `pin` | string | — | Wallet PIN. Ignored for API-key requests. | | `promoCode` | string | — | Discount code. Not applied for API-channel requests. | | `coupon` | string | — | Alias of `promoCode`. Not applied for API-channel requests. | ### Success response — `200` ```json { "msg": "MTN Airtime Purchase of ₦500 for 08012345678 was successful", "data": { "code": 200, "reference": "IP|TN|A|123|20260918120000_ASDFG", "msg": "Airtime purchase successful", "message": "Top-up of ₦500 to 08012345678 delivered successfully.", "description": "MTN Airtime Purchase", "type": "airtime" }, "code": 200 } ``` ### Pending — `300` The provider has not confirmed delivery yet. The wallet stays debited and the platform polls the provider. ```json { "code": 300, "reference": "IP|TN|A|123|20260918120000_ASDFG", "error": [ { "path": "airtime-merrybills", "msg": "Your purchase is being verified with the provider. We will confirm delivery shortly." } ] } ``` **Do not retry.** Wait for the [webhook](webhooks.md) or poll [`resolve`](transactions.md#resolve-a-transaction). ### Failure — `400` The provider declined. The wallet was refunded. ```json { "code": 400, "reference": "IP|TN|A|123|20260918120000_ASDFG", "error": [ { "path": "airtime-merrybills", "msg": "Insufficient balance on your provider account" } ] } ``` ### Validation errors — `300` ```json { "error": [ { "value": "", "msg": "Phone number is required", "path": "phone", "location": "body" } ] } ``` --- ## Guards that apply to this endpoint The order of the middleware chain is significant. A request is rejected at the first guard that fails. | Guard | Rejects when | Response | |---|---|---| | `UserAuth` | Bad credential, non-agent key, missing `User-Agent` | `401` / `403` / `500` | | `checkNetworkState` | The network's category is flagged `networkBad` | `300` with `canSchedule: true` | | `UserActive` | Account suspended or inactive | `403` | | `TransactionStatusAndKYCCheck` | No BVN and no NIN | `300`, `path: "kyc_required"` | | `UserAuthSouceNecessary` | Unknown channel | `300`, `"Invalid source header"` | | `UserAuthSouceNecessaryChecker` | `usecases.airtime !== 'enable'` or the channel is switched off | `300`, `"Service is currently unavailable"` | | `MaximumLimiterPurchase` | Too many purchases (session channels only) | `429` | | express-validator | Missing/invalid field | `300` | Then, inside the service, in order: | Check | Rejects when | Response | |---|---|---| | Phone/network match | The number belongs to a different network | `300` | | KYC purchase gate | `privilege === 'user'` with no BVN/NIN | `300`, `path: "kyc"` | | Minimum / maximum | Amount below `usecases.airtimeMini` or above `usecases.airtimeMax` | `300` | | Amount sanity | `amount <= 0` | `300`, `"Invalid amount, kindly double check"` | | Service enabled | `usecases.airtime !== 'enable'` | `300` | | Product enabled | The `airtime` product is not `enable` | `300` | | Wallet exists | The user has no wallet record | `300` | | Wallet balance | Insufficient funds | `300` | | Telephone blacklist | The number is on the blacklist | `300` | | Biller health | The provider is flagged `down` | `300` | | Duplicate guards | An identical request is in flight | `300` | | Biller minimum | Below the provider's minimum (e.g. ₦100 for VTUplug) | `300` | ### The phone/network match If the number's actual carrier does not match the `network` you named, the request is rejected: ```json { "error": [ { "msg": "The phone number 08012345678 belongs to GLO, not MTN", "path": "network" } ] } ``` The carrier is resolved through the MCD/Sprintcheck operator lookup and cached for 24 hours. The check **fails open** if the lookup service is down, so a provider outage will not block your purchases. --- ## Airtime-to-cash (converter) The airtime-to-cash flow lets a customer convert a bonus airtime balance into cash. It is an **app/web-only** feature — the API channel receives `403` *"This service is not available for api channel"*. | Method | Path | Access | |---|---|---| | `GET` | `/api/v1/airtime/converter` | App/web — converter history | | `POST` | `/api/v1/airtime/converter` | App/web — submit a conversion | | `POST` | `/api/v1/airtime/converter/{id}` | Admin — decline | | `PUT` | `/api/v1/airtime/converter/{id}` | Admin — mark successful | | `GET` | `/api/v1/airtime/converter-stats` | Admin | | `GET` | `/api/v1/airtime/converter-number` | App/web | | `POST` | `/api/v1/airtime/converter-number` | Admin | | `PUT` | `/api/v1/airtime/converter-number/{id}` | Admin | `POST /api/v1/airtime/converter` requires `network`, `credit_mode`, `reference` and `amount` in the body, plus a verified email, an active account, completed KYC, and the `airtimetocash` permission for the deciding role. --- ## Banks | Method | Path | Access | Description | |---|---|---|---| | `GET` | `/api/v1/airtime/banks` | API key | List of supported banks | | `POST` | `/api/v1/airtime/banks` | App/web | Resolve an account holder's name | `POST` requires `bank_code` and `account_number` (both numeric). --- ## Related * [Data](data.md) — bundles, same response envelope * [Transactions](transactions.md) — finding and resolving a purchase * [Errors](../errors.md) — the `300` vs `400` rule --- # data Source: https://developer.instapaybill.com/docs/api/data/ # Data Activate mobile data bundles from the live plan catalogue. --- ## Buy data
POST/api/v1/data/buy
**Auth:** API key (live or test) or session. ```bash curl -X POST https://api.instapaybill.com/api/v1/data/buy \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "Content-Type: application/json" \ -H "User-Agent: my-integration/1.0" \ -d '{ "phone": "08012345678", "planId": "MTN-1.5GB-30D", "network": "MTN", "apiRefId": "order-1002" }' ``` ### Request body | Field | Type | Required | Description | |---|---|---|---| | `phone` | string | ✅ | Nigerian mobile number (`en-NG`). | | `planId` | string | ✅ | Provider plan identifier. See [catalogue](#data-plans). | | `network` | string | ✅ | Network the plan belongs to. **Required in practice** — the plan lookup is `{ planId, network }`. | | `apiRefId` | string | — | Your order reference. API-channel only. | | `pin` | string | — | Wallet PIN. Ignored for API-key requests. | | `promoCode` / `coupon` | string | — | Not applied for API-channel requests. | !!! danger "`network` is not optional in practice" There is no validator for `network` on this route, but the plan is looked up with `DataPlanModel.findOne({ planId, network: network?.toUpperCase() })`. A missing `network` produces a `"Plan Id not found"` error that looks like a bad plan id. Always send it. ### Success response — `200` ```json { "msg": "MTN Data Purchase of ₦500 for 08012345678 | 30Days 5GB was successful", "data": { "code": 200, "reference": "IP|TN|D|124|20260918120000_ASDFG", "msg": "Data purchase successful", "message": "Data bundle activated on 08012345678", "description": "MTN Data Purchase of ₦500 for 08012345678 | 30Days 5GB", "type": "data" }, "code": 200 } ``` ### Pending — `300` ```json { "code": 300, "reference": "IP|TN|D|124|20260918120000_ASDFG", "error": [ { "path": "data-merrybills", "msg": "Your purchase is being verified with the provider. We will confirm delivery shortly." } ] } ``` ### Failure — `400` ```json { "code": 400, "reference": "IP|TN|D|124|20260918120000_ASDFG", "error": [ { "path": "data-merrybills", "msg": "Plan not available for selected network" } ] } ``` !!! note "One internal code is normalised" The provider layer can return an internal retry code of `301`. The API always reports that as `300` to you, so a `300` in the response means "pending" in every case. --- ## Guards Identical to [airtime](airtime.md#guards-that-apply-to-this-endpoint), with the plan-specific additions: | Check | Rejects when | Response | |---|---|---| | Plan exists | No plan matches `{ planId, network }` | `300`, `"Plan Id not found"` | | Biller not retired | The plan's biller was decommissioned | `300` + `hold: true` | | Category enabled | The plan's category is off | `300` | | Phone/network match | The number's carrier differs from the plan's network | `300` | | Bucket wallet | The plan routes to a bucket-wallet SKU | routed, not rejected | --- ## Agent data catalogue
GET/api/v1/data/agent-data
The single most useful endpoint for building a plan picker. It returns the data plans **and** the cable plans in one call. ```bash curl https://api.instapaybill.com/api/v1/data/agent-data \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" ``` ```json { "msg": "Agent data retrieved successfully", "data": { "data": [ /* data plans */ ], "cables": { /* cable plans */ } } } ``` A data plan entry carries the fields you need to render and buy: ```json { "planId": "MTN-1.5GB-30D", "network": "MTN", "price": 500, "provider_price": 480, "validity": "30Days", "size": "1.5GB", "category": "", "biller": "" } ``` Use `planId` + `network` + `price` for the buy call. `provider_price` is the platform's cost — do not send it. --- ## Available networks
GET/api/v1/data/networks
```json { "success": true, "msg": "Available networks retrieved successfully", "data": { "networks": ["AIRTEL", "GLO", "MTN", "T2MOBILE"], "count": 4 } } ``` Derived from data plans that are `enabler: 'enable'` and `available: true`. Note the `success` key — this endpoint uses a slightly different envelope from the purchase endpoints. --- ## Plans for one network
GET/api/v1/data/networks/{network}/plans
```bash curl https://api.instapaybill.com/api/v1/data/networks/MTN/plans \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" ``` Returns the active plans for one network. Cheaper than fetching the whole catalogue when you only need one network. --- ## Data plans
GET/api/v1/data/
```bash curl "https://api.instapaybill.com/api/v1/data/?category=&planId=" \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" ``` Both query parameters are optional filters. The full list is large — prefer [`/agent-data`](#agent-data-catalogue) or [`/networks/{network}/plans`](#plans-for-one-network) for a UI. --- ## Related * [Airtime](airtime.md) — same envelope, simpler body * [Catalogue](catalogue.md) — networks, categories, providers * [Transactions](transactions.md) — resolving a data purchase --- # electricity Source: https://developer.instapaybill.com/docs/api/electricity/ # Electricity Buy prepaid electricity units for Nigerian distribution companies (discos) and receive a meter token. --- ## Electricity discos
GET/api/v1/electricity/
```bash curl https://api.instapaybill.com/api/v1/electricity/ \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" ``` Returns the list of discos you can pay, with their logos and metadata. Each entry carries a `disco` code (e.g. `EEDC`, `IKEDC`) and a `name`. --- ## Validate a meter
POST/api/v1/electricity/validate
Confirms a meter number belongs to a real customer and returns their name. Do this before a purchase so you can show the customer who the units are going to. ```bash curl -X POST https://api.instapaybill.com/api/v1/electricity/validate \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "Content-Type: application/json" \ -H "User-Agent: my-integration/1.0" \ -d '{ "meterNo": "09012345678", "disco": "EEDC", "type": "prepaid" }' ``` ### Request body | Field | Type | Required | Description | |---|---|---|---| | `meterNo` | string | ✅ | Meter number. Numeric. | | `disco` | string | ✅ | Disco code, e.g. `EEDC`. | | `type` | string | — | `prepaid` or `postpaid`. Defaults to prepaid. | ### Success response — `200` ```json { "msg": "EEDC validation completed", "data": { "status": "200", "message": "Meter validated successfully", "customerName": "JOHN DOE", "customerAddress": "12 Test Street, Lagos", "outstandingAmount": null, "minimumPayable": null, "meterNo": "09012345678", "disco": "EEDC", "type": "prepaid" } } ``` ### Unverifiable meter — `200` !!! warning "This is not an error status" When the provider cannot confirm a meter, the response is still HTTP `200`. The signal is in the body: `customerName` and `customerAddress` are `null` and `message` says the number could not be verified. ```json { "msg": "EEDC validation completed", "data": { "status": "200", "message": "Could not verify this number. Please double-check it before paying.", "customerName": null, "customerAddress": null, "outstandingAmount": null, "minimumPayable": null, "meterNo": "09012345678", "disco": "EEDC", "type": "prepaid" } } ``` **Check `customerName` before proceeding.** An HTTP 200 alone does not mean the meter is valid. ### Explicitly invalid meter — `300` When the provider rejects the number outright (HTTP 400 or 422 from the provider, with a message matching an invalid-meter pattern): ```json { "error": [ { "msg": "Invalid meter number for selected DISCO", "path": "meterNo" } ] } ``` --- ## Buy electricity
POST/api/v1/electricity/buy
```bash curl -X POST https://api.instapaybill.com/api/v1/electricity/buy \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "Content-Type: application/json" \ -H "User-Agent: my-integration/1.0" \ -d '{ "meterNo": "09012345678", "disco": "EEDC", "type": "prepaid", "amount": 5000, "phoneNumber": "08012345678", "apiRefId": "order-2001" }' ``` ### Request body | Field | Type | Required | Description | |---|---|---|---| | `meterNo` | string | ✅ | Meter number. Numeric. | | `disco` | string | ✅ | Disco code. | | `type` | string | ✅ | `prepaid` or `postpaid`. | | `amount` | number | ✅ | Amount in naira. | | `phoneNumber` | string | ✅ | Nigerian mobile number (`en-NG`). The customer's contact number. | | `apiRefId` | string | — | Your order reference. API-channel only. | | `pin` | string | — | Wallet PIN. Ignored for API-key requests. | | `promoCode` / `coupon` | string | — | Not applied for API-channel requests. | ### Success response — `200` ```json { "msg": "prepaid EEDC Electricity Purchase of ₦5,000 for 09012345678 was successful", "data": { "code": 200, "reference": "IP|TN|E|125|20260918120000_ASDFG", "msg": "Purchase successful", "message": "Units: 66.67 kWh", "token": "1234-5678-9012", "unit": "66.67", "description": "EEDC prepaid Electricity Purchase", "type": "electricity" }, "code": 200 } ``` !!! tip "Electricity is the only service that returns a token" `data.token` is the prepaid token the customer types into their meter. `data.unit` is the kilowatt-hours purchased. Note the field is `unit` (singular) here, while the [webhook](webhooks.md#live-webhooks) payload also uses `unit`. ### Pending — `300` ```json { "code": 300, "reference": "IP|TN|E|125|20260918120000_ASDFG", "error": [ { "path": "electricity-merrybills", "msg": "Your purchase is being verified with the provider. We will confirm delivery shortly." } ] } ``` ### Failure — `400` ```json { "code": 400, "reference": "IP|TN|E|125|20260918120000_ASDFG", "error": [ { "path": "electricity-merrybills", "msg": "Electricity provider currently unavailable. Try again later." } ] } ``` --- ## Guards | Check | Rejects when | Response | |---|---|---| | `checkNetworkState` | The disco's category is flagged `networkBad` | `300` with `canSchedule: true` | | `UserActive` | Account suspended | `403` | | `TransactionStatusAndKYCCheck` | No BVN and no NIN | `300`, `path: "kyc_required"` | | `usecases.electricity` | The service is switched off | `300` | | Category enabled | The disco's category is off | `300` | | Wallet balance | Insufficient funds | `300` | | Biller health | The provider is flagged `down` | `300` | | Duplicate guards | An identical request is in flight | `300` | | KYC purchase gate | `privilege === 'user'` with no BVN/NIN | `300`, `path: "kyc"` | --- ## Related * [Cable TV](cables.md) — the other "subscription" service, same validate-then-buy pattern * [Transactions](transactions.md) — resolving a purchase * [Webhooks](webhooks.md) — receiving the token asynchronously --- # cables Source: https://developer.instapaybill.com/docs/api/cables/ # Cable TV Renew or subscribe to DStv, GOtv, ShowMax and Startimes. !!! note "Startimes" Startimes is purchased through the same `cables` endpoints, using the `STARTIMES` cable type. There is no separate live Startimes purchase route — only the [sandbox](sandbox.md) has one. --- ## Cable plans
GET/api/v1/cables/
**Public — no credential required.** ```bash curl "https://api.instapaybill.com/api/v1/cables/?type=DSTV" ``` Returns the cable plans for a product group. The `type` query parameter takes `DSTV`, `GOTV`, `STARTIMES` or `SHOWMAX`. Each plan carries the fields you need to buy it: ```json { "name": "Compact", "productsCode": "DSTV_COMPACT", "packagename": "Compact", "amount": 5000, "category": "" } ``` `productsCode` is the provider's plan identifier and is what you send as `productsCode` in the buy request. --- ## Cable types
GET/api/v1/cables/types
**Auth:** API key or session. ```bash curl https://api.instapaybill.com/api/v1/cables/types \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" ``` Returns the supported cable types with their logos — the equivalent of `GET /api/v1/electricity/` for discos. Use this to build a provider picker. --- ## Validate a smart card
POST/api/v1/cables/validate
Confirms an IUC / smart card number and returns the subscriber's current bouquet and due date. ```bash curl -X POST https://api.instapaybill.com/api/v1/cables/validate \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "Content-Type: application/json" \ -H "User-Agent: my-integration/1.0" \ -d '{ "smartCardNo": "1234567890", "type": "DSTV" }' ``` ### Request body | Field | Type | Required | Description | |---|---|---|---| | `smartCardNo` | string | ✅ | IUC / smart card number. Numeric. | | `type` | string | ✅ | `DSTV`, `GOTV`, `STARTIMES`, `SHOWMAX`. | ### Success response — `200` ```json { "msg": "DSTV cables validation successful", "data": { "status": "200", "customerName": "ADEBAYO SANDBOX", "accountStatus": "active", "currentBouquet": "Compact", "minimumPayable": 9000, "dueDate": "2026-10-18", "message": "Smart card validated successfully" } } ``` `minimumPayable` is the outstanding balance on the account, and `dueDate` is when it must be paid. `accountStatus` is `"active"` for a good subscriber. ### Unknown smart card — `300` ```json { "error": [ { "msg": "Smart card number not found", "path": "smartCardNo" } ] } ``` --- ## Buy cable TV
POST/api/v1/cables/buy
```bash curl -X POST https://api.instapaybill.com/api/v1/cables/buy \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "Content-Type: application/json" \ -H "User-Agent: my-integration/1.0" \ -d '{ "smartCardNo": "1234567890", "type": "DSTV", "productsCode": "DSTV_COMPACT", "packagename": "Compact", "amount": 5000, "apiRefId": "order-3001" }' ``` ### Request body | Field | Type | Required | Description | |---|---|---|---| | `smartCardNo` | string | ✅ | IUC / smart card number. Numeric. | | `type` | string | ✅ | Cable type. | | `productsCode` | string | — | Provider plan code. Required by some providers. | | `packagename` | string | — | Provider package name. | | `amount` | number | — | Amount in naira. | | `dstvaddonCode` | string | — | DStv add-on package code. | | `apiRefId` | string | — | Your order reference. API-channel only. | | `pin` | string | — | Wallet PIN. Ignored for API-key requests. | | `promoCode` / `coupon` | string | — | Not applied for API-channel requests. | Only `smartCardNo` and `type` are validated as required. Supply the plan fields (`productsCode`, `packagename`, `amount`) — the platform uses them to build the provider request. ### Success response — `200` ```json { "msg": "DSTV Cables Purchase ₦5,000 of Compact to 1234567890 was successful", "data": { "code": 200, "reference": "IP|TN|C|126|20260918120000_ASDFG", "description": "DSTV Cable Subscription", "message": "DSTV Compact activated on 1234567890", "type": "cables" }, "code": 200 } ``` !!! warning "Cables responses have no `data.msg`" Every other purchase service includes a `data.msg` field copied from the provider. Cables does not — its `data` object is built explicitly as `{ code, reference, description, message, type }`. If your client reads `data.msg`, it will be `undefined` for cable purchases. Read `data.message` instead; it is present on all five services. ### Pending — `300` ```json { "code": 300, "reference": "IP|TN|C|126|20260918120000_ASDFG", "error": [ { "path": "cables-merrybills", "msg": "Your purchase is being verified with the provider. We will confirm delivery shortly." } ] } ``` ### Failure — `400` ```json { "code": 400, "reference": "IP|TN|C|126|20260918120000_ASDFG", "error": [ { "path": "cables-merrybills", "msg": "Cable provider declined transaction" } ] } ``` --- ## Guards | Check | Rejects when | Response | |---|---|---| | `checkNetworkState` | The cable category is flagged `networkBad` | `300` with `canSchedule: true` | | `UserActive` | Account suspended | `403` | | `TransactionStatusAndKYCCheck` | No BVN and no NIN | `300`, `path: "kyc_required"` | | `usecases.cables` | The service is switched off | `300` | | Category enabled | The cable category is off | `300` | | Wallet balance | Insufficient funds | `300` | | Biller health | The provider is flagged `down` | `300` | | Duplicate guards | An identical request is in flight | `300` | | KYC purchase gate | `privilege === 'user'` with no BVN/NIN | `300`, `path: "kyc"` | --- ## Related * [Electricity](electricity.md) — same validate-then-buy pattern * [Education](education.md) — the fifth purchase service * [Catalogue](catalogue.md) — provider metadata --- # education Source: https://developer.instapaybill.com/docs/api/education/ # Education Generate result-checker PINs (WAEC, JAMB, NECO and others) that candidates use to check their results. --- ## Education plans
GET/api/v1/education/
**Auth:** API key or session. ```bash curl https://api.instapaybill.com/api/v1/education/ \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" ``` Returns the available exam types with their prices: ```json { "msg": "Education plans retrieved successfully", "data": [ { "type": "WAEC", "price": 2000, "category": "", "image": { "id": "", "url": "" } } ] } ``` Use `type` in the buy request. --- ## Generate result-checker PINs
POST/api/v1/education/buy
```bash curl -X POST https://api.instapaybill.com/api/v1/education/buy \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "Content-Type: application/json" \ -H "User-Agent: my-integration/1.0" \ -d '{ "type": "WAEC", "numberOfPin": 2, "apiRefId": "order-4001" }' ``` ### Request body | Field | Type | Required | Description | |---|---|---|---| | `type` | string | ✅ | Exam type. Uppercased for the plan lookup — `waec` and `WAEC` both work. | | `numberOfPin` | number | ✅ | How many PINs to generate. Must be greater than 0. | | `apiRefId` | string | — | Your order reference. API-channel only. | | `pin` | string | — | Wallet PIN. Ignored for API-key requests. | | `promoCode` / `coupon` | string | — | Not applied for API-channel requests. | ### Success response — `200` ```json { "msg": "WAEC Education Purchase of ₦2,000 for numberOfPin: 2 was successful", "data": { "code": 200, "reference": "IP|TN|ED|127|20260918120000_ASDFG", "msg": "PINs generated successfully", "data_pin": { "serial": "WAE1234567890", "pin": "1234-5678-9012" }, "description": "WAEC Pin Purchase of ₦2,000 for numberOfPin: 2", "type": "education", "message": "1. Serial: WAE1234567890 / PIN: 1234-5678-9012" }, "code": 200 } ``` !!! warning "`data.data_pin` shape varies by provider" The raw PIN payload is passed through from the provider, so its shape is **not** normalised. It may be a single object, an array of objects, or a map keyed by serial — depending on which provider served the request. In practice you will see one of: ```json "data_pin": { "serial": "WAE1234567890", "pin": "1234-5678-9012" } ``` ```json "data_pin": [ { "serial": "WAE1234567890", "pin": "1234-5678-9012" } ] ``` ```json "data_pin": { "WAE1234567890": "1234-5678-9012" } ``` Write your client to handle all three. The [sandbox](sandbox.md) returns a consistent `pins` array, which is the shape you should code against. ### Pending — `300` ```json { "code": 300, "reference": "IP|TN|ED|127|20260918120000_ASDFG", "error": [ { "path": "education-merrybills", "msg": "Your purchase is being verified with the provider. We will confirm delivery shortly." } ] } ``` ### Failure — `400` ```json { "code": 400, "reference": "IP|TN|ED|127|20260918120000_ASDFG", "error": [ { "path": "education-merrybills", "msg": "Education PIN provider unavailable" } ] } ``` !!! note "`data.msg` may be absent" The education provider layer differs per biller: some return `message`, some return `msg`. Because the response spreads the provider result directly, `data.msg` is not guaranteed. `data.message` always is. --- ## Guards | Check | Rejects when | Response | |---|---|---| | `checkNetworkState` | The education category is flagged `networkBad` | `300` with `canSchedule: true` | | `UserActive` | Account suspended | `403` | | `TransactionStatusAndKYCCheck` | No BVN and no NIN | `300`, `path: "kyc_required"` | | `usecases.education` | The service is switched off | `300` | | Plan exists | No plan matches the uppercased `type` | `300` | | Pin count | `numberOfPin <= 0` | `300` | | Wallet balance | Insufficient funds | `300` | | Biller health | The provider is flagged `down` | `300` | | Duplicate guards | An identical request is in flight | `300` | | KYC purchase gate | `privilege === 'user'` with no BVN/NIN | `300`, `path: "kyc"` | --- ## Related * [Catalogue](catalogue.md) — plan and category metadata * [Sandbox](sandbox.md) — practice the PIN flow with a consistent response shape --- # transactions Source: https://developer.instapaybill.com/docs/api/transactions/ # Transactions List, resolve and inspect the transactions your integration created. --- ## List transactions
GET/api/v1/transactions/
**Auth:** API key or session. ```bash curl "https://api.instapaybill.com/api/v1/transactions/?type=airtime" \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" ``` | Query param | Required | Description | |---|---|---| | `type` | ✅ | Transaction type filter, e.g. `airtime`, `data`, `electricity`, `cables`, `education`. | The response is paginated. Default page size is 10 (configurable via `DEFAULT_PAGE_LIMIT` in `config/constants.js`). !!! info "Session vs API shape" With an API key, `data` contains the transaction list. With a session JWT, the same endpoint returns the same list but the platform also applies admin-only behaviour — an admin sees every user's transactions. --- ## Resolve a transaction
GET/api/v1/transactions/resolve
Finds a single transaction by **your** reference or by the platform's internal reference. This is the endpoint to use for reconciliation and for checking on a purchase that returned `300`. ```bash curl -G https://api.instapaybill.com/api/v1/transactions/resolve \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" \ --data-urlencode "apiRefID=order-1001" ``` ```bash curl -G https://api.instapaybill.com/api/v1/transactions/resolve \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" \ --data-urlencode "reference=IP|TN|A|123|20260918120000_ASDFG" ``` | Query param | Description | |---|---| | `apiRefID` | **Your** reference, as sent in the `apiRefId` request field. Note the capital `ID` here versus `apiRefId` on the request. | | `reference` | The platform's internal reference (`item_id`). | **At least one is required.** Sending neither returns `300` with *"Our Reference or Your Reference is required"*. ```json { "msg": "Transaction retrieved successfully", "data": { "item_id": "IP|TN|A|123|20260918120000_ASDFG", "apiRefId": "order-1001", "type": "airtime", "status": true, "statusText": "Airtime Purchase Successful", "code": 200, "amount": 500, "properties": { "phone": "08012345678", "network": "MTN" } } } ``` Read `code` to learn the final outcome: | `code` | Meaning | |---|---| | `200` | Delivered | | `300` | Still pending — the provider has not confirmed | | `400` | Failed and refunded | !!! tip "The resolve loop for pending purchases" A purchase that returns `300` is still being verified. Poll `resolve` every 60 seconds until `code` becomes `200` or `400`. The [webhook](webhooks.md) is the better option when you have a public HTTPS endpoint — it pushes the result to you. --- ## Success rates
GET/api/v1/transactions/success-rates
**Auth:** API key or session. ```bash curl https://api.instapaybill.com/api/v1/transactions/success-rates \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" ``` Returns the success rate per service — useful for monitoring your integration's health and for alerting on provider degradation. --- ## Transaction lifecycle ``` Request │ ▼ ┌─────────────┐ │ Debit │ wallet debited atomically; pending intent row written └──────┬──────┘ │ ▼ ┌─────────────┐ │ Provider │ airtime / data / electricity / cables / education └──────┬──────┘ │ ┌────┴─────┐ │ │ ▼ ▼ code 200 code 300 code 400 │ │ │ ▼ ▼ ▼ Settled Held — wallet stays Failed — │ debited, platform wallet refunded │ polls the provider │ │ │ ├──────► 200 (settled later) │ └──────► 400 (refunded later) ▼ Webhook: code 200 ``` The webhook fires when the transaction is **resolved** — which may be immediately, or much later if the purchase was held. See [Webhooks](webhooks.md). --- ## Reading the transaction fields | Field | Type | Description | |---|---|---| | `item_id` | string | The platform's internal reference. Returned as `data.reference` on purchase responses. | | `apiRefId` | string | Your reference. Present only for API-channel purchases. | | `type` | string | `airtime`, `data`, `electricity`, `cables`, `education`. | | `status` | boolean | `true` on success, `false` on failure. | | `statusText` | string | Human-readable outcome. | | `code` | number | `200` / `300` / `400`. The authoritative verdict. | | `amount` | number | Face value charged. | | `reference` | string | The **provider's** reference, when the provider supplied one. | | `provider` | string | Which upstream provider served the request. | | `properties` | object | Service-specific detail — `phone`, `network`, `token`, `unit`, `data_pin`, `customerName`, `customerAddress`. | | `hold` | boolean | `true` when the transaction is held pending provider confirmation. | | `domain` | string | `live` or `test` — which wallet it drew from. | | `channel` | string | `api`, `app`, `web`, `whatsapp-agent`. | | `createdAt` | date | When the purchase was created. | --- ## Other transaction endpoints These are app/web or admin only and are listed for completeness. | Method | Path | Access | Description | |---|---|---|---| | `GET` | `/api/v1/transactions/{id}` | App/web | Transaction detail | | `PUT` | `/api/v1/transactions/{id}` | App/web | Delete a transaction | | `GET` | `/api/v1/transactions/{id}/receipt.pdf` | App/web | Receipt PDF | | `PUT` | `/api/v1/transactions/` | App/web | Bulk delete own transactions | | `GET` | `/api/v1/transactions/analytics-by-product` | Admin | Product analytics | | `GET` | `/api/v1/transactions/profit-stats` | Superadmin | Profit statistics | | `GET` | `/api/v1/transactions/profit-comparison` | Superadmin | Profit comparison | | `GET` | `/api/v1/transactions/admin/success-rates` | Admin | Platform-wide success rates | | `GET` | `/api/v1/transactions/manage-unresloved-transactions` | Admin | Unresolved transactions | --- ## Related * [Webhooks](webhooks.md) — the push-based alternative to polling * [Errors](../errors.md) — what each `code` means * [Rate Limits](../rate-limits.md) — the duplicate-request guard --- # wallet-keys Source: https://developer.instapaybill.com/docs/api/wallet-keys/ # Wallet & Keys Your wallet holds the money that purchases draw from. Keys are the credentials that let the API spend it. --- ## Wallet balance
GET/api/v1/wallet/manage-wallet-balance
**Auth:** API key or session. ```bash curl https://api.instapaybill.com/api/v1/wallet/manage-wallet-balance \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" ``` ```json { "msg": "Wallet balance retrieved successfully", "data": { "available": 45000, "commission": 1250, "bonus": 0, "referral": 0, "pending_deficit": 0 } } ``` The response is sent with `Cache-Control: no-store` — do not cache it. ### The four wallet buckets | Bucket | Purpose | |---|---| | `available` | Spendable balance. Purchases draw from here. | | `commission` | Commission earned from your own sales. Move to `available` to spend it. | | `bonus` | Promotional bonus. Move to `available` to spend it. | | `referral` | Referral earnings. Move to `available` to spend it. | **Only `available` can fund a purchase.** A user with ₦0 in `available` and ₦5,000 in `commission` cannot buy anything until the commission is moved. ### Live vs test wallets The key prefix decides which set of buckets you see: | Key | Buckets | |---|---| | `sk_live_…` | `available`, `commission`, `bonus`, `referral` | | `sk_test_…` | `test_available`, `test_commission`, `test_bonus`, `test_referral` | The response uses the same field names either way — the backend maps them. The [sandbox](sandbox.md) is different again: it reports a single flat **₦100,000** virtual balance and has no buckets at all. ### `pending_deficit` A non-zero `pending_deficit` means money was taken from `available` for transactions that are still being verified. It is the amount at risk. --- ## Generate a payment reference
GET/api/v1/wallet/generate-wallet-reference
**Auth:** API key or session. ```bash curl https://api.instapaybill.com/api/v1/wallet/generate-wallet-reference \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" ``` Returns a unique reference you can attach to a top-up so the incoming payment is attributed to your account. Used by the funding flows (Paystack, Monnify, Flutterwave). --- ## Wallet history | Method | Path | Access | Description | |---|---|---|---| | `GET` | `/api/v1/wallet/` | App/web | Wallet ledger. Requires a `type` query parameter. | | `GET` | `/api/v1/wallet/wallet-data` | App/web | Wallet record | | `GET` | `/api/v1/wallet/manage-wallet-stat` | App/web | Wallet statistics | | `GET` | `/api/v1/wallet/manage-wallet-stat-monthly` | App/web | Monthly statistics | | `GET` | `/api/v1/wallet/manage-wallet-stat-daily` | App/web | Daily statistics | | `GET` | `/api/v1/wallet/manage-wallet` | App/web | Wallet user record | | `PUT` | `/api/v1/wallet/manage-wallet` | App/web | Transfer to another user. Requires `amount`, `user`, `type`, `pin` and a reCAPTCHA token. | | `POST` | `/api/v1/wallet/manage-wallet-pin` | App/web | Set a wallet PIN | | `PUT` | `/api/v1/wallet/manage-wallet-pin` | App/web | Change a wallet PIN (also sends `oldPin`) | | `DELETE` | `/api/v1/wallet/manage-wallet-pin` | App/web | Disable the wallet PIN | | `GET` | `/api/v1/wallet/manage-card` | App/web | Saved cards | | `GET` | `/api/v1/wallet/generate-virtual-account` | App/web | Virtual account | | `POST` | `/api/v1/wallet/manage-paystack` | App/web | Fund via Paystack (`reference`) | | `POST` | `/api/v1/wallet/manage-monnify` | App/web | Fund via Monnify (`transactionReference`) | | `POST` | `/api/v1/wallet/manage-flutterwave` | App/web | Fund via Flutterwave (`flw_ref`, `transaction_id`, `amount`) | | `PUT` | `/api/v1/wallet/manage-commission` | App/web | Move commission to `available` | | `PUT` | `/api/v1/wallet/manage-bonus` | App/web | Move bonus to `available` | | `PUT` | `/api/v1/wallet/manage-referral` | App/web | Move referral to `available` | | `POST` | `/api/v1/wallet/manage-withdrawal` | App/web | Withdraw to a bank account (`amount`, `pin`) | !!! info "Wallet transfers are app/web only" Moving money between users is deliberately not exposed to the API channel. The route carries `UserNonApiAuth`, so an API-key request gets `403` *"This service is not available for api channel"*. The [WhatsApp agent channel](agent-channel.md) blocks it too, with *"Use the app or website for wallet transfers and balance moves"*. Buying is the API's job; moving money between users is the app's. --- ## API keys Key management happens in the [developer portal](https://developer.instapaybill.com/keys), not over the API. The `/api/v1/keys/*` routes require a **session** and are explicitly blocked for the API channel. | Method | Path | Access | Description | |---|---|---|---| | `POST` | `/api/v1/keys/` | App/web, agent | Generate `sk_test_` and `sk_live_` | | `GET` | `/api/v1/keys/` | App/web, agent | Fetch the current keys | | `POST` | `/api/v1/keys/webhook` | App/web, agent | Set the webhook URL (`webhook`) | | `PUT` | `/api/v1/keys/webhook` | App/web, agent | Clear the webhook URL | The service also enforces: ```js ensureAllowedChannel(userAuth) // 300 if userAuth === 'api' ensureLiveChannel(userDomain) // 300 if userDomain === 'test' ``` So even a session with a test key cannot manage production keys. ### The portal endpoints The developer portal uses its own SSO-guarded routes: | Method | Path | Description | |---|---|---| | `GET` | `/api/v1/sandbox/keys` | Sandbox key, webhook URL, delivery status and counters | | `POST` | `/api/v1/sandbox/keys/regenerate` | Rotate the sandbox key | | `POST` | `/api/v1/sandbox/keys/webhook` | Set the sandbox webhook URL | | `GET` | `/api/v1/sandbox/live-keys` | Live key and webhook shown in the portal | | `POST` | `/api/v1/sandbox/live-keys/regenerate` | Generate or rotate the live key | | `POST` | `/api/v1/sandbox/live-keys/webhook` | Set the production webhook URL | | `DELETE` | `/api/v1/sandbox/live-keys/webhook` | Clear the production webhook URL | All of them require the developer session cookie and the exact portal `Origin` header. The portal displays only **Sandbox key** and **Live key**. Older integration test credentials remain backend compatibility fields and are not offered in the portal. ### Key formats | Key | Format | Scope | |---|---|---| | Live | `sk_live_` + 24 alphanumeric chars | Real wallet, real providers | | Sandbox | `sk_test_` + 64 hex chars | Simulated only, `/api/v1/sandbox/*` | !!! danger "Rotating a key is immediate and total" Regenerating invalidates the old key instantly. Any integration still using it starts getting `403` *"Invalid Authentication, Unauthorized User"* on the very next request. Rotate deliberately, and update every consumer of the key in the same operation. --- ## Current user
GET/api/v1/user/
**Auth:** API key or session. ```bash curl https://api.instapaybill.com/api/v1/user/ \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" ``` Returns the identity the key belongs to: ```json { "msg": "User retrieved successfully", "data": { "email": "agent@example.com", "firstName": "Ada", "lastName": "Okafor", "telephone": "08012345678", "privilege": "agent", "isEmailVerified": true, "status": true, "statusText": "activated" } } ``` Use it as a startup check: if `privilege` is not `agent` or `status` is `false`, the key cannot transact. --- ## Notifications | Method | Path | Access | Description | |---|---|---|---| | `GET` | `/api/v1/notification/` | API key | List notifications. Supports pagination and filters. | | `PUT` | `/api/v1/notification/` | API key | Mark all as read | | `POST` | `/api/v1/notification/` | API key | Create a notification for the user (`message`) | | `PUT` | `/api/v1/notification/remove-for-me` | API key | Remove notifications for the current user | | `DELETE` | `/api/v1/notification/remove-for-me` | API key | Remove notifications for the current user | Notifications carry the outcome of your transactions, including pending purchase alerts. --- ## Related * [Authentication](../authentication.md) — the full credential model * [Webhooks](webhooks.md) — how settlement notifications are delivered --- # catalogue Source: https://developer.instapaybill.com/docs/api/catalogue/ # Catalogue Reference data — networks, categories, providers and settings. Most of it is public, so you can load it before a user signs in. --- ## Networks
GET/api/v1/network/
**Auth:** API key or session. ```bash curl https://api.instapaybill.com/api/v1/network/ \ -H "Authorization: Bearer $IPB_LIVE_KEY" \ -H "User-Agent: my-integration/1.0" ``` The canonical list of supported mobile networks, with logos and metadata. **Values to use in purchase requests:** `MTN`, `AIRTEL`, `GLO`, `T2MOBILE`. --- ## Validate a phone number
GET/api/v1/network/validate-number/{phoneNumber}
**Public — no credential required.** ```bash curl https://api.instapaybill.com/api/v1/network/validate-number/08012345678 ``` Resolves a number to its carrier using the MCD/Sprintcheck operator lookup. Useful in a UI to pre-select the network and to catch a carrier mismatch before the purchase. The lookup is cached for 24 hours. --- ## Categories
GET/api/v1/category/
**Public — no credential required.** ```bash curl https://api.instapaybill.com/api/v1/category/ ``` The product tree. A category is the unit that binds a product to a network and to a biller, and it is also the unit the network-health guard acts on. ```json { "msg": "Categories retrieved successfully", "data": [ { "_id": "", "name": "MTN AIRTIME", "product": "", "biller": "", "billerList": [""], "enabler": "enable", "networkBad": "disable" } ] } ``` | Field | Description | |---|---| | `enabler` | `enable` / `disable`. A disabled category rejects purchases with `300`. | | `networkBad` | `enable` when the network is degraded. Purchases are refused but can be scheduled. | | `biller` | The primary provider for this category. | | `billerList` | Every provider that can serve this category. | !!! tip "Category names" Airtime categories are named `_AIRTIME` (e.g. `MTN_AIRTIME`). Billers that serve the same product across networks have their own category (e.g. `T2MOBILE_AIRTIME`) — these are routing labels and never block customers, even when the customer-facing category is flagged. --- ## Startimes plans
GET/api/v1/startime/
**Public — no credential required.** ```bash curl https://api.instapaybill.com/api/v1/startime/ ``` The Startimes package catalogue. !!! note "Startimes purchases go through the cable endpoints" There is no live `POST /api/v1/startime/buy`. Startimes is bought with [`POST /api/v1/cables/buy`](cables.md#buy-cable-tv) using the `STARTIMES` cable type. The [sandbox](sandbox.md) does have a dedicated `POST /api/v1/sandbox/startimes` route. --- ## Settings
GET/api/v1/settings/
**Public — no credential required.** ```bash curl https://api.instapaybill.com/api/v1/settings/ ``` Platform-wide configuration: commission rates, cashback percentages, funding limits and transfer limits. The commission rates here are the ones used to calculate your earnings. --- ## Use cases
GET/api/v1/usecase/
**Public — no credential required.** ```bash curl https://api.instapaybill.com/api/v1/usecase/ ``` The feature-flag document. This is the single best endpoint for diagnosing *"why is my purchase being rejected with 'Service is currently unavailable'?"*. | Flag | Effect when not `enable` | |---|---| | `apiStatus` | The whole API channel is switched off | | `appStatus` / `webStatus` / `adminStatus` | The corresponding channel is switched off | | `airtime` | Airtime purchases are refused | | `data` | Data purchases are refused | | `electricity` | Electricity purchases are refused | | `cables` | Cable purchases are refused | | `education` | Education purchases are refused | | `kycRequired` | When `disable`, purchases are allowed without KYC | | `ninBvnRestriction` | When `enable`, wallet transfers require KYC on both sides | | `whatsappBuysEnabled` | The WhatsApp channel is paused | | `whatsappPilotMode` | The WhatsApp channel is restricted to a pilot phone list | | `airtimeMini` / `airtimeMax` | The allowed airtime amount range | | `minimumFunding` / `maximumFunding` | The allowed funding range | | `transferFundingMini` / `transferFundingMax` / `transferFundLimit` | Transfer limits | | `duplicateTransactionTimer` / `duplicateTransactionTimerStatus` | Duplicate-guard window | | `registration` / `login` / `otpRegistration` | Account flows | | `autoFund` / `microAutoFund` | Auto-funding behaviour | | `referralBonus` / `referralDataPlanBonus` | Referral rewards | | `cgStatus` | Bucket wallet (CG wallet) feature | | `nightPromoStatus` | Night promo window | | `whatsappDailyCap` | Per-phone daily cap on the WhatsApp channel | --- ## Other public endpoints | Method | Path | Description | |---|---|---| | `GET` | `/api/v1/faqs/` | Public FAQ list | | `GET` | `/api/v1/promo/active` | Whether a promo widget should be shown | | `GET` | `/api/v1/giveaway/count` | Giveaway entries used vs limit | | `GET` | `/api/v1/voice-assistant/health` | Voice service health | --- ## The full catalogue in one call If you are building a plan picker, do not fetch each catalogue endpoint separately. Use [`GET /api/v1/data/agent-data`](data.md#agent-data-catalogue), which returns data plans and cable plans together in a single request. --- ## Related * [Data](data.md) — data plan catalogue * [Cable TV](cables.md) — cable plan catalogue * [Electricity](electricity.md) — disco list * [Education](education.md) — exam plan list --- # webhooks Source: https://developer.instapaybill.com/docs/api/webhooks/ # Webhooks The purchase response tells you what happened *so far*. The webhook tells you the *final* outcome. For any purchase that returns `300`, the webhook is how you learn whether it was delivered or failed. --- ## Registering a webhook URL Set a public HTTPS URL on the [API Keys page](https://developer.instapaybill.com/keys), or over the portal API: ```bash # Sandbox curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/keys/webhook \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{"webhookUrl":"https://your-server.com/webhooks/instapaybill"}' # Production curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/live-keys/webhook \ -H "Cookie: __Secure-ipb_developer=..." \ -H "Origin: https://developer.instapaybill.com" \ -H "Content-Type: application/json" \ -d '{"webhook":"https://your-server.com/webhooks/instapaybill"}' ``` The URL must be a **public HTTPS** URL, at most 2048 characters: ```json { "error": [ { "msg": "Use a public HTTPS webhook URL.", "path": "webhookUrl" } ] } ``` **Your endpoint must be reachable from the public internet.** Private IPs, loopback addresses and hostnames that resolve to them are rejected — this is an SSRF guard, and it is enforced by resolving the hostname and checking every A/AAAA record. --- ## Live webhooks Live webhooks fire when a transaction is **resolved** — which may be immediately after the purchase, or much later if the purchase was held at `300` and the provider confirmed afterwards. ### Payload ```json { "code": 200, "type": "airtime", "reference": "IP|TN|A|123|20260918120000_ASDFG", "description": "MTN Airtime Purchase", "message": "Airtime Purchase Successful" } ``` | Field | Type | Description | |---|---|---| | `code` | number | The final verdict: `200` delivered, `300` still pending, `400` failed. | | `type` | string | Service: `airtime`, `data`, `electricity`, `cables`, `education`. | | `reference` | string | The platform's internal reference (`item_id`). | | `description` | string | What was bought. | | `message` | string | The outcome text. | Three optional fields are added when the transaction carries them: | Field | Present for | Source | |---|---|---| | `data_pin` | Education | The raw PIN payload from the provider. | | `token` | Electricity | The prepaid meter token. | | `unit` | Electricity | The kilowatt-hours purchased. | ### The signature header Every live webhook carries: ``` x-webhook-signature: ``` The HMAC is **SHA-512**, keyed with your **live key**, over the **exact raw request body**: ```js const hash = crypto .createHmac('sha512', live_key) .update(JSON.stringify(body)) .digest('hex'); ``` !!! danger "Verify the signature over the raw body, not a re-serialised object" The signature is computed over `JSON.stringify(body)` — the platform's own serialisation. If you parse the JSON and re-serialise it, key order or number formatting can differ and the HMAC will not match. Read the raw bytes from the request. #### Verifying in Node.js ```js import crypto from 'node:crypto'; export function verifyWebhook(rawBody, signatureHeader, liveKey) { const expected = crypto .createHmac('sha512', liveKey) .update(rawBody) // raw bytes, not JSON.parse(rawBody) .digest('hex'); const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(String(signatureHeader ?? ''), 'utf8'); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } ``` ```js app.post('/webhooks/instapaybill', express.raw({ type: 'application/json' }), (req, res) => { if (!verifyWebhook(req.body, req.get('x-webhook-signature'), process.env.IPB_LIVE_KEY)) { return res.status(401).end(); } const event = JSON.parse(req.body); // …handle the event… res.status(200).end(); }); ``` !!! note "Use the live key, not the test key" The signing key is always `live_key`, even if the transaction was created with a test key. There is one webhook secret per account: the live key. #### Verifying in Python ```python import hmac, hashlib def verify(raw_body: bytes, signature: str | None, live_key: str) -> bool: if not signature: return False expected = hmac.new(live_key.encode(), raw_body, hashlib.sha512).hexdigest() return hmac.compare_digest(expected, signature) ``` #### Verifying in PHP ```php $expected = hash_hmac('sha512', $rawBody, $liveKey); $valid = hash_equals($expected, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? ''); ``` Always use a constant-time comparison. A plain `===` leaks timing information. ### Delivery guarantees | Property | Behaviour | |---|---| | **Trigger** | When the transaction resolves — by provider callback, by the pending-transaction cron, or by an admin action. | | **Scope** | Only for transactions created on the `api` channel. App and web purchases never trigger your webhook. | | **Timeout** | 10 seconds. | | **Redirects** | Disabled. A 301/302 is not followed. | | **Retries** | Up to **6 attempts** total. Failed deliveries are re-sent by the resend-webhooks cron. | | **Body limit** | 1 MB. | | **Proxies** | Disabled. | | **DNS** | The target is resolved once and the IP is pinned — no second lookup, so DNS rebinding cannot redirect the call. | !!! tip "Respond 2xx fast" The platform does not wait for your processing to finish. Acknowledge with a `200` and do the work asynchronously, or you will burn the 10-second timeout and trigger a retry. ### Idempotency Because deliveries are retried, **your handler must be idempotent**. The `reference` field is stable across retries — use it as your idempotency key and store it before doing any work. --- ## Sandbox webhooks Sandbox webhooks are fired on every sandbox transaction, immediately after it is recorded. ### Payload ```json { "event": "sandbox.transaction", "reference": "SBTEST-ABCD1234EF", "service": "airtime", "code": 200, "data": { "code": 200, "reference": "SBTEST-ABCD1234EF", "description": "MTN Airtime Purchase", "message": "Top-up of ₦500 to 08012345678 delivered successfully.", "type": "airtime" }, "timestamp": "2026-10-19T12:00:00.000Z" } ``` | Field | Type | Description | |---|---|---| | `event` | string | Always `sandbox.transaction`. | | `reference` | string | The sandbox transaction reference. | | `service` | string | `airtime`, `data`, `electricity`, `cables`, `education`, `startimes`. | | `code` | number | The simulated outcome: `200`, `300` or `400`. | | `data` | object | The full simulated response body. | | `timestamp` | string | ISO-8601 timestamp. | ### Headers ``` Content-Type: application/json X-InstaPayBill-Sandbox: 1 ``` !!! tip "The sandbox header is your discriminator" `X-InstaPayBill-Sandbox: 1` is **always** present on sandbox deliveries and never on live ones. Use it to route test traffic away from your production order pipeline. ### No signature Sandbox webhooks are **not signed**. The delivery is HTTPS-only with redirects disabled, but there is no HMAC to check. Do not let sandbox traffic drive real fulfilment logic — check the header first. ### Delivery status The platform tracks the last delivery on the sandbox key record: ```json { "webhookUrl": "https://your-server.com/webhooks/instapaybill", "webhookLastDeliveredAt": "2026-10-19T12:00:00.000Z", "webhookLastStatus": 200 } ``` Available from `GET /api/v1/sandbox/keys`. --- ## Inbound webhooks (provider → InstaPayBill) These are for providers and partners, not for your integration. They are listed so you can recognise them if you see them in logs. | Method | Path | Provider | Verification | |---|---|---|---| | `POST` | `/api/v1/wallet/manage-webhook` | Paystack | Provider signature | | `POST` | `/api/v1/wallet/manage-webhook-monnify` | Monnify | Provider token | | `POST` | `/api/v1/wallet/manage-webhook-palmpay` | PalmPay | RSA-SHA1 signature | | `POST` | `/api/v1/wallet/manage-webhook-flutterwave` | Flutterwave | HMAC over the raw body | | `POST` | `/api/v1/wallet/manage-webhook-budpay` | Budpay | Provider check | | `POST` | `/api/v1/wallet/manage-webhook-sprintcheck` | Sprintcheck | HMAC-SHA512 of the raw body | | `POST` | `/api/v1/transactions/manage-autosyncng-webhook` | AutoSyncNG | Signature in the controller | | `POST` | `/api/v1/transactions/manage-ogdams-webhook` | Ogdams | Signature in the controller | | `POST` | `/api/v1/transactions/manage-ses-notifications` | AWS SES | SNS topic ARN allowlist | | `POST` | `/api/v1/webhooks/onesignal` | OneSignal | `x-webhook-secret` or `Authorization: Bearer` | All of these are public routes (no user credential) and are protected by their own signature verification, plus a 240 req/min rate limit on the biller callback routes. --- ## The RSA signature scheme Two providers (PalmPay and easeID) use a different scheme from the HMAC one. It is worth knowing if you ever need to debug a callback. **Signing** (what InstaPayBill does when calling out): ``` signature = Base64(RSA-SHA1( UPPERCASE(MD5( sortedParams )) )) ``` * `sortedParams` — the keys sorted alphabetically, joined as `key=value&key=value`, with falsy values dropped. * The MD5 hex digest is uppercased before signing. **Verifying** (what a receiver does): ``` messageDigest = UPPERCASE(MD5( sortedParams )) isVerified = RSA-SHA1-verify( messageDigest, base64Signature, publicKey ) ``` * The verifier excludes the `sign` field but keeps `undefined`-valued keys. The two differ in how they treat empty values — the signer drops them, the verifier keeps them. That asymmetry is real and is in the source, not a documentation error. --- ## Testing your webhook ```bash # 1. Point the sandbox at a local tunnel ngrok http 8080 # 2. Register the tunnel URL as the sandbox webhook curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/keys/webhook \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{"webhookUrl":"https://your-tunnel.ngrok.io/webhooks/instapaybill"}' # 3. Trigger a sandbox transaction curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/airtime \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{"phone":"08012345678","amount":500,"network":"MTN","scenario":"success"}' ``` You should receive a `sandbox.transaction` event with `X-InstaPayBill-Sandbox: 1` within a second or two. --- ## Related * [Transactions](transactions.md) — the `resolve` polling alternative * [Errors](../errors.md) — the `300` vs `400` rule for webhooks --- # sandbox Source: https://developer.instapaybill.com/docs/api/sandbox/ # Sandbox Every sandbox endpoint is a simulation. It never reaches a provider, never charges a wallet, and never moves real money — but the request validation, the response shapes and the error semantics are the same as the live API, so you can build the whole integration against it. The sandbox is where the [developer portal](https://developer.instapaybill.com/playground) runs its interactive playground. --- ## Authentication Sandbox endpoints use the **sandbox key** — a separate credential from your production `sk_test_` key. It does not interoperate with the production key store, and it only authenticates the `/api/v1/sandbox/*` routes. ```bash export IPB_SANDBOX_KEY="sk_test_..." ``` Both header styles work: ```bash -H "Authorization: Bearer $IPB_SANDBOX_KEY" -H "X-API-Key: $IPB_SANDBOX_KEY" ``` The sandbox key is bound to your agent identity. If the linked account is suspended or its email becomes unverified, sandbox calls start returning `403`. --- ## The `scenario` field Every sandbox purchase accepts an optional `scenario` field that forces a specific outcome. This is how you test your error handling without waiting for a real provider to fail. | `scenario` | Result | Available on | |---|---|---| | `success` *(default)* | HTTP `200`, `code: 200` | All | | `failure` | HTTP `400`, `code: 400` | All | | `pending` | HTTP `300`, `code: 300` | Airtime only | | `invalid_meter` | HTTP `400`, invalid meter error | Electricity only | ```bash curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/airtime \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{"phone":"08012345678","amount":500,"network":"MTN","scenario":"failure"}' ``` ```json { "code": 400, "reference": "SBTEST-ABCD1234EF", "error": [ { "path": "airtime-sandbox", "msg": "Transaction failed: phone number not eligible for this network" } ] } ``` --- ## Airtime
POST/api/v1/sandbox/airtime
```bash curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/airtime \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "08012345678", "amount": 500, "network": "MTN", "scenario": "success" }' ``` | Field | Type | Required | |---|---|---| | `phone` | string | ✅ | | `amount` | number | ✅ | | `network` | string | ✅ | | `scenario` | string | — | ```json { "msg": "MTN Airtime Purchase of ₦500 for 08012345678 was successful", "data": { "code": 200, "reference": "SBTEST-ABCD1234EF", "description": "MTN Airtime Purchase", "message": "Top-up of ₦500 to 08012345678 delivered successfully.", "type": "airtime" }, "code": 200 } ``` --- ## Data
POST/api/v1/sandbox/data
```bash curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/data \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{"phone":"08012345678","planId":"MTN-1.5GB-30D","network":"MTN"}' ``` | Field | Type | Required | |---|---|---| | `phone` | string | ✅ | | `planId` | string | ✅ | | `network` | string | ✅ | | `scenario` | string | — | ```json { "msg": "MTN Data Purchase for 08012345678 was successful", "data": { "code": 200, "reference": "SBTEST-ABCD1234EF", "description": "MTN Data Purchase", "message": "Data bundle activated on 08012345678. Valid for 30 days.", "type": "data" }, "code": 200 } ``` --- ## Electricity ### Validate
POST/api/v1/sandbox/electricity/validate
```bash curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/electricity/validate \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{"meterNo":"09012345678","disco":"EEDC","type":"prepaid"}' ``` | Field | Type | Required | |---|---|---| | `meterNo` | string | ✅ | | `disco` | string | ✅ | | `type` | string | — | | `scenario` | string | — | ```json { "msg": "Meter validated successfully", "data": { "customerName": "JOHN SANDBOX", "customerAddress": "12 Test Street, Lagos", "accountStatus": "active", "meterNo": "09012345678", "disco": "EEDC", "type": "prepaid" } } ``` With `scenario: "failure"` the endpoint returns HTTP `400` with *"Meter number not found on this disco"*. ### Buy
POST/api/v1/sandbox/electricity
```bash curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/electricity \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{"meterNo":"09012345678","disco":"EEDC","type":"prepaid","amount":5000,"phoneNumber":"08012345678"}' ``` | Field | Type | Required | |---|---|---| | `meterNo` | string | ✅ | | `disco` | string | ✅ | | `type` | string | — | | `amount` | number | ✅ | | `phoneNumber` | string | ✅ | | `scenario` | string | — | ```json { "msg": "Electricity purchase of ₦5,000 successful", "data": { "code": 200, "reference": "SBTEST-ABCD1234EF", "description": "EEDC prepaid Electricity Purchase", "message": "Units: 66.67 kWh\nToken: 1234-5678-9012\nCustomer Name: JOHN SANDBOX\nCustomer Address: 12 Test Street, Lagos", "token": "1234-5678-9012", "units": "66.67", "customerName": "JOHN SANDBOX", "type": "electricity" }, "code": 200 } ``` !!! note "`units` here, `unit` in the live API" The sandbox returns `data.units`; the live API returns `data.unit`. The [sandbox webhook](webhooks.md#sandbox-webhooks) mirrors the sandbox shape. Read both fields if your client shares code between the two. With `scenario: "invalid_meter"`: ```json { "code": 400, "error": [ { "path": "meterNo", "msg": "Invalid meter number for selected DISCO" } ] } ``` --- ## Cable TV ### Validate
POST/api/v1/sandbox/cables/validate
```bash curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/cables/validate \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{"smartCardNo":"1234567890","type":"DSTV"}' ``` ```json { "msg": "Smart card validated successfully", "data": { "customerName": "ADEBAYO SANDBOX", "accountStatus": "active", "currentBouquet": "DSTV Compact", "iucNumber": "1234567890", "cableType": "DSTV", "minimumPayable": 9000, "dueDate": "2026-11-18" } } ``` ### Buy
POST/api/v1/sandbox/cables
```bash curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/cables \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{"smartCardNo":"1234567890","type":"DSTV"}' ``` ```json { "msg": "DSTV subscription renewed successfully", "data": { "code": 200, "reference": "SBTEST-ABCD1234EF", "description": "DSTV Cable Subscription", "message": "DSTV plan renewed on 1234567890. Customer: ADEBAYO SANDBOX.", "type": "cables" }, "code": 200 } ``` --- ## Education
POST/api/v1/sandbox/education
```bash curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/education \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{"type":"WAEC","numberOfPin":2}' ``` | Field | Type | Required | |---|---|---| | `type` | string | ✅ | | `numberOfPin` | number | — (defaults to 1, capped at 10) | | `scenario` | string | — | ```json { "msg": "WAEC Result Checker PIN(s) generated successfully", "data": { "code": 200, "reference": "SBTEST-ABCD1234EF", "description": "WAEC Result Checker - 2 PIN", "message": "1. Serial: WAE1234567890 / PIN: 1234-5678-9012\n2. Serial: WAE9876543210 / PIN: 9876-5432-1098", "pins": [ { "serial": "WAE1234567890", "pin": "1234-5678-9012" }, { "serial": "WAE9876543210", "pin": "9876-5432-1098" } ], "type": "education" }, "code": 200 } ``` !!! tip "The sandbox `pins` array is the shape to code against" The live API's `data.data_pin` comes straight from the provider and its shape varies between providers. The sandbox normalises it to a `pins` array of `{ serial, pin }`. Write your client against that shape and make the live path tolerant. --- ## Startimes
POST/api/v1/sandbox/startimes
```bash curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/startimes \ -H "Authorization: Bearer $IPB_SANDBOX_KEY" \ -H "Content-Type: application/json" \ -d '{"smartCardNo":"1234567890","planId":"bouquet-1","phone":"08012345678"}' ``` | Field | Type | Required | |---|---|---| | `smartCardNo` | string | ✅ | | `planId` | string | ✅ | | `phone` | string | ✅ | | `scenario` | string | — | ```json { "msg": "Startimes subscription purchased successfully", "data": { "code": 200, "reference": "SBTEST-ABCD1234EF", "description": "Startimes Bouquet-1 Subscription", "message": "Startimes bouquet-1 plan activated on 1234567890. Customer: SANDBOX USER.", "type": "startimes" }, "code": 200 } ``` --- ## Sandbox transactions The sandbox records every simulated transaction. These two routes are **session-protected** — they require the developer portal cookie and the exact portal `Origin` header, not the sandbox key. | Method | Path | Description | |---|---|---| | `GET` | `/api/v1/sandbox/transactions` | List. `page` (≥1), `limit` (≤50). | | `GET` | `/api/v1/sandbox/transactions/{id}` | Single transaction by Mongo id. | --- ## Sandbox key and stats | Method | Path | Description | |---|---|---| | `GET` | `/api/v1/sandbox/keys` | The sandbox key, webhook URL, delivery status and counters | | `GET` | `/api/v1/sandbox/stats` | Request counters | | `POST` | `/api/v1/sandbox/keys/regenerate` | Rotate the sandbox key | | `POST` | `/api/v1/sandbox/keys/webhook` | Set the sandbox webhook URL | ```json { "msg": "Keys retrieved", "data": { "sk_test": "sk_test_…", "webhookUrl": "https://your-server.com/webhooks/instapaybill", "webhookLastDeliveredAt": "2026-10-19T12:00:00.000Z", "webhookLastStatus": 200, "stats": { "totalRequests": 42, "successful": 38, "failed": 4, "walletBalance": 100000 } } } ``` The wallet starts with **₦100,000 of virtual funds**. Each successful sandbox API purchase reduces `walletBalance`; failure and pending outcomes leave it unchanged. Purchases above the remaining balance fail with `400` and `Insufficient sandbox wallet balance.` Balances persist across sessions and include previously saved successful purchases. No real wallet is charged. Successful purchase responses include `data.amount` and `data.walletBalance`. Transaction records include `amount`, `amountMinor` (the deduction in kobo), `balanceBeforeMinor` and `balanceAfterMinor`. Failed and pending records have `amountMinor: 0`. The ledger and balance deduction are saved in one atomic insert, so concurrent requests cannot overspend or lose a debit. | Service | Simulated price | |---|---| | Airtime | Requested `amount` | | Electricity | Requested `amount` | | Data | ₦500 per request | | Cable TV | ₦9,000 per request | | Education | ₦3,700 per PIN (1–10 PINs) | | Startimes | ₦1,500 per request | These fixed simulator prices do not use the production plan catalogue. Amounts must be positive, with at most two decimal places. A request's plan ID does not change these simulated prices. The playground defaults to **Sandbox API call**, which saves transactions and updates the virtual balance. **Simulated (instant)** is a browser-only response preview; it does not save a transaction or change the wallet. --- ## Sandbox vs live — what differs | | Sandbox | Live | |---|---|---| | Base path | `/api/v1/sandbox/*` | `/api/v1/*` | | Key | Sandbox key (`sk_test_` + 64 hex) | Live key (`sk_live_` + 24 alnum) | | Providers | Never contacted | Real providers | | Wallet | Starts at ₦100,000; successful purchases deduct virtual funds | Four real buckets | | Response shape | Normalised per service | Provider-dependent in places | | Forced outcomes | `scenario` field | Not available | | Webhook signature | None | `x-webhook-signature` (HMAC-SHA512) | | Webhook header | `X-InstaPayBill-Sandbox: 1` | none | | KYC / account guards | Active, verified agent required; production KYC not simulated | Applied | | Duplicate guard | Not applied | Applied | !!! tip "Build in the sandbox, verify in production" The sandbox checks virtual wallet funds but does not run production KYC, biller health or duplicate-purchase guards. Agent eligibility is checked for sandbox access. Those are the failures your production integration will actually hit, so test them against production with a small live amount before going live. --- ## Related * [Quickstart](../getting-started.md) — first call * [Webhooks](webhooks.md) — sandbox event format * [Errors](../errors.md) — the error contract --- # agent-channel Source: https://developer.instapaybill.com/docs/api/agent-channel/ # Agent Channel (WhatsApp) The `/api/v1/agent/*` routes are the server-to-server surface used by the InstaPayBill WhatsApp assistant (Halo). They are **not** a general-purpose integration API — they authenticate a server with a shared secret and act on behalf of a customer identified by phone number. !!! warning "Not a general integration surface" These routes bypass the normal per-user API key model: a single shared secret authorises the caller, and the *customer* is identified by a header. If you are building a product integration, use the standard [API-key endpoints](overview.md) instead. --- ## Authentication Two headers are required on every request: ``` x-agent-key: x-user-phone-number: 08012345678 ``` * `x-agent-key` is compared in constant time against `process.env.IP_AGENT_KEY`. A mismatch returns `401` with `{ "error": [{ "msg": "Unauthorized", "path": "agent" }] }`. * `x-user-phone-number` resolves the customer. A missing header returns `401` with *"No phone Id provided"*; an unknown number returns `401` with *"No user found"*. The request then runs with that customer's identity: ```js req.user = user; req.userDomain = 'live'; req.userAuth = 'whatsapp-agent'; ``` !!! info "The identity is server-established" The `whatsapp-agent` channel is set by the server after it has resolved the customer — it is never asserted by a client header. This is why the channel skips the wallet PIN check and why the daily purchase cap is enforced per phone number. --- ## Endpoint map ### Customers | Method | Path | Description | |---|---|---| | `POST` | `/api/v1/agent/auth` | Agent login. Returns `{ data: { token } }`. | | `GET` | `/api/v1/agent/user?phoneNumber=…` | Customer lookup | | `GET` | `/api/v1/agent/receipt` | Branded receipt PDF | `GET /api/v1/agent/user` returns the customer profile plus the flags a buying assistant needs: ```json { "data": { "user": { "email": "…", "telephone": "08012345678", "privilege": "user" }, "isPinSet": true, "hasKYC": true, "isEmailVerified": true, "isSuspended": false, "wallet": { "available": 5000, "commission": 0, "bonus": 0 }, "lastTransaction": { } } } ``` ### Purchases All require `x-agent-key`, `x-user-phone-number`, a completed KYC, an active account, and a valid `frontend-source` channel. | Method | Path | Body | |---|---|---| | `POST` | `/api/v1/agent/airtime/buy` | `amount`, `network`, `phone` | | `POST` | `/api/v1/agent/data/buy` | `planId`, `phone` | | `POST` | `/api/v1/agent/electricity/buy` | `type`, `disco`, `amount`, `meterNo`, `phoneNumber` | | `POST` | `/api/v1/agent/electricity/validate` | `meterNo`, `disco`, `type` | | `POST` | `/api/v1/agent/cable/buy` | `smartCardNo`, `type` | | `POST` | `/api/v1/agent/cable/validate` | `smartCardNo`, `type` | | `POST` | `/api/v1/agent/education/buy` | `numberOfPin`, `type` | | `POST` | `/api/v1/agent/biz/verify` | `verifyType` + `name` or `biz_id` | These call the same services as the public API, so the response envelope and the `code` semantics are identical — see [Airtime](airtime.md#buy-airtime) for a worked example. ### Wallet | Method | Path | Description | |---|---|---| | `GET` | `/api/v1/agent/wallet/recipient-lookup` | Resolve a transfer recipient. `q` + `type` (`telephone`, `wallet`, `email`). | !!! danger "Wallet transfers are blocked on this channel" A router-level guard rejects **every** `POST` under `/api/v1/agent/wallet/*` with HTTP `403`: ```json { "error": [{ "msg": "Use the app or website for wallet transfers and balance moves" }] } ``` This means `POST /api/v1/agent/wallet/transfer`, `/move-commission`, `/move-bonus` and `/move-referral` are **unreachable**, even though they are declared in the route file. Only the `GET` recipient lookup works. Use the app or website for balance moves. ### Verification and notifications | Method | Path | Description | |---|---|---| | `POST` | `/api/v1/agent/biz/verify` | Business name or RC/BN verification | | `POST` | `/api/v1/agent/security-alert` | Email a security alert to the customer. Body: `phoneNumber`, `event`, `detail`. | | `POST` | `/api/v1/agent/otp/send` | Send a purchase OTP to the customer's WhatsApp. Body: `phoneNumber`. | `POST /api/v1/agent/otp/send` returns the OTP in the response body when the request is agent-authenticated — which is what makes the WhatsApp purchase flow work without asking the customer to type a code they received by SMS. --- ## Channel-specific rules ### Wallet PIN is skipped The wallet PIN check is bypassed for agent-authenticated requests. The WhatsApp flow uses a single-use OTP instead, with a 10-minute expiry. ### Promotional codes are not applied `applyPromoToPurchase` is skipped when the channel is `api` or `whatsapp-agent`. Promotional pricing does not apply on this channel. ### Daily purchase cap A per-phone-number cap of **₦100,000/day** applies, summed from transactions on the `whatsapp-agent` channel with `code` in `{200, 300}`. ### Pilot mode When `usecases.whatsappPilotMode` is enabled, purchases are restricted to the phone numbers in `usecases.whatsappPilotPhones`. ### Global pause `usecases.whatsappBuysEnabled` must be `enable` or the buy routes return `300` with *"WhatsApp purchases are briefly paused for maintenance…"* and `path: "whatsapp_paused"`. This guard **fails open** on a database error. ### OTP rate limit `POST /api/v1/agent/otp/send` is capped at **5 requests per 10 minutes per phone number**, keyed on the phone number rather than the IP — every request comes from the same Halo server, so an IP key would throttle all customers together. --- ## Middleware order ``` requireAgentApiKey → 401 if x-agent-key is wrong attachAgentPhoneUser → 401 if the phone header is missing or unknown checkNetworkState → 300 + canSchedule if the network is degraded RequireAgentKYC → 300 if the customer has no BVN and no NIN UserActive → 403 if the account is suspended UserAuthSouceNecessary → 300 if the channel header is invalid UserAuthSouceNecessaryChecker → 300 if the channel is switched off MaximumLimiterPurchase → 429 on excess express-validator → 300 on a bad field ``` --- ## Related * [Authentication](../authentication.md) — the credential model * [Errors](../errors.md) — the error contract * [MCP](mcp.md) — the tool surface for AI agents --- # mcp Source: https://developer.instapaybill.com/docs/api/mcp/ # MCP Server The InstaPayBill API exposes a **Model Context Protocol** server so AI agents can query platform data directly — wallet balances, transactions, plans, billers and customers — without hand-rolling HTTP calls. The MCP server is mounted on the **same Express application** as the REST API, at `https://api.instapaybill.com/mcp`. --- ## Transport `StreamableHTTPServerTransport`, configured for **stateless JSON responses**: ```js const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless enableJsonResponse: true, // JSON, not SSE }); ``` No session id is issued and no SSE stream is kept open, so the server scales horizontally without sticky sessions. The server rewrites the `Accept` header if needed, so clients that only send one of `application/json` or `text/event-stream` still work: ```js if (!hasJson || !hasSse) { req.headers.accept = [accept, 'application/json', 'text/event-stream'] .filter(Boolean).join(', '); } ``` !!! warning "The bundled MCP README is out of date" `mcp/README.md` and `mcp/SUMMARY.md` describe an older SSE design (`GET /mcp/sse`, `POST /mcp/message`) with 22 tools in 7 domain files. The live implementation is `mcp/index.js` with **Streamable HTTP** on `app.all('/mcp')` and **50 tools** in 50 files. Use `mcp/index.js` as the source of truth. --- ## Authentication Two layers. ### Layer 1 — `X-API-Key` Every non-`GET` request must carry the shared service key: ``` X-API-Key: ``` `GET` requests pass through unauthenticated — that is what makes `/mcp/health` and `/mcp/tools` publicly readable. A missing or wrong key returns `401`: ```json { "error": "Unauthorized: valid X-API-Key is required" } ``` ### Layer 2 — a user Bearer token for `tools/call` `tools/call` requests additionally require a user-scoped JWT: ``` Authorization: Bearer ``` Without it: ```json { "error": "Unauthorized: Bearer token is required for tools/call" } ``` The token is verified with HS256 against `AGENT_TOKEN_SECRET` (falling back to `ACCESS_TOKEN_SECRET`), with issuer `AGENT_TOKEN_ISSUER` (default `api.instapaybill.com`). It must carry a string `sub`. --- ## Issuing a user token
POST/api/v1/agent/token
Exchange the service key and a customer email for a user access token. ```bash curl -X POST https://api.instapaybill.com/api/v1/agent/token \ -H "Content-Type: application/json" \ -d '{ "service_key": "", "email": "customer@example.com" }' ``` ```json { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600 } ``` The token payload: ```json { "sub": "", "email": "customer@example.com", "role": "user", "isAdmin": false, "type": "mcp-user-access" } ``` | Failure | Response | |---|---| | Missing `service_key` or `email` | `300` — *"service_key and email are required"* | | Wrong `service_key` | `401` — *"Unauthorized: invalid service key"* | | Unknown email | `404` — *"User not found"* | Tokens expire after `AGENT_TOKEN_EXPIRES_IN_SECONDS` (default 3600 seconds). --- ## Discovery | Method | Path | Auth | Returns | |---|---|---|---| | `GET` | `/mcp/health` | None | `{ status, service, version, totalTools, tools }` | | `GET` | `/mcp/tools` | None | `[{ name, description }]` | ```bash curl https://api.instapaybill.com/api/v1/mcp/health | jq '.totalTools' ``` ```json { "status": "ok", "service": "utilities-mcp-server", "version": "1.0.0", "totalTools": 50, "tools": [{ "name": "get_wallet_balance", "description": "…" }] } ``` --- ## Calling the server ```bash curl -X POST https://api.instapaybill.com/mcp \ -H "X-API-Key: " \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_wallet_balance", "arguments": { "userId": "" } } }' ``` The response is JSON-RPC with the tool result as a single text content block: ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\n \"success\": true,\n \"data\": { … }\n}" } ] } } ``` The tool result is JSON-stringified inside the text block, so a client must parse the text to get structured data. --- ## Tool catalogue 50 tools, each a separate class in `mcp/tools/`. The registered name is declared in the file and is what you pass as `params.name`. ### Wallet | Tool | Purpose | |---|---| | `get_wallet_balance` | All wallet balances for a user | | `get_wallet_data` | The wallet record | | `get_wallet_history` | Wallet ledger entries | | `get_wallet_user` | The wallet's owner | | `get_wallet_stat` | Wallet statistics | | `get_wallet_bonus_history` | Bonus history | | `get_wallet_funding` | Funding history | | `get_wallet_manual_history` | Manual funding history | ### Transactions | Tool | Purpose | |---|---| | `get_transactions` | Transaction list with filters | | `get_transaction_details` | A single transaction | | `get_transaction_by_reference` | Look up by reference | | `get_transactions_stat` | Transaction analytics | | `find_transactions_stat` | Search transaction stats | | `get_data_transactions` | Data transactions | | `get_key_patron_transactions_stat` | High-value customer stats | ### Profit and analytics | Tool | Purpose | |---|---| | `get_transactions_profit_stat` | Profit statistics | | `get_transaction_profit_comparison` | Profit comparison | | `get_transaction_analytics_by_product` | Per-product analytics | | `get_manual_approval` | Manual approval queue | ### Users | Tool | Purpose | |---|---| | `get_user_details` | A user's profile | | `get_users` | User list | | `get_users_by_email` | Look up by email | | `get_user_total_amount` | Total transacted per user | | `get_referrals` | A user's referrals | | `get_user_context` | A user's stored context | | `get_user_contexts_batch` | Context for many users | ### Data plans and catalogue | Tool | Purpose | |---|---| | `get_normal_data_plans` | Active data plans | | `get_transformed_data_plans` | Data plans in agent shape | | `get_data_plans_by_network` | Plans for one network | | `get_available_networks` | Networks with active plans | | `get_cables_plan` | Cable plans | | `get_cables_type` | Cable types | | `get_e_lectricity_disco` | Electricity discos | | `get_education_plans` | Education exam plans | | `get_category` | Product categories | | `get_biller` | Biller records | | `get_billers_balance` | Biller balances | ### Validation | Tool | Purpose | |---|---| | `validate_cables_i_u_c` | Validate a smart card | | `validate_e_lectricity_meter_no` | Validate a meter number | ### Notifications | Tool | Purpose | |---|---| | `get_all_notifications` | User notifications | | `get_admin_notifications` | Admin notifications | | `get_user_notification_admin` | Notification admin view | ### Automation and upgrades | Tool | Purpose | |---|---| | `get_auto_buy_list` | Scheduled auto-buy jobs | | `get_become_an_agent_top_user` | Top agent-approval candidates | | `get_upgrade_details` | Agent upgrade request details | ### Miscellaneous | Tool | Purpose | |---|---| | `get_settings` | Platform settings | | `get_gateways` | Payment gateways | | `get_card_saved` | Saved cards | | `get_active_rooms` | Active voice-assistant rooms | | `get_room_details` | A single room | !!! warning "Three tool names are misspelled — use them verbatim" The names contain a stray capital `E` from the original class names: * `get_e_lectricity_disco` (not `get_electricity_disco`) * `validate_e_lectricity_meter_no` (not `validate_electricity_meter_no`) * `validate_cables_i_u_c` (not `validate_cables_iuc`) These are the registered names. A `tools/call` with the "corrected" name fails with an unknown-tool error. --- ## How tools execute Each tool calls a **service function directly**, not the HTTP endpoint. It synthesises a request/response pair: ```js const mockReq = { query: { … }, user: { isAdmin: true, _id: 'admin' }, userAuth: 'admin' }; const mockRes = { status: (code) => ({ json: (data) => { … } }), json: (data) => { … } }; await serviceFn(mockReq, mockRes); ``` !!! danger "The MCP surface is fully privileged" Every tool executes with `isAdmin: true` and `userAuth: 'admin'`. There is no per-tool permission check and no scoping to the token's `sub`. The `X-API-Key` is the real control point. Anyone holding it can read any user's data through these tools. Treat it as a production secret, issue user tokens only for accounts you intend to expose, and never ship the service key to a client. Note that the *user* token identifies who is asking but does not limit what they can read. --- ## Tool schemas Each tool declares a Zod schema, which becomes the MCP `inputSchema`. Two schema styles are used: * **Object schemas** — most tools. Arguments are a named object. * **String schemas** — a few tools take a bare string argument. The `Accept` header must include both `application/json` and `text/event-stream` for strict MCP clients; the server normalises it either way. --- ## Errors The MCP layer uses its own error shape, not the REST envelope: ```json { "error": "Unauthorized: valid X-API-Key is required" } ``` ```json { "error": "Unauthorized: Bearer token is required for tools/call" } ``` ```json { "error": "Unauthorized: token subject (sub) is required" } ``` ```json { "error": "Internal server error" } ``` A tool that throws returns a JSON-RPC error object. --- ## Related * [Authentication](../authentication.md) — the REST credential model for comparison --- # architecture Source: https://developer.instapaybill.com/docs/backend/architecture/ # Architecture How the InstaPayBill backend is put together, and how a request travels through it. --- ## Stack | Layer | Technology | |---|---| | Runtime | Node.js 22, ES modules | | HTTP | Express 4 | | Database | MongoDB via Mongoose 7 | | Cache / locks | Redis (ioredis) | | Realtime | Socket.IO on the same HTTP server | | Validation | express-validator + Zod | | Security | helmet, cors, mongo-sanitize, express-rate-limit | | Payments | Paystack, Monnify, Flutterwave, PalmPay, BudPay (retired) | | AI | Model Context Protocol server, Gemini, LiveKit voice | | Mail | nodemailer + AWS SES | --- ## Layer map ``` HTTP request │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Server.js — global middleware stack │ │ compression → json → cors → cookieParser → │ │ mongoSanitize → device detection → blocklistGuard → │ │ authLimiter → fileUpload → helmet → static │ └──────────────────────┬──────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Routes/*.js — 50 route modules │ │ URL → middleware chain → controller. No business logic. │ └──────────────────────┬──────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Middlewares/ — 13 modules │ │ Auth · RouteGuards · RateLimiter · NetworkInterception │ │ Blocklist · Recaptcha · validate · Paginator · Db … │ └──────────────────────┬──────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Controllers/*.js — 44 thin controllers │ │ hasValidationErrors() → service call → response │ └──────────────────────┬──────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Services/*.js — 67 service modules │ │ Business logic: DB reads/writes, provider calls, │ │ wallet debit/credit, transaction records, commission, │ │ referral, promo, webhooks. │ └───────────┬──────────────────────────────┬──────────────┘ │ │ ▼ ▼ ┌──────────────────────┐ ┌──────────────────────────────┐ │ Models/*.js — 89 │ │ Providers / Billers │ │ Mongoose models │ │ Merrybills, Ringo, Chosen, │ │ + MongoDB │ │ RPI, Ogdams, AutoSyncNG, │ │ + Redis │ │ VTUplug, Cash2bill, … │ └──────────────────────┘ └──────────────────────────────┘ ``` --- ## The global middleware stack The order in `Server.js` is load-bearing. | # | Middleware | Why it is there | |---|---|---| | 1 | `app.set('trust proxy', …)` | Correct client IPs behind a load balancer, so rate limiting and the blocklist see the real caller. `TRUST_PROXY` is a comma-separated list, default `'loopback'`. | | 2 | `compression()` | Response compression. | | 3 | `express.json({ limit: '10mb' })` | Body parsing. The `verify` hook keeps the raw body on `req.rawBody` for signature verification. | | 4 | `express.urlencoded()` | Form-encoded bodies (One-Click unsubscribe, SES callbacks). | | 5 | `morgan` + `morgan-body` | Request logging. **Skipped when `NODE_ENV === 'test'`.** | | 6 | `cors({ credentials: true })` | Origin allowlist. Requests with no `Origin` are allowed — that is what lets `curl` and server-to-server calls work. | | 7 | `cookieParser()` | Reads the developer portal cookie. | | 8 | `mongoSanitize()` | Strips `$`-prefixed keys to block NoSQL injection. | | 9 | Device detection | `node-device-detector` + ClientHints → `req.deviceInfo`. Must run before the blocklist guard. | | 10 | **`blocklistGuard`** | Runs **before route matching**, so a blocked actor cannot reach even a 404. | | 11 | `authLimiter` | 50 requests / 15 min on the eight auth paths. | | 12 | `fileUpload()` | Multipart handling, 10 MB cap. | | 13 | `helmet()` | Security headers. `frame-ancestors: 'none'`. | | 14 | `express.static(Public/)` | Static assets. | | 15 | `registerRoutes(app)` | All 50 route groups under `/api/v1/*`. | | 16 | MCP mount | `setupMCP(app)` — `/mcp`, `/mcp/health`, `/mcp/tools`. | | 17 | **Fallback** | The last handler. Returns `300` *"Route not found"* for anything unmatched. | ### Process-level guards At the very top of `Server.js`, before Express is even imported: ```js process.on('unhandledRejection', (reason) => { console.error(...) }); process.on('uncaughtException', (err) => { console.error(...) }); ``` The reasoning is in the source: a single unhandled error — a `ReferenceError` in a post-purchase timer, for example — must not take payments down for every user. Money paths use atomic Mongo updates, so logging and continuing is safer than crashing and restarting, which would drop every in-flight request. --- ## The route registry `utils/routes.js` mounts every group with a rate limiter: ```js app.use('/api/v1/airtime', apiLimiterFive, AirtimeRoute); app.use('/api/v1/data', apiLimiterFive, DataRoute); app.use('/api/v1/electricity', apiLimiterDouble, ElectricityRoute); // … 50 groups app.use('/api/v1/sandbox', apiLimiterDouble, SandboxRoute); ``` No route is mounted at the root of `/api/v1` — every path includes its group prefix. The **fallback is registered last**, after the MCP mount, so MCP and API handlers get first refusal on a matching path. --- ## Controllers Controllers are deliberately thin. The base class handles validation and error shaping: ```js export default class BaseController { hasValidationErrors(req, res) { const errors = validationResult(req); if (!errors.isEmpty()) { res.status(300).json({ error: errors.array() }); return true; } return false; } async run(res, cb) { try { const payload = await cb(); return res.json(payload); } catch (error) { return this.sendError(res, error); } } } ``` Two things to notice: * **Validation failures return `300`, not `400`.** The platform's convention treats a malformed request as "correctable", not "permanently failed". * `run()` catches everything and funnels it into `sendError`, which emits `{ code: , error: [...] }` for service-thrown errors and a generic 500 otherwise. --- ## Services The service layer is where all the business logic lives. It is the largest layer — 67 modules. ### The base class ```js export default class BaseService { createError(status = 500, msg = 'Request failed', path = 'server', errors) { const err = new Error(msg); err.status = status; err.errors = errors || [{ msg, path }]; return err; } throwError(status, msg, path, errors) { throw this.createError(...); } ensure(condition, status, msg, path, errors) { if (!condition) this.throwError(status, msg, path, errors); } ensureLiveChannel(userDomain) { if (userDomain === 'test') this.throwError(300, 'This service not available for test channel', 'domain'); } } ``` Services throw; the controller catches and shapes the response. That is why `throwError(300, …)` and `throwError(400, …)` produce different HTTP statuses with the same body shape. ### The legacy adapter Many services still contain legacy `(req, res)`-style functions. `invokeLegacy` adapts them: ```js const res = { headersSent: false, status(code) { statusCode = code; return this; }, json(data) { captured = data; return this; }, }; ``` The long comment in the source explains why: legacy functions wrap their body in `try/catch` and respond with `res.status(500).json({ msg: 'Server Error: ' + e.message })`. When the original adapter *threw* on a 4xx, that throw landed in the legacy catch block and the user saw HTTP 500 *"Server Error: Wrong OTP"* instead of 404 *"Wrong OTP"*. The adapter now records the response and re-throws the status after the handler returns. ### Service groups | Group | Modules | |---|---| | **Base** | `BaseService` | | **Purchases** | `AirtimeService`, `DataService`, `CablesService`, `ElectricityService`, `EducationService`, `StartimesService`, `VerificationService`, `CgWalletService` | | **Money** | `WalletService`, `CommissionService`, `PromoService`, `NightPromoService`, `ReferralService` | | **Transactions** | `TransactionService`, `QueuedPurchaseService`, `AutoBuyService` + `AutoBuyHelpers` | | **Users** | `UserService`, `ApikeyService`, `DeveloperSsoService`, `SandboxService`, `BecomeAgentService` | | **Admin & ops** | `AdminService`, `AdminRetriesService`, `CronService`, `RetryLockHelpers`, `AdminAnalyticsService`, `UserAnalyticsService`, `AdminSendDataService` | | **Providers** | `Services/billers/*Adapter.js`, `BillerFactory.js` | | **Virtual accounts** | `Services/virtual-accounts/{index,monnify,palmpay,budpay}.js` | | **AI & CRM** | `AiService`, `VoiceAssistantService`, `CrmService`, `CrmHaloService`, `CrmWhatsAppService` | | **Support** | `NotificationService`, `RiskService`, `AnomalyDetectionService`, `ActivityService`, `ReceiptService`, `FileUploadService`, `GeoLocationService`, `IpAllowlistService`, `UseCaseService`, `ContactService`, `UnsubscribeService`, `DebugService` | --- ## The provider adapter layer A newer, uniform interface sits alongside the legacy per-biller branches. ```js // Services/billers/BillerAdapter.js class BillerAdapter { validate(payload) {} // check the request before dispatch getPlans(category) {} // fetch the plan catalogue purchase(payload) {} // execute the purchase queryStatus(reference) {} // check on a pending transaction getWalletBalance() {} // optional } ``` `BillerFactory` holds the registry: ```js const REGISTRY = { merrybills, vtuplug, autosyncng, cash2bill, ringo, utilities, infinitytopup, }; forBiller(doc) { const adapter = REGISTRY[doc.billerId]; if (!adapter) throw new Error(`No adapter registered for biller ${doc.billerId}`); return adapter; } ``` !!! danger "`BillerFactory.forBiller()` throws for an unregistered biller" This is a deliberate fail-fast. A new biller **must** be added to the registry before any service can route to it — otherwise the purchase throws instead of silently failing. !!! note "The adapter layer is largely dormant" The live services still use their own per-biller branches. The Ringo adapter is explicitly documented as *"dormant until AirtimeService and DataService are migrated AND the per-surface `BILLER_ADAPTER` flag is flipped, so registering this has zero live impact."* --- ## Redis Redis backs the purchase dedup lock, the SSO codes and sessions, the blocklist cache, the CRM presence keys and the user-context cache. ```js // config/redis.js lazyConnect: true, // retry forever, backoff capped at 5s ``` The retry-forever behaviour is deliberate. The previous implementation gave up after 5 failed attempts, so a brief Redis blip silently disabled data-purchase idempotency until the API was manually restarted. **Redis outages are handled differently per feature:** | Feature | On Redis outage | |---|---| | Purchase dedup lock | **Fails open** — the purchase proceeds | | Developer SSO | **Fails closed** — `503` | | Blocklist cache | **Fails open** | --- ## Socket.IO `SocketServer.js` bolts Socket.IO onto the same HTTP server, so there is no second port to operate. It applies the same origin allowlist as the CORS policy. The purchase path emits a `newTransaction` event to the user's room (`user:`) after a successful purchase, so the app and web dashboard update live. --- ## Request lifecycle at a glance ``` POST /api/v1/airtime/buy │ ├─ Global stack (compression, cors, sanitize, device, blocklist, limiter) │ ├─ UserAuth → resolves the key, sets req.user / userAuth / userDomain ├─ checkNetworkState → 300 + canSchedule if the network is degraded ├─ UserActive → 403 if suspended ├─ TransactionStatusAndKYCCheck → 300 if no KYC ├─ UserAuthSouceNecessary → 300 if the channel header is invalid ├─ UserAuthSouceNecessaryChecker → 300 if the channel is switched off ├─ MaximumLimiterPurchase → 429 on excess ├─ express-validator → 300 on a bad field │ ├─ Controller.hasValidationErrors │ ├─ Service.buyAirtime │ ├─ phone/network match ├─ KYC purchase gate │ ├─ min/max amount ├─ wallet lookup │ ├─ promo code ├─ balance check │ ├─ telephone blacklist ├─ biller health │ ├─ duplicate guards (3 layers) │ ├─ write pending intent row │ ├─ wallet PIN check (skipped for API keys) │ ├─ ATOMIC wallet debit │ ├─ provider call │ ├─ outcome handling: settle | hold | refund │ ├─ commission credit + recoupment │ ├─ referral system │ └─ socket emit │ └─ res.json({ msg, data, code }) ``` --- ## Background jobs Two schedulers start with the server: * **`initAutoBuyJobs()`** — restores scheduled auto-buy jobs on startup. Each job takes an atomic per-day lock, so duplicate execution across workers is impossible. A previous implementation gated on the PM2 instance id being `"0"`, which never held in fork mode — so a single `pm2 reload` silently unregistered the entire scheduler. * **`initPromoPushSchedule()`** — a daily promo push at 09:00 and 21:00 Africa/Lagos. Single worker, so it fires once per slot. Cron-triggered operations are exposed over `POST /api/v1/cron/*`, guarded by `x-hw-api-key`. There are 18 of them, covering pending-transaction retries, held-refund reconciliation, queued-purchase processing, biller balance lookups and more. --- ## Related * [Purchase Lifecycle](purchase-lifecycle.md) — the money path in detail * [Money & Commissions](money-model.md) — wallets, margin, commission * [Data Models](data-models.md) — the 89 Mongo collections * [Providers & Billers](providers.md) — upstream integrations * [Security & Guards](security.md) — every guard on the request path --- # purchase-lifecycle Source: https://developer.instapaybill.com/docs/backend/purchase-lifecycle/ # Purchase Lifecycle The complete path a purchase takes, from the request to the settled transaction — including the branches where money is held, refunded, or recovered after a crash. --- ## The happy path ``` 1. Request 2. ── Guards ──────────────────────────────────────────── 3. Duplicate lock acquired (Redis, 180 s) 4. Pending intent row written (transactionIntents) 5. Wallet debited atomically (available -= amount) 6. Provider called 7. ── Provider says 200 ───────────────────────────────── 8. Transaction record saved (status: true, code: 200) 9. Wallet ledger row written (debit) 10. Commission credited 11. Referral reward applied 12. Socket event emitted 13. Response: { msg, data, code: 200 } 14. Webhook fires (async, non-blocking) ``` Steps 1–6 all happen **before** the provider is contacted. Steps 7 onward branch on the provider's verdict. --- ## Step by step ### 1. Request ```http POST /api/v1/airtime/buy HTTP/1.1 Authorization: Bearer sk_live_... Content-Type: application/json User-Agent: my-integration/1.0 {"phone":"08012345678","amount":500,"network":"MTN","apiRefId":"order-1001"} ``` ### 2. Guards See [Security & Guards](security.md) for the full list. The order matters — the first failure short-circuits. ### 3. The atomic purchase lock Before anything else touches the wallet, a Redis key is taken: ``` purchaselock:airtime:::[:] ``` Acquired with `SET key value PX 180000 NX` — only the first request in a burst wins. !!! note "Why a lock, not just a check" The `scopedDuplicateGuard` reads *committed* transactions. A burst of identical requests arriving in the same moment all pass the check before any of them has saved a row. The incident this prevents: on 24 July 2026, one customer received 8 × ₦10,000 of airtime in one burst. The lock is the fix; the guard alone was not enough. The lock **fails open** if Redis is unreachable. ### 4. The pending intent row ```js item_id = getSerialNo(res, TransactionModel, 'TN|A', { … }); // → IP|TN|A|123|20260918120000_ASDFG ``` A `PendingTransactionModel` row is written to the `transactionIntents` collection **before** the provider is called, carrying `statusText: 'pending'`, `prevBalance`, `balance`, `domain`, `channel`, `transactionType` and a random `reference`. This row is the write-ahead record. If the process dies mid-purchase, the row is the only durable evidence that money may have moved. ### 5. The wallet debit The debit is a single atomic Mongo update: ```js await PendingTransactionModel.updateOne({ _id }, { $set: { debitAttemptedAt: new Date(), debitAmount: factorPrice } }); const _debitResult = await WalletModel.findOneAndUpdate( { user: req.user._id, [balanceField]: { $gte: Number(factorPrice) } }, { $inc: { [balanceField]: -Number(factorPrice) } }, { new: true } ); if (!_debitResult) { await PendingTransactionModel.deleteOne({ _id }); throwError(300, 'Insufficient wallet fund (concurrent purchase drained your balance…)', 'wallet'); } await PendingTransactionModel.updateOne({ _id }, { $set: { debitConfirmedAt: new Date() } }); ``` Two properties make this safe: * The `$gte` condition means the balance can **never** go negative, even if two purchases race. The second one simply fails the filter. * `debitAttemptedAt` / `debitConfirmedAt` bracket the debit, so the orphan monitor can detect a debit that happened without a matching transaction. `balanceField` is `test_available` for a test key, `available` otherwise. ### 6. The provider call The biller is resolved from the category, its health is checked, and the request is dispatched. The response is normalised: ```js dataRes = { code, reference, msg, message }; // code ∈ {200, 300, 400, 301} ``` ### 7–12. Outcome handling | Provider verdict | Wallet | Transaction | Webhook | |---|---|---|---| | `code === 200` | Debited (final) | Saved, `status: true`, `code: 200` | `code: 200` | | `code === 300` | **Debited, held** | Saved, `code: 300`, `hold: true` | `code: 300` | | `code === 400`, permanent message | **Refunded** | Saved, `status: false`, `code: 400` | `code: 400` | | `code === 400`, transient message | **Debited, held** | Saved, `code: 300`, `hold: true` | later | | Transport error (5xx, reset, no response, HTML gateway page) | **Debited, held** | Saved, `code: 300`, `hold: true` | later | | Unhandled exception | **Refunded** | Deleted | — | The last row is the important one. Every purchase service wraps its provider call in a `try/catch` whose catch block **unconditionally refunds**: ```js catch (error) { await WalletModel.updateOne({ user }, { $inc: { [balanceField]: factorPrice } }); // delete the orphaned WalletHistory and TransactionModel rows // classify the error, then return a message that says the wallet was refunded } ``` The customer-facing message explicitly states whether their wallet was refunded or not debited, so it can be passed through verbatim. ### The permanent vs transient distinction A provider `400` is **not** automatically a refund. The message is classified: ```js isUnrecoverableBillerMessage(msg) // → terminal, refund now isTransientRetryableBillerMessage(msg) // → hold, let the cron verify ``` A *transient* provider failure is **held, not refunded** — the provider may still have delivered the service, and refunding would lose the customer's airtime. The pending-transaction cron verifies it at the provider later. ### The hold path A held transaction (`code: 300`) is: * **Still debited.** The customer's money is committed until the outcome is known. * Recorded with `hold: true`. * Emailed to the customer via `pendingTransactionMailer`. * Picked up by a per-biller pending cron, which re-queries the provider and resolves it to `200` (delivered) or `400` (failed and refunded). ### 13. The response ```js res.json({ msg, data, code }); ``` `data` is the compact envelope for API-channel requests, and the full transaction document for session requests. ### 14. The webhook Fired asynchronously, after the transaction resolves. It never blocks or delays the response. --- ## The duplicate guards Three layers, all applied **before** the wallet debit. ### Layer 1 — the Redis lock Atomic `SET … NX` with a 180-second TTL, keyed on the user, phone, amount and plan. Only one request in a burst proceeds. ### Layer 2 — the committed-transaction check ```js // A transaction with the same user + phone (+ planId, amount) // created within windowMs = 180_000, excluding server-side retries. ``` The error message tells the caller exactly what to do: > "We just processed an identical request to 08012345678 42 seconds ago. If > that one's still pending, please give it 138 seconds to settle before > retrying." ### Layer 3 — the body hash ```js generateTransactionHash(body) = JSON.stringify(body); ``` An in-memory hash set with a 60-minute TTL. An identical request body within the hour is rejected with *"Duplicate transaction detected"*. All three return HTTP `300`. --- ## The refund guard Refunds are the most dangerous operation in the system, because a double refund is real money lost. The incident that shaped this code, from `utils/refund-guard.js`: > On 18 May 2026, two automated refund paths passed the check 452 ms apart > before either wrote its row, and both credited. A wallet went > 100.93 → 1400.93 → 2700.93 — ₦1,300 lost. Three protections now exist: **1. An atomic claim.** The transaction carries `refundInProgress`, `refundClaimedAt` and `refundClaimedBy`. A refund is claimed with `findOneAndUpdate({ refundInProgress: { $ne: true } }, …)` — the loser gets *"Already in progress"*. **2. Evidence checks.** `wasWalletDebited(tx)` compares `prevBalance` against `balance` to confirm money actually left. `hasOwnDebitHistory(tx)` looks for a linked `WalletHistory` row with `type: 'debit'`. `hasExistingRefund(tx)` covers sibling transactions for the same logical purchase. **3. Stale-lock expiry.** A lock that is never released (because the process died) expires after 90 seconds, so a crash cannot block refunds forever. The retry path has the same structure with `retryInProgress`, `retryStartedAt` and `retryStartedBy`. --- ## The orphan monitor A background check looks for a pending intent row that has `debitConfirmedAt` set but no matching `TransactionModel` row — the signature of a crash between the debit and the transaction save. Two silent-debit incidents on 25–26 July 2026 prompted this. The monitor alerts rather than auto-refunding, because a human deciding is safer than a script guessing. --- ## The funding dedup Wallet funding callbacks from payment providers can arrive more than once. `utils/funding-dedup.js` claims the reference before crediting: ```js await FundingDedupModel.create({ reference: `${provider}:${reference}`, provider }); ``` The `reference` field is **uniquely indexed**. A duplicate insert fails with `E11000`, and the second credit is skipped **before any money moves**. The legacy `pendinghistories` guard remains as a longer-term backstop, and a 90-day TTL keeps the collection lean. --- ## Commission and referral On a successful purchase: 1. The commission rate is read from `SettingsModel` — a per-network rate with agent and reseller tiers, falling back to the base rate. 2. `returnValue = (commission / 100) * factorPrice`. 3. A `WalletHistory` row with `title: 'commission'`, `type: 'credit'` is written, and `wallet.commission` is incremented. 4. `applyCommissionRecoupment(...)` runs immediately afterwards. 5. `TransactionModel.commission` is set to the commission ledger row's id. 6. The referral system runs, crediting the referrer. See [Money & Commissions](money-model.md) for the margin and recoupment logic. --- ## Scheduled purchases `POST /api/v1/schedule/purchase` queues a purchase for later: ```json { "type": "airtime", "payload": { "phone": "08012345678", "amount": 500, "network": "MTN" } } ``` `type` must be one of `airtime`, `data`, `cables`, `electricity`, `education`. This is what the `canSchedule: true` response points at when a network is in a bad state. Queued purchases are processed by a cron, and expired ones are cleaned up automatically. --- ## Related * [Money & Commissions](money-model.md) — how profit is calculated * [Security & Guards](security.md) — the guard chain in detail * [Providers & Billers](providers.md) — what happens during step 6 * [API Errors](../errors.md) — the client-visible contract --- # money-model Source: https://developer.instapaybill.com/docs/backend/money-model/ # Money & Commissions How the platform decides what a purchase costs, what the agent earns, and how errors in the ledger are recovered. --- ## The wallet A wallet has four buckets, each with a `_test_` twin: | Bucket | Live field | Test field | Purpose | |---|---|---|---| | Spendable | `available` | `test_available` | Funds purchases draw from | | Commission | `commission` | `test_commission` | Earned from your own sales | | Bonus | `bonus` | `test_bonus` | Promotional grants | | Referral | `referral` | `test_referral` | Referral earnings | | — | `pending_deficit` | — | Money at risk in held transactions | **Only `available` funds a purchase.** Commission, bonus and referral balances must be moved to `available` first, via the app or website. ### The zero floor `available` can never go negative. This is enforced in two places: * A **schema validator** rejects a negative value on write. * **Pre-update middleware** intercepts `updateOne`, `findOneAndUpdate` and `updateMany` and blocks a negative result. Combined with the `$gte` condition on the purchase debit, two concurrent purchases can never overdraw the wallet. The second one fails the filter and returns *"Insufficient wallet fund (concurrent purchase drained your balance…)"*. ### The wallet ledger Every balance movement is recorded in `WalletHistory`: | Field | Description | |---|---| | `walletId` | The wallet it belongs to | | `transaction` | The related transaction | | `user` | The user | | `title` | `wallet` or `commission` | | `type` | `debit` or `credit` | | `amount` | The amount moved | | `prevBalance` | Balance before | | `balance` | Balance after | | `usage` | What it was for — `airtime`, `data`, … | | `item_id` | The transaction reference | | `domain` | `live` or `test` | | `channel` | `api`, `app`, `web`, `whatsapp-agent` | | `authorizer` | Who approved it, for admin-initiated movements | The ledger is the audit trail. A balance can always be reconstructed from it. --- ## Where the money goes For a ₦500 airtime purchase on MTN with a 2.5% agent cashback rate: ``` Customer pays ₦500.00 ├─ wallet debit -₦500.00 (from `available`) └─ billed to provider ₦500.00 Agent earns 2.5% of ₦500.00 = ₦12.50 └─ credited to `commission` ``` The commission sits in `commission` until it is moved to `available`, at which point it becomes spendable. --- ## How profit is calculated ```js profit = (billerCommission% − customerCashback%) × faceValue ``` Both sides are **derived from live configuration**, never hardcoded. The reason is in the source of `utils/product-margin.js`: > A hardcoded percentage is only true until somebody edits a cashback rate on > the dashboard, and then it lies silently — which is precisely how both of the > following happened. ### The cables case `CablesService` hardcoded `profit = factorPrice / 100` — a flat 1%. Merrybills pays 1.2% and the platform paid customers 1% cashback, so the real margin was 0.2%. Over 90 days that recorded ₦4,617 of profit on ₦467,500 of turnover where about ₦935 was earned — a **5× overstatement**. ### The electricity case `ElectricityService` hardcoded `profit: 0`, on the belief stated in its own comment that *"Merrybills bills us at our wholesale rate... the margin lives in the Merrybills wholesale spread"*. That was measured and found false: across 195 electricity transactions the spread was exactly 0.0000%. Merrybills charges full face value and pays commission **out of band** into a separate balance, exactly as it does for airtime. The real margin was 0.6% − 0.5% = 0.1%. ### The current approach ```js // utils/product-margin.js const PRODUCT_BILLER_COMMISSION = { merrybills: { cables: 1.2, electricity: { EEDC: 1.5, AEDC: 1.2, BEDC: 1.2, EKEDC: 1.0, JEDC: 1.0, KAEDC: 1.0, IBEDC: 0.6, IKEDC: 0.6, DEFAULT: 0.6, }, }, }; ``` The customer cashback percentage is read live from `SettingsModel` — `cablesCommission`, `cablesResellerCommission`, `cablesAgentCommission` and the electricity equivalents — selected by the user's privilege tier. !!! tip "Unmeasured billers record zero" If a biller's commission has not been verified, the platform records **zero**, not a guess. Zero is wrong-but-honest; a guess is wrong-and-convincing, and would put fabricated profit on the books. A negative net margin is recorded honestly and logged, rather than being clamped to zero. ### Disco pattern matching Disco codes are matched most-specific-first, because `'KAEDCO'.includes('AEDC')` is `true`. Matching `AEDC` before `KAEDC` would apply the wrong rate to the wrong disco. --- ## The airtime margin guard ```js // utils/airtime-margin-guard.js assertAirtimeMarginPositive({ provider, network, privilege }); ``` This guard **refuses to sell a loss-making route**. From the source: > Merrybills cost us ₦310,749 over 26,829 sales (5 May – 23 Jul 2026) because > they charged full face value while we paid customers 2.5–4% cashback. A failing route returns `300` with *"This service is temporarily unavailable for this network."* The provider is never named to the customer. The guard **fails open** for an unmeasurable biller — it blocks a known-loss route, but it does not block a route it simply has not measured. ### Airtime's own profit calculation `AirtimeService._computeAirtimeProfit` derives: ``` profit = factorPrice × (billerDiscount% − cashback%) ``` This reproduces the historical 1.00% for Ringo (MTN 3.5−2.5, GLO 5−4, AIRTEL 3.5−2.5, 9MOBILE 5−4) from the rate table rather than from a hardcoded constant. --- ## Commission recoupment Before the 6 April 2026 fix, agents received cashback on transactions that later failed. The unrecovered amount is stored per user: ```js user.pendingCommissionReversal ``` After **every** fresh commission credit: ```js applyCommissionRecoupment({ userId, transactionId, creditAmount, type, descSuffix }); ``` It deducts `min(creditAmount, pending, currentCommissionBalance)` — the wallet can never go negative, and the balance is never overdrawn. The update is **optimistic-locked**: ```js updateOne({ _id, commission: currentBal }, { $inc: { commission: -recoup } }) ``` If the balance changed between the read and the write, the update matches nothing and the cycle is skipped. The next commission credit retries it. Every recoupment writes a traceable ledger row with `item_id: 'IP|WH|C|RECOUP||'`, which feeds the daily summary cron. Hook points: `AirtimeService`, `CablesService`, `ElectricityService`, and `CommissionService.creditCommissionOnRetrySuccess`. --- ## `pending_deficit` A non-zero `pending_deficit` on the wallet means money was taken from `available` for transactions that are still being verified. It is the amount currently at risk. It rises when a purchase is held (`code: 300`) and falls when the hold resolves — up on delivery, down on a refund. --- ## Moving money between buckets | Movement | Endpoint | Access | |---|---|---| | Commission → `available` | `PUT /api/v1/wallet/manage-commission` | App/web | | Bonus → `available` | `PUT /api/v1/wallet/manage-bonus` | App/web | | Referral → `available` | `PUT /api/v1/wallet/manage-referral` | App/web | | User → user transfer | `PUT /api/v1/wallet/manage-wallet` | App/web | **None of these are available to the API channel.** They carry `UserNonApiAuth` (or are blocked by the agent-channel wallet guard), so an API-key request gets `403` *"This service is not available for api channel"*. The reason is the trust model: a purchase key is scoped to spending from your own wallet. Moving money *between* accounts is a different, higher-risk operation, and it stays on the app and website where it can carry reCAPTCHA, a wallet PIN and KYC checks. --- ## Funding the wallet | Provider | Endpoint | Body | |---|---|---| | Paystack | `POST /api/v1/wallet/manage-paystack` | `reference` | | Monnify | `POST /api/v1/wallet/manage-monnify` | `transactionReference` | | Flutterwave | `POST /api/v1/wallet/manage-flutterwave` | `flw_ref`, `transaction_id`, `amount` | | Virtual account | `GET /api/v1/wallet/generate-virtual-account` | — | | Payment reference | `GET /api/v1/wallet/generate-wallet-reference` | — | Funding is **app/web only** — an API integration spends from a funded wallet, it does not fund one. Funding credits are protected by the [funding dedup](purchase-lifecycle.md#the-funding-dedup), so a provider webhook that arrives twice cannot credit twice. --- ## Withdrawals `POST /api/v1/wallet/manage-withdrawal` (app/web, requires `amount` and a wallet `pin`) moves money out to a saved bank account. Admin endpoints manage the approval queue. --- ## Related * [Purchase Lifecycle](purchase-lifecycle.md) — where commission is credited * [Wallet & Keys](../api/wallet-keys.md) — the client-facing wallet API * [Data Models](data-models.md) — the wallet and ledger collections --- # data-models Source: https://developer.instapaybill.com/docs/backend/data-models/ # Data Models 89 Mongoose models over MongoDB, plus Redis for locks and short-lived state. This page covers the ones that matter for understanding how the API behaves. --- ## Core money models ### `UserModel` — `users` The identity and permission record. | Field | Description | |---|---| | `email`, `telephone` | Login identifiers | | `firstName`, `lastName`, `slug`, `avatar` | Profile | | `privilege` | `superadmin`, `manager`, `accountant`, `developer`, `reseller`, `topuser`, `user`, `marketer`, `agent` | | `isAdmin` | Boolean admin flag — separate from `privilege` | | `status` / `statusText` | `true`/`activated`, `false`/`suspended`, `pending` | | `isEmailVerified` | Required for the developer portal and for KYC-gated routes | | `BVN`, `NIN`, `bvnNumber`, `ninNumber` | KYC identifiers. Either one satisfies the KYC gate. | | `KYCAttempts` | Verification attempt counter | | `pendingCommissionReversal` | Unrecouped commission from the pre-6-Apr-2026 bug | | `is2FAEnabled` / `is2FASecret` | TOTP 2FA | | `marketingEmails` | Opt-in state | | `tempToken` | Temporary access token for flows like email verification | ### `WalletModel` — `wallets` | Field | Description | |---|---| | `wallet_id` | 8-character public identifier | | `available` | Spendable balance. Floor of 0, enforced by validator and middleware. | | `commission` / `bonus` / `referral` | Earned balances | | `test_available` / `test_commission` / `test_bonus` / `test_referral` | Test-domain twins | | `pending_deficit` | Money at risk in held transactions | | `pending_reason` | Why the deficit exists | | `wallet_pin` | bcrypt hash of the 4-digit PIN | | `whatsapp_otp` / `whatsapp_otp_expires_at` | Single-use purchase OTP for the WhatsApp channel, 10-minute expiry | | `virtualAccounts[]` | Provisioned virtual accounts | | `monnifyAccount` | Monnify reserved account details | ### `WalletHistory` — `walletHistories` The ledger. Every balance movement, in both directions. See [Money & Commissions](money-model.md#the-wallet-ledger) for the fields. ### `TransactionModel` — `transactions` The purchase record. | Field | Description | |---|---| | `item_id` | The internal reference — `IP\|TN\|A\|123\|_` | | `provider` | Which upstream biller served it | | `type` | `airtime`, `data`, `electricity`, `cables`, `education` | | `reference` | The **provider's** reference, when supplied | | `apiRefId` | **Your** reference. Stored only for API-channel purchases. | | `status` / `statusText` | Outcome | | `code` | `200` / `300` / `400` — the authoritative verdict | | `properties` | Service detail: `phone`, `network`, `token`, `unit`, `data_pin`, `customerName`, `customerAddress`, `resData`, `dataRes` | | `deviceProperties` | The device fingerprint at purchase time | | `amount` / `actualAmount` | Face value and the amount actually charged | | `profit` | Derived margin | | `prevBalance` / `balance` | Wallet balance before and after | | `domain` | `live` or `test` | | `channel` | `api`, `app`, `web`, `whatsapp-agent` | | `commission` | The commission ledger row's id | | `transactionType` / `walletType` / `volume` | Bucket-wallet routing detail | | `hold` | `true` when the transaction is held pending confirmation | | `retryInProgress` / `retryStartedAt` / `retryStartedBy` | Retry lock | | `refundInProgress` / `refundClaimedAt` / `refundClaimedBy` | Refund lock | | `originalTransaction` | Set on a server-side retry, so the duplicate guard ignores it | | `case` | Support case reference | | `userDeleted` | Set when the owning user is deleted | ### `PendingTransactionModel` — `transactionIntents` The write-ahead intent, written **before** the provider call. | Field | Description | |---|---| | `statusText` | `pending` while in flight | | `prevBalance` / `balance` | Balance before and after the intended debit | | `debitAttemptedAt` | The debit was attempted | | `debitConfirmedAt` | The debit was confirmed | | `debitAmount` | How much was debited | | `domain` / `channel` / `transactionType` | Routing context | | `reference` | A random per-attempt reference | This is the record that makes crash recovery possible. A row with `debitConfirmedAt` set and no matching `TransactionModel` is the signature of a debit that happened without a settled transaction — the orphan monitor alerts on it. ### `PendingHistoryModel` — `walletOperations` The wallet-operation ledger, used by the manual-funding approval flow. | Field | Description | |---|---| | `amount` / `gain` | The amount moved and the resulting gain | | `data` | Free-form operation detail | | `status` / `statusText` | Approval state | | `domain` | `live` or `test` | | `reference` | The funding reference | | `type` | `virtual`, `transfer`, … | | `provider` | Defaults to `paystack` | | `action` | `approved`, `rejected`, … | | `wallet` / `wallet2` | refPath to `WalletHistory` rows | | `authorizer` | The admin who approved it | | `channel` | The channel the funding came from | --- ## Catalogue models | Model | Stores | |---|---| | `ProductModel` | Products: `airtime`, `data`, `electricity`, `cables`, `education`. Has `enabler`. | | `CategoryModel` | The product × network × biller binding. Holds `biller`, `billerList[]`, `enabler`, `networkBad`. | | `DataPlanModel` | Data plans: `planId`, `network`, `price`, `provider_price`, `validity`, `size`, `category`, `biller`, `enabler`, `available`. | | `DataPriceModel` | Per-biller pricing overrides for a data plan. | | `CablesModel` | Cable plans: `name`, `productsCode`, `packagename`, `amount`, `category`, `image`. | | `ElectricityModel` | Discos: `disco`, `fullname`, `image`, `category`. | | `EducationPlanModel` | Exam types: `type`, `price`, `category`, `image`. | | `StartimesModel` | Startimes packages. | | `NetworkModel` | Networks: `name`, `image`, `bucketWalletEnabled`. | | `BillerModel` | A provider: `billerId`, `name`, `apiKey`, `email`, `password`, `secondApiKey`, `thirdApiKey`, `encryptionKey`, `healthStatus`. | | `BillerFundingModel` | Biller top-up history. | | `GatewayModel` | Payment gateways. | | `ServiceIconModel` | Service icons shown in the app. | | `SliderModel` | App home sliders. | | `FaqModel` | Public FAQs. | | `ContentModel` | Uploaded content. | | `BlacklistModel` | Blocked references by type — e.g. `type: 'telephone'`. | --- ## Access and identity models | Model | Stores | |---|---| | `ApikeyModel` | One row per user: `test_key`, `live_key`, `webhook`. | | `JwtToken` | Session rows: `user`, `token`, `channel`. Presence here is what makes a JWT "active". | | `LoginModel` | Failed-attempt counters and suspension windows. | | `PermissionModel` | `user` + `slug` grants. 29 slugs are in use. | | `UseCaseModel` | The feature-flag document — channel kill switches, amount limits, KYC and restriction switches. | | `SettingsModel` | Commission and cashback rates. | | `AgentModel` | WhatsApp agent credentials, AES-GCM encrypted. | | `BecomeAgentModel` | Agent upgrade applications. | | `ManualAccountModel` | Manual (cash-office) accounts. | | `DeletedUserModel` | Tombstones for deleted accounts. | | `BankModel` / `CardModel` | Saved banks and cards. | | `UserContextModel` | The compact user summary the AI assistant loads. | | `UserLocationModel` | Geo data. | | `IpAllowlistModel` | Superadmin network allowlist. | | `BlocklistModel` | Blocked IPs, devices, user-agents, emails and phones. | | `RiskAssessmentModel` | Per-user risk scores. | | `NotificationEventModel` | OneSignal delivery events. | --- ## Money-adjacent models | Model | Stores | |---|---| | `CgWalletModel` | Bucket-wallet SKUs. | | `CgBalanceModel` | Per-user, per-bucket volume. | | `CgHistoryModel` | Bucket-wallet history. | | `FundingDedupModel` | Unique-indexed funding claims — `reference` = `:`. | | `UnattributedFundingModel` | Funding that could not be matched to a wallet. | | `PendingManualFundingModel` | The manual-approval queue. | | `WithdrawalModel` | Withdrawal requests. | | `BonusModel` | Bonus grants. | | `ReferralModel` | Referral records. | | `PromoCodeModel` / `PromoUsageModel` / `PromoPushModel` | Discount codes, redemptions, and push campaigns. | | `AutoBuyModel` | Scheduled auto-buy jobs. | | `QueuedPurchaseModel` | Queued (scheduled) purchases. | | `GiveawayEntryModel` | Giveaway entries. | | `SprintcheckWebhookLog` | Sprintcheck callback log. | --- ## AI and CRM models | Model | Stores | |---|---| | `AiConversationModel` / `AiMessageModel` | AI assistant conversations. | | `AiEnquiryConversationModel` / `AiEnquiryMessageModel` | Enquiry conversations. | | `AiFeedbackModel` | Thumbs-up/down feedback. | | `CrmTicketModel` / `CrmMessageModel` / `CrmNoteModel` | CRM tickets and their content. | | `CrmTagModel` / `CrmSegmentModel` | Tags and segments. | | `CrmSavedReplyModel` / `CrmReminderModel` / `CrmMacroModel` / `CrmCounterModel` | Agent productivity tooling. | | `CrmAuditLogModel` | CRM audit trail. | | `KbArticleModel` | Knowledge base articles. | | `CustomFieldDefModel` | Custom field definitions. | | `HaloRatingModel` | Agent/customer ratings. | | `WhatsAppConversationModel` / `WhatsAppMessageModel` | WhatsApp inbox. | --- ## Operational models | Model | Stores | |---|---| | `ActivityModel` | The admin activity log. | | `ContactModel` | Contact-form messages. | | `NotificationModel` | User notifications. | | `EmailBounceModel` / `EmailSuppressionModel` | Bounce and suppression handling. | | `MarketingUnsubscribeModel` | Marketing opt-outs. | | `SimGatewayModel` | SIM gateway inventory. | | `SandboxUserModel` | The sandbox profile: `sk_test`, `webhookUrl`, `webhookLastDeliveredAt`, `webhookLastStatus`. | | `SandboxTransactionModel` | Sandbox transactions: `reference`, `service`, `scenario`, `code`, `request`, `response`, `webhookDelivered`, `webhookDeliveredAt`, `webhookStatusCode`. | | `WebhookModel` | Live webhook delivery log: `attempt`, `code`, `response`. | | `AirtimeConverter` / `ConversionNumber` | Airtime-to-cash records. | | `ContentModel` | Uploaded content. | --- ## Pinned collection names ```js // config/financial-collections.js export const financialCollections = [ { model: 'WalletHistory', collection: 'walletHistories' }, { model: 'WalletOperation', collection: 'walletOperations' }, { model: 'TransactionIntent', collection: 'transactionIntents' }, { model: 'DataWalletHistory', collection: 'walletHistories' }, ]; ``` !!! danger "Why these are pinned" Without an explicit collection name, Mongoose pluralises the model name. A rename — `WalletHistory` → `WalletHistories`, say — would silently start writing to a **new** collection, orphaning the entire ledger. Pinning the names makes that impossible. --- ## Redis keys | Key | Purpose | TTL | |---|---|---| | `developer:sso:` | The one-time SSO code | 60 s | | `developer:session:` | The developer portal session | 3600 s | | `purchaselock::::` | The purchase dedup lock | 180 s | | `presence:user:` | CRM presence | 60 s | | Blocklist cache | In-process `Map`, refreshed every 60 s | — | The SSO code is stored under a **hash** of the code, not the code itself, so a Redis dump does not hand out live authorisation codes. --- ## Related * [Money & Commissions](money-model.md) — how the wallet and ledger work * [Purchase Lifecycle](purchase-lifecycle.md) — when each model is written * [Providers & Billers](providers.md) — the `BillerModel` and its credentials --- # providers Source: https://developer.instapaybill.com/docs/backend/providers/ # Providers & Billers The upstream systems that actually deliver the service. The platform is a router: your request goes in, the platform picks a provider, debits the wallet, and dispatches. --- ## The routing model ``` Your request │ ▼ ┌──────────────────────┐ │ Category │ product × network │ ├─ biller │ the primary provider │ ├─ billerList[] │ every provider that can serve it │ ├─ enabler │ 'enable' / 'disable' │ └─ networkBad │ 'enable' = network degraded └──────────┬───────────┘ │ ▼ ┌──────────────────────┐ │ BillerModel │ apiKey, email, password, secondApiKey, │ ├─ healthStatus │ thirdApiKey, encryptionKey │ └─ billerId │ └──────────┬───────────┘ │ ▼ assertBillerHealthy(biller) │ ▼ Provider call ``` ### Step 1 — the category resolves the biller A `CategoryModel` row binds a product and a network to one or more billers. The category is also the unit the **network-health guard** acts on. ### Step 2 — the biller document carries the credentials ```js { billerId: 'merrybills', name: 'Merrybills', apiKey: '…', email: '…', password: '…', secondApiKey: '…', thirdApiKey: '…', encryptionKey: '…', healthStatus: 'ok' // 'down' blocks purchases } ``` ### Step 3 — health is checked before any money moves ```js // utils/billerHealth.js assertBillerHealthy(biller); ``` If `healthStatus === 'down'` the purchase is refused with `300` and *"This service is temporarily unavailable. Please try again in a few minutes."* The **provider's name is never leaked to the customer** — the message says "this service", not "Merrybills is down". The health flag is flipped by a monitoring process that checks each provider every minute. ### Step 4 — the dispatch Data and airtime go through a central dispatcher in `Services/AdminSendDataService.js`: ```js switch (biller.billerId) { case 'glo-world': return requestGloWorld(…); case 'autosyncng': return requestAutoSyncNG(…); case 'ogdams': return requestOgdams(…); case 'chosen': return requestChosen(…); case 'cash2bill': return requestCash2bill(…); case 'inhouse': return requestInhouse(…); case 'ringo': return requestRingo(…); case 'vtuplug': return requestVTUplug(…); case 'infinitytopup': return requestInfinitytopup(…); default: return { code: 300, msg: 'No available biller to process this transaction. Held for manual retry.', hold: true }; } ``` !!! warning "The `default` branch holds, it does not fail" An unregistered biller results in `code: 300` with `hold: true` — the transaction is held for manual retry, not rejected. The money stays debited until an admin resolves it. Electricity, cables and education have their own per-biller branches inside their services. --- ## Content providers ### Merrybills `merrybills.com/api/v2/{airtime,cable}` * **Products:** airtime (MTN, Glo, Airtel and the default product), cable (DStv, GOtv, Startimes, ShowMax — all four product groups come back from a single call), electricity, education. * **Pricing model:** charges **full face value** and pays commission **out of band** into a separate balance. This is why the face value is echoed back in the response, and why the profit calculation has to subtract cashback from the separately-measured commission rate. * **Commission:** 1.2% on cables; per-disco on electricity (EEDC 1.5%, AEDC/BEDC 1.2%, EKEDC/JEDC/KAEDC 1.0%, IBEDC/IKEDC 0.6%). ### Ringo `api.approot.ng/api/agent/p2` * **Products:** airtime, data, electricity, education. * **Auth:** `email` and `password` headers — not a Bearer token. * **Product ids:** `MFIN-5-OR` (MTN), `MFIN-1-OR` (Glo), `MFIN-6-OR` (Airtel), `MFIN-2-OR` (9mobile/T2Mobile). * **Known quirk:** a *"Processing"* response omits `amountCharged`, so the platform falls back to the face value when computing profit. * **Status:** the adapter is registered but dormant — the live services still use their own branch. ### Chosen `thechosendata.com/api/topup/` * **Products:** airtime, data, cables, education. * **Auth:** `Authorization: Token `. * **Response note:** this biller's response includes both `message` and `msg`, so `data.msg` is present for it. ### RPI (RPI Data) `rpidatang.com/api/topup/` * **Products:** airtime, data, cables, education. * **Auth:** `Token `. ### Ogdams `simhosting.ogdams.ng/api/v1/vend/{airtime,data}` * **Products:** airtime, data. * **Network ids:** 1–4. * **Known quirk:** a `code: 424` from Ogdams is translated to `400` — a decline, not a pending state. ### AutoSyncNG `autosyncng.com/api/v1/data/{transfer,corporate,sme}` * **Product:** data. * **Routing:** chosen by `plan.providerNetworkId` — `6` → transfer, `14`/`11`/`13`/`10` → corporate, `4`/`5` → sme, otherwise `/api/v1/data`. * **Callback:** the request posts a `webhook_url` and `pin` so the biller can call back with the result. That is the `POST /api/v1/transactions/manage-autosyncng-webhook` route. ### VTUplug `vtuplug.com/api/airtime`, `/api/data` * **Products:** airtime, data. * **Auth:** `Authorization: Token `. * **Network ids:** 1 = MTN, 2 = Airtel, 3 = Glo, 4 = 9mobile/T2Mobile. * **Minimum:** ₦100 per airtime purchase. ### Cash2bill Data. Has a registered adapter, but the live path still uses the per-biller branch. ### Infinitytopup Data. Same status as Cash2bill. ### Glo World `gift-api.gloworld.com` * **Product:** Glo data only. * **Auth:** `x-api-key` header. * **Response codes:** `resultCode '0000'` maps to `200`, `'0002'` maps to `300`. ### Others | Provider | Products | Notes | |---|---|---| | **SMEPlug** | Airtime | | | **Husmo** | Airtime, data, education | `husmodataapi.com/api/topup/` | | **DataStation** | Airtime | `datastation.com.ng/api/topup/` | | **DataMeSolution** | Airtime | `datamesolution.com/api/topup/` | | **Utilities / in-house** | Airtime, electricity | The platform's own biller. Only used when `AGENT_ACCOUNT_NAME !== 'InstaPayBill'`. | --- ## Payment and funding providers These are for **funding a wallet**, not for buying. | Provider | Purpose | Verification | |---|---|---| | **Paystack** | Wallet funding by reference; callback at `POST /api/v1/wallet/manage-webhook` | Provider signature | | **Monnify** | Virtual account provisioning (`/api/v1/auth/login`, `/api/v1/bank-transfer/reserved-accounts`); callback at `/manage-webhook-monnify` | Provider token | | **PalmPay** | Virtual account provisioning; callback at `/manage-webhook-palmpay` | RSA-SHA1 signature | | **Flutterwave** | Wallet funding (`flw_ref`, `transaction_id`, `amount`); callback at `/manage-webhook-flutterwave` | HMAC over the raw body, timing-safe | | **BudPay** | **Decommissioned 14 June 2026.** Never provisions new accounts. The import remains in the tree but is unused; the callback route still exists. | Provider check | | **easeID** | RSA-signed requests for identity and KYC-adjacent operations | RSA-SHA1 | ### The Flutterwave verification ```js isValidFlutterwaveWebhookSignature(...) // timing-safe HMAC comparison validatedFlutterwavePayment(...) // status 'successful', currency 'NGN', // numeric id, flw_ref present, // email matches, // amount_settled <= amount ``` The `amount_settled <= amount` check matters — it prevents a provider reporting a larger settled amount than was expected from over-crediting a wallet. --- ## Carrier and balance lookups | Partner | Purpose | |---|---| | **MCD / Sprintcheck (Megasprint)** | Phone number → carrier validation, biller balance lookup, and a funding callback with an HMAC-SHA512 `signature` header | | **easeID** | Identity verification | ### The phone→carrier lookup Used by the phone/network match guard on every airtime and data purchase. * Endpoint: `/cac/name` on the MCD reseller host. * Credentials live on the MCD biller document. * Results are cached for **24 hours**. * The check **fails open** — if the lookup service is down, the purchase proceeds rather than being blocked. !!! tip "Why the check exists" Sending an MTN top-up to a Glo number silently fails at the provider and wastes the customer's money. The check catches it before the wallet is debited, with a precise message: *"The phone number 08012345678 belongs to GLO, not MTN"*. --- ## Notification and messaging providers | Provider | Purpose | |---|---| | **OneSignal** | Push notification delivery. Events arrive at `POST /api/v1/webhooks/onesignal`, verified with `x-webhook-secret` or `Authorization: Bearer` against `ONESIGNAL_WEBHOOK_SECRET`. | | **AWS SES / SNS** | Transactional email, plus bounce and complaint feedback at `POST /api/v1/transactions/manage-ses-notifications`, guarded by a topic ARN allowlist. | | **Termii / SMS providers** | OTP delivery | --- ## The provider adapter interface ```js // Services/billers/BillerAdapter.js class BillerAdapter { validate(payload) {} // check the request before dispatch getPlans(category) {} // fetch the plan catalogue purchase(payload) {} // execute the purchase queryStatus(reference) {} // check on a pending transaction getWalletBalance() {} // optional } ``` `BillerFactory` holds the registry and **throws** for an unregistered biller: ```js forBiller(doc) { const adapter = REGISTRY[doc.billerId]; if (!adapter) throw new Error(`No adapter registered for biller ${doc.billerId}`); return adapter; } ``` !!! danger "A new biller must be registered before it can be used" This is a deliberate fail-fast guard. If you add a biller to the database without a registry entry, every purchase routed to it throws instead of silently failing — which would otherwise show up as a mysterious `500` in production. The registered adapters: `merrybills`, `vtuplug`, `autosyncng`, `cash2bill`, `ringo`, `utilities` (in-house), `infinitytopup`. --- ## Health monitoring A background process checks each biller every minute and flips `biller.healthStatus` to `down` when it fails. Purchases against a downed biller are refused with `300` **before** the wallet is debited. The biller's identity is never exposed in the customer-facing message. --- ## Related * [Purchase Lifecycle](purchase-lifecycle.md) — step 6, the provider call * [Money & Commissions](money-model.md) — where the provider's commission rate comes from * [Data Models](data-models.md) — the `BillerModel` document --- # security Source: https://developer.instapaybill.com/docs/backend/security/ # Security & Guards Every guard on the request path, in the order it runs, and what it protects against. --- ## The guard chain ``` Request │ ├─ CORS allowlist browser origins ├─ mongoSanitize NoSQL injection ├─ Device detection fingerprint construction ├─ blocklistGuard blocked IPs / devices / user-agents ├─ authLimiter auth endpoint throttling │ ├─ UserAuth credential + privilege ├─ checkNetworkState network health ├─ UserActive account status ├─ TransactionStatusAndKYCCheck KYC + suspension ├─ UserAuthSouceNecessary channel validity ├─ UserAuthSouceNecessaryChecker channel kill switch ├─ MaximumLimiterPurchase purchase throttling ├─ express-validator field validation │ ├─ requirePermission per-endpoint grants ├─ UserAdminAuth / UserSuperAdminAuth / UserAgentAuth role checks ├─ UserNonApiAuth channel restrictions ├─ UserEmailVerified email verification ├─ ValidateRecaptcha bot protection │ └─ Service-level checks balance, blacklist, duplicates, biller health ``` --- ## CORS ```js // utils/origin-policy.js const DEFAULT = [ 'https://instapaybill.com', 'https://www.instapaybill.com', 'https://admin.instapaybill.com', 'https://crm.instapaybill.com', 'https://developer.instapaybill.com', 'http://localhost:3005', 'http://localhost:3006', 'http://localhost:3007', ]; ``` Overridable with `CORS_ALLOWED_ORIGINS` (comma-separated). ```js app.use(cors({ credentials: true, origin: (origin, cb) => { if (!origin) return cb(null, true); // no Origin → allowed if (isAllowedOrigin(origin)) return cb(null, true); console.warn('CORS blocked origin:', origin); return cb(null, false); }, })); ``` !!! info "No-Origin requests are allowed" Native apps, server-to-server calls and `curl` send no `Origin` header. They are allowed through. Only *unknown browser origins* are blocked — the callback returns `false`, so no CORS headers are emitted at all. `credentials: true` is required for the `__Secure-ipb_developer` cookie. The Socket.IO server applies the same allowlist for both `cors.origin` and `allowRequest`. --- ## The blocklist guard ```js // Middlewares/Blocklist.js app.use(blocklistGuard); ``` It is mounted **before route matching**, so a blocked actor cannot reach even a 404. It checks three signals: | Signal | Notes | |---|---| | Client IP | Trust-proxy aware | | Device fingerprint | SHA-256 over OS, client, device and the IP network prefix | | User-agent | Canonicalised, trimmed, capped at 512 characters | A match returns: ```json { "error": [{ "msg": "Access Denied", "param": "blocklist" }] } ``` Note the key is `param`, not `path`. ### The device fingerprint ```js crypto.createHash('sha256') .update(parts.join('|')) .digest('hex') .slice(0, 32); ``` It combines the attacker-controlled `User-Agent` with the **server-side IP network prefix** — the IP is normalised to a /24 (or /64 for IPv6). That means a fingerprint cannot be forged by changing the user-agent alone, but the same device behind the same network produces a stable fingerprint. ### The hot path is in-memory The blocklist is cached in a `Map` of `Set`s, refreshed every **60 seconds**. Fresh writes prime the cache immediately, so a manual block takes effect without waiting for the next refresh. The guard **fails open** on an unexpected error. ### Auto-detection heuristics | Bucket | Threshold | Window | Reason | |---|---|---|---| | `login-fail` | 10 | 15 min | Excessive failed logins | | `tx-fail` | 20 | 60 min | Excessive failed transactions | | `signup` | 5 | 60 min | Signup flood | Each crossing writes a `suspicious_events` row, pruned after 30 days. !!! warning "Auto-blocking is opt-in and off by default" `BLOCKLIST_AUTOBLOCK=enable` turns it on. It is off by default for a concrete reason, from the source: > our customers sit behind Nigerian carrier-grade NAT, so one public IP can > front thousands of unrelated users. Auto-blocks always skip the user-agent signal, because the mobile app sends one identical user-agent for every customer. --- ## Authentication guards ### `UserAuth` Resolves the caller. See [Authentication](../authentication.md) for the full detail. The key behaviours: * A `Bearer` header with more than two parts is rejected with `403`. * The key's owner must be an agent or an admin. * A JWT must be HS256-signed **and** have a live session row. * A missing `User-Agent` is a `500`. ### Role guards | Guard | Rule | Failure message | |---|---|---| | `UserAdminAuth` | `req.user.isAdmin` | *"Endpoint is available to admins alone"* | | `UserSuperAdminAuth` | `privilege === 'superadmin'` | *"Endpoint is available to super-admins alone"* | | `UserAgentAuth` | `privilege === 'agent'` | *"You need to be an agent to access this endpoint"* | | `UserAdminDevAuth` | `privilege ∈ {developer, superadmin}` | *"Endpoint is available to developer alone"* | | `UserAdminManagerAuth` | `privilege ∈ {manager, superadmin}` | *"Endpoint is available to manager alone"* | | `UserAdminAccountantAuth` | `privilege ∈ {accountant, superadmin}` | *"Endpoint is available to accountant alone"* | | `UserNonAdminAuth` | `!req.user.isAdmin` | *"Endpoint is available to user alone"* | | `UserNonApiAuth` | `req.userAuth !== 'api'` | *"This service is not available for api channel"* | `UserSuperAdminAuth` exists for defence in depth on money-sensitive operations — it is applied **after** `UserAdminAuth`, so an admin who is not a superadmin is still blocked. ### Permission grants ```js // Middlewares/RouteGuards.js export const requirePermission = slug => async (req, res, next) => { if (req?.user?.privilege !== 'superadmin') { const findOne = await PermissionModel.findOne({ user: { $in: [req?.user?._id] }, slug }); if (!findOne) return ErrorResponse(res, 300, 'Permission denied', 'permission'); } next(); }; ``` 29 slugs are in use: `airtimetocash`, `datadetails`, `electricitydetails`, `cablesdetails`, `educationdetails`, `billerdetails`, `networkdetails`, `productdetails`, `categorydetails`, `switchbillercategory`, `settingsdetails`, `usecasedetails`, `sliderdetails`, `notificationdetails`, `faqdetails`, `gatewaydetails`, `becomeagent`, `deletetransaction`, `failsuccesstransaction`, `editprofiledetails`, `switchprofiledetails`, `suspendprofiledetails`, `addadminprofiledetails`, `addbankprofiledetails`, `walletgivebonus`, `walletdebitcredit`, `cgwalletdetails`, `cgwalletdebitcredit`, `cgwalletpurchase`. !!! note "The status code is `300`, the body code is `400`" `ErrorResponse` hardcodes `code: 400` regardless of the HTTP status. See [Errors](../errors.md#a-caveat-on-the-code-field). Branch on the HTTP status. --- ## KYC Two independent gates. ### `TransactionStatusAndKYCCheck` Mounted on the money-movement routes — the five buys, `/schedule/purchase`, the wallet transfer. ```js if (req?.user?.status === false) { return ErrorResponse(res, 403, 'Your account has been suspended. Transactions are not allowed. Please contact support.', 'account_suspended'); } const usecase = await UseCaseModel.findOne(); if (usecase?.kycRequired !== 'disable' && !req?.user?.isAdmin) { const hasKYC = !!req?.user?.BVN || !!req?.user?.NIN; if (!hasKYC) { return ErrorResponse(res, 300, 'Please complete KYC verification (NIN or BVN) to make a purchase.', 'kyc_required'); } } ``` !!! note "Why the middleware's presence is the signal" The source records that this check was previously gated on a hardcoded URL list like `/buy-data`, which never matched the real URLs (`/api/v1/data/buy`). It therefore **silently never enforced**. Now, the fact that the middleware is mounted on the route *is* the signal — a new money route gets the gate by mounting the middleware, and cannot forget to. The kill-switch is `usecases.kycRequired = 'disable'`. ### `RequireAgentKYC` The agent-channel equivalent. Admins bypass. HTTP `300` with `path: 'kyc_required'`. ### The in-service KYC gate A second, independent gate inside `AirtimeService` and `DataService`, scoped to `privilege === 'user'` only. It accepts any of `BVN`, `NIN`, `bvnNumber` or `ninNumber`, returns HTTP `300` with `path: 'kyc'`, and **fails open** on a database error. ### Recipient KYC `UserAuthNINBVNRestriction` applies to wallet transfers when `usecases.ninBvnRestriction === 'enable'`: * The recipient must exist — otherwise `404` *"Receipient not found"*. * The recipient must have KYC — otherwise `300` *"Recipient is yet to complete KYC"*. * The sender must have KYC — otherwise `300` *"Please complete KYC to proceed or contact our support."* --- ## Account status ```js // UserActive if (!['pending', 'activated'].includes(req?.user?.statusText ?? '')) { return res.status(403).json({ error: [{ msg: `Access denied, your account is ${statusText || 'inactive/suspended'}. Contact support for help`, detail: 'Account inactive, either not yet activated or suspended, contact admin', }], }); } ``` Only `pending` and `activated` pass. A `suspended` account gets `403`. --- ## Channel kill switches `UserAuthSouceNecessaryChecker` reads the `UseCaseModel` document and gates by channel: | `req.userAuth` | Field | Failure | |---|---|---| | `app` | `usecases.appStatus` | `300`, param `channel` | | `api` | `usecases.apiStatus` | `300`, param `channel` | | `web` | `usecases.webStatus` | `300`, param `channel_web` | | `admin` | `usecases.adminStatus` | `300`, param `channel` | `UserAuthSouceNecessary` separately validates that the channel is in `{admin, web, app, api, ios-app, whatsapp-agent}`. !!! warning "The API channel can be switched off entirely" `usecases.apiStatus` disables the whole API surface. If your integration suddenly gets `300` *"Service is currently unavailable"* on every endpoint, check `GET /api/v1/usecase/` first. --- ## Network interception ```js // Middlewares/NetworkInterception.js — checkNetworkState ``` Runs on every buy route. It resolves the buy's category — airtime by `_AIRTIME`, data by the plan's category, cables by the cable type's category, electricity by the disco's category, education by the plan's category — and if `category.networkBad === 'enable'`: ```json { "code": 300, "canSchedule": true, "action": "schedule", "error": [{ "path": "network_bad", "msg": "The network for MTN is currently in a bad state, but you can schedule the purchase and we will complete it once the network recovers." }] } ``` Admins bypass. !!! note "Only the customer-facing category blocks the buy" A biller-specific category such as `T2MOBILE_AIRTIME` is a routing label and never blocks customers, even when the customer-facing category is flagged. Otherwise a single degraded biller would take down the whole network for customers. --- ## reCAPTCHA ```js // Middlewares/Recaptcha.js export const ValidateRecaptcha = (expectedAction = 'generic', minScore = 0.5) => { … } ``` Applied to **registration** (`x-recaptcha-token`, expected action `registration`) and **wallet transfers** (action `wallet-transfer`). The threshold is **0.5** — Google's recommended value. It was previously 0.9, which blocked real mobile customers, who typically score 0.5–0.7 on reCAPTCHA Enterprise. Bypassed when `req.agentAuthenticated === true` and for admins. A missing token returns `300`; an action mismatch returns `400`. !!! note "Not on the purchase endpoints" reCAPTCHA guards registration and wallet transfers. The purchase endpoints use the KYC gate, the account-status guard and the rate limiters instead. --- ## Secret comparison Shared secrets are compared in constant time: ```js // utils/secret-compare.js import crypto from 'node:crypto'; export const sameSecret = (a, b) => { const bufA = Buffer.from(String(a ?? '')); const bufB = Buffer.from(String(b ?? '')); if (bufA.length !== bufB.length) return false; return crypto.timingSafeEqual(bufA, bufB); }; ``` Used for the agent key, the cron key and the OneSignal webhook secret. --- ## Webhook SSRF protection Outbound webhook targets must resolve to **public** addresses: ```js // utils/public-url.js parsePublicUrl(raw) // parse and validate publicLookup(hostname) // resolve DNS publicHttpAgent // pins the resolved IP publicHttpsAgent ``` The hostname is resolved **once** and the IP is pinned into the HTTP agent — so a DNS rebinding attack that changes the answer between the check and the request cannot redirect the call to an internal address. Also: redirects are disabled, proxies are disabled, the timeout is 10 seconds, and the body is capped at 1 MB. A blocked target records a webhook row with `code: 400` and `response: { error: 'blocked: webhook must be a public http(s) URL' }` — and no request is made. --- ## Input and body limits | Limit | Value | Where | |---|---|---| | JSON body | 10 MB | `express.json({ limit: '10mb' })` | | URL-encoded body | 10 MB | `express.urlencoded()` | | File upload | 10 MB | `express-fileupload` | | Sandbox webhook body | 1 MB | — | | Live webhook body | 1 MB | — | | OneSignal body | 32 KB | — | | SES body | 256 KB | — | `mongoSanitize()` runs globally, stripping `$`-prefixed keys to block NoSQL injection. --- ## Helmet and CSP ```js app.use(helmet({ contentSecurityPolicy: { directives: { 'frame-ancestors': "'none'", imgSrc: ["'self'", 'https://res.cloudinary.com', 'https://api.instapaybill.com', '*'], }, }, })); ``` `frame-ancestors: 'none'` blocks the API being framed by any site. --- ## The agent and cron key headers | Header | Secret | Used by | |---|---|---| | `x-agent-key` | `IP_AGENT_KEY` | `/api/v1/agent/*` — the WhatsApp assistant | | `x-ip-api-key` | `IP_CRON_API_KEY` | `/api/v1/cron/*` — the 18 scheduled operations | Both are compared in constant time. A mismatch returns `401`: ```json { "error": [{ "msg": "Unauthorized", "path": "agent" }] } ``` !!! danger "Never expose these to a client" Both are shared server-to-server secrets with no per-caller scoping. The agent key grants purchase capability on behalf of any customer whose phone number is known. Keep both in a secret manager. --- ## Session revocation A JWT that is still cryptographically valid but has been revoked returns `401`: ```js if (!(await hasActiveSession(user?.id, token, tokenAuth))) { return res.status(401).json({ error: [{ msg: 'Session expired. Please sign in again.', path: 'token' }] }); } ``` Revocation happens on logout, on role removal, on password change, and on expiry. A revoked session cannot be resurrected by replaying the token. --- ## Rate limiting See [Rate Limits](../rate-limits.md) for the numbers. In summary: | Scope | Limit | |---|---| | Group limiters | 60 / 120 / 300 per minute | | Purchase limit (session channels) | 1 request / 5 seconds | | Auth endpoints | 50 per 15 minutes | | Password reset | 3 per 15 minutes | | Agent OTP | 5 per 10 minutes per phone | | Biller callbacks | 240 per minute | --- ## Related * [Authentication](../authentication.md) — the credential model * [Developer Portal SSO](developer-sso.md) — the portal's own security model * [Rate Limits](../rate-limits.md) — budgets and the duplicate guard --- # developer-sso Source: https://developer.instapaybill.com/docs/backend/developer-sso/ # Developer Portal SSO The [developer portal](https://developer.instapaybill.com) is a separate browser application that needs to know which InstaPayBill user is signed in — without ever holding that user's website session. It uses a **PKCE + authorization-code** handshake with a tab-scoped binding, and ends in a host-only cookie. --- ## The problem it solves The customer website and the developer portal are different origins. A cookie set on `instapaybill.com` is not sent to `developer.instapaybill.com`. The naive solutions are all bad: | Naive approach | Why it fails | |---|---| | Forward the website JWT to the portal | Leaks the main session token to a second origin | | Put the JWT in a URL fragment | Leaks into browser history, referrers and logs | | A shared "developer login" | A second credential to secure, and it bypasses agent approval | | Open the portal in the same tab | The user loses their place in the app | --- ## The handshake ``` ┌────────────┐ ┌────────────┐ ┌────────────┐ │ Developer │ │ API │ │ Customer │ │ portal │ │ │ │ website │ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │ │ │ 1. generate verifier + state │ │ │ challenge = S256(verifier) │ │ │ │ │ │ 2. redirect ────────────────────►│ │ │ /developer?challenge&state │ │ │ │ 3. authorize ────────────►│ │ │◄─── code (60 s, one-time) │ │ 4. redirect with #code ─────────│ │ │ │ │ │ 5. POST /sandbox/auth/sso/exchange │ │ { code, verifier } │ │ │ │ 6. verify PKCE │ │ │ set session cookie │ │◄── Set-Cookie + user ──────────│ │ │ │ │ │ 7. GET /sandbox/auth/me ────────►│ │ │◄── { user } ────────────────────│ │ ``` ### Step 1 — the portal generates the PKCE pair ```js const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))); const state = base64url(crypto.getRandomValues(new Uint8Array(32))); const challenge = base64url(new Uint8Array( await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) )); sessionStorage.setItem('developer_sso', JSON.stringify({ verifier, state, createdAt: Date.now() })); ``` The verifier and state live in **tab-scoped session storage** — they are cleared when the tab closes and are not shared with other tabs. ### Step 2 — redirect to the customer website ``` https://instapaybill.com/developer?challenge=…&state=… ``` The user authenticates normally on the website they already have a session for. ### Step 3 — the API issues a one-time code ```http POST /api/v1/sandbox/auth/sso/authorize Origin: https://instapaybill.com Authorization: Content-Type: application/json { "challenge": "<43-char S256 challenge>" } ``` Guards: `customerOrigin` → `UserAuth` → `UserNonApiAuth` → `issueSsoCode`. The API verifies the challenge format, checks the channel is `web`, and confirms the website session row exists in `JwtToken`. It then stores a 32-byte base64url code in Redis: ``` developer:sso: TTL 60 s SET NX ``` `SET NX` means a second authorization attempt with the same code cannot overwrite the first. Response: ```json { "msg": "SSO authorized", "data": { "code": "<43 chars>" } } ``` ### Step 4 — the portal receives the code The website redirects back with the code in the **URL fragment**: ``` https://developer.instapaybill.com/sso/callback#code=…&state=… ``` The fragment is never sent to a server, so the code is not in the server access log, not in the referrer, and not visible to the website. ### Step 5 — the portal exchanges the code ```http POST /api/v1/sandbox/auth/sso/exchange Origin: https://developer.instapaybill.com Content-Type: application/json { "code": "<43 chars>", "verifier": "<43-128 chars>" } ``` The API: 1. Fetches and **deletes** the code with an atomic Redis `GETDEL` — a code works exactly once. 2. Recomputes `S256(verifier)` and compares it to the stored challenge. 3. Creates the sandbox profile if it does not exist (generating `sk_test_<64 hex>`). 4. Signs a session JWT and stores `developer:session:` in Redis with a 3600-second TTL. 5. Sets the cookie. ### Step 6 — the cookie ```js const cookieOptions = { httpOnly: true, secure: true, sameSite: 'strict', path: '/api/v1/sandbox', maxAge: 60 * 60 * 1000, }; res.cookie('__Secure-ipb_developer', sessionToken, cookieOptions); ``` | Property | Value | Why | |---|---|---| | `httpOnly` | `true` | JavaScript can never read it — XSS cannot steal it | | `secure` | `true` | HTTPS only. The `__Secure-` prefix enforces this | | `sameSite` | `strict` | Never sent on a cross-site request | | `path` | `/api/v1/sandbox` | Scoped to the sandbox routes only | | `maxAge` | 1 hour | Matches the server-side session TTL | The session JWT payload: ```json { "uid": "", "sid": "", "type": "developer-session", "aud": "instapaybill-developer", "iss": "instapaybill-api" } ``` ### Step 7 — the portal reads its identity ```http GET /api/v1/sandbox/auth/me Origin: https://developer.instapaybill.com Cookie: __Secure-ipb_developer=… ``` ```json { "data": { "user": { "id": "", "email": "agent@example.com", "name": "Ada Okafor", "privilege": "agent" } } } ``` --- ## The agent gate Three conditions, checked on every portal request: ```js // Services/DeveloperSsoService.js if (user.privilege !== 'agent') throw 403 'Become an approved agent to access the developer sandbox.'; if (user.statusText !== 'activated' || user.status === false) throw 403 'Your agent account must be active.'; if (user.isEmailVerified !== true) throw 403 'Verify your email before opening the developer sandbox.'; ``` `developerSessionAuth` re-validates the **original website session** on every request, not just at exchange time: ```js const { user, sandboxUser } = await developerSso.session(req.cookies?.[COOKIE]); req.user = user; req.sandboxUser = sandboxUser; req.userAuth = 'web'; req.userDomain = 'live'; ``` !!! danger "This is what makes revocation immediate" If the user logs out of the main website, has their role removed, or their account is suspended, the **next portal request** fails — even though the portal cookie is still valid and an hour has not passed. The website session is the root of trust; the portal session is a derived, revocable capability. --- ## Origin enforcement Two separate origin guards, each with an exact allowlist: ```js export const developerOrigin = (req, res, next) => { if (req.get('Origin') !== portalOrigin()) return res.status(403).json({ error: [{ msg: 'Use the developer sandbox website.', path: 'origin' }] }); res.set('Cache-Control', 'no-store'); next(); }; export const customerOrigin = (req, res, next) => { if (!customerOrigins().includes(req.get('Origin'))) return res.status(403).json({ error: [{ msg: 'Open the sandbox from the customer website.', path: 'origin' }] }); res.set('Cache-Control', 'no-store'); next(); }; ``` | Guard | Accepts | Used by | |---|---|---| | `customerOrigin` | `https://instapaybill.com`, `https://www.instapaybill.com` | `POST /sandbox/auth/sso/authorize` | | `developerOrigin` | `https://developer.instapaybill.com` | Every other `/sandbox/*` route | The origins are configurable via `CUSTOMER_WEB_ORIGIN` and `DEVELOPER_PORTAL_ORIGIN`. !!! note "The CORS allowlist is necessary but not sufficient" `https://developer.instapaybill.com` must also be in `CORS_ALLOWED_ORIGINS` for the cookie to be sent. But the exact-match `developerOrigin` guard is what actually enforces the policy — the CORS list is a browser-level control, the origin guard is a server-level one. --- ## The portal API All of these require the developer session cookie **and** the exact portal `Origin`. | Method | Path | Purpose | |---|---|---| | `GET` | `/api/v1/sandbox/auth/me` | Current identity | | `POST` | `/api/v1/sandbox/auth/logout` | Revoke the session and clear the cookie | | `GET` | `/api/v1/sandbox/keys` | Sandbox key, webhook URL, delivery status, counters | | `POST` | `/api/v1/sandbox/keys/regenerate` | Rotate the sandbox key | | `POST` | `/api/v1/sandbox/keys/webhook` | Set the sandbox webhook URL | | `GET` | `/api/v1/sandbox/live-keys` | Live key and webhook shown in the portal | | `POST` | `/api/v1/sandbox/live-keys/regenerate` | Generate or rotate the live key | | `POST` | `/api/v1/sandbox/live-keys/webhook` | Set the production webhook URL | | `DELETE` | `/api/v1/sandbox/live-keys/webhook` | Clear the production webhook URL | | `GET` | `/api/v1/sandbox/transactions` | Sandbox transaction list | | `GET` | `/api/v1/sandbox/transactions/{id}` | Sandbox transaction detail | The purchase endpoints under `/api/v1/sandbox/*` use the **sandbox key**, not the session cookie — that is what makes them usable from a script. ### Standalone accounts are disabled ```js const ssoRequired = (_req, res) => res.status(403).json({ error: [{ msg: 'Sign in through your approved InstaPayBill agent account.', path: 'agent' }], }); router.post('/auth/register', apiLimiter, ssoRequired); router.post('/auth/login', apiLimiter, ssoRequired); ``` Both routes exist and both always return `403`. There is no standalone sandbox signup or password login — a sandbox account can only exist because an approved agent signed in through the website. --- ## Logout ```js export async function developerLogout(req, res) { if (req.cookies?.[COOKIE]) await developerSso.logout(req.cookies[COOKIE]); res.clearCookie(COOKIE, cookieOptions); return res.json({ msg: 'Signed out' }); } ``` The Redis session row is deleted, so the token is invalid immediately — not merely when the cookie expires. --- ## Security properties | Property | How | |---|---| | The website JWT never reaches the portal | The portal only ever receives a short-lived one-time code | | The code cannot be replayed | Redis `GETDEL` — atomic single use | | The code cannot be stolen and used elsewhere | Bound to the PKCE challenge; the exchanger must prove possession of the verifier | | The code cannot be hijacked by another tab | Bound to the tab's `state`, checked in the client before the exchange | | The code expires quickly | 60-second TTL in Redis | | The portal session cannot be read by JavaScript | `httpOnly` cookie | | The portal session cannot be replayed from another site | `sameSite: 'strict'` | | The portal session is revocable at any moment | The original website session is re-validated on every request | | The portal session is scoped | `path: '/api/v1/sandbox'` | | Responses are not cached | `Cache-Control: no-store` on every portal route | --- ## Related * [Authentication](../authentication.md) — the REST credential model * [Sandbox](../api/sandbox.md) — the endpoints the portal drives * [Security & Guards](security.md) — the wider guard model