# 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