Identity platform

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:


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.

  1. GET /portal/payment-providers lists the providers you can choose from ({ providers: [{ id, name, imageUrl? }] }).
  2. POST /portal/cards/start with { "provider": "<id>", "currency": "USD" | "NGN" } returns 201 { reference, redirectUrl, pay }. Send the browser to redirectUrl.
  3. After paying, the provider sends the browser back to /portal?payment=<reference>. The portal then calls POST /portal/payments/<reference>/verify, which returns { status: "SUCCEEDED" | "PENDING" | "FAILED" }. A payment whose browser never returned is settled automatically within a few minutes.

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#


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:

  1. 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's audience, shown on the dashboard), expiry and scope. The source helper TenantTokenVerifier does this and enforces required permissions. It cannot see server-side revocation until the token expires.
  2. 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#

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.