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: <jwt> |
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.
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.
curl https://api.instapaybill.com/api/v1/wallet/manage-wallet-balance \
-H "Authorization: Bearer $IPB_LIVE_KEY" \
-H "User-Agent: my-integration/1.0"
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 <key>¶
- The prefix is case-sensitive:
Bearer, notbearer. - Exactly one space.
Bearer sk_live_x(two spaces) splits into three parts and is rejected with403. - No extra segments —
Bearer sk_live_x extrais rejected with403.
Privilege requirement¶
The key's owner must be an agent or an admin:
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:
This affects non-browser clients (servers, CLI tools, agents). Send something identifiable:
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.
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:
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:
The backend verifies it with HS256 against ACCESS_TOKEN_SECRET and then
checks that the session row still exists in the jwttokens collection:
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 |
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".
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.
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();
};
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 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/*returns403with "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 for the full surface.
The developer portal session¶
The developer portal does not use any of the above. It uses a PKCE + SSO handshake that ends in a host-only cookie.
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 |