Overview

IdentityOS is onboarding and identity-verification infrastructure. Your server creates an onboarding session, sends the person to the hosted link, and receives the outcome by polling the API or via a signed webhook. You receive a minimal verified profile — never raw documents, selfies, or biometric templates.

Base URL: https://<your-deployment>/api/public/v1

API keys are created per environment in the console. Sandbox keys run fully synthetic flows; production keys enforce the real verification policy.

Authentication

Send your secret API key as a bearer token. Keys are stored hashed and shown only once at creation.

Request header
Authorization: Bearer sk_test_…

All mutating requests accept an Idempotency-Key header (8–128 URL-safe characters). Retrying with the same key replays the original response instead of creating a duplicate session.

Who can manage keys

Only organisation Owners and Admins can create or revoke API keys. Developers can see key prefixes, environments, permissions and last use, but cannot change keys. Each create or revoke also requires confirming your password immediately beforehand; that confirmation is valid once, for 10 minutes, for that organisation only. Accounts that sign in with Google only cannot manage keys until reauthentication for Google is supported.

Key permissions (least privilege)

Every key carries an explicit set of scopes, chosen at creation and fixed for the key's lifetime. Anything outside that set is refused with 403 insufficient_scope. To change permissions, create a new key and revoke the old one. Use a separate key per service and per environment; sandbox keys never reach production data and vice versa.

Scopes
onboarding:create  POST /v1/onboarding/sessions
onboarding:read    GET  /v1/onboarding/sessions/{id}
Examples
# Create-only key — e.g. a signup service that starts onboarding
scopes: onboarding:create

# Read-only key — e.g. a reconciliation job or webhook consumer that confirms results
scopes: onboarding:read

# Standard integration key — one backend that does both
scopes: onboarding:create, onboarding:read

Create a session

Creates a resumable onboarding session and returns a hosted link to send to the person being verified.

POST /v1/onboarding/sessions
curl -X POST https://<host>/api/public/v1/onboarding/sessions \
  -H "Authorization: Bearer sk_test_…" \
  -H "Idempotency-Key: signup-8f3a21c9" \
  -H "Content-Type: application/json" \
  -d '{
    "client_reference": "user_1042",
    "country": "GH",
    "document_type": "ghana_card",
    "return_url": "https://app.example.com/kyc/done"
  }'
FieldTypeDescription
client_referencestring, optionalYour own reference, echoed back on retrieval and webhooks.
countryISO 3166-1 alpha-2Selects the verification policy (e.g. GH).
return_urlhttps URL, optionalWhere to send the person when they finish. Its origin must be listed exactly in the environment's allowed return origins (console Settings). Otherwise invalid_return_url.
document_typeenumghana_card, passport, national_id, residence_permit.
201 Created
{
  "id": "onb_X7k2pQ9mR4tVwY1z",
  "object": "onboarding_session",
  "livemode": false,
  "status": "created",
  "client_reference": "user_1042",
  "expires_at": "2026-10-07T17:00:00.000Z",
  "verification_policy": {
    "id": "gh.ghana_card.sandbox",
    "version": "2026.10.0",
    "country": "GH",
    "document_type": "ghana_card",
    "authoritative_required": false,
    "document_capture": true,
    "sandbox_simulation": true,
    "lawful_exception": false
  },
  "hosted_url": "https://<host>/onboard?s=onb_X7k2pQ9mR4tVwY1z&t=…"
}

The hosted link is one-time-tokenised and expires with the session. Sessions are resumable: a person who drops off can re-enter through the same link while it is valid.

Retrieve a session

Returns the session state and, once completed, the minimal verified profile for the decision's run. Results are scoped to your environment.

GET /v1/onboarding/sessions/{id}
curl https://<host>/api/public/v1/onboarding/sessions/onb_X7k2pQ9mR4tVwY1z \
  -H "Authorization: Bearer sk_test_…"
200 OK
{
  "id": "onb_X7k2pQ9mR4tVwY1z",
  "object": "onboarding_session",
  "livemode": false,
  "status": "approved",
  "current_step": "complete",
  "client_reference": "user_1042",
  "created_at": "2026-10-06T16:40:00.000Z",
  "completed_at": "2026-10-06T16:47:12.000Z",
  "expires_at": "2026-10-07T17:00:00.000Z",
  "verification_policy": { "id": "gh.ghana_card.sandbox", "version": "2026.10.0", … },
  "result": {
    "onboarding_id": "onb_X7k2pQ9mR4tVwY1z",
    "status": "approved",
    "legal_full_name": "…",
    "date_of_birth": "…",
    "identity_reference": "GHA-********-*",
    "checks": {
      "document": "passed",
      "liveness": "passed",
      "face_match": "passed",
      "duplicate_face": "no_match",
      "authoritative_identity": "not_performed"
    },
    "risk_level": "not_assessed",
    "ghana_card_verified": false,
    …
  }
}

Session statuses: created, started, awaiting_phone, awaiting_identity_details, awaiting_document, awaiting_liveness, processing, manual_review, approved, rejected, expired.

Webhooks

Register an HTTPS endpoint in the console. Every event is delivered from a durable outbox with retries, and each request is signed with your endpoint's secret.

identityos-signature header
identityos-signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
Signature verification (Node)
const [t, v1] = header.split(",").map((p) => p.split("=")[1]);
const expected = createHmac("sha256", webhookSecret)
  .update(`${t}.${rawBody}`)   // exact request body, unparsed
  .digest("hex");

if (Math.abs(Date.now() / 1000 - Number(t)) > 300 ||
    !timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
  return res.status(401).end();
}
Payload
{
  "id": "evt_…",
  "type": "onboarding.completed",
  "created_at": "…",
  "data": { "onboarding_id": "onb_…", "client_reference": "user_1042", "state": "approved" }
}

Event types: onboarding.started, onboarding.completed, onboarding.review_required, onboarding.rejected, onboarding.expired. Headers also include identityos-event-id and identityos-delivery-attempt.

Verify against the exact raw body. Delivery is at-least-once — make your handler idempotent on the event id. Events never contain raw documents, selfies, OTPs, or biometric data.

Errors

Errors return a stable machine-readable code.

Error shape
{
  "error": {
    "code": "unsupported_document",
    "message": "Unsupported country/document combination."
  }
}
StatusCodeMeaning
403insufficient_scopeValid key without the permission this operation needs. Use a key with the right scope.
400invalid_requestBody failed validation.
400invalid_return_urlreturn_url is unsafe or its origin is not allowed.
400unsupported_documentNo policy for that country/document pair.
401unauthenticatedMissing or invalid API key.
404not_foundSession does not exist in your environment.
429rate_limitedToo many requests; back off and retry.

Verification policy

Every session is bound to a versioned policy selected by country and document type. No API field can relax a policy.

For Ghana Card in production, the policy requires authoritative identity verification through an approved authority provider. Until that provider is connected, authoritative_identity is not_performed, document capture and OCR are blocked, and sessions cannot be approved. Local document checks, liveness, and face match are supporting signals only — they never constitute official Ghana Card verification.

A missing risk assessment is reported as not_assessed, never as low risk.

Sandbox

Sandbox keys run the full flow against synthetic data: deterministic document OCR, mock liveness and face match, and a fixed verification code. Sandbox results carry livemode: false and confer no production assurance.

Try the interactive walkthrough at /demo — the sandbox OTP is 000000.

SDKs

Three packages, version 0.1.0-alpha.1 (pre-release, not yet on a public registry). The server package holds your secret key; the browser and React packages never accept one and only open the hosted link your server created. Document and selfie capture stays inside the hosted flow — no images or biometric data pass through the SDKs.

@identityos/nodeServer. Create/retrieve sessions, typed errors, retries, webhook verification.
@identityos/webBrowser. Open the hosted link in a popup, new tab or redirect; observe the result.
@identityos/reactReact component and hook over the browser package.

The full contract is in the OpenAPI 3.1 spec atopenapi/identityos-v1.yaml.

TypeScript (server)

server.ts
import { IdentityOS, constructEvent, SIGNATURE_HEADER } from "@identityos/node";

const identity = new IdentityOS({
  apiKey: process.env.IDENTITYOS_SECRET_KEY!,   // sk_test_… or sk_live_…
  baseUrl: process.env.IDENTITYOS_BASE_URL!,
});

// Idempotency-Key is generated automatically, so retries never duplicate sessions.
const session = await identity.onboardingSessions.create({
  client_reference: "user_1042", country: "GH", document_type: "ghana_card",
});
// Send session.hosted_url to your frontend. Store session.id.

const latest = await identity.onboardingSessions.retrieve(session.id);
latest.result?.checks.authoritative_identity; // "not_performed" until an authority is connected

// Webhooks: pass the RAW body, not parsed JSON.
const event = await constructEvent({
  payload: rawBody, signature: req.headers[SIGNATURE_HEADER], secret: process.env.IDENTITYOS_WEBHOOK_SECRET!,
});

Retries: GET requests and session creation (always idempotent) retry on network errors, timeouts, 429 and 5xx with backoff, honouring retry-after. Other 4xx errors never retry. Responses are validated; unknown statuses raise a ResponseValidationError rather than being guessed.

Browser

checkout.ts
import { launchOnboarding } from "@identityos/web";

button.onclick = () => launchOnboarding({
  // Your backend creates the session and returns only hosted_url.
  hostedUrl: fetch("/api/identity/start", { method: "POST" }).then((r) => r.json()).then((j) => j.hosted_url),
  mode: "popup",                          // or "new_tab" / "redirect"
  allowedOrigins: ["https://identity.example.com"],
  checkStatus: (id) => fetch(`/api/identity/status/${id}`).then((r) => r.json()).then((j) => j.status),
  onAdvisoryComplete: (msg) => { /* verified signal, not authoritative */ },
  onComplete: (status, { source }) => { /* source "backend" = confirmed by your server */ },
  onClose: () => { /* closed before finishing */ },
});

In popup mode, if the session was created with a return_url, the SDK accepts the hosted flow's completion message only from the IdentityOS origin of the hosted link, from that popup, and for that session; then it asks your backend via checkStatus. Without a return_url it falls back to polling. Iframe embedding is not offered: the flow needs the camera and must not be framed. Redirect mode returns to your return_url.

React

VerifyButton.tsx
import { IdentityOSOnboarding } from "@identityos/react";

<IdentityOSOnboarding
  getHostedUrl={() => startOnServer()}
  checkStatus={(id) => statusFromServer(id)}
  allowedOrigins={["https://identity.example.com"]}
  onComplete={(status, { source }) => source === "backend" && setStatus(status)}
>
  Verify your identity
</IdentityOSOnboarding>

Renders a button and a polite live region announcing progress. Use theuseIdentityOSOnboarding hook for a custom UI.

Mobile architecture

Your backend creates the session, your app launches the hosted session. Secret keys stay on your server. The app only receives the hosted link and later a status from your own backend. Document, selfie and biometric capture happens inside the hosted IdentityOS flow in the system browser — never in your app, and never through an SDK.

Flow
App                         Your backend                 IdentityOS
 │ tap "Verify" ───────────▶ create()  (sk_ key) ───────▶ POST /v1/onboarding/sessions
 │ ◀──────── hosted_url ──── ◀──────────────────────────
 │ launch(hosted_url) ─────────────────────────────────▶ hosted flow (browser)
 │ poll status / on resume ▶ retrieve() (sk_ key) ─────▶ GET /v1/onboarding/sessions/{id}
 │ ◀──────── status ─────── ◀──── + signed webhooks ────

All client SDKs reject secret-key strings, require https (http only for explicit local development), use the system browser rather than a WebView, and report only what they can observe. Return links are advisory, so the final result always comes from your backend. Version 0.1.0-alpha.1, unpublished.

Completion & return flow

The canonical flow: your backend creates the session with an allowed return_url → your app launches hosted_url → IdentityOS completes → a secure advisory completion signal and/or return → your app asks its backend → your backend retrieves the authoritative result and/or consumes the webhook.

Return flow
Backend ── POST /v1/onboarding/sessions { return_url } ──▶ IdentityOS
App     ── open hosted_url (popup / browser) ─────────────▶ hosted flow
Hosted  ── postMessage(msg, exact return origin) ─▶ opener   (web popup only, advisory)
Hosted  ── redirect return_url?identityos_session_id=…&identityos_status=…  (advisory)
App     ── "check my session" ──▶ Backend ── GET /v1/onboarding/sessions/{id} (authoritative)
                                   Backend ◀── signed webhook (authoritative)
Completion message (version 1)
{
  "type": "identityos.onboarding.complete",
  "version": 1,
  "session_id": "onb_…",
  "status": "approved" | "rejected" | "manual_review" | "expired",
  "livemode": false,
  "completed_at": "2026-10-06T12:00:00.000Z"
}

Allowed return origins are set per environment in console Settings: exact origins only, no wildcards, https (http://localhost allowed in sandbox only). return_url must not contain credentials or a fragment. The message is sent only to window.opener with the exact return origin as its target, never "*", and only once per session. The redirect appends only identityos_session_id and identityos_status — never the hosted token, API keys or identity data. Neither signal is authoritative; never grant access from them alone.

Mobile: return only through HTTPS Universal Links (iOS) or verified Android App Links. Custom URL schemes are intentionally unsupported in this phase because any app can claim them. Each mobile SDK provides a return-link parser that checks the origin and session id, then you ask your backend for the result.

Mobile return links
// React Native
listenForReturnLink({ expectedSessionId: session.sessionId,
  allowedReturnOrigins: ["https://app.example.com"],
  onReturn: () => watch.checkNow() });

// Flutter (feed URIs from app_links or your own channel)
final r = parseReturnLink(uri.toString(), expectedSessionId: id, allowedReturnOrigins: ['https://app.example.com']);

// iOS (.onOpenURL / NSUserActivity.webpageURL)
let r = try ReturnLink(url: url, expectedSessionID: id, allowedReturnOrigins: ["https://app.example.com"])

// Android (App Link activity)
val r = ReturnLink.parse(intent.data.toString(), id, listOf("https://app.example.com"))

React Native

VerifyScreen.tsx
import { launchHostedSession, watchSessionStatus } from "@identityos/react-native";

const { session } = await launchHostedSession({ hostedUrl, allowedOrigins: ["https://identity.example.com"] });
const watch = watchSessionStatus({
  sessionId: session.sessionId,
  checkStatus: (id) => myBackend.status(id),     // your server calls retrieve()
  onComplete: (status) => setStatus(status),
});
// re-checks when the app returns to the foreground; call watch.dispose() on unmount

Uses core Linking; Expo's in-app browser is optional via expoWebBrowserOpener.

Flutter

verify.dart
final launch = await IdentityOS.launch(hostedUrl, allowedOrigins: ['https://identity.example.com']);
final poller = SessionStatusPoller(
  sessionId: launch.session.sessionId,
  checkStatus: (id) async => parseStatusOrThrow(await myBackend.status(id)),
  onComplete: (s) => print(s.wire),
)..start();
ResumePollingObserver(poller); // re-check on resume

iOS (Swift)

VerifyViewController.swift
let session = try HostedSession(hostedURL: hostedURL, allowedOrigins: ["https://identity.example.com"])
launcher.present(session, from: self)            // SFSafariViewController
let poller = try SessionStatusPoller(sessionID: session.sessionID,
  provider: ClosureStatusProvider { id in try await myBackend.status(id) })
for try await status in await poller.statuses() where status.isTerminal { /* done */ }

Your app needs no camera permission for this; the hosted flow asks inside Safari and its content is isolated from your app.

Android (Kotlin)

VerifyActivity.kt
val launch = IdentityOS.launch(this, hostedUrl, allowedOrigins = listOf("https://identity.example.com"))
poller = SessionStatusPoller(launch.session.sessionId,
  { id -> SessionStatus.fromWire(myBackend.status(id)) }, listener).also { it.start() }

override fun onResume() { super.onResume(); io.execute { poller?.checkNow() } }

Opens in a Custom Tab (falls back to the default browser). No WebView.