Skip to content

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, 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:

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:

{ "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.

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:

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:

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".

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: <IP_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 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