Integration guide: REST application flow (Path B)#
This guide takes a developer from zero to a working integration using the self-service portal and the application REST API. It is one of two supported paths. The other is the operator-registered, hosted OIDC flow in the hosted integration guide (Path A). Both paths are fully supported and produce the same tenant-context tokens and the same RBAC.
Choosing a path#
| Path A: hosted OIDC | Path B: REST application flow (this guide) | |
|---|---|---|
| Application registered by | A platform operator | You, through /portal |
| End-user sign-up and sign-in | On this service's hosted pages; your backend never sees a password | Your backend posts credentials to /tenant/user/register and /tenant/user/login |
| Your backend credential | X-API-Key with onboarding scopes |
clientId + API key, exchanged for a short-lived signed token |
| Protocol | OIDC authorization code + PKCE, then /auth/context |
Plain JSON over HTTPS |
| Users and tenants | One identity, many tenants, chosen at login | Every end user joins your workspace's single tenant |
| Billing, budget, KYC add-ons | Not applicable | Built in |
| Pick it when | You want the strongest isolation of end-user credentials, multiple customer tenants per application, or passkeys on hosted pages | You want the fastest start, your own login UI, and a single tenant per application |
Rules that hold on both paths:
- user-mgt knows nothing about your product. Permission keys are opaque strings you define, and roles and permissions belong to exactly one tenant.
- Creating a workspace creates one protected
TENANT_ADMINrole and assigns it to the creator. NoTENANT_MEMBER(or any other) role is created for you. - A tenant can never read or manage another tenant's roles, permissions or members.
Part 1: Developer setup (once, in the portal)#
Open https://<service-host>/portal. It is a static page with a strict CSP;
everything below can also be done directly against the JSON endpoints listed
(each needs the developer's bearer token from step 2).
1. Register and verify your email#
POST /auth/register with { "email", "password" } (password 8-128 characters).
A verification link is emailed; open it and confirm. If the email does not arrive,
POST /auth/resend-verification with { "email" } (5 per hour).
2. Sign in#
POST /auth/login with { "email", "password" } returns
{ user, tokens: { accessToken, accessTokenExpiresAt, refreshTokenId, refreshToken, ... } }.
Send Authorization: Bearer <accessToken> on every /portal/* call. These routes
only ever act on applications and cards that identity owns; an application ID you do not own behaves as if it does not exist (404).
3. Create an application#
POST /portal/applications with { "name": "My App" } returns 201 and a dashboard.
In one transaction this creates your application, its client, your home tenant,
the protected TENANT_ADMIN role and your membership in it. Keep these three
values (they are always shown on the dashboard):
| Value | Meaning |
|---|---|
clientId |
Your client identifier. It has the same value as tenantId |
tenantId |
The tenant all your end users join |
applicationId |
Sent as X-Application-Id on every request |
One login can own several applications (the default limit is 10, operator-configurable). Each
has its own IDs, keys, budget, features and KYC settings; cards and the collection
currency belong to the account and are shared. GET /portal/account lists your
applications and billing; GET /portal/applications/:applicationId returns one
dashboard. Every per-application call below lives under
/portal/applications/:applicationId/....
4. Add a payment card (and a backup)#
A card lifts the usage cap (see Usage tiers). You pay on a payment provider's own secure page, so card details never touch this service.
GET /portal/payment-providerslists the providers you can choose from ({ providers: [{ id, name, imageUrl? }] }).POST /portal/cards/startwith{ "provider": "<id>", "currency": "USD" | "NGN" }returns201 { reference, redirectUrl, pay }. Send the browser toredirectUrl.- After paying, the provider sends the browser back to
/portal?payment=<reference>. The portal then callsPOST /portal/payments/<reference>/verify, which returns{ status: "SUCCEEDED" | "PENDING" | "FAILED" }. A payment whose browser never returned is settled automatically within a few minutes.
- Verification charge. The payment is a small charge (by default USD 0.15 or NGN 100) that is refunded after 48 hours. A declined or abandoned payment stores no card. When the provider tokenizes the card on this first payment, the token is kept (never shown) for future use.
- Backup cards. Add more than one (up to 5).
PUT /portal/cards/:id/defaultchooses the default.DELETE /portal/cards/:idremoves a card only while another verified card remains; removing the last one returns409 LAST_PAYMENT_METHOD. - Collection currency. Billing is always in USD.
PUT /portal/billingwith{ "collectionCurrency": "USD" | "NGN" }chooses which currency payments are made in (NGN uses the operator's exchange rate).
Status today: staging offers two sandbox providers (one always succeeds, one always
declines). Real providers appear in the list once the payment aggregator is connected; until
then, with no gateway configured, the list is empty and starting a payment returns
503 PAYMENT_GATEWAY_UNAVAILABLE.
Top up the identity-check wallet#
NIN and BVN checks are paid from a prepaid wallet (see the wallet notes under Features below).
POST /portal/topups/start with { "provider", "currency": "USD" | "NGN", "amountUsdMinor": 1000 }
(USD cents; operator-set minimum and maximum) returns the provider checkout URL exactly like a
card payment, and the same verify call credits the wallet once. In NGN you pay the equivalent
at the operator's rate and the wallet is credited in USD. GET /portal/wallet shows the balance
and ledger.
5. Generate an API key#
POST /portal/applications/:applicationId/api-keys with { "name": "backend", "expiresInDays": 90 }
(1-365, default 90) returns 201:
{ "id": "...", "apiKey": "umk_<id>.<secret>", "scopes": ["tokens:issue"],
"expiresAt": "...", "clientId": "...", "applicationId": "..." }
The secret is shown once; store it in your backend's secret manager. A card is
not required to create a key, but without a verified card the application is on
the smallest usage tier. You may hold up to 10 active keys per application. Manage keys under
/portal/applications/:applicationId/api-keys/:id:
| Action | Call | Effect |
|---|---|---|
| Rotate | POST .../rotate with { "graceHours": 24, "expiresInDays": 90 } |
Issues a replacement (secret shown once). The old key keeps working for the grace period (0 to 168 hours; 0 retires it immediately) |
| Revoke | POST .../revoke |
Ends the key at once, including tokens already issued from it |
| Delete | DELETE ... |
Permanently removes a revoked or expired key (409 KEY_STILL_ACTIVE otherwise); the audit event is kept |
Secrets are stored only as hashes, so a lost key cannot be recovered, only rotated.
Usage tiers and billing#
Every call made on behalf of your application (the /application/auth exchange,
every call carrying your application token, and calls with your end users' tenant
tokens) is one request. Health checks, documentation, discovery and the developer
portal itself are not attributable to an application and are not counted.
| Tier | Condition | Allowance | Over the allowance |
|---|---|---|---|
| Unverified | No verified card | 10 requests per day | 429 QUOTA_EXCEEDED |
| Testing | Verified card, mode TESTING |
1,000 requests per month | 429 QUOTA_EXCEEDED |
| Production | Verified card, mode PRODUCTION |
1,000 free requests per week | Billed at USD 1 per 1,000 requests; never blocked |
Windows are UTC (day, calendar month, week starting Monday). The 429 body carries
details: { tier, limit, window, resetsAt, upgrade }. Switch modes with
PUT /portal/applications/:applicationId/mode { "mode": "PRODUCTION" }; going live needs a verified card
(409 VERIFIED_CARD_REQUIRED). All limits and prices are set by the service
operator, so read the live values from GET /portal/applications/:applicationId (plan) rather
than hard-coding them. Feature calls such as NIN/BVN validation are billed
separately from your wallet at their own unit prices, shown on the dashboard in USD.
6. Optional: budget, features and KYC#
- Budget:
PUT /portal/applications/:applicationId/budgetwith{ "amountMinor", "durationDays", "alertThresholdPercent" }. Amounts are minor units (US cents), at least 100 (USD 1.00); duration 1-365 days; threshold 1-99. You are emailed once when usage reaches the threshold and once at 100% of each period. Budgets only alert and never block calls.DELETE .../budgetremoves it. - Features:
PUT /portal/applications/:applicationId/features/:keywith{ "enabled": true }fornin_validationorbvn_validation. Each validation call is billed at the unit price shown on the dashboard. - KYC requirement:
PUT /portal/applications/:applicationId/kycwith{ "required": true, "methods": ["nin"] }. Methods must have their feature enabled first (409otherwise). When required, end users cannot log in until they pass one accepted method. GET /portal/applications/:applicationIdreturns everything above plus current-period usage.
Part 2: Backend integration (every request)#
7. Exchange credentials for a signed token#
POST /application/auth
Authorization: Basic base64("<clientId>:<apiKey>")
curl -sS -X POST "$BASE/application/auth" \
-H "Authorization: Basic $(printf '%s:%s' "$CLIENT_ID" "$API_KEY" | base64 -w0)"
Response 200:
{ "accessToken": "<RS256 JWT>", "tokenType": "Bearer",
"expiresAt": "2026-01-01T00:15:00.000Z", "applicationId": "...", "tenantId": "..." }
The token lives 15 minutes by default (PORTAL_APPLICATION_TOKEN_TTL_SECONDS,
60-3600). Cache it in memory and request a new one shortly before expiresAt.
Never log it and never ship the API key or token to a browser or mobile client:
call this endpoint from your server only.
Bad credentials return an indistinguishable 401. Repeated failures from one IP
are progressively delayed, then rate limited (429).
8. Call every other endpoint with two headers#
Authorization: Bearer <accessToken>
X-Application-Id: <applicationId>
X-Application-Id must equal the application inside the token. On every request
the service re-checks that the key is still active and that the application and
tenant are ACTIVE. Any failure is 401. On 401, request a fresh token once;
if that also fails, the key was revoked or expired.
The tenant always comes from the verified token. Never send a tenant ID in a body to select one.
9. Register an end user#
POST /tenant/user/register
Authorization: Bearer <accessToken>
X-Application-Id: <applicationId>
Content-Type: application/json
{ "email": "user@example.com", "password": "at-least-8-characters" }
201:
{ "user": { "id": "...", "email": "...", "status": "PENDING", "emailVerified": false },
"tenantId": "...", "membershipId": "...", "status": "verification_sent",
"kycRequired": false, "kycMethods": [] }
The user receives a verification email and is a member of your tenant with no
roles. An email that is already registered in your application returns 409. The same email in
another application, or in the developer portal, is a different account and does not conflict:
your users' accounts exist only inside your application. Show your user a "check your email" screen.
10. Verify KYC if you require it (optional)#
If KYC is required and the matching feature is enabled:
POST /kyc/nin { "number": "12345678901", "userId": "<user.id>" }
POST /kyc/bvn { "number": "12345678901", "userId": "<user.id>" }
200: { "method", "valid", "maskedName"?, "userId"?, "charged" }. Numbers are
exactly 11 digits. With userId, the result is recorded for that member, and only
a keyed hash of the number is kept, never the number. Errors: 403 FEATURE_DISABLED,
404 (unknown user), 503 KYC_PROVIDER_UNAVAILABLE. Each answered check is paid from your prepaid identity-check wallet (default USD 0.07 per check), valid or not; a check the vendor could not answer is not charged. When the wallet is at or below the minimum (default USD 0.10) checks are refused with 402 WALLET_INSUFFICIENT until you top up. You are emailed as the wallet is used up (at 50, 75, 80, 90, 95 and 99 percent by default).
Check status any time with GET /kyc/users/:userId:
{ userId, required, acceptedMethods, verified, satisfied }.
Staging uses a sandbox provider (a number is valid unless it ends in 0) until the
operator enables the Dojah adapter, which checks real NIN and BVN records. A
lookup that fails because the vendor is unavailable returns 503 and is not billed.
11. Log an end user in#
POST /tenant/user/login
Authorization: Bearer <accessToken>
X-Application-Id: <applicationId>
{ "email": "user@example.com", "password": "...", "deviceName": "web" }
200:
{ "user": { "id": "...", "email": "...", "applicationOwner": false },
"context": { ... tenant, membership, roles, permissions ... },
"tokens": { "accessToken": "...", "accessTokenExpiresAt": "...",
"refreshTokenId": "...", "refreshToken": "...", "refreshTokenExpiresAt": "..." } }
| Status / code | Meaning and what to do |
|---|---|
401 |
Wrong password, unknown user or not a member of this tenant (deliberately indistinguishable). Do not reveal which |
403 EMAIL_NOT_VERIFIED |
Ask the user to open the emailed link. Use POST /auth/resend-verification |
403 KYC_REQUIRED |
details carries { userId, methods }. Run step 10, then retry |
429 |
Back off. Failed logins are throttled per IP and per account |
The returned tokens.accessToken is the end-user session token for your own
product. Keep it server-side in your session store and give the browser a Secure,
HttpOnly, SameSite cookie.
The application owner. The developer who created the application in the portal
can sign in to it through this same endpoint with their portal email and password.
The response has the same shape, with user.applicationOwner: true (always false for end users); the context is the application's home tenant
(applicationHome: true) with the owner's TENANT_ADMIN authority, so your
product can recognise its own administrator. This applies only to the owner of this
application: another developer's platform account is rejected like any other
unknown user, and an end user who registers with the owner's email is a separate
account that keeps its own password. The owner needs a verified portal email, and is
not subject to the end-user identity-check requirement (step 10). Everything else
on this page is unchanged.
12. Refresh an end-user session#
POST /auth/context/refresh with { "refreshTokenId", "refreshToken" } returns a
new { context, tokens }. The old pair is consumed; persist the new pair atomically
and serialize concurrent refreshes. Replaying a consumed pair revokes the whole
session family, so never retry a refresh blindly.
13. Authorize requests in your own API#
Your product's API receives the end-user session token. Two ways to validate it:
- Local verification (fast, no network per request): verify the RS256
signature against the JWKS from
/.well-known/openid-configuration, then check issuer, audience (your application'saudience, shown on the dashboard), expiry andscope. The source helperdoes this and enforces required permissions. It cannot see server-side revocation until the token expires.TenantTokenVerifier - Live introspection (sees revocation):
POST /internal/tokens/introspect, which needs an application service token. Service-client provisioning is still operator managed.
Whichever you use, every read or write in your product must be scoped by the
token's application_id and tenant_id and must check the required opaque
permission. TenantBoundRepository in the same SDK folder provides scoped
MongoDB query helpers.
14. Define permissions and roles for your product#
Authority over roles and permissions belongs to the TENANT_ADMIN. Open a session for
your own application with POST /portal/applications/:applicationId/tenant-session
(using your developer sign-in; no end-user login is involved) to get an admin
session token, then use the tenant administration routes with
Authorization: Bearer <that token>:
| Operation | Endpoint |
|---|---|
| Register / list / deprecate permission keys | POST, GET /tenant/permissions, DELETE /tenant/permissions/:key |
| Create / list / update / delete custom roles | POST, GET /tenant/roles, PUT, DELETE /tenant/roles/:roleId |
| List members; assign roles, suspend, remove | GET /tenant/members, PATCH /tenant/members/:membershipId |
| Invite someone with explicit roles | POST /tenant/invitations, DELETE /tenant/invitations/:id |
| Review administration history | GET /tenant/audit |
Typical bootstrap order: register your permission keys, create roles that bundle
those keys (1-64 per role), then assign roles to members (1-10 per member).
Registering or changing permissions invalidates the administrator's own session,
so open a new tenant-session after registering keys and before creating roles;
a stale token returns 401. Keys
have no required format and user-mgt never interprets them. TENANT_ADMIN does
not inherit your business permissions; give an administrator a custom role as well
if they need business access. After any role or permission change, affected
sessions are invalidated, so refresh or log in again.
Errors#
Every error is { "error": { "code", "message", "details"? } }.
| Status | Typical cause |
|---|---|
400 |
Malformed input or missing required header (for example no Authorization) |
401 |
Bad or expired token, mismatched X-Application-Id, revoked key |
403 |
EMAIL_NOT_VERIFIED, KYC_REQUIRED, FEATURE_DISABLED, or insufficient authority |
404 |
Unknown user, card, key or feature |
402 |
WALLET_INSUFFICIENT for an identity check when the wallet is at or below the minimum |
409 |
Duplicate email, LAST_PAYMENT_METHOD, VERIFIED_CARD_REQUIRED, limits reached |
429 |
Rate limited, throttled or QUOTA_EXCEEDED; wait or change tier |
503 |
PAYMENT_GATEWAY_UNAVAILABLE or KYC_PROVIDER_UNAVAILABLE (provider not configured) |
Network failures and timeouts: fail closed. Do not retry registrations or refreshes without checking state first.
Minimal backend example (Node 20+)#
const BASE = process.env.IDENTITY_BASE_URL!;
const APP_ID = process.env.IDENTITY_APPLICATION_ID!;
let cached: { token: string; expiresAt: number } | undefined;
async function appToken(): Promise<string> {
if (cached && cached.expiresAt - Date.now() > 30_000) return cached.token;
const basic = Buffer.from(`${process.env.IDENTITY_CLIENT_ID}:${process.env.IDENTITY_API_KEY}`).toString('base64');
const res = await fetch(`${BASE}/application/auth`, { method: 'POST', headers: { authorization: `Basic ${basic}` } });
if (!res.ok) throw new Error(`application auth failed: ${res.status}`);
const body = await res.json() as { accessToken: string; expiresAt: string };
cached = { token: body.accessToken, expiresAt: Date.parse(body.expiresAt) };
return cached.token;
}
async function call(path: string, body: unknown) {
return fetch(`${BASE}${path}`, {
method: 'POST',
headers: { authorization: `Bearer ${await appToken()}`, 'x-application-id': APP_ID, 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
export const registerUser = (email: string, password: string) => call('/tenant/user/register', { email, password });
export const loginUser = (email: string, password: string) => call('/tenant/user/login', { email, password });
IDENTITY_* are your backend's own variable names, not service settings.
Security checklist#
- Keep the API key and application token on your server only; HTTPS everywhere.
- Treat
401from/application/authas a configuration or revocation problem, not something to retry in a loop. - Do not echo login failure reasons to end users beyond what the table above allows.
- Store end-user session tokens server-side behind an HttpOnly cookie, with CSRF protection on your own mutation routes.
- Rotate API keys on a schedule; revoke any key suspected of exposure immediately.
- Remember that your backend handles raw end-user passwords on this path. If you do not want that, use the hosted flow.
Operator notes#
Settings that affect this path are documented in
rest-developer-portal.md:
PORTAL_PAYMENT_PROVIDER, PORTAL_KYC_PROVIDER,
PORTAL_APPLICATION_TOKEN_TTL_SECONDS, PORTAL_KYC_HASH_KEY. Sandbox adapters are
refused in production. Interactive API reference is at /docs in staging.