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 the wallet 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; the wallet (step 4) belongs
to the account and is 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. Fund your wallet#
Everything billable is paid from one prepaid wallet per account, shared by all your applications: NIN and BVN checks, and production requests beyond the free allowance (see Usage tiers). The first successful top-up also lifts the starter usage cap and lets you take an application to production. 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/topups/startwith{ "provider": "<id>", "currency": "USD" | "NGN", "amountMinor": 2500 }returns201 { reference, redirectUrl, pay, creditMicroUsd }. 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, and a wallet is credited exactly once per payment.
- Any amount, with a minimum. Choose the amount in the currency you pay in (
amountMinoris cents or kobo): at least USD 25 or NGN 20,000, with no maximum. The operator can change the minimums, so read them fromGET /portal/wallet. - Currency. Billing is always in USD. A USD payment credits the same amount. An NGN payment credits its USD equivalent at the operator's exchange rate, fixed when the payment starts.
- Verified amounts. A payment only credits the wallet when the provider reports exactly the amount and currency that were requested.
Status today: staging offers two sandbox providers (one always succeeds, one always
declines). Real payments go through SimplePay once it is connected; until a gateway is
configured the list is empty and starting a payment returns 503 PAYMENT_GATEWAY_UNAVAILABLE.
The wallet#
GET /portal/wallet returns the balance, the ledger and what you need to know before calling a
paid feature:
| Field | Meaning |
|---|---|
balanceMicroUsd |
Current balance in micro-USD (1,000,000 = USD 1). It can be negative |
requiredMicroUsd |
The least a balance must hold for an identity check to run: the price of a check (default USD 0.07) |
checksAvailable |
balanceMicroUsd >= requiredMicroUsd |
overdrawn |
The balance is below zero |
funded |
At least one top-up has succeeded |
- Identity checks (NIN, BVN) are debited up front. They are refused with
402 WALLET_INSUFFICIENT(details: { requiredMicroUsd, balanceMicroUsd }) when the balance is below the price of that check, and resume as soon as a top-up brings it back to the price. - Production requests beyond the free weekly allowance are charged to the wallet as they happen. They are never blocked: if the wallet runs out, the balance goes below zero and keeps counting what you owe. Your next top-up pays that off first. While the balance is below the price of a check, NIN and BVN stay off.
- The ledger shows each top-up and each check, and one line per application per day for HTTP
requests (
featureKey: "http_requests"). - Alerts. You are emailed when the wallet is 50, 75, 80, 90, 95 and 99 percent used (operator settings), once when identity checks pause because the balance is below their price, once when the wallet becomes overdrawn, and when a top-up switches checks back on.
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 funded wallet is
not required to create a key, but until you top up 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 | Wallet never topped up | 10 requests per day | 429 QUOTA_EXCEEDED |
| Testing | Wallet funded, mode TESTING |
1,000 requests per month | 429 QUOTA_EXCEEDED |
| Production | Wallet funded, mode PRODUCTION |
1,000 free requests per week | Charged to the wallet at USD 1 per 1,000 requests (USD 0.001 each); never blocked, even at a zero or negative balance |
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 funded wallet
(409 WALLET_FUNDING_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 paid from the same 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.
6a. Optional: send emailed links to your own pages#
By default the links in your users' emails (verify email, reset password, invitation) open the hosted pages on this service, and nothing here changes that. If you would rather keep your users inside your own product, choose where each link lands:
PUT /portal/applications/:applicationId/action-urls
Authorization: Bearer <developer accessToken>
{ "verifyEmail": "https://app.example.com/verify",
"resetPassword": "https://app.example.com/reset-password",
"invitation": "https://app.example.com/join" }
Each key is optional and independent: leave one out to keep its current value, send null
to go back to the hosted page for that action. URLs must use https (plain http is accepted
only for localhost in development), with no credentials, query string or fragment. The
email then links to <your url>?tokenId=...&token=... (for invitations,
?invitationId=...&token=...). Your page reads those values and your backend redeems them with
the endpoints in Part 3. The values are returned in the dashboard as actionUrls.
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. The link in that email opens the hosted
page unless you chose your own page (step 6a); either way the token is redeemed with
step 15.
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 wallet (default USD 0.07 per check), valid or not; a check the vendor could not answer is not charged. When the balance is below the price of the check (USD 0.07 by default) it is 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) and when checks pause.
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, or send a new one with POST /tenant/user/resend-verification (step 15) |
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.
Part 3: Manage your users and members entirely over REST#
Everything below uses the two headers from step 8 (Authorization: Bearer <application token>
and X-Application-Id); the tenant always comes from the token. Call these from your
backend, relaying what your own screens collect. None of this removes or replaces the hosted
pages, which keep working for anyone who does not set action URLs.
An application token acts for your tenant, so anything holding your API key can manage its members. Keep the key on your server and rotate it if it is exposed.
15. Account lifecycle for an end user#
| Step | Call | Notes |
|---|---|---|
| Verify email | POST /tenant/user/verify-email { "tokenId", "token" } |
Values from the emailed link. 200 { "status": "verified" }. A token issued by another application is 401 |
| Resend verification | POST /tenant/user/resend-verification { "email" } |
Always 202, whether or not the address exists. Limited per address (5 per hour) |
| Forgot password | POST /tenant/user/forgot-password { "email" } |
Always 202. The email links to your resetPassword page, or the hosted page if unset |
| Reset password | POST /tenant/user/reset-password { "tokenId", "token", "newPassword" } |
200 { "status": "updated" }. All of the user's sessions end and the token is single use |
| Change password | POST /tenant/user/change-password { "email", "currentPassword", "newPassword" } |
401 unless the current password is right (throttled together with sign-in). All of the user's sessions end |
Passwords are 8 to 128 characters. After a reset or change, sign the user in again with
POST /tenant/user/login.
16. Invite and onboard members#
| Step | Call |
|---|---|
| List roles to choose from | GET /tenant/user/roles |
| Invite | POST /tenant/user/invitations { "email", "roleIds": [1-10 role IDs] } returns 201 { "invitationId" } |
| List pending invitations | GET /tenant/user/invitations |
| Resend | POST /tenant/user/invitations/:id/resend (202). The earlier link stops working and the 24 hour expiry restarts |
| Cancel | DELETE /tenant/user/invitations/:id |
The invitation email links to your invitation page (or the hosted page if unset) with
invitationId and token in the query. From there your backend calls, with the token in the
body so it stays out of URLs and logs:
POST /tenant/user/invitations/inspect { "invitationId", "token" }
-> { "tenant": { "name" }, "invitation": { "email", "expiresAt" }, "accountExists": false }
POST /tenant/user/invitations/accept { "invitationId", "token", "password" }
-> { "user": { "id", "email" }, "tenantId", "membershipId", "status": "joined" }
Use accountExists to show "choose a password" or "enter your password". A new person's
password is set by accept, and their email counts as verified because the one-time link
reached only that mailbox. An existing account must present its own password, and a member who
registered earlier simply receives the invited roles. accept does not sign anyone in: call
POST /tenant/user/login afterwards, which still applies every sign-in rule (KYC included).
An invitation for another tenant, or one that is expired, used or cancelled, is 401.
17. Manage members#
| Operation | Call |
|---|---|
| List | GET /tenant/user/members?limit=50&after=<nextCursor>&status=ACTIVE returns { members, nextCursor } (limit up to 100, nextCursor is null on the last page) |
| Read one | GET /tenant/user/members/:membershipId |
| Suspend, reactivate, remove, change roles | PATCH /tenant/user/members/:membershipId with status (ACTIVE, SUSPENDED, REMOVED) and/or roleIds (up to 10; [] clears them) |
A member looks like { membershipId, userId, email, userStatus, emailVerified, status, roleIds, createdAt }.
Changing a member ends their sessions, and the tenant must always keep an active, verified
administrator (409 otherwise). A member who self-registered holds no roles until you assign
some. Creating roles and registering permissions stays with the tenant administration routes in
step 14.
With steps 9, 11, 12 and 15 to 17 a tenant can register, verify, sign in, recover, invite, onboard, assign and remove its users without sending anyone to a hosted page.
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, key or feature |
402 |
WALLET_INSUFFICIENT for an identity check when the wallet balance is below the price of the check |
409 |
Duplicate email, WALLET_FUNDING_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.