# Introduction FOID (Fintech Open Identity) lets your business verify customers without making them prove who they are again. A person verifies once with FOID (NIN, BVN, and a live face check), and then shares that result with each business they choose. FOID records the consent, and the person can take it back at any time from their FOID app. For your business, that means: - **Less onboarding friction.** People who already have a FOID share a verified identity in one tap. - **Consent you can show.** Every read of a person's data goes through a consent grant with a purpose and scopes. Revoke it and the data stops. - **No raw numbers to protect.** FOID never sends you a full NIN or BVN. You get "verified" plus the last 4 digits. ## Three ways to integrate | | What it's for | Who collects consent | Face check | Start here | |---|---|---|---|---| | **Continue with FOID** | A sign-in / sign-up button. The person approves on FOID's screen and your server gets their verified details. | FOID, on its consent screen | A quick face re-match the first time they share with you (live) | [Continue with FOID](http://localhost:5177/guide/continue-with-foid.md) | | **Hosted verification** | Onboarding anyone, FOID account or not. You create a verification; the person completes NIN / BVN / liveness on a FOID page. | FOID, on the hosted page | Yes, a liveness check | [Hosted verification](http://localhost:5177/guide/hosted-verification.md) | | **Server-to-server checks** | Back-office checks when you already hold the number and the person's consent. | You — you attest you collected it | No | [NIN and BVN checks](http://localhost:5177/guide/checks.md) | How to choose: - Your customers may already have a FOID, and you want sign-up to be one tap: **Continue with FOID**. People without a FOID can still finish inside the same popup. - You need a liveness check, or you don't want to handle camera code or consent screens yourself: **Hosted verification**. - You already have the NIN or BVN and consent from your own forms, and only need a match result: **Server-to-server checks**. You can use more than one. Everything about a person lands on the same [subject](http://localhost:5177/guide/concepts.md#subjects), keyed by your own `customer_ref`. ## What you need - A **FOID console** account. Sign up at the console, verify your email, and you get an organisation with **test mode** ready to use. - A **test API key** for the Open API (checks, verifications, subjects). - For Continue with FOID, a **sign-in app** (client ID plus client secret or key pair). - Optionally, a **webhook endpoint** so FOID can tell you when things finish. Test mode is free and returns sandbox data, so you can build the whole flow before you go live. See [Test and live mode](http://localhost:5177/guide/test-and-live.md). ## How these docs are organised - **Start here:** [Quickstart](http://localhost:5177/guide/quickstart.md) (your first API call in five minutes), [Core concepts](http://localhost:5177/guide/concepts.md) and [Test and live mode](http://localhost:5177/guide/test-and-live.md). - **Integrate:** one guide per integration, plus [Subjects and consent](http://localhost:5177/guide/subjects.md) and [Webhooks](http://localhost:5177/guide/webhooks.md). - **Production:** [Going live](http://localhost:5177/guide/going-live.md), the [Security checklist](http://localhost:5177/guide/security.md) and [Errors, limits, idempotency](http://localhost:5177/guide/errors.md). - **[SDKs](http://localhost:5177/sdks/index.md):** Python, Node and React, plus the [Harbor example app](http://localhost:5177/sdks/example-app.md) that uses all three. - **[API reference](http://localhost:5177/api/index.md):** every endpoint with real example responses. - **[Playground](http://localhost:5177/playground.md):** click the real "Continue with FOID" button and see what your server would receive. # Quickstart Make your first FOID API call in about five minutes, in test mode. Nothing here touches real identity data or costs money. ## 1. Create a console account Sign up in the FOID console with your work email and company name, then enter the 6-digit code FOID emails you. Signing up creates your **organisation**, and the console opens in **Test mode** (an orange strip across the top). ## 2. Create a test API key Go to **API keys & webhooks**, then **Create key**, with the console in Test mode. Copy the key: it starts with `FOID_TEST_` and is shown **once**. FOID only stores a hash of it. Keep it on your server, for example in an environment variable: ```bash export FOID_API_KEY=FOID_TEST_… ``` ## 3. Call the API `GET /ping` checks that your key works. ```bash curl https://api.foid.ng/api/open/v1/ping \ -H "Authorization: Bearer $FOID_API_KEY" ``` ```python import os from foid import Foid foid = Foid(api_key=os.environ["FOID_API_KEY"]) print(foid.request("GET", "/ping")) ``` ```js import { Foid } from '@foid/node' const foid = new Foid({ apiKey: process.env.FOID_API_KEY }) console.log(await foid.request('GET', '/ping')) ``` ```json { "ok": true, "organisation": "Acme Wallet", "env": "test" } ``` Running FOID yourself? Use `http://localhost:8000` as the base URL: `baseUrl` in Node, `base_url` in Python. The SDKs default to `https://api.foid.ng`. To install the SDKs, see [SDKs](http://localhost:5177/sdks/index.md). ## 4. Run a check Look up an NIN and match it against the name you hold. In test mode the **last digit** of the number decides the result: `0`–`7` pass, `8` is a name mismatch, `9` is not found. See [Test and live mode](http://localhost:5177/guide/test-and-live.md#sandbox-numbers). ```bash curl https://api.foid.ng/api/open/v1/checks/nin \ -H "Authorization: Bearer $FOID_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "number": "12345678901", "first_name": "Ada", "last_name": "Obi", "date_of_birth": "1994-03-12", "customer_ref": "cust_1001", "consent": { "obtained": true, "purpose": "Account opening" } }' ``` ```python result = foid.check_nin( number="12345678901", first_name="Ada", last_name="Obi", date_of_birth="1994-03-12", customer_ref="cust_1001", consent={"obtained": True, "purpose": "Account opening"}, ) print(result["status"], result["data"]["nin_last4"]) # passed 8901 ``` ```js const result = await foid.checkNin({ number: '12345678901', first_name: 'Ada', last_name: 'Obi', date_of_birth: '1994-03-12', customer_ref: 'cust_1001', consent: { obtained: true, purpose: 'Account opening' }, }) console.log(result.status, result.data.nin_last4) // passed 8901 ``` `consent.obtained: true` is your statement that the person agreed to this check. FOID records it. The response includes a `subject_id`, FOID's ID for this person at your business. The full response is in the [API reference](http://localhost:5177/api/reference.md#post-checks-nin). ## 5. Or start a hosted verification When you also want a liveness check, or prefer FOID to collect consent, create a verification and send the person to its `hosted_url`: ```python v = foid.create_verification( checks=["nin", "liveness"], purpose="Open a wallet", customer_ref="cust_1001", first_name="Ada", last_name="Obi", return_url="https://acme.example/kyc/done", idempotency_key="onboard-cust_1001", ) print(v["hosted_url"]) # send the person here ``` ```js const v = await foid.createVerification({ checks: ['nin', 'liveness'], purpose: 'Open a wallet', customer_ref: 'cust_1001', first_name: 'Ada', last_name: 'Obi', return_url: 'https://acme.example/kyc/done', idempotencyKey: 'onboard-cust_1001', }) console.log(v.hosted_url) // send the person here ``` ## Next - Add a sign-in button: [Continue with FOID](http://localhost:5177/guide/continue-with-foid.md), or try it first in the [Playground](http://localhost:5177/playground.md). - Get told when things finish: [Webhooks](http://localhost:5177/guide/webhooks.md). - See every SDK feature working: the [Harbor example app](http://localhost:5177/sdks/example-app.md). # Core concepts ## Organisations and environments Your business is an **organisation** in the FOID console. Every organisation has two separate environments: - **Test:** sandbox data, free, available immediately. - **Live:** real lookups, billed, available after FOID approves your business (KYB). Test and live never mix. Keys, sign-in apps, webhooks, subjects and logs all belong to one environment. A test key can't read a live subject. See [Test and live mode](http://localhost:5177/guide/test-and-live.md). ## Subjects A **subject** is a person as your business knows them. Every check, verification and Continue with FOID sign-in is recorded against a subject, and the subject's `id` (`subject_id`) is stable for your business. - Subjects belong to **one** organisation and environment. Two businesses that verify the same person each get their own subject and never see each other's. - A person doesn't need a FOID account to be a subject. `has_foid_account` tells you whether the subject is linked to one. - FOID never stores a subject's full NIN or BVN: only a keyed hash (to recognise the same number again) and the last 4 digits. ### customer_ref `customer_ref` is **your** ID for the person, such as your user ID. Pass it on checks and verifications and FOID reuses the same subject for that reference, so a person's NIN check, BVN check and liveness all end up together. You can also pass `subject_id` directly. ## Consent and scopes Every read of a person's data goes through a **consent grant** between the subject and your organisation. A grant has a purpose and a set of **scopes**: | Scope | What you can read | |---|---| | `profile` | Name: first, middle, last. Always included in Continue with FOID. | | `identity` | NIN / BVN check results (verified + last 4), and the name and date of birth on record | | `liveness` | Liveness results, and re-verification against the captured face | | `contact` | Email address and phone number (phone masked) | | `financial` | The trust report and `credential.state_changed` events. FOID account holders only. | Checks imply scopes: `nin` and `bvn` need `identity`; `liveness` and `reverify` need `liveness`. On a hosted verification you can ask for `contact` and `financial` on top. Where consent comes from is recorded as the grant's **source**: | Source | How it was collected | Strength | |---|---|---| | `foid_oauth` | The person approved on FOID's consent screen in [Continue with FOID](http://localhost:5177/guide/continue-with-foid.md) | Strongest: the person signed in and approved | | `hosted_flow` | The person accepted FOID's consent screen in a [hosted verification](http://localhost:5177/guide/hosted-verification.md) | Strong: FOID showed and recorded it | | `business_attested` | You sent `consent.obtained: true` with a [server-to-server check](http://localhost:5177/guide/checks.md) | Weakest: your own attestation | The person sees the source in **Connected apps** in their FOID app. You see it in your own logs. **Revocation.** A person can revoke your access at any time. From that moment, reads of their data return `403 consent_revoked`, verification results come back redacted, queued webhooks about them are cancelled, and you get one `consent.revoked` event. See [Subjects and consent](http://localhost:5177/guide/subjects.md#when-a-person-revokes-consent). ## Credential state A FOID account holder has a living **credential**, not a one-time check. Its state can change after verification, for example when connected bank data shows something that needs another look: `active` → `under_review` → `downgraded` → `revoked` Businesses with the `financial` scope can read the state in the [trust report](http://localhost:5177/guide/subjects.md#trust-report) and get `credential.state_changed` webhooks when it changes. The credential state is a set of verified signals, not a score. The decision about what to do stays with you. ## Keys and credentials FOID gives you four kinds of credential. Each can do as little as possible, so a leak of one has limited damage. | Credential | Looks like | Where it lives | What it can do | |---|---|---|---| | **API key** | `FOID_TEST_…` / `FOID_LIVE_…` | Your server only | The whole Open API: checks, verifications, subjects. Shown once, stored hashed. | | **Client ID** | `foid_app_test_…` / `foid_app_live_…` | Browser and mobile apps (public) | Only opens the Continue with FOID popup for your sign-in app. Reads nothing. | | **Client secret** | `foid_cs_test_…` / `foid_cs_live_…` | Your server only | Only exchanges a one-time sign-in code for that one app. Shown once, stored hashed. When you rotate it, the old secret keeps working for 24 hours. | | **Key pair** (`private_key_jwt`) | A private key PEM + a public JWK | Private key on your server, public key in the console | Replaces the client secret: your server signs each code exchange. FOID only holds the public key, so FOID holds no secret to leak. | Why the client secret isn't the API key: OAuth secrets tend to end up in more places, such as auth libraries and config files. If the API key were used as the secret, one leak would expose every customer you've ever verified. A separate secret limits a leak to "exchange codes for this app", and those codes are useless without the PKCE verifier. The browser only ever sees the **client ID**. The API key, client secret and private key stay on your server. ## Test and live data - Test mode returns **sandbox data**. Checks pass or fail by the [last digit](http://localhost:5177/guide/test-and-live.md#sandbox-numbers), and Continue with FOID shares the person's real name but sandbox data for everything else. - Live mode returns real results and is billed per check. More in [Test and live mode](http://localhost:5177/guide/test-and-live.md). # Test and live mode Every organisation has a **test** and a **live** environment. Switch between them with the Test / Live toggle in the console sidebar. In Test mode the console shows an orange strip, so sandbox data is never mistaken for real customers. ## Test mode Test mode is for building and demos: - **Free.** Test checks are never billed. - **Everything passes with sandbox data.** No real registry (NIMC, NIBSS) is contacted. Responses have the same shape as live, so your code works unchanged when you switch. - **Available immediately** after you sign up. No business verification needed. - **Separate data.** Test keys, sign-in apps, webhooks and subjects are invisible to live, and the other way round. ### Sandbox numbers For NIN and BVN checks, the **last digit** of the 11-digit number decides the result: | Last digit | Result | |---|---| | `0` – `7` | Found. The record echoes the name and date of birth you sent, so it `passed`. | | `8` | Found under a different name: `failed`, reason `name_mismatch` | | `9` | `failed`, reason `not_found` | A number that isn't exactly 11 digits is refused with `400 invalid_nin` / `invalid_bvn`, as in live. ### Hosted verification in test mode The hosted page shows a **Sandbox** badge. NIN and BVN steps follow the sandbox numbers above. The liveness step is a real camera check, so use your own face. ### Continue with FOID in test mode Sign-in is the one thing in test mode that is **real**, like "Sign in with Google" in a test project: - Any FOID user can sign in to your test app, with their own email and one-time code. - The consent screen is clearly marked **Test app — not checked by FOID**. - Your server gets the person's **real first and last name** only. Everything else is sandbox data in the live shape: date of birth `1990-01-01`, NIN last 4 `0001`, BVN last 4 `0002`, email `sandbox.person@example.com`, phone `+2348000000001`, and liveness "passed now". - There's no face re-match in test mode. You see what sign-in looks like end to end without receiving anyone's real identity data. Try it in the [Playground](http://localhost:5177/playground.md). ### localhost Test sign-in apps accept `http://localhost:` and `http://127.0.0.1:` as origins and redirect URIs **without registering them**. Your local dev server just works. Registered `https://` URLs work too. Test webhook endpoints may also use `http://localhost` / `http://127.0.0.1`, so you can receive webhooks on your machine. See [Webhooks: allowed URLs](http://localhost:5177/guide/webhooks.md#allowed-urls). ## Live mode Live mode uses real identity providers and real data: - **Billed** per check (checks with `status: "error"`, for example a provider outage, are not billed). - **Live API keys** (`FOID_LIVE_…`), live sign-in apps and live webhooks are separate from your test ones. - **https only**: sign-in app origins, redirect URIs and webhook URLs. No localhost. - **Continue with FOID** shares real data, and the person does a quick face re-match the first time they share with you. - **IP allow-list** (optional): if you set one in Settings, live API keys only work from those addresses. ### Turning live mode on (KYB) Live mode is locked until FOID verifies your business (KYB): 1. In the console, open **Go live** and submit your business details (CAC registration number, TIN, registered address) and the required documents. 2. FOID reviews them. You keep building in test mode meanwhile. The owners are emailed when the review is done. 3. Once approved, you can create live API keys and live sign-in apps. Before that, live calls are refused with `403 live_mode_disabled`. The full checklist is in [Going live](http://localhost:5177/guide/going-live.md). # Continue with FOID A sign-in button, like "Sign in with Google", where the person arrives **already verified**. They approve what to share on FOID's own screen, and your server gets their verified details, never a raw NIN or BVN. Under the hood it's OAuth 2.0 authorization code with PKCE: the browser gets a one-time **code**, and your **server** exchanges it for the result using credentials only your server has. Try the real button. It uses FOID's own test app for these docs: > Interactive "Continue with FOID" playground (needs a browser): http://localhost:5177/playground More options (labels, themes, sizes, scopes) and the matching code are in the [Playground](http://localhost:5177/playground.md). ## How it works ``` Your page FOID popup Your server FOID API ───────── ────────── ─────────── ──────── ──▶ email + one-time code (client ID, PKCE) consent screen ("Acme wants…") face check (live, first time) ◀── code ─────── postMessage to your origin ── code + verifier ──────────────────────────────▶ foid.oauth.exchange() ──▶ POST /oauth/token ◀── the person's result ``` 1. The button opens a FOID popup with your **client ID**, the scopes you ask for, and a PKCE challenge. 2. The person enters their email and the 6-digit code FOID emails them. No password. 3. FOID shows a consent screen listing your business and each scope. In live mode, the first time they share with you, they also do a ~5-second face check that is matched against their enrolled FOID face. 4. The popup posts a one-time `code` back to your page, and only to your registered origin. 5. Your page sends `code` and `codeVerifier` to your server. Your server exchanges them **once, within 60 seconds**, with its client secret or key pair. 6. The result tells you who signed in and what they shared. Store `subject_id` on your user. ## 1. Register a sign-in app In the console, open **Sign-in apps**, then **Create app**, and choose Test or Live: | Field | Notes | |---|---| | Name | Shown to the person on FOID's consent screen. | | Allowed origins | Where the button runs, as scheme + host (+ port), e.g. `https://app.acme.com`. Popup results are only posted to these origins. | | Redirect URIs | For the no-popup fallback, exact match, e.g. `https://app.acme.com/auth/foid/callback`. | | Scopes | The most this app may ask for: `identity`, `contact`, `liveness`, `financial`. `profile` (name) is always included. | **Test apps** also accept `http://localhost:` and `http://127.0.0.1:` without registering them. **Live apps** need `https://` and exact matches. Up to 10 apps per environment, and up to 10 origins and 10 redirect URIs per app. You get: - a **client ID** (`foid_app_test_…`), which is public and goes in your front end; - a **client secret** (`foid_cs_test_…`), shown **once**, which stays on your server. You can rotate it (the old one keeps working for 24 hours) or revoke it. Or use a [key pair](#key-pair-private-key-jwt) instead of a secret. ## 2. Add the button With React, use [`@foid/react`](http://localhost:5177/sdks/react.md): ```jsx import { FoidProvider, ContinueWithFoid } from '@foid/react' export default function App() { return ( fetch('/auth/foid', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, codeVerifier }), }) } onError={(e) => console.log(e.code)} // popup_closed | access_denied | popup_blocked | state_mismatch /> ) } ``` - `foidUrl` is the FOID web app that hosts the popup (`http://localhost:5173` when running FOID locally). - `scope` is space-separated. Ask only for what you need; the app's registered scopes are the ceiling. - Prefer your own button? Use `useFoidLogin()`. No React? `signInWithFoid()` from `@foid/react/core`. See [React SDK](http://localhost:5177/sdks/react.md). The SDK creates the PKCE pair and a random `state` in the browser, checks that the response's `state` matches, and only accepts messages from the FOID origin and the popup it opened. ## 3. Exchange the code on your server ```js import express from 'express' import { Foid } from '@foid/node' const foid = new Foid({ clientId: process.env.FOID_CLIENT_ID, clientSecret: process.env.FOID_CLIENT_SECRET, baseUrl: 'https://api.foid.ng', }) const app = express() app.use(express.json()) app.post('/auth/foid', async (req, res) => { const { code, codeVerifier } = req.body const person = await foid.oauth.exchange(code, codeVerifier) // Link or create your user by person.subject_id, then start your own session. res.json({ name: `${person.subject.first_name} ${person.subject.last_name}` }) }) ``` ```python import os from fastapi import FastAPI from foid import Foid foid = Foid(client_id=os.environ["FOID_CLIENT_ID"], client_secret=os.environ["FOID_CLIENT_SECRET"], base_url="https://api.foid.ng") app = FastAPI() @app.post("/auth/foid") def sign_in(body: dict): person = foid.oauth.exchange(body["code"], body["codeVerifier"]) # Link or create your user by person["subject_id"], then start your own session. return {"name": f'{person["subject"]["first_name"]} {person["subject"]["last_name"]}'} ``` ```bash curl https://api.foid.ng/api/open/v1/oauth/token \ -u "$FOID_CLIENT_ID:$FOID_CLIENT_SECRET" \ -d grant_type=authorization_code \ -d code="$CODE" \ -d code_verifier="$CODE_VERIFIER" ``` The token endpoint accepts a form or JSON body, with the client secret as HTTP Basic (recommended) or as `client_id` + `client_secret` fields. Full details: [`POST /oauth/token`](http://localhost:5177/api/reference.md#post-oauth-token). Rules for the code: - **Single use** and valid for **60 seconds**. - Bound to your client ID, the PKCE verifier and, in redirect mode, the exact `redirect_uri`. - **A second exchange of the same code revokes the grant** it created. FOID treats a reused code as leaked, cancels that sign-in, and posts an alert in your console. Make sure your server exchanges each code exactly once. ## The result ```json { "object": "foid_sign_in", "env": "test", "sandbox": true, "nonce": "n_51b7", "subject_id": "ce669b19-ced4-4ff9-ac99-01d560450380", "scopes": ["contact", "identity", "profile"], "foid_account": true, "face_rematched_at": null, "subject": { "id": "ce669b19-ced4-4ff9-ac99-01d560450380", "customer_ref": null, "env": "test", "consent": { "status": "active", "scopes": ["contact", "identity", "profile"] }, "has_foid_account": true, "created_at": "2026-11-03T20:25:02.497513+00:00", "first_name": "Chiamaka", "middle_name": null, "last_name": "Eze", "date_of_birth": "1990-01-01", "nin": { "verified": true, "last4": "0001" }, "bvn": { "verified": true, "last4": "0002" }, "email": "sandbox.person@example.com", "phone": "**********0001" }, "checks": [] } ``` | Field | Meaning | |---|---| | `subject_id` | Stable ID for this person at your business. Store it on your user; the same person signing in again gets the same ID. | | `subject` | What the granted scopes allow: `profile` gives the name; `identity` adds date of birth and `nin` / `bvn` (`verified`, `last4`); `contact` adds email and a masked phone; `liveness` adds `liveness.passed_at`. | | `scopes` | What the person approved. `profile` is always there. | | `checks` | Check results on file for this subject (with `identity` or `liveness`). | | `foid_account` | `true` if the person has a FOID account. False for a new person who chose not to save to FOID. | | `face_rematched_at` | When the person passed the fresh face re-match for this sign-in (live), else `null`. | | `nonce` | The `nonce` you passed when starting sign-in. Compare it to detect a replayed sign-in. | | `sandbox` / `env` | `true` / `test` for a test app. In test mode only the name is real. | Reading the person again later uses your **API key**, for example [`GET /subjects/{id}`](http://localhost:5177/api/reference.md#get-subjects-subject-id), and still goes through their consent. If they revoke, you get `403 consent_revoked`. The client secret never becomes a standing data key. ## Redirect fallback (no popup) If the browser blocks the popup and you passed `redirectUri`, the SDK navigates the whole page to FOID instead (`response_mode=query`). FOID sends the person back to your redirect URI with `?code=…&state=…`. On that page: ```js import { handleRedirectCallback } from '@foid/react/core' const result = handleRedirectCallback() // null if this page load isn't a FOID return if (result) { await fetch('/auth/foid', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: result.code, codeVerifier: result.codeVerifier, redirectUri: location.origin + '/auth/foid/callback' }), }) } ``` In redirect mode, your server must pass the **same** `redirect_uri` to the exchange (`redirect_uri=` in Python, `{ redirectUri }` in Node). The redirect URI must be registered on the app (test apps: any localhost URL works). ## Server-led sign-in You can also create the PKCE pair on your server with the SDK's `create_pkce()` / `createPkce()`, keep the verifier, state and nonce server-side, and send the browser to: ``` {foidUrl}/oauth/authorize?client_id=…&scope=profile%20identity&state=…&nonce=… &code_challenge=…&code_challenge_method=S256 &response_mode=query&redirect_uri=https://app.acme.com/auth/foid/callback ``` Use `response_mode=web_message&origin=https://app.acme.com` to get the code by `postMessage` in a popup instead. The [Harbor example app](http://localhost:5177/sdks/example-app.md) does both. ## Key pair (private_key_jwt) Recommended for banks: instead of a client secret, your server holds a **private key** and signs each exchange. FOID only stores the public key, so there is no secret on FOID's side to leak. 1. Generate a key pair with either SDK. Each writes `foid_private_key.pem` and prints the public JWK: ```bash python -m foid keygen # pip install 'foid[keypair]' npx foid keygen # from @foid/node ``` 2. In the console, open **Sign-in apps**, click **Edit** on the app, choose **Key pair**, paste the public JWK and save. An app uses a key pair **or** client secrets, not both. 3. Pass the private key instead of the secret: ```js import { readFileSync } from 'node:fs' const foid = new Foid({ clientId: process.env.FOID_CLIENT_ID, privateKeyPem: readFileSync('foid_private_key.pem', 'utf8'), keyId: 'a1b2c3d4', // the "kid" from the public JWK (optional) }) ``` ```python foid = Foid(client_id=os.environ["FOID_CLIENT_ID"], private_key_pem=open("foid_private_key.pem").read(), key_id="a1b2c3d4") # the "kid" from the public JWK (optional) ``` The SDK signs a 60-second ES256 assertion (`iss` = `sub` = client ID, `aud` = the token URL, a random `jti`). FOID accepts EC P-256 (ES256) and RSA ≥ 2048-bit (RS256) public keys, and refuses assertions that expire more than 5 minutes ahead. Never paste the private key into the console: FOID rejects a JWK that contains private parts. ## What the person goes through **Returning FOID users** sign in with email and code, then approve. In **live** mode: - The **first time** they share with your business, FOID asks for a fresh **face re-match**: a short liveness check matched against their enrolled FOID face. Reusing an old check would let someone who can read the person's email share their identity. Later sign-ins to your business don't need it. - If their FOID is missing something your scopes need, they finish just that step first: liveness enrolment, or NIN verification when you ask for `identity`. **People without a FOID account** can still finish. Inside the popup they enter their name as on their NIN, then complete a hosted verification (NIN, plus liveness in live mode or when you ask for `liveness`). Your server gets the result either way, passed or failed. If their NIN check passed, FOID then offers **Save to FOID**: an explicit button, never pre-ticked, that creates a FOID account from what they just verified so they never repeat it. Saying no still gives you the result. `foid_account` tells you which happened. The popup never redirects errors in your app's setup back to you. An unknown client ID, an origin or redirect URI that isn't registered, or a scope your app isn't allowed are shown to the person in the popup. Your page only sees `popup_closed` when they close it. ## Errors **In the browser** (`onError` / `FoidSignInError.code`): | Code | Meaning | |---|---| | `popup_closed` | The person closed the FOID window, or closed it after an error was shown there. | | `access_denied` | The person pressed Cancel, or declined consent. | | `popup_blocked` | The browser blocked the popup and you didn't pass `redirectUri`. | | `state_mismatch` | The response didn't match this sign-in. It was ignored (protects against login CSRF). | | `missing_client_id` | `` has no `clientId`. | **From the token endpoint** (OAuth format: `{"error": "…", "error_description": "…"}`): | HTTP | `error` | Meaning | |---|---|---| | 400 | `invalid_grant` | Code unknown, expired (60 s), already used, for another app, wrong `code_verifier`, or `redirect_uri` doesn't match | | 400 | `unsupported_grant_type` | `grant_type` isn't `authorization_code` | | 401 | `invalid_client` | Wrong client ID or secret, revoked secret, or a bad / expired / wrongly addressed client assertion | The SDKs raise these as `FoidError` with `code` set to the `error` value. ## Security rules - Only the **client ID** goes in the browser or mobile app. The client secret, private key and API key stay on your server. - Exchange each code **once**, on your server, within 60 seconds. - Pass a `nonce` and compare it with `result.nonce` if you need to bind the result to a session. - Don't trust anything the browser tells you about the person. Use the exchange result. - Register exact `https://` origins and redirect URIs for live apps. Every token exchange appears in the console under **Request logs**, and each share appears in the person's **Connected apps** as "Continue with FOID", where they can revoke it. # Hosted verification Send a customer to a FOID-hosted page that collects their consent and runs the checks you ask for: NIN (by number, or by scanning an NIN slip or NIMC card), BVN and a liveness check. The page is branded with your business name and logo, plus "Secured by FOID". You write no camera code and no consent screen. The person does **not** need a FOID account. If they have one, the page offers "Already have FOID? Sign in to share it instead", and they can share their existing verified results. ## 1. Create a verification (your server) ```bash curl https://api.foid.ng/api/open/v1/verifications \ -H "Authorization: Bearer $FOID_API_KEY" \ -H "Idempotency-Key: onboard-cust_1001" \ -H "Content-Type: application/json" \ -d '{ "checks": ["nin", "liveness"], "purpose": "Open a wallet", "customer_ref": "cust_1001", "first_name": "Ada", "last_name": "Obi", "return_url": "https://acme.example/kyc/done" }' ``` ```python v = foid.create_verification( checks=["nin", "liveness"], purpose="Open a wallet", customer_ref="cust_1001", first_name="Ada", last_name="Obi", return_url="https://acme.example/kyc/done", idempotency_key="onboard-cust_1001", ) ``` ```js const v = await foid.createVerification({ checks: ['nin', 'liveness'], purpose: 'Open a wallet', customer_ref: 'cust_1001', first_name: 'Ada', last_name: 'Obi', return_url: 'https://acme.example/kyc/done', idempotencyKey: 'onboard-cust_1001', }) ``` | Field | Notes | |---|---| | `checks` | One or more of `nin`, `bvn`, `liveness`, `reverify`. `reverify` is a liveness check matched 1:1 against this subject's earlier liveness face. Use it instead of `liveness`, not with it, and only for a subject that already has a face on file (`409 no_face_on_file` otherwise). | | `purpose` | Shown to the person on the consent screen. 3–200 characters. | | `customer_ref` | Your ID for the person. Reusing it reuses the same [subject](http://localhost:5177/guide/concepts.md#subjects). | | `subject_id` | Alternative to `customer_ref`; needed for `reverify`. | | `first_name`, `middle_name`, `last_name`, `date_of_birth` | Matched against the NIN / BVN record. `date_of_birth` is `YYYY-MM-DD`. | | `email`, `phone` | Optional. `email` helps link the person to a FOID account later. | | `scopes` | Extra scopes beyond what the checks imply: `contact`, `financial`. | | `return_url` | Where the page sends the person when they finish or cancel. | | `metadata` | Your own string key/value pairs, echoed back. | Send an `Idempotency-Key` so a retried request doesn't create a second verification. See [Idempotency](http://localhost:5177/guide/errors.md#idempotency). The response (`201`) is the verification, including `id`, `status: "pending"`, `hosted_url` and `client_token`. Field by field: [`POST /verifications`](http://localhost:5177/api/reference.md#post-verifications). ## 2. Send the person to it **Redirect, link or QR code.** Open `hosted_url`, e.g. `https://app.foid.ng/verify/vs_…`. **Embed it in your web page.** `foid-verify.js` opens the same flow in a modal over your page: ```html ``` | `FoidVerify` | | |---|---| | `open({ clientToken, onComplete, onCancel, onClose, origin, url })` | Opens the modal. Pass `clientToken`, or a full `url` (the `hosted_url`). `origin` overrides where the page is loaded from (defaults to the origin the script came from). Only one modal is open at a time. | | `close()` | Closes it. Escape and the × button also close it. | | `version` | Script version string. | The iframe is allowed camera access. Results are only accepted from the FOID origin, and the modal closes itself shortly after `onComplete` / `onCancel`. **Mobile apps** (React Native, Flutter, native): open `hosted_url` in a WebView or in-app browser **with camera permission**, and watch for navigation to your `return_url`. > **What the browser tells you is a hint only. Always confirm on your server with the `verification.completed` webhook or `GET /verifications/{id}`.** ## 3. What the person sees 1. Your branding, the purpose and the scopes, then a consent screen. Declining cancels the verification. 2. Each step in turn: NIN (number or document scan), BVN, liveness. Each step allows **3 attempts**. 3. A finish screen. With `return_url`, they're redirected after a moment to: ``` https://acme.example/kyc/done?verification_id=…&status=completed&outcome=passed ``` ## 4. Read the result ```python v = foid.get_verification(verification_id) ``` ```js const v = await foid.getVerification(verificationId) ``` ```json { "id": "…", "object": "verification", "status": "completed", "outcome": "passed", "checks": ["nin", "liveness"], "consent": { "status": "active", "scopes": ["identity", "liveness"] }, "reused_foid_credential": false, "results": { "nin": { "status": "passed", "reasons": [], "data": { "method": "number", "nin_last4": "8901", "name_match": true, "dob_match": null } }, "liveness": { "status": "passed", "data": { "face_quality": 0.82 } } } } ``` (Shortened; see the [full response](http://localhost:5177/api/reference.md#get-verifications-verification-id).) **`status`:** `pending` (created) → `in_progress` (consent given) → `completed`, `cancelled` or `expired`. **`outcome`** (once completed): | `outcome` | Meaning | |---|---| | `passed` | Every requested check passed. | | `failed` | At least one check failed or errored, or wasn't finished. | | `needs_review` | Nothing failed, but something needs a human look (e.g. `low_ocr_confidence` on a document scan). | Each entry in `results` is a check result (see [check statuses and reasons](http://localhost:5177/guide/checks.md#statuses-and-reasons)), or `{"status": "pending"}` for a step not done yet. If the person later revokes consent, results come back as `{"status": "passed", "redacted": true}`: you keep the outcome, not the data. `reused_foid_credential: true` means the person signed in with FOID and shared results they had already verified. `hosted_url` and `client_token` are returned while the verification is still `pending` or `in_progress`, so you can resend the link. They become `null` once it's closed, and they are never included in webhooks. ## Webhooks Subscribe to these in [Webhooks](http://localhost:5177/guide/webhooks.md): - `verification.completed`: the full verification (same shape as above, without the link). - `verification.cancelled`: the person declined, or you cancelled. - `verification.expired`: nobody finished it in time. ## Cancel and expiry - **Cancel** an open verification with `POST /verifications/{id}/cancel` (`request()` in the SDKs). Cancelling a closed one returns `409 verification_closed`. - **Expiry:** links live for **24 hours** by default (the FOID server's `VERIFICATION_TTL_HOURS`). After that the status becomes `expired` and the page refuses the link. - **List** with `GET /verifications?status=&customer_ref=&limit=&offset=`. ## Re-verify a returning customer To confirm it's still the same person (before a large withdrawal, a device change or a password reset), create a verification with `checks: ["reverify"]` and the `subject_id`. The person does a liveness check and FOID matches it against the face captured in their earlier verification. ```json { "checks": ["reverify"], "subject_id": "c9981038-…", "purpose": "Confirm it's you before a large transfer" } ``` # NIN and BVN checks Server-to-server lookups for when you already hold the number **and the person's consent**, for example from your own onboarding form. FOID looks the number up and tells you whether the name and date of birth you hold match the record. There's no face check and no FOID page: use [hosted verification](http://localhost:5177/guide/hosted-verification.md) when you need those. ## Run a check ```bash curl https://api.foid.ng/api/open/v1/checks/nin \ -H "Authorization: Bearer $FOID_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "number": "12345678901", "first_name": "Ada", "last_name": "Obi", "date_of_birth": "1994-03-12", "customer_ref": "cust_1001", "consent": { "obtained": true, "purpose": "Account opening" } }' ``` ```python result = foid.check_nin( number="12345678901", first_name="Ada", last_name="Obi", date_of_birth="1994-03-12", customer_ref="cust_1001", consent={"obtained": True, "purpose": "Account opening"}, ) ``` ```js const result = await foid.checkNin({ number: '12345678901', first_name: 'Ada', last_name: 'Obi', date_of_birth: '1994-03-12', customer_ref: 'cust_1001', consent: { obtained: true, purpose: 'Account opening' }, }) ``` `POST /checks/bvn` (`check_bvn` / `checkBvn`) takes the same body. | Field | Notes | |---|---| | `number` | The 11-digit NIN or BVN. Anything else is refused with `400 invalid_nin` / `invalid_bvn`. FOID never stores it: only a keyed hash and the last 4 digits. | | `consent.obtained` | Must be `true`: your statement that the person agreed to this check. | | `consent.purpose` | Why you're checking, 3–200 characters. Recorded with the consent. | | `first_name`, `middle_name`, `last_name` | The name you hold. First and last name are matched against the record. | | `date_of_birth` | `YYYY-MM-DD`. Matched when both sides have it. | | `customer_ref` / `subject_id` | Which [subject](http://localhost:5177/guide/concepts.md#subjects) this belongs to. Reusing `customer_ref` reuses the subject. | | `email`, `phone` | Optional, stored on the subject. | Both endpoints accept an `Idempotency-Key` header, so a retry isn't billed twice. See [Idempotency](http://localhost:5177/guide/errors.md#idempotency). ### Consent attestation `consent.obtained: true` is your legal attestation that you collected the person's consent yourself. FOID records it as a consent grant with source `business_attested` (the weakest kind), and the person sees it labelled that way in their FOID app if they have one. Without `consent`, or with `obtained: false`, the request is refused with `422`. ## The result ```json { "id": "880af60a-e95c-4679-b205-21e710d775e9", "type": "nin", "status": "passed", "reasons": [], "data": { "method": "number", "nin_last4": "8901", "name_match": true, "name_score": 1.0, "dob_match": true, "record": { "first_name": "Ada", "middle_name": null, "last_name": "Obi", "date_of_birth": "1994-03-12" } }, "provider": "mock", "verification_id": null, "subject_id": "c9981038-6db3-4236-8fca-d8bbb89dadfa", "created_at": "2026-11-03T20:25:02.091651+00:00", "customer_ref": "cust_1001" } ``` | Field | Meaning | |---|---| | `status` | `passed`, `failed`, `needs_review` or `error`. See below. | | `reasons` | Why it didn't pass. Empty when `passed`. | | `data.nin_last4` / `data.bvn_last4` | The last 4 digits. Never the full number. | | `data.name_match`, `data.name_score` | Whether the name matched, and how closely (0–1). | | `data.dob_match` | `true` / `false`, or `null` if either side had no date of birth. | | `data.record` | The name and date of birth on the record. | | `subject_id` | The subject this check is now attached to. | | `provider` | Which identity provider answered (`mock` in test mode). | ### Statuses and reasons | `status` | `reasons` | Meaning | |---|---|---| | `passed` | — | Found, and first + last name match (and date of birth, if both sides have one). | | `failed` | `not_found` | No record for this number. | | | `name_mismatch` | Found, but the name you sent doesn't match. | | | `dob_mismatch` | Found, but the date of birth doesn't match. | | | `name_not_supplied` | You didn't send a name to match. | | | `document_unreadable`, `unsupported_document` | Document check: couldn't read an NIN from the image. | | `needs_review` | `low_ocr_confidence` | Document check: an NIN was read, but not confidently. | | `error` | `provider_unavailable`, `ocr_unavailable` | Not the person's fault. Retry later. **Not billed.** | In test mode, the last digit of the number picks the result. See [sandbox numbers](http://localhost:5177/guide/test-and-live.md#sandbox-numbers). Each check also sends a `check.completed` [webhook](http://localhost:5177/guide/webhooks.md) with the same result. ## Check an NIN from a document `POST /checks/nin-document` reads the NIN from a photo of an NIN slip or NIMC card (OCR), then looks it up like `/checks/nin`. The face on the document isn't used. It's a `multipart/form-data` upload: ```bash curl https://api.foid.ng/api/open/v1/checks/nin-document \ -H "Authorization: Bearer $FOID_API_KEY" \ -F file=@nin-slip.jpg \ -F consent_obtained=true \ -F purpose="Account opening" \ -F customer_ref=cust_1001 \ -F first_name=Ada -F last_name=Obi ``` - If you don't send a name, the name printed on the document is used for the match. - `file`: JPEG, PNG or WebP, up to 8 MB (`400 unsupported_file_type`, `413 file_too_large`). - `consent_obtained` must be `true` (`400 consent_required`). - Limited to 30 requests per minute. - The SDKs don't have a helper for this endpoint yet. Call it with your HTTP client. ## Reading checks later Every check is stored on the subject: [`GET /subjects/{id}/checks`](http://localhost:5177/api/reference.md#get-subjects-subject-id-checks) lists the ones your consent still covers. See [Subjects and consent](http://localhost:5177/guide/subjects.md). # Subjects and consent A **subject** is a person as your business knows them. Checks, verifications and Continue with FOID sign-ins all attach to a subject, and every read of a subject goes through the person's consent. Background: [Core concepts](http://localhost:5177/guide/concepts.md#subjects). ## List and read subjects | Endpoint | SDK | Returns | |---|---|---| | `GET /subjects?customer_ref=&limit=&offset=` | `request("GET", "/subjects", …)` | Your subjects, newest first | | `GET /subjects/{id}` | `get_subject(id)` / `getSubject(id)` | One subject, filtered by the scopes you hold | | `GET /subjects/{id}/checks` | `request()` | Every check result your consent still covers | | `GET /subjects/{id}/trust-report` | `request()` | The trust report (scope `financial`) | | `DELETE /subjects/{id}` | `request("DELETE", …)` | Erases the subject (`204`) | ```python subject = foid.get_subject(subject_id) checks = foid.request("GET", f"/subjects/{subject_id}/checks") by_ref = foid.request("GET", "/subjects", params={"customer_ref": "cust_1001"}) ``` ```js const subject = await foid.getSubject(subjectId) const checks = await foid.request('GET', `/subjects/${subjectId}/checks`) const byRef = await foid.request('GET', '/subjects', { params: { customer_ref: 'cust_1001' } }) ``` A subject only shows what its scopes allow: | Always | `profile` or `identity` | `identity` | `contact` | `liveness` | |---|---|---|---|---| | `id`, `customer_ref`, `env`, `consent` (`status`, `scopes`), `has_foid_account`, `created_at` | `first_name`, `middle_name`, `last_name` | `date_of_birth`, `nin` and `bvn` as `{verified, last4}` | `email`, `phone` (masked) | `liveness.passed_at` | `consent.status` is `active`, `revoked`, or `none` (nothing granted yet). Full responses: [`GET /subjects/{id}`](http://localhost:5177/api/reference.md#get-subjects-subject-id). ## Trust report For people with a FOID account who granted you the `financial` scope, the trust report gives verified signals about their identity and connected financial data, and the current **credential state**: ```json { "object": "trust_report", "subject_id": "…", "credential_state": "active", "income_band": "high", "account_stability": "stable", "repayment_reliability": "…", "verification_completeness": 75, "verifications": { "email_verified": true, "nin_verified": true, "bvn_validated": true, "identity_verified": true, "phone_verified": false, "bill_verified": false }, "signals": [{ "message": "…", "change": "active → under_review", "reason": "…", "at": "…" }], "last_refreshed_at": "…", "disclaimer": "Verified signals only. FOID does not score creditworthiness or recommend lending decisions." } ``` - `credential_state` is `active`, `under_review`, `downgraded` or `revoked`. - `verification_completeness` (0–100) is how much of the person's identity is verified on FOID. - `signals` lists dated changes to the credential state. The report is a set of verified signals, **not a score and not a recommendation**. Any decision stays with you. | Error | When | |---|---| | `403 scope_not_granted` | You don't hold an active `financial` grant for this person. | | `403 consent_revoked` | The person revoked your access. | | `409 no_foid_account` | The subject isn't a FOID account holder. | | `404 credential_not_found` | The person has no FOID credential yet. | Subscribe to `credential.state_changed` to hear when the state changes. It's sent only to businesses holding an active `financial` grant. ## When a person revokes consent A person with a FOID account can revoke your access at any time from **Connected apps** in their FOID app. Immediately: - `GET /subjects/{id}` and `/checks` return `403 consent_revoked`. - `GET /verifications/{id}` still shows `status` and `outcome`, but each result becomes `{"status": "…", "redacted": true}`. - Queued webhooks about that person are **cancelled** and can't be re-sent. - You get exactly one `consent.revoked` webhook with the revoked scopes, who revoked and when. Stop using the person's data and delete what you hold, under your own obligations (NDPA 2023). ## People who aren't on FOID (yet) You can verify anyone; they don't need a FOID account. If the person later joins FOID, their subject is linked to the new account only with strong evidence: the NIN you verified must match theirs, **and** either their verified FOID email matches the email you recorded, or their FOID face matches the liveness face you captured. A typed NIN alone is never enough. Once linked: - They see your business in **Connected apps** and can revoke. - You get a `subject.linked` webhook, and `has_foid_account` becomes `true`. - You get **no new data**. To read FOID-held data (such as the trust report), ask in a new verification or a Continue with FOID sign-in, and they approve. Test-mode subjects are never linked to real FOID accounts automatically. ## Delete a subject `DELETE /subjects/{id}` erases the subject and everything FOID holds about it **for your business**: check results, the liveness face, consent records. Pending webhooks about the subject are cancelled. It returns `204` with no body; reading the subject afterwards gives `404 subject_not_found`. ```python foid.request("DELETE", f"/subjects/{subject_id}") # returns None ``` ```js await foid.request('DELETE', `/subjects/${subjectId}`) // resolves to null ``` This doesn't touch the person's own FOID account, or other businesses' subjects. # Webhooks FOID sends signed `POST` requests to your server when something happens: a verification finishes, a check completes, a person revokes consent. Use webhooks rather than polling, and to confirm anything a browser told you. ## Add an endpoint In the console, open **API keys & webhooks** and add an endpoint, with the console in the environment you want (test or live). Choose the events (or all of them). The **signing secret** (`whsec_…`) is shown **once**. You can roll it later. Each endpoint belongs to one environment. Test events never go to live endpoints. ## Events | Event | When | `data` | |---|---|---| | `verification.completed` | A hosted verification finished | The verification (no `hosted_url` / `client_token`) | | `verification.cancelled` | The person declined consent, or you cancelled | `{id, status, …}` (with `reason: "consent_declined"` when the person declined) | | `verification.expired` | Nobody finished it before the link expired | `{id, status: "expired"}` | | `check.completed` | A server-to-server check finished | The check result | | `consent.revoked` | The person revoked your access | `subject_id`, `customer_ref`, `scopes`, `revoked_by`, `revoked_at` | | `subject.linked` | A subject you verified was linked to a FOID account | `{subject_id, customer_ref, foid_account: true}` | | `credential.state_changed` | A FOID credential changed state. Only sent if you hold the `financial` scope. | `subject_id`, `customer_ref`, `previous_state`, `credential_state`, … | | `webhook.test` | You pressed **Send test** in the console | `{"message": "Hello from FOID"}` | Every event has the same envelope: ```json { "id": "evt_5b2c…", "type": "verification.completed", "env": "live", "created": 1793560000, "data": { "…": "…" } } ``` Each request has these headers: | Header | | |---|---| | `FOID-Signature` | `t=,v1=`. Verify it, see below. | | `FOID-Event-Id` | The event `id`. Use it to ignore duplicates. | | `Content-Type` | `application/json` | | `User-Agent` | `FOID-Webhooks/1` | ## Verify the signature The signature is an HMAC-SHA256 of `"."` with your endpoint's signing secret. Always verify it on the **raw request body** (the exact bytes you received, not re-serialised JSON), and reject anything older than 5 minutes. ```python import os from fastapi import FastAPI, HTTPException, Request from foid import verify_webhook, WebhookError app = FastAPI() SECRET = os.environ["FOID_WEBHOOK_SECRET"] @app.post("/webhooks/foid") async def foid_webhook(request: Request): raw = await request.body() try: event = verify_webhook(raw, request.headers.get("FOID-Signature", ""), SECRET) except WebhookError: raise HTTPException(400, "bad signature") if event["type"] == "verification.completed": ... # update your records, keyed by event["data"]["id"] return {"received": True} ``` ```js import express from 'express' import { verifyWebhook } from '@foid/node' const app = express() // express.raw: the signature is over the exact bytes, so don't parse JSON first app.post('/webhooks/foid', express.raw({ type: 'application/json' }), (req, res) => { let event try { event = verifyWebhook(req.body, req.get('FOID-Signature'), process.env.FOID_WEBHOOK_SECRET) } catch { return res.status(400).send('bad signature') } if (event.type === 'verification.completed') { // update your records, keyed by event.data.id } res.json({ received: true }) }) ``` ```python import hashlib, hmac, time def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) if abs(time.time() - int(parts["t"])) > tolerance: return False expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts["v1"]) ``` The SDKs raise `WebhookError` (Python) or an `Error` (Node) when the header is missing or malformed, the timestamp is outside the tolerance (default 300 seconds), or the signature doesn't match. On success they return the parsed event. ## Delivery and retries - Respond with any **2xx** within 10 seconds. Do the slow work afterwards. - Anything else (non-2xx, timeout, connection error) is retried with backoff: after 10 s, 30 s, 2 min, 10 min, 30 min, 1 h and 3 h. After 8 failed attempts in total the delivery is marked **failed**, and your developers get an alert in the console. - FOID doesn't follow redirects. Point the endpoint at the final URL. - Deliveries can arrive more than once and out of order. De-duplicate on `FOID-Event-Id`, and read the current state from the API if order matters. **Consent comes first.** A delivery about a person is cancelled at send time if you no longer hold an active consent grant for them, even if it was queued before they revoked. Those deliveries can't be re-sent. `consent.revoked` itself is always delivered. ## Delivery log, retry and test In the console under **API keys & webhooks**, each endpoint shows its recent deliveries (status, attempts, last HTTP code, last error and payload). You can **retry** a delivery and **send a test** `webhook.test` event at any time. ## Allowed URLs A webhook URL is something FOID's server calls, so FOID checks it to make sure it can't be pointed at FOID's own network. | | Test endpoints | Live endpoints | |---|---|---| | Scheme | `http://` or `https://` | `https://` only | | `localhost`, `127.0.0.1`, `::1` | Allowed, so you can receive webhooks on your own machine. A FOID server with `ENV=production` refuses them unless `WEBHOOK_ALLOW_LOCALHOST=true`. | Refused | | Private, link-local, carrier-grade NAT, reserved, multicast or unspecified addresses (`10.x`, `192.168.x`, `169.254.169.254`, `fd00::/8`, `0.0.0.0`, …) | Refused | Refused | | Internal hostnames: single-label names (`intranet`) and `*.internal`, `*.local`, `*.localdomain`, `*.lan`, `*.home`, `*.corp`, `*.intranet`, `*.home.arpa` | Refused | Refused | | A username or password in the URL | Refused | Refused | A refused URL is rejected when you save it with `400 invalid_webhook_url` and a message saying why. FOID checks again at **every delivery**: it resolves the hostname, refuses if **any** address is internal, and connects to the address it checked (the `Host` header and the TLS certificate check still use your hostname). A DNS record changed to an internal address after you saved the URL can't redirect deliveries. A refused delivery shows `blocked: …` as its last error. # Going live Everything you build in test mode works in live mode with live credentials. Live mode adds real data, billing, and stricter rules. ## 1. Verify your business (KYB) Live mode is locked until FOID approves your business. In the console, open **Go live**: 1. Fill in your business details: CAC registration number (RC), TIN and registered address. 2. Upload the required documents. 3. Submit. FOID reviews them and emails your organisation's owners with the result. If something is missing, the review note says what. Until approval, live calls are refused with `403 live_mode_disabled`, and you can't create live API keys or live sign-in apps. ## 2. Create live credentials Switch the console to **Live**, then: | Credential | Where | Notes | |---|---|---| | Live API key (`FOID_LIVE_…`) | API keys & webhooks | Shown once. Store it in your secret manager, not in code. | | Live sign-in app (`foid_app_live_…`) | Sign-in apps | Exact `https://` origins and redirect URIs. No localhost. Prefer a [key pair](http://localhost:5177/guide/continue-with-foid.md#key-pair-private-key-jwt). | | Live webhook endpoint | API keys & webhooks | `https://` only, public address. See [allowed URLs](http://localhost:5177/guide/webhooks.md#allowed-urls). | Then point your production config at them. Nothing else in your code changes: responses have the same shape in both modes. ## 3. What changes in live - **Real lookups.** NIN / BVN checks go to the identity provider; there are no sandbox numbers. - **Billing.** Each check is billed (checks with `status: "error"` are not). See **Usage & Billing** in the console. - **Continue with FOID** shares the person's real verified data. The first time a person shares with you, they do a quick face re-match. People missing a required step (liveness enrolment, or NIN when you ask for `identity`) complete it in the popup. - **Stricter URLs.** https only, everywhere. ## 4. Lock it down - **IP allow-list.** In **Settings**, restrict live API keys to your servers' IP addresses or CIDR ranges. Calls from anywhere else get `403 ip_not_allowed`. This applies to live API keys, not to test keys or the token endpoint. - **Two-step sign-in for the team.** In **Settings**, require every team member to use an authenticator app to open your organisation. - **Key creation alerts.** Owners and admins are emailed when an API key is created (on by default, in **Settings**). - **Least privilege.** Give teammates the lowest role that works (**Team**). Developers can manage keys and webhooks. - **Rotate secrets.** - API keys: create the new key, deploy it, then revoke the old one. - Client secrets: rotate in **Sign-in apps**. The old secret keeps working for 24 hours so you can deploy without downtime. - Webhook signing secrets: roll from the endpoint. Deploy the new secret right away. ## 5. Monitor - **Request logs** show every Open API call and token exchange, with its `FOID-Request-Id`. - The webhook **delivery log** shows failures. Developers are alerted when an endpoint keeps failing. - Your console notifications flag security events, such as a sign-in code used twice. Before launch, run through the [Security checklist](http://localhost:5177/guide/security.md). # Security checklist Go through this before you go live, and again whenever you change how you handle FOID credentials. ## Credentials - [ ] The **API key**, **client secret** and **private key** exist only on your servers, in a secret manager or environment variables. Never in front-end code, mobile apps, git or logs. - [ ] The browser and mobile apps only ever see the **client ID** (and a verification's `client_token`, which works for that one verification only). - [ ] Live sign-in apps use a **key pair** (`private_key_jwt`) where possible. - [ ] You know how to rotate each credential, and who can. See [Going live](http://localhost:5177/guide/going-live.md#_4-lock-it-down). - [ ] An **IP allow-list** is set for live API keys. - [ ] Your team uses two-step sign-in, and roles are as low as they can be. ## Continue with FOID - [ ] Codes are exchanged on your **server**, **once**, within 60 seconds. A second exchange revokes the grant. - [ ] You let the SDK check `state` (it does by default), and pass and compare a `nonce` if you bind sign-in to a session. - [ ] You trust only the exchange result, never anything the browser reports about the person. - [ ] Live origins and redirect URIs are exact `https://` URLs you control. - [ ] You key your users on `subject_id`, not on email or name. ## Webhooks - [ ] Every webhook is verified with the signing secret against the **raw** body, and rejected if older than 5 minutes. - [ ] Your handler is idempotent (de-duplicates on `FOID-Event-Id`) and returns 2xx quickly. - [ ] The endpoint is `https://` on a public address. ## Hosted verification - [ ] You confirm the result on your server (`verification.completed` webhook or `GET /verifications/{id}`). Never act on `onComplete` or `return_url` alone. - [ ] You send an `Idempotency-Key` when creating verifications. ## Personal data and consent - [ ] For server-to-server checks, you really collected the person's consent before sending `consent.obtained: true`. It's your legal attestation. - [ ] You ask for the fewest scopes you need. - [ ] On `consent.revoked`, you stop using the person's data and delete what you hold, under your obligations (NDPA 2023). - [ ] You use `DELETE /subjects/{id}` when a customer asks you to erase their data. - [ ] You don't log full request or response bodies that contain personal data. ## Operations - [ ] Errors are handled by `error.code`, with retries and backoff for `429` and `5xx`. - [ ] You record `FOID-Request-Id` from responses, so you can trace a call in **Request logs** and with support. # Errors, limits, idempotency ## Error shape Open API errors look like this: ```json { "error": { "type": "permission", "code": "consent_revoked", "message": "The person has revoked this business's access to their data", "param": null } } ``` - **`code`** is stable. Branch on it. - **`message`** is for humans and may change. - **`param`** names the field at fault, when there is one. - **`type`** groups codes by HTTP status: `invalid_request` (400/413/422), `authentication` (401), `permission` (403), `not_found` (404), `conflict` (409), `gone` (410), `rate_limit` (429), `provider` (502/503). Two responses use a different shape: - **`422` validation errors** (missing field, wrong type, bad format) come back as a list: ```json { "detail": [{ "type": "missing", "loc": ["body", "consent"], "msg": "Field required" }] } ``` - **`429` rate limit:** `{"error": "Rate limit exceeded: 120 per 1 minute"}`. The [token endpoint](http://localhost:5177/api/reference.md#post-oauth-token) uses OAuth's format instead: `{"error": "invalid_grant", "error_description": "…"}`. See [Continue with FOID: errors](http://localhost:5177/guide/continue-with-foid.md#errors). In the SDKs, every non-2xx response raises `FoidError` with `status`, `code`, `message` (Python) or `description` (Node), and the parsed `body`. For the two shapes above, `code` is `http_error` and the details are in `body`. ## Error codes | HTTP | `code` | Meaning | What to do | |---|---|---|---| | 400 | `invalid_nin`, `invalid_bvn` | The number isn't exactly 11 digits | Fix the input | | 400 | `consent_required` | Document check sent without `consent_obtained=true` | Collect and send consent | | 400 | `unsupported_file_type` | Document isn't JPEG, PNG or WebP | Re-upload | | 400 | `invalid_webhook_url` | The webhook URL isn't allowed. See [allowed URLs](http://localhost:5177/guide/webhooks.md#allowed-urls). | Use a public https URL | | 401 | `missing_api_key` | No `Authorization: Bearer …` header | Send the key | | 401 | `invalid_api_key` | Unknown, malformed or revoked key | Check which key you deployed | | 403 | `live_mode_disabled` | Live mode isn't enabled yet (KYB) | Use a test key, or [go live](http://localhost:5177/guide/going-live.md) | | 403 | `ip_not_allowed` | Live key used from an IP outside your allow-list | Call from an allow-listed server | | 403 | `organisation_suspended` | The organisation isn't active | Contact FOID | | 403 | `consent_revoked` | The person revoked your access | Stop and delete, see [revocation](http://localhost:5177/guide/subjects.md#when-a-person-revokes-consent) | | 403 | `scope_not_granted` | You don't hold the scope this read needs | Ask for it in a new verification or sign-in | | 404 | `subject_not_found` | No such subject in this environment | Check the ID and the key's environment | | 404 | `verification_not_found` | No such verification in this environment | Check the ID and the key's environment | | 404 | `credential_not_found` | The person has no FOID credential yet (trust report) | — | | 409 | `verification_closed` | The verification is already completed or cancelled | — | | 409 | `idempotency_key_reused` | Same `Idempotency-Key`, different request body | Use a new key per distinct request | | 409 | `no_face_on_file` | `reverify` for a subject without an earlier liveness face | Use `liveness` first | | 409 | `no_foid_account` | Trust report for someone without a FOID account | — | | 413 | `file_too_large` | Document image over 8 MB | Re-upload smaller | | 422 | (validation) | Body failed validation. See `detail`. | Fix the request | | 429 | (rate limit) | Too many requests | Back off and retry | The hosted page and the sign-in popup show their own errors to the person (for example `too_many_attempts` after 3 tries on one step, or `verification_expired`). Your server sees the outcome on the verification instead. ## Idempotency Network retries shouldn't create a second verification or pay for a second check. `POST /verifications`, `POST /checks/nin` and `POST /checks/bvn` accept an **`Idempotency-Key`** header: - Same key + same body: you get the **original response** back, with the header `Idempotent-Replayed: true`. Nothing new is created or billed. - Same key + different body: `409 idempotency_key_reused`. - Keys are remembered for 24 hours, per organisation and environment. Use something unique to the action, such as `onboard-` or a UUID you store with the request. ```bash curl https://api.foid.ng/api/open/v1/verifications \ -H "Authorization: Bearer $FOID_API_KEY" \ -H "Idempotency-Key: onboard-cust_1001" \ -H "Content-Type: application/json" \ -d '{"checks": ["nin"], "purpose": "Open a wallet", "customer_ref": "cust_1001"}' ``` ```python v = foid.create_verification(checks=["nin"], purpose="Open a wallet", customer_ref="cust_1001", idempotency_key="onboard-cust_1001") ``` ```js const v = await foid.createVerification({ checks: ['nin'], purpose: 'Open a wallet', customer_ref: 'cust_1001', idempotencyKey: 'onboard-cust_1001' }) ``` The SDKs' `check_nin` / `checkNin` don't take an idempotency key. For idempotent checks, call `request("POST", "/checks/nin", json=…, idempotency_key=…)` (Python) or `request('POST', '/checks/nin', { json, idempotencyKey })` (Node). ## Rate limits | Endpoints | Limit | |---|---| | `POST /verifications`, `POST /checks/nin`, `POST /checks/bvn` | 120 requests per minute per API key | | `POST /checks/nin-document` | 30 per minute | | `POST /oauth/token` | 60 per minute | Over the limit you get `429`. Wait and retry with exponential backoff. ## Request IDs Every Open API response has a **`FOID-Request-Id`** header. The same ID appears in the console under **Request logs**. Log it with your own request, and quote it when you contact support. # Use FOID docs with AI Building with an AI assistant? Give it the FOID docs, so it writes code against the real SDKs and API instead of guessing. There are three ways to do it: | | Best for | Setup | |---|---|---| | [Copy page / Open in…](#copy-a-page) | A quick question about one page | None — the menu at the top of every page | | [`llms.txt`](#llms-txt) | Pasting all the docs into a chat or a prompt | None — a URL | | [MCP server](#mcp-server) | Your editor or agent searching the docs while it works | One command | ## Copy a page Every page has a **Copy page** button at the top right. Its menu has: - **Copy page** — the page as clean markdown (code samples included), ready to paste into any AI tool. - **View as Markdown** — the same text in your browser. Every page has one: add `.md` to the URL, e.g. /guide/quickstart.md. - **Open in ChatGPT / Claude / Perplexity** — opens a new chat that reads this page. When you run the docs on your own machine (`localhost`), those services can't reach it, so the page is copied to your clipboard and the chat asks you to paste it. ## llms.txt The docs follow the [llms.txt](https://llmstxt.org) convention: | URL | What it is | |---|---| | /llms.txt | An index: every page with a one-line summary and a link to its markdown | | /llms-full.txt | Every page in one file — paste it into a chat or a system prompt | | `/.md` | One page as markdown | All three are generated from the same pages you're reading, every time the docs are built, so they never drift. ## MCP server FOID runs a [Model Context Protocol](https://modelcontextprotocol.io) server that lets AI tools search and read these docs on their own. It's read-only, needs no key, and only serves the public docs — nothing about any person or business. | | | |---|---| | URL | `https://api.foid.ng/mcp` | | Running FOID locally | `http://localhost:8000/mcp` (the backend; it reads the docs from the docs site, so run `pnpm dev` in `docs/` too) | | Transport | Streamable HTTP | **Tools** | Tool | What it does | |---|---| | `search_foid_docs` | `query`, optional `limit` (1–10) → the best-matching sections, each with an excerpt and a link | | `read_foid_doc` | `path` (e.g. `/guide/continue-with-foid`) → that page as markdown | | `list_foid_docs` | Every page, grouped by section | ### Add it to your tool ```bash claude mcp add --transport http foid-docs https://api.foid.ng/mcp ``` ```json { "mcpServers": { "foid-docs": { "url": "https://api.foid.ng/mcp" } } } ``` ```json { "servers": { "foid-docs": { "type": "http", "url": "https://api.foid.ng/mcp" } } } ``` ```json { "mcpServers": { "foid-docs": { "serverUrl": "https://api.foid.ng/mcp" } } } ``` ```json { "mcpServers": { "foid-docs": { "command": "npx", "args": ["-y", "mcp-remote", "https://api.foid.ng/mcp"] } } } ``` - **Claude (desktop and claude.ai):** Settings → Connectors → **Add custom connector**, name it *FOID Docs*, URL `https://api.foid.ng/mcp`. - **ChatGPT:** Settings → Apps & Connectors → Advanced settings → turn on **Developer mode**, then **Create** a connector with the URL `https://api.foid.ng/mcp` and no authentication. Claude and ChatGPT connect from their own servers, so they need the public `https://` URL. Editor tools like Claude Code, Cursor and VS Code run on your machine and can use `http://localhost:8000/mcp`. Then just ask, for example: *"Using the FOID docs, add Continue with FOID to my Next.js app with a Node backend."* ### Check it's working ```bash curl -s https://api.foid.ng/mcp -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_foid_docs","arguments":{"query":"verify webhook signature"}}}' ``` You should get back sections from the [Webhooks](http://localhost:5177/guide/webhooks.md) guide. > **Running FOID yourself** The MCP server is part of the backend: `POST /mcp`. It loads the docs from `DOCS_SITE_URL/docs-index.json` and caches them for 10 minutes. Turn it off with `DOCS_MCP=false`. # SDKs | Package | Runs on | What it does | |---|---|---| | [`foid`](http://localhost:5177/sdks/python.md) | Your server: Python ≥ 3.10 | Continue with FOID code exchange (client secret or key pair), the Open API, webhook verification, PKCE helpers, `python -m foid keygen` | | [`@foid/node`](http://localhost:5177/sdks/node.md) | Your server: Node ≥ 18, no dependencies | The same as Python, for Node. `npx foid keygen` | | [`@foid/react`](http://localhost:5177/sdks/react.md) | The browser | ``, the `` button, `useFoidLogin()`, plus a framework-free core (`@foid/react/core`) | The server SDKs hold your secrets. The React SDK only ever has your public **client ID**. A typical setup uses one of each: the button in your front end, and the server SDK in the endpoint that receives the code. ## Install > **Not on PyPI / npm yet** The SDKs are not published to the public registries yet. Install them from the FOID repository, or from packed files. **From the repository** (with the FOID repo checked out next to your project): ```bash pip install "./sdks/python[keypair]" # or, in a uv project: uv add ./sdks/python --extra keypair ``` ```bash npm install ./sdks/node ``` ```bash npm install ./sdks/react ``` The `keypair` extra pulls in `cryptography`, which is only needed for key pairs (`private_key_jwt`) and `keygen`. Client-secret apps and the Open API need only `httpx`. **From packed files.** This is how the [Harbor example app](http://localhost:5177/sdks/example-app.md) does it, as if the SDK came from a registry: ```bash # from the FOID repo; OUT is a vendor/ folder in your project OUT=/path/to/your-project/vendor (cd sdks/python && uv build --wheel -o "$OUT") # foid-0.1.0-py3-none-any.whl (cd sdks/node && npm pack --pack-destination "$OUT") # foid-node-0.1.0.tgz (cd sdks/react && npm pack --pack-destination "$OUT") # foid-react-0.1.0.tgz ``` Then depend on the file: `"@foid/node": "file:vendor/foid-node-0.1.0.tgz"` in `package.json`, or `foid = { path = "vendor/foid-0.1.0-py3-none-any.whl" }` under `[tool.uv.sources]`. ## Configuration The server SDKs default to the hosted API at `https://api.foid.ng`. When you run FOID yourself, pass the base URL: ```python from foid import Foid foid = Foid(api_key="FOID_TEST_…", base_url="http://localhost:8000") ``` ```js import { Foid } from '@foid/node' const foid = new Foid({ apiKey: 'FOID_TEST_…', baseUrl: 'http://localhost:8000' }) ``` The React SDK defaults to `https://app.foid.ng` for the popup. Pass `foidUrl="http://localhost:5173"` to `` locally. ## Which credential goes where | Credential | Python | Node | React | |---|---|---|---| | API key (`FOID_TEST_…`) | `api_key=` | `apiKey` | never | | Client ID (`foid_app_…`) | `client_id=` | `clientId` | `` | | Client secret (`foid_cs_…`) | `client_secret=` | `clientSecret` | never | | Private key PEM | `private_key_pem=` | `privateKeyPem` | never | | Webhook secret (`whsec_…`) | `verify_webhook(…, secret)` | `verifyWebhook(…, secret)` | never | One client object can hold several credentials. It uses the API key for the Open API, and the client secret or private key for `oauth.exchange`. See [Keys and credentials](http://localhost:5177/guide/concepts.md#keys-and-credentials). ## See it working - [Playground](http://localhost:5177/playground.md): the real button, in your browser. - [Harbor example app](http://localhost:5177/sdks/example-app.md): a complete app with a React front end and a Python or Node back end, exercising every SDK feature. # Python SDK — `foid` A server-side SDK for Python ≥ 3.10. It covers the Continue with FOID code exchange (client secret or key pair), the Open API, webhook signature verification and PKCE helpers. It's synchronous (built on `httpx`) and holds secrets, so **never ship it to a client**. ```bash pip install "./sdks/python[keypair]" # see SDKs → Install; not on PyPI yet ``` The `keypair` extra adds `cryptography`, needed only for key pairs and `python -m foid keygen`. ## Quick start ```python import os from foid import Foid, FoidError foid = Foid( api_key=os.environ["FOID_API_KEY"], # Open API client_id=os.environ["FOID_CLIENT_ID"], # Continue with FOID client_secret=os.environ["FOID_CLIENT_SECRET"], ) # Continue with FOID: the code + verifier from your front end person = foid.oauth.exchange(code, code_verifier) print(person["subject_id"], person["subject"]["first_name"]) # Open API result = foid.check_nin(number="12345678901", first_name="Ada", last_name="Obi", consent={"obtained": True, "purpose": "Account opening"}) ``` ## `Foid` ```python Foid(*, api_key=None, client_id=None, client_secret=None, private_key_pem=None, key_id=None, base_url="https://api.foid.ng", timeout=15.0) ``` All arguments are keyword-only. Pass only the credentials you use. | Argument | Used for | |---|---| | `api_key` | The Open API (`request()` and the helpers). `FOID_TEST_…` / `FOID_LIVE_…`. | | `client_id` | `oauth.exchange()`: your sign-in app's client ID. | | `client_secret` | `oauth.exchange()` for client-secret apps. | | `private_key_pem` | `oauth.exchange()` for key-pair apps. If set, it's used instead of `client_secret`. | | `key_id` | Optional `kid` put in the assertion header (the `kid` from your public JWK). | | `base_url` | The FOID API host, e.g. `http://localhost:8000` when running FOID locally. A trailing `/` is ignored. | | `timeout` | HTTP timeout in seconds. | The client keeps one `httpx.Client` open. Close it with `foid.close()`, or use it as a context manager: ```python with Foid(api_key=key) as foid: foid.request("GET", "/ping") ``` ### `foid.oauth.exchange(code, code_verifier, redirect_uri=None)` Exchanges the one-time code from the sign-in popup for the person's result. Calls `POST /api/open/v1/oauth/token`. Do it **once, within 60 seconds**. - With `client_secret`: sends HTTP Basic `client_id:client_secret`. - With `private_key_pem`: sends a signed `client_assertion` (ES256, valid 60 seconds, `aud` = the token URL). - `redirect_uri` is required only in redirect mode, and must be the exact URI used to start sign-in. Returns the result dict (`object: "foid_sign_in"`, `subject_id`, `subject`, `scopes`, `checks`, `foid_account`, `face_rematched_at`, `nonce`, `sandbox`, `env`). See [the result](http://localhost:5177/guide/continue-with-foid.md#the-result). Raises: - `ValueError` if `client_id` is missing, or there's neither `client_secret` nor `private_key_pem`. - `FoidError` from the token endpoint, with `code` set to the OAuth error: `invalid_grant` (expired, used, wrong verifier) or `invalid_client` (wrong secret or assertion). ```python try: person = foid.oauth.exchange(code, code_verifier) except FoidError as e: if e.code == "invalid_grant": ... # ask the person to sign in again ``` ### `foid.request(method, path, *, json=None, params=None, idempotency_key=None)` Calls any Open API endpoint with your API key. `path` is relative to `/api/open/v1`, e.g. `"/subjects"`. - `json`: the request body. `params`: query parameters. `idempotency_key`: sent as the `Idempotency-Key` header. - Returns the parsed JSON, or `None` for an empty response (e.g. `204` from `DELETE`). - Raises `ValueError` if `api_key` isn't set, and `FoidError` for any `4xx` / `5xx`. ```python foid.request("GET", "/verifications", params={"status": "pending"}) foid.request("POST", f"/verifications/{vid}/cancel") foid.request("GET", f"/subjects/{sid}/trust-report") foid.request("DELETE", f"/subjects/{sid}") # None foid.request("POST", "/checks/nin", json=body, idempotency_key="chk-cust_1001") ``` ### Open API helpers Shortcuts for `request()`. Keyword arguments become the JSON body. | Method | Calls | |---|---| | `get_subject(subject_id)` | `GET /subjects/{id}` | | `create_verification(**body)` | `POST /verifications`. Pass `idempotency_key=` to send the header; it isn't part of the body. | | `get_verification(verification_id)` | `GET /verifications/{id}` | | `check_nin(**body)` | `POST /checks/nin` | | `check_bvn(**body)` | `POST /checks/bvn` | ```python v = foid.create_verification(checks=["nin", "liveness"], purpose="Open a wallet", customer_ref="cust_1001", first_name="Ada", last_name="Obi", return_url="https://acme.example/kyc/done", idempotency_key="onboard-cust_1001") print(v["hosted_url"]) ``` Field details: [Hosted verification](http://localhost:5177/guide/hosted-verification.md), [NIN and BVN checks](http://localhost:5177/guide/checks.md), [API reference](http://localhost:5177/api/reference.md). ### `foid.webhooks.verify(payload, header, secret, tolerance=300)` The same as the module-level [`verify_webhook`](#verify-webhook). ## `verify_webhook(payload, header, secret, tolerance=300)` ```python from foid import verify_webhook, WebhookError event = verify_webhook(raw_body, request.headers.get("FOID-Signature", ""), "whsec_…") ``` Checks the `FOID-Signature` header (`t=…,v1=…`) against the **raw** body (`bytes` or `str`) and returns the parsed event dict. Raises `WebhookError` if the header is missing or malformed, the timestamp is more than `tolerance` seconds away from now, or no `v1` signature matches. See [Webhooks](http://localhost:5177/guide/webhooks.md#verify-the-signature). ## `create_pkce()` ```python from foid import create_pkce p = create_pkce() # {"code_verifier", "code_challenge", "state", "nonce"} ``` For servers that start the sign-in themselves (server-led PKCE). Keep `code_verifier`, `state` and `nonce` on the server; put `code_challenge` (S256), `state` and `nonce` in the authorize URL. See [Server-led sign-in](http://localhost:5177/guide/continue-with-foid.md#server-led-sign-in). ## Key pairs — `foid.keypair` Needs the `keypair` extra. | Function | Returns | |---|---| | `generate_keypair()` | `(private_key_pem, public_jwk)`: a new EC P-256 key. Keep the PEM secret; paste the JWK (with a random `kid`) into the console. | | `client_assertion(private_key_pem, client_id, audience, kid=None)` | The signed ES256 JWT that `oauth.exchange` sends (`iss` = `sub` = client ID, `aud` = audience, 60-second expiry, random `jti`). You rarely need it directly. | From the command line: ```bash python -m foid keygen ``` This writes `foid_private_key.pem` in the current directory (refusing to overwrite an existing one) and prints the public JWK to paste into **Sign-in apps → Edit → Key pair**. Then: ```python foid = Foid(client_id="foid_app_live_…", private_key_pem=open("foid_private_key.pem").read(), key_id="") ``` ## Errors ### `FoidError` Raised for any non-2xx response. | Attribute | | |---|---| | `status` | HTTP status | | `code` | The Open API `error.code` (e.g. `consent_revoked`), the OAuth `error` for the token endpoint (e.g. `invalid_grant`), or `http_error` when the body has neither (e.g. `422` validation errors, `429`) | | `message` | Human-readable message | | `body` | The parsed response body, when it was JSON | ```python try: foid.get_subject(sid) except FoidError as e: if e.code == "consent_revoked": ... # stop using and delete this person's data elif e.status == 422: print(e.body["detail"]) ``` Network failures and timeouts surface as `httpx` exceptions (`httpx.HTTPError`). Missing credentials raise `ValueError`. ### `WebhookError` Raised by `verify_webhook` / `webhooks.verify` when a signature can't be verified. ## Reference | Name | Kind | |---|---| | `Foid` | client class | | `FoidError`, `WebhookError` | exceptions | | `create_pkce` | function | | `verify_webhook` | function | | `foid.keypair.generate_keypair`, `foid.keypair.client_assertion` | functions (`keypair` extra) | | `foid.__version__` | `"0.1.0"` | # Node SDK — `@foid/node` A server-side SDK for Node ≥ 18. It covers the Continue with FOID code exchange (client secret or key pair), the Open API, webhook signature verification and PKCE helpers. It's ESM, has **no dependencies** (it uses `fetch` and `node:crypto`), and ships TypeScript types. It holds secrets, so **never bundle it into a browser app**. ```bash npm install ./sdks/node # see SDKs → Install; not on npm yet ``` ## Quick start ```js import { Foid, FoidError } from '@foid/node' const foid = new Foid({ apiKey: process.env.FOID_API_KEY, // Open API clientId: process.env.FOID_CLIENT_ID, // Continue with FOID clientSecret: process.env.FOID_CLIENT_SECRET, }) // Continue with FOID: the code + verifier from your front end const person = await foid.oauth.exchange(code, codeVerifier) console.log(person.subject_id, person.subject.first_name) // Open API const result = await foid.checkNin({ number: '12345678901', first_name: 'Ada', last_name: 'Obi', consent: { obtained: true, purpose: 'Account opening' }, }) ``` ## `new Foid(options)` | Option | Used for | |---|---| | `apiKey` | The Open API (`request()` and the helpers). `FOID_TEST_…` / `FOID_LIVE_…`. | | `clientId` | `oauth.exchange()`: your sign-in app's client ID. | | `clientSecret` | `oauth.exchange()` for client-secret apps. | | `privateKeyPem` | `oauth.exchange()` for key-pair apps. If set, it's used instead of `clientSecret`. | | `keyId` | Optional `kid` put in the assertion header. | | `baseUrl` | The FOID API host. Default `https://api.foid.ng`; e.g. `http://localhost:8000` locally. | | `fetch` | A custom `fetch` implementation (for tests, proxies or retries). Default `globalThis.fetch`. | All options are optional. Pass only the credentials you use. ### `foid.oauth.exchange(code, codeVerifier, { redirectUri }?)` Exchanges the one-time code from the sign-in popup for the person's result. Calls `POST /api/open/v1/oauth/token`. Do it **once, within 60 seconds**. - With `clientSecret`: sends HTTP Basic `clientId:clientSecret`. - With `privateKeyPem`: sends a signed `client_assertion` (ES256, valid 60 seconds, `aud` = the token URL). - `redirectUri` is required only in redirect mode, and must be the exact URI used to start sign-in. Resolves to the result (`FoidSignInResult` in the types). See [the result](http://localhost:5177/guide/continue-with-foid.md#the-result). Rejects with: - `Error('clientId is required')`, or `Error('Pass clientSecret, or privateKeyPem for a key-pair app')`. - `FoidError` from the token endpoint, with `code` set to the OAuth error: `invalid_grant` or `invalid_client`. ```js try { const person = await foid.oauth.exchange(code, codeVerifier) } catch (e) { if (e instanceof FoidError && e.code === 'invalid_grant') { // ask the person to sign in again } } ``` ### `foid.request(method, path, { json, params, idempotencyKey }?)` Calls any Open API endpoint with your API key. `path` is relative to `/api/open/v1`. - `json`: the request body. `params`: query parameters (an object). `idempotencyKey`: sent as `Idempotency-Key`. - Resolves to the parsed JSON, or `null` for `204` (e.g. `DELETE`). - Rejects with `Error('apiKey is required for the Open API')` without an API key, and `FoidError` for any `4xx` / `5xx`. ```js await foid.request('GET', '/verifications', { params: { status: 'pending' } }) await foid.request('POST', `/verifications/${vid}/cancel`) await foid.request('GET', `/subjects/${sid}/trust-report`) await foid.request('DELETE', `/subjects/${sid}`) // null await foid.request('POST', '/checks/nin', { json: body, idempotencyKey: 'chk-cust_1001' }) ``` ### Open API helpers | Method | Calls | |---|---| | `getSubject(id)` | `GET /subjects/{id}` | | `createVerification({ idempotencyKey, ...body })` | `POST /verifications`. `idempotencyKey` becomes the header; the rest is the body. | | `getVerification(id)` | `GET /verifications/{id}` | | `checkNin(body)` | `POST /checks/nin` | | `checkBvn(body)` | `POST /checks/bvn` | Request bodies use the API's snake_case field names (`customer_ref`, `first_name`, `return_url`, …). ```js const v = await foid.createVerification({ checks: ['nin', 'liveness'], purpose: 'Open a wallet', customer_ref: 'cust_1001', first_name: 'Ada', last_name: 'Obi', return_url: 'https://acme.example/kyc/done', idempotencyKey: 'onboard-cust_1001', }) console.log(v.hosted_url) ``` ### `foid.webhooks.verify(payload, header, secret, toleranceSeconds?)` The same as [`verifyWebhook`](#verifywebhook). ## `verifyWebhook(payload, header, secret, toleranceSeconds = 300)` ```js import { verifyWebhook } from '@foid/node' const event = verifyWebhook(rawBody, req.headers['foid-signature'], process.env.FOID_WEBHOOK_SECRET) ``` Checks the `FOID-Signature` header against the **raw** body (`Buffer` or string) and returns the parsed event. Throws an `Error` if the header is missing or malformed, the timestamp is outside the tolerance, or the signature doesn't match. With Express, read the body with `express.raw({ type: 'application/json' })`. See [Webhooks](http://localhost:5177/guide/webhooks.md#verify-the-signature). ## `createPkce()` ```js import { createPkce } from '@foid/node' const { codeVerifier, codeChallenge, state, nonce } = createPkce() ``` For servers that start the sign-in themselves. Keep `codeVerifier`, `state` and `nonce` server-side. See [Server-led sign-in](http://localhost:5177/guide/continue-with-foid.md#server-led-sign-in). ## Key pairs | Function | Returns | |---|---| | `generateKeyPair()` | `{ privateKeyPem, publicJwk }`: a new EC P-256 key (PKCS#8 PEM; JWK with `alg: "ES256"` and a random `kid`). | | `clientAssertion(privateKeyPem, clientId, audience, kid?)` | The signed ES256 JWT that `oauth.exchange` sends. You rarely need it directly. | From the command line (the package's `foid` binary): ```bash npx foid keygen ``` This writes `foid_private_key.pem` (mode `600`, refusing to overwrite an existing one) and prints the public JWK to paste into **Sign-in apps → Edit → Key pair**. Then: ```js import { readFileSync } from 'node:fs' const foid = new Foid({ clientId: 'foid_app_live_…', privateKeyPem: readFileSync('foid_private_key.pem', 'utf8'), keyId: '' }) ``` ## Errors ### `FoidError` Thrown for any non-2xx response. `err.message` is `" : "`. | Property | | |---|---| | `status` | HTTP status | | `code` | The Open API `error.code` (e.g. `consent_revoked`), the OAuth `error` for the token endpoint (e.g. `invalid_grant`), or `http_error` when the body has neither (e.g. `422` validation errors, `429`) | | `description` | Human-readable message | | `body` | The parsed response body | ```js try { await foid.getSubject(sid) } catch (e) { if (e instanceof FoidError && e.code === 'consent_revoked') { // stop using and delete this person's data } } ``` Network failures reject with `fetch`'s own `TypeError`. Missing credentials reject with a plain `Error`. ## Exports | Name | Kind | |---|---| | `Foid` (also the default export) | client class | | `FoidError` | error class | | `createPkce`, `generateKeyPair`, `clientAssertion`, `verifyWebhook` | functions | | `DEFAULT_BASE_URL` | `"https://api.foid.ng"` | Types (`FoidOptions`, `FoidSignInResult`, …) are in `index.d.ts`. # React SDK — `@foid/react` The browser side of [Continue with FOID](http://localhost:5177/guide/continue-with-foid.md), in the style of `@react-oauth/google`: a provider, a branded button, a hook, and a framework-free core. It opens the FOID popup with PKCE, checks the response, and hands you a one-time `code` and `codeVerifier` to send to **your server**, which exchanges them with the [Python](http://localhost:5177/sdks/python.md) or [Node](http://localhost:5177/sdks/node.md) SDK. It only ever uses your public **client ID**. It never sees a secret, and you should never exchange the code in the browser. ```bash npm install ./sdks/react # see SDKs → Install; not on npm yet ``` `react` (≥ 17) is a peer dependency. The package has no JSX and no build step. > Interactive "Continue with FOID" playground (needs a browser): http://localhost:5177/playground Every option, with live code, in the [Playground](http://localhost:5177/playground.md). ## Quick start ```jsx import { FoidProvider, ContinueWithFoid } from '@foid/react' export default function SignUp() { return ( fetch('/auth/foid', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, codeVerifier }), }) } onError={(e) => console.log(e.code)} /> ) } ``` ## `` ```jsx … ``` | Prop | | |---|---| | `clientId` | Required. Your sign-in app's public client ID. | | `foidUrl` | The FOID web app that hosts the popup. Default `https://app.foid.ng`; `http://localhost:5173` when running FOID locally. | Put it once near the root (or around the part of the page with the button). `useFoidLogin` and `` throw if they're used outside it. ## `` The FOID-branded button. It shows the FOID mark and "Waiting for FOID…" while the popup is open. | Prop | Default | | |---|---|---| | `onSuccess` | — | `({ code, state, codeVerifier }) => void`. Send `code` and `codeVerifier` to your server. | | `onError` | — | `(error: FoidSignInError) => void`. See [errors](#errors). | | `scope` | `"profile"` | Space-separated: `profile` (always included), `identity`, `contact`, `liveness`, `financial`. Can't exceed the app's registered scopes. | | `nonce` | — | Echoed back as `result.nonce` from the exchange, so you can bind the sign-in to a session. | | `state` | random | Your own `state`. By default a random one is made and checked for you. | | `codeChallenge` | — | Your own PKCE challenge (S256), when your server holds the verifier. `codeVerifier` then comes back `null`. | | `redirectUri` | — | Fallback when the popup is blocked: the page navigates to FOID and returns here. Must be registered on the app (any localhost URL in test). | | `text` | `"continue_with"` | `continue_with`, `signin_with`, `signup_with`, `verify_with`: "Continue with / Sign in with / Sign up with / Verify with FOID". | | `theme` | `"filled"` | `filled` (purple) or `outline` (white with a border). | | `size` | `"large"` | `large` (44 px), `medium` (38 px), `small` (32 px). | | `width` | auto | Any CSS width, e.g. `260` or `"100%"`. | | `disabled` | `false` | | | `className`, `style` | — | Applied to the ` } ``` Call it directly in the click handler, not after an `await`, or the browser may block the popup. ## Core (no React) — `@foid/react/core` ```js import { signInWithFoid, createPkcePair, handleRedirectCallback, FoidSignInError } from '@foid/react/core' ``` These are also re-exported from `@foid/react`. ### `signInWithFoid(options)` ```js button.addEventListener('click', async () => { try { const { code, state, codeVerifier } = await signInWithFoid({ clientId: 'foid_app_test_…', foidUrl: 'https://app.foid.ng', scope: 'identity', }) await sendToServer(code, codeVerifier) } catch (e) { console.log(e.code) // FoidSignInError } }) ``` Options: `clientId` (required), `foidUrl`, `scope`, `nonce`, `state`, `codeChallenge`, `redirectUri`, `width` (default 460) and `height` (default 720) for the popup window. What it does: 1. Opens a popup **synchronously**, so call it straight from a click. 2. Creates a PKCE pair (unless you passed `codeChallenge`) and a random `state`. 3. Points the popup at `{foidUrl}/oauth/authorize?…&response_mode=web_message&origin=`. 4. Resolves with `{ code, state, codeVerifier }` when FOID posts back. It only accepts messages from the FOID origin and the popup it opened, with a matching `state`. If the popup is blocked and you passed `redirectUri`, it stores the state and verifier in `sessionStorage` and navigates the page to FOID with `response_mode=query`. The returned promise never settles, because the page is leaving. Finish on the redirect page with `handleRedirectCallback()`. ### `handleRedirectCallback(search = window.location.search)` Call it on your `redirectUri` page. It returns `{ code, state, codeVerifier }` when this page load is a FOID return, or `null` otherwise. It throws `FoidSignInError` with `state_mismatch` or `access_denied` (or the error FOID returned). It clears the stored state, so call it once per page load and then clean the URL. ```js const result = handleRedirectCallback() if (result) { history.replaceState({}, '', location.pathname) // drop ?code=&state= await sendToServer(result.code, result.codeVerifier, location.origin + location.pathname) } ``` Your server must pass the same `redirect_uri` to the exchange in redirect mode. ### `createPkcePair()` Resolves to `{ codeVerifier, codeChallenge }` (S256, made with Web Crypto). Use it when you want to hold the verifier yourself and pass only `codeChallenge` to `signInWithFoid`. Make the pair **before** the click, so the popup still opens synchronously. ## Errors `onError` and rejected promises receive a `FoidSignInError` with a `code`: | `code` | Meaning | |---|---| | `popup_closed` | The person closed the FOID window. This is also what you see if the popup showed a setup error (unknown client ID, origin not allowed, scope not allowed) and they closed it. | | `access_denied` | The person pressed Cancel or declined. | | `popup_blocked` | The popup was blocked and there's no `redirectUri`. | | `state_mismatch` | The response didn't match this sign-in, so it was ignored. | | `missing_client_id` | No `clientId`. | | `unknown` | Anything unexpected (`` / `useFoidLogin` wrap it). | Errors from the code exchange (`invalid_grant`, `invalid_client`) happen on your server. See the [server SDKs](http://localhost:5177/sdks/node.md#errors). ## Notes - **Only the client ID** belongs in the browser. Exchange codes on your server, once, within 60 seconds. - **Origins:** the popup only posts the code back to an origin registered on the app (test apps: any `http://localhost` port). - **Next.js / SSR:** the button and hook only touch `window` when clicked, so they render on the server. Call `handleRedirectCallback` in an effect or a client component. - **TypeScript:** types are in `index.d.ts` and `core.d.ts`. # Example app (Harbor) **Harbor** is a pretend digital bank that integrates FOID the way a real customer would. It's in the repo under `demo/`. One React front end (with `@foid/react`) runs with one of two back ends: | Command (in `demo/`) | Back end | SDK | Open | |---|---|---|---| | `docker compose up python --build` | FastAPI (`python/app.py`) | `foid` | `http://localhost:3100` (blue accent) | | `docker compose up node --build` | `node:http` (`node/server.mjs`) | `@foid/node` | `http://localhost:3200` (amber accent) | Both back ends expose the same small `/api`. Every route is one SDK call, so clicking through the UI exercises the whole SDK. The SDKs are installed from packed files in `vendor/` folders, exactly like packages from a registry. Nothing is imported from `../sdks`. You can run both at once; `docker compose down` stops them. ## Run it **1. Start FOID** (from the repo root): ```bash cd backend && uv run uvicorn app.main:app --reload # :8000 — API, token endpoint, webhooks cd frontend && pnpm dev # :5173 — the sign-in popup and hosted pages cd enterprise && pnpm dev # :5174 — the console, to get keys ``` **2. Get keys from the console** (Test mode), and put them in `demo/.env` (`cp .env.example .env`): | Console page | Do this | `.env` | |---|---|---| | API keys & webhooks | Create a test key | `FOID_API_KEY` | | Sign-in apps | Create a test app, tick every scope. Origins are optional (test apps accept any localhost port). | `FOID_CLIENT_ID`, `FOID_CLIENT_SECRET` | | Sign-in apps (optional) | A second app for the key pair: in Harbor, open **Setup → Generate key pair**, then paste the public JWK into that app under **Edit → Key pair** | `FOID_KEYPAIR_CLIENT_ID` | | API keys & webhooks | Add `http://localhost:3100/webhooks/foid` | `FOID_WEBHOOK_SECRET_PYTHON` | | API keys & webhooks | Add `http://localhost:3200/webhooks/foid` | `FOID_WEBHOOK_SECRET_NODE` | **3. Start Harbor:** `docker compose up python --build` (or `node`). Restart it after editing `.env`. The **Setup** tab shows which values are set and can test the API key. The containers use the host network but only listen on `127.0.0.1`, so they reach FOID on `localhost:8000`, and FOID can deliver webhooks to `localhost:3100` / `3200`. On Docker Desktop (macOS / Windows), turn on host networking in Settings → Resources → Network. ## What each tab exercises | Tab | What you can try | SDK calls | |---|---|---| | **Continue with FOID** | `` in every label, theme and size; `useFoidLogin()`; `signInWithFoid()` with `createPkcePair()`; the redirect fallback (block popups to see it); server-led sign-in as a popup or full-page redirect. Switch between **client secret** and **key pair**. The nonce is checked on every result. | `@foid/react`, `oauth.exchange`, `create_pkce` / `createPkce` | | **Verifications** | Create (with **Send twice** to show the idempotency key), open the hosted page or **embed** it with `foid-verify.js`, refresh, cancel, list. | `create_verification`, `get_verification`, `request()` | | **NIN / BVN checks** | Attested-consent checks. Untick consent to see the API refuse. | `check_nin`, `check_bvn` | | **Subjects** | List, get, checks, trust report (needs the `financial` scope from a sign-in), delete. | `get_subject`, `request()` | | **Webhooks** | A live list of deliveries the SDK verified. Use **Send test** in the console. **Send a forged event** must show as rejected. | `webhooks.verify` | | **Setup** | Config status, API key test, key pair generation. | `generate_keypair` / `generateKeyPair` | ## Without Docker ```bash cd demo/python && uv sync && uv run uvicorn app:app --port 3100 # or: cd demo/node && npm install && npm start cd demo && npm install && DEMO_BACKEND=http://127.0.0.1:3100 npm run dev # UI on :5300 with hot reload ``` ## After changing an SDK ```bash cd demo && ./scripts/pack-sdks.sh # re-packs ../sdks into the vendor folders and refreshes the lockfiles docker compose build python node ``` The full walkthrough is in `demo/README.md`. # API overview The FOID Open API is a JSON-over-HTTPS API for your **server**. Never call it from a browser or mobile app — that would expose your API key. For the browser side, use the [React SDK](http://localhost:5177/sdks/react.md) or the [hosted verification](http://localhost:5177/guide/hosted-verification.md) page. Every endpoint, with real example responses: **[Endpoints →](http://localhost:5177/api/reference.md)** ## Base URL | | | |---|---| | Production | `https://api.foid.ng/api/open/v1` | | Local (running FOID yourself) | `http://localhost:8000/api/open/v1` | ## Authentication Send your API key as a bearer token: ```http GET /api/open/v1/ping Authorization: Bearer FOID_TEST_… ``` | Key | Environment | What it does | |---|---|---| | `FOID_TEST_…` | Test | Sandbox: every check passes with dummy data, never billed. | | `FOID_LIVE_…` | Live | Real lookups, billed. Only after FOID approves your business (KYB). | Create keys in the console under **API keys**. A key is shown **once** — FOID stores only a hash. If your business has an **IP allow-list**, live keys only work from those addresses (`403 ip_not_allowed`). The one exception is the [token endpoint](http://localhost:5177/api/reference.md#post-oauth-token) for Continue with FOID, which uses your **sign-in app's** client ID + client secret (or a key pair), not the API key. See [Keys and credentials](http://localhost:5177/guide/concepts.md#keys-and-credentials). ## Requests and responses - Send JSON with `Content-Type: application/json` (the document check uses `multipart/form-data`). - IDs are opaque strings; store them as text. - Timestamps are ISO 8601 in UTC, e.g. `2026-11-03T20:25:02.091651+00:00`. - Fields you didn't send and don't apply come back as `null`. - Lists return `{"object": "list", "data": [...]}`. Use `limit` (max 100) and `offset` where supported. - Every response carries a **`FOID-Request-Id`** header. Quote it when you contact support; the same ID appears in the console under **Request logs**. ## Errors Errors have one shape: ```json { "error": { "type": "permission", "code": "consent_revoked", "message": "The person has revoked this business's access to their data", "param": null } } ``` | HTTP | `type` | Typical `code`s | |---|---|---| | 400 | `invalid_request` | `consent_required`, `unsupported_file_type` | | 401 | `authentication` | missing, wrong or revoked API key | | 403 | `permission` | `consent_revoked`, `scope_not_granted`, `ip_not_allowed` | | 404 | `not_found` | `subject_not_found`, `verification_not_found` | | 409 | `conflict` | `verification_closed`, `idempotency_key_reused`, `no_face_on_file`, `no_foid_account` | | 502 / 503 | `provider` | an identity provider is down — retry later | Branch on `code`, not on `message` (messages are for humans and may change). `param` names the field at fault when there is one. More in [Errors, limits, idempotency](http://localhost:5177/guide/errors.md). Two responses don't use this shape yet: - **`422` — the request body didn't validate** (missing field, wrong type, bad format). The body lists every problem: `{"detail": [{"loc": ["body", "number"], "msg": "Field required", "type": "missing"}]}` - **`429` — rate limited:** `{"error": "Rate limit exceeded: 120 per 1 minute"}`. Back off and retry. The token endpoint is different: it uses OAuth's standard error format, `{"error": "invalid_grant", "error_description": "…"}`. ## Idempotency `POST /verifications`, `POST /checks/nin` and `POST /checks/bvn` accept an **`Idempotency-Key`** header. Send the same key with the same body and you get the original response back (with `Idempotent-Replayed: true`) instead of a second verification or a second billed check. The same key with a *different* body is refused with `409 idempotency_key_reused`. Keys are remembered for 24 hours. ```http POST /api/open/v1/verifications Authorization: Bearer FOID_TEST_… Idempotency-Key: onboard-cust_1001 ``` ## Rate limits 120 requests per minute per API key on the verification and check endpoints; 60 per minute on the token endpoint. Over the limit you get `429` — wait and retry with backoff. ## Versioning The version is in the path (`/v1`). Within `v1` we only make additive changes: new endpoints, new optional fields, new event types and new error codes. Write your code to ignore fields it doesn't know. Breaking changes would ship as `/v2`. Changes are listed in the [changelog](http://localhost:5177/changelog.md). # Endpoints Every endpoint of the Open API, generated from the FOID backend. The examples are real responses from a test-mode run (sandbox data). Base URL: `https://api.foid.ng/api/open/v1` (locally `http://localhost:8000/api/open/v1`). Authenticate with `Authorization: Bearer FOID_TEST_…` (or `FOID_LIVE_…`) — except the token endpoint, which uses the sign-in app's client credentials. See [Overview](http://localhost:5177/api/index.md) for auth, errors and limits. ## Connection ### GET `/ping` **Response `200`** ```json { "ok": true, "organisation": "Acme Wallet", "env": "test" } ``` ## Verifications Hosted verification: create a link, send the person to it, read the result. ### POST `/verifications` Optional header: `Idempotency-Key` — repeat the same request safely (same key + same body = same result). **JSON body** | Field | Type | Required | Notes | |---|---|---|---| | `customer_ref` | string | | Your own id for this person; max length 128 | | `subject_id` | string | | | | `first_name` | string | | max length 80 | | `middle_name` | string | | max length 80 | | `last_name` | string | | max length 80 | | `date_of_birth` | string | | pattern `^\d{4}-\d{2}-\d{2}$` | | `email` | string (email) | | | | `phone` | string | | max length 20 | | `checks` | array of `nin` \| `bvn` \| `liveness` \| `reverify` | yes | min items 1 | | `scopes` | array of `contact` \| `financial` | | | | `purpose` | string | yes | Shown to the person on the consent screen; min length 3; max length 200 | | `return_url` | string | | max length 500 | | `metadata` | object | | | > **Example request** ```json { "checks": [ "nin", "liveness" ], "purpose": "Open a wallet", "customer_ref": "cust_1001", "first_name": "Ada", "last_name": "Obi", "return_url": "https://acme.example/onboarding/done" } ``` **Response `201`** ```json { "id": "d9588b86-660a-4737-a7a4-ff70ef99ed35", "object": "verification", "env": "test", "status": "pending", "outcome": null, "checks": [ "nin", "liveness" ], "scopes": [ "identity", "liveness" ], "purpose": "Open a wallet", "subject_id": "c9981038-6db3-4236-8fca-d8bbb89dadfa", "customer_ref": "cust_1001", "consent": { "status": "none", "scopes": [] }, "reused_foid_credential": false, "results": { "nin": { "status": "pending" }, "liveness": { "status": "pending" } }, "metadata": {}, "expires_at": "2026-11-04T20:25:01.979339+00:00", "completed_at": null, "created_at": "2026-11-03T20:25:01.979556+00:00", "hosted_url": "https://app.foid.ng/verify/vs_\u2026", "client_token": "vs_\u2026" } ``` ### GET `/verifications` | Parameter | In | Type | Required | |---|---|---|---| | `status` | query | string | | | `customer_ref` | query | string | | | `limit` | query | integer | | | `offset` | query | integer | | **Response `200`** ```json { "object": "list", "data": [ { "id": "d9588b86-660a-4737-a7a4-ff70ef99ed35", "object": "verification", "env": "test", "status": "pending", "outcome": null, "checks": [ "nin", "liveness" ], "scopes": [ "identity", "liveness" ], "purpose": "Open a wallet", "subject_id": "c9981038-6db3-4236-8fca-d8bbb89dadfa", "customer_ref": "cust_1001", "consent": { "status": "none", "scopes": [] }, "reused_foid_credential": false, "results": { "nin": { "status": "pending" }, "liveness": { "status": "pending" } }, "metadata": {}, "expires_at": "2026-11-04T20:25:01.979339+00:00", "completed_at": null, "created_at": "2026-11-03T20:25:01.979556+00:00", "hosted_url": "https://app.foid.ng/verify/vs_\u2026", "client_token": "vs_\u2026" } ], "has_more": false } ``` ### GET `/verifications/{verification_id}` | Parameter | In | Type | Required | |---|---|---|---| | `verification_id` | path | string | yes | **Response `200`** ```json { "id": "d9588b86-660a-4737-a7a4-ff70ef99ed35", "object": "verification", "env": "test", "status": "pending", "outcome": null, "checks": [ "nin", "liveness" ], "scopes": [ "identity", "liveness" ], "purpose": "Open a wallet", "subject_id": "c9981038-6db3-4236-8fca-d8bbb89dadfa", "customer_ref": "cust_1001", "consent": { "status": "none", "scopes": [] }, "reused_foid_credential": false, "results": { "nin": { "status": "pending" }, "liveness": { "status": "pending" } }, "metadata": {}, "expires_at": "2026-11-04T20:25:01.979339+00:00", "completed_at": null, "created_at": "2026-11-03T20:25:01.979556+00:00", "hosted_url": "https://app.foid.ng/verify/vs_\u2026", "client_token": "vs_\u2026" } ``` ### POST `/verifications/{verification_id}/cancel` | Parameter | In | Type | Required | |---|---|---|---| | `verification_id` | path | string | yes | **Response `200`** ```json { "id": "d9588b86-660a-4737-a7a4-ff70ef99ed35", "object": "verification", "env": "test", "status": "cancelled", "outcome": null, "checks": [ "nin", "liveness" ], "scopes": [ "identity", "liveness" ], "purpose": "Open a wallet", "subject_id": "c9981038-6db3-4236-8fca-d8bbb89dadfa", "customer_ref": "cust_1001", "consent": { "status": "none", "scopes": [] }, "reused_foid_credential": false, "results": { "nin": { "status": "pending" }, "liveness": { "status": "pending" } }, "metadata": {}, "expires_at": "2026-11-04T20:25:01.979339+00:00", "completed_at": null, "created_at": "2026-11-03T20:25:01.979556+00:00" } ``` ## Checks Server-to-server checks. You attest you collected the person's consent. ### POST `/checks/nin` Look up an NIN and match it against the name / date of birth you send. Optional header: `Idempotency-Key` — repeat the same request safely (same key + same body = same result). **JSON body** | Field | Type | Required | Notes | |---|---|---|---| | `customer_ref` | string | | Your own id for this person; max length 128 | | `subject_id` | string | | | | `first_name` | string | | max length 80 | | `middle_name` | string | | max length 80 | | `last_name` | string | | max length 80 | | `date_of_birth` | string | | pattern `^\d{4}-\d{2}-\d{2}$` | | `email` | string (email) | | | | `phone` | string | | max length 20 | | `number` | string | yes | 11-digit NIN or BVN | | `consent` | object | yes | | | `consent.obtained` | `true` | yes | You confirm you collected the person's consent for this check | | `consent.purpose` | string | yes | min length 3; max length 200 | > **Example request** ```json { "number": "12345678901", "first_name": "Ada", "last_name": "Obi", "date_of_birth": "1994-03-12", "customer_ref": "cust_1001", "consent": { "obtained": true, "purpose": "Account opening" } } ``` **Response `200`** ```json { "id": "880af60a-e95c-4679-b205-21e710d775e9", "type": "nin", "status": "passed", "reasons": [], "data": { "method": "number", "nin_last4": "8901", "name_match": true, "name_score": 1.0, "dob_match": true, "record": { "first_name": "Ada", "middle_name": null, "last_name": "Obi", "date_of_birth": "1994-03-12" } }, "provider": "mock", "verification_id": null, "subject_id": "c9981038-6db3-4236-8fca-d8bbb89dadfa", "created_at": "2026-11-03T20:25:02.091651+00:00", "customer_ref": "cust_1001" } ``` ### POST `/checks/bvn` Look up a BVN and match it against the name / date of birth you send. Optional header: `Idempotency-Key` — repeat the same request safely (same key + same body = same result). **JSON body** | Field | Type | Required | Notes | |---|---|---|---| | `customer_ref` | string | | Your own id for this person; max length 128 | | `subject_id` | string | | | | `first_name` | string | | max length 80 | | `middle_name` | string | | max length 80 | | `last_name` | string | | max length 80 | | `date_of_birth` | string | | pattern `^\d{4}-\d{2}-\d{2}$` | | `email` | string (email) | | | | `phone` | string | | max length 20 | | `number` | string | yes | 11-digit NIN or BVN | | `consent` | object | yes | | | `consent.obtained` | `true` | yes | You confirm you collected the person's consent for this check | | `consent.purpose` | string | yes | min length 3; max length 200 | > **Example request** ```json { "number": "12345678901", "first_name": "Ada", "last_name": "Obi", "date_of_birth": "1994-03-12", "customer_ref": "cust_1001", "consent": { "obtained": true, "purpose": "Account opening" } } ``` **Response `200`** ```json { "id": "c9e2b5a1-8322-4f2b-b6c6-5795a4b275f4", "type": "bvn", "status": "passed", "reasons": [], "data": { "method": "number", "bvn_last4": "8901", "name_match": true, "name_score": 1.0, "dob_match": true, "record": { "first_name": "Ada", "middle_name": null, "last_name": "Obi", "date_of_birth": "1994-03-12" } }, "provider": "mock", "verification_id": null, "subject_id": "c9981038-6db3-4236-8fca-d8bbb89dadfa", "created_at": "2026-11-03T20:25:02.114253+00:00", "customer_ref": "cust_1001" } ``` ### POST `/checks/nin-document` Extract the NIN from a document image (OCR), then look it up. The document photo is not used. **multipart form** | Field | Type | Required | Notes | |---|---|---|---| | `file` | string | yes | Photo/scan of an NIN slip or NIMC card | | `consent_obtained` | boolean | yes | | | `purpose` | string | yes | min length 3; max length 200 | | `customer_ref` | string | | | | `subject_id` | string | | | | `first_name` | string | | | | `middle_name` | string | | | | `last_name` | string | | | | `date_of_birth` | string | | | Returns the same shape as [`POST /checks/nin`](#post-checks-nin), plus the NIN read from the document (last 4 only). ## Subjects The people you have verified. Every read goes through the person's consent. ### GET `/subjects` | Parameter | In | Type | Required | |---|---|---|---| | `customer_ref` | query | string | | | `limit` | query | integer | | | `offset` | query | integer | | **Response `200`** ```json { "object": "list", "data": [ { "id": "c9981038-6db3-4236-8fca-d8bbb89dadfa", "customer_ref": "cust_1001", "env": "test", "consent": { "status": "active", "scopes": [ "identity" ] }, "has_foid_account": false, "created_at": "2026-11-03T20:25:01.976005+00:00", "first_name": "Ada", "middle_name": null, "last_name": "Obi", "date_of_birth": "1994-03-12", "nin": { "verified": true, "last4": "8901" }, "bvn": { "verified": true, "last4": "8901" } } ] } ``` ### GET `/subjects/{subject_id}` | Parameter | In | Type | Required | |---|---|---|---| | `subject_id` | path | string | yes | **Response `200`** ```json { "id": "c9981038-6db3-4236-8fca-d8bbb89dadfa", "customer_ref": "cust_1001", "env": "test", "consent": { "status": "active", "scopes": [ "identity" ] }, "has_foid_account": false, "created_at": "2026-11-03T20:25:01.976005+00:00", "first_name": "Ada", "middle_name": null, "last_name": "Obi", "date_of_birth": "1994-03-12", "nin": { "verified": true, "last4": "8901" }, "bvn": { "verified": true, "last4": "8901" } } ``` ### GET `/subjects/{subject_id}/checks` | Parameter | In | Type | Required | |---|---|---|---| | `subject_id` | path | string | yes | **Response `200`** ```json { "object": "list", "data": [ { "id": "c9e2b5a1-8322-4f2b-b6c6-5795a4b275f4", "type": "bvn", "status": "passed", "reasons": [], "data": { "method": "number", "bvn_last4": "8901", "name_match": true, "name_score": 1.0, "dob_match": true, "record": { "first_name": "Ada", "middle_name": null, "last_name": "Obi", "date_of_birth": "1994-03-12" } }, "provider": "mock", "verification_id": null, "subject_id": "c9981038-6db3-4236-8fca-d8bbb89dadfa", "created_at": "2026-11-03T20:25:02.114253+00:00" }, { "id": "880af60a-e95c-4679-b205-21e710d775e9", "type": "nin", "status": "passed", "reasons": [], "data": { "method": "number", "nin_last4": "8901", "name_match": true, "name_score": 1.0, "dob_match": true, "record": { "first_name": "Ada", "middle_name": null, "last_name": "Obi", "date_of_birth": "1994-03-12" } }, "provider": "mock", "verification_id": null, "subject_id": "c9981038-6db3-4236-8fca-d8bbb89dadfa", "created_at": "2026-11-03T20:25:02.091651+00:00" } ] } ``` ### GET `/subjects/{subject_id}/trust-report` Financial trust signals + living credential state. FOID account holders only, scope `financial`. | Parameter | In | Type | Required | |---|---|---|---| | `subject_id` | path | string | yes | **Response `403`** ```json { "error": { "type": "permission", "code": "scope_not_granted", "message": "No active consent for scope 'financial'" } } ``` ### DELETE `/subjects/{subject_id}` Erase a subject and everything FOID holds about them for your business (checks, face, consent). | Parameter | In | Type | Required | |---|---|---|---| | `subject_id` | path | string | yes | **Response `204`** — no body ## Continue with FOID Exchange the one-time code from the sign-in popup. Client credentials, not the API key. ### POST `/oauth/token` **Form body** (`application/x-www-form-urlencoded` or JSON) | Field | Required | Notes | |---|---|---| | `grant_type` | yes | `authorization_code` | | `code` | yes | The one-time code from the popup. Single use, expires after 60 seconds. | | `code_verifier` | yes | The PKCE verifier that matches the `code_challenge` used to start sign-in. | | `redirect_uri` | redirect mode | Exactly the one used to start sign-in (`response_mode=query`). | | `client_id` + `client_secret` | one auth method | Or HTTP Basic `client_id:client_secret` (recommended). | | `client_assertion_type` + `client_assertion` | one auth method | `private_key_jwt`: an ES256/RS256 JWT, `iss`=`sub`=client ID, `aud`=this URL, expires ≤ 5 min. | Errors use OAuth's format: `{"error": "invalid_grant", "error_description": "…"}`. **Response `200`** ```json { "object": "foid_sign_in", "env": "test", "sandbox": true, "nonce": "n_51b7", "subject_id": "ce669b19-ced4-4ff9-ac99-01d560450380", "scopes": [ "contact", "identity", "profile" ], "foid_account": true, "face_rematched_at": null, "subject": { "id": "ce669b19-ced4-4ff9-ac99-01d560450380", "customer_ref": null, "env": "test", "consent": { "status": "active", "scopes": [ "contact", "identity", "profile" ] }, "has_foid_account": true, "created_at": "2026-11-03T20:25:02.497513+00:00", "first_name": "Chiamaka", "middle_name": null, "last_name": "Eze", "date_of_birth": "1990-01-01", "nin": { "verified": true, "last4": "0001" }, "bvn": { "verified": true, "last4": "0002" }, "email": "sandbox.person@example.com", "phone": "**********0001" }, "checks": [] } ``` # Help centre FOID is your verified identity, built once and reused. You prove who you are one time — your NIN, BVN and a quick face check — and then share that proof with banks and apps **only when you say yes**. You can see who has access and take it back at any time. ## Getting started - [Create your FOID](http://localhost:5177/help/getting-started.md) — sign up and confirm your email - [Verify your identity](http://localhost:5177/help/verify-identity.md) — face check, NIN, BVN and phone - [Your FOID code](http://localhost:5177/help/foid-code.md) — show someone you're verified without reading out your NIN ## Sharing with businesses - [Continue with FOID](http://localhost:5177/help/continue-with-foid.md) — signing in to a bank or app with FOID - [Connected apps and consent](http://localhost:5177/help/connected-apps.md) — see who has access and revoke it ## Your account - [Keep your account safe](http://localhost:5177/help/security.md) — password, two-factor authentication, fingerprint/face sign-in, login alerts - [FAQ](http://localhost:5177/help/faq.md) — quick answers > **What businesses see** Businesses get **confirmations** — "NIN verified", "BVN verified", "face check passed" — never your full NIN or BVN. They only see what you approved on the consent screen. # Create your FOID You need an email address you can open and your 11-digit NIN. ## Sign up 1. Open FOID and choose **Create your FOID** (on the log-in page), or go straight to the sign-up page. 2. Fill in **First name**, **Middle name** (optional), **Last name**, your **NIN**, **Email address**, **Password** and **Confirm Password**. - Use your names **exactly as they appear on your NIN** — FOID checks them against the NIN record later. - As you type your password, a checklist shows what's still missing. See [password rules](http://localhost:5177/help/security.md#your-password). 3. Accept the terms and press **Sign Up**. You land on your dashboard straight away. ## Confirm your email Until you confirm your email, a purple bar at the top says **"Your email is not verified — you are in view-only mode."** 1. Open the email from FOID and copy the 6-digit code. 2. Press **Verify** on the purple bar, enter the code and press **Verify Email**. 3. You'll see **Email verified!** — your account is now fully active. Didn't get it? Check spam, then press **Resend code** in the same window. The code expires after 30 minutes. > **Accounts whose email isn't confirmed are **deleted automatically after 3 days**.** ## Next step Go to **My Identity** and [verify your identity](http://localhost:5177/help/verify-identity.md). That's what lets you share a verified FOID with banks and apps. ## Logging in later Use **Log In** with your email and password. You can also turn on [fingerprint or face sign-in](http://localhost:5177/help/security.md#fingerprint-or-face-sign-in-biometric-unlock) and [two-factor authentication](http://localhost:5177/help/security.md#two-factor-authentication). Forgot your password? Choose **Forgot password** on the log-in page, enter your email, press **Send reset code**, then enter the code and your new password. # Verify your identity Everything happens on **My Identity** in the left menu. The **Verification progress** card shows each step and ticks it off when it's done. Do the steps in this order — the NIN check only unlocks after the face check. ## 1. Liveness check (face check) A short camera check that proves a real, live person is there — not a photo or a screen. 1. Under **Liveness check**, press **Verify now**. 2. Choose **Use this device**, or **Continue on my phone** to scan a QR code and use your phone's camera instead (no need to log in on the phone; the link lasts 10 minutes and works once). 3. Allow camera access when your browser asks. 4. Follow the prompts — for example **Turn your head to your left**, **Turn your head to your right**, **Open your mouth wide**. 5. When it says **Liveness confirmed**, you're done. **Tips:** find good, even light on your face; remove sunglasses or hats; make sure only you are in view. **If it fails:** the message tells you why — *Your face left the camera view*, *More than one face was in view*, *The image looked like a photo or screen*, or *The check took too long*. Just try again. FOID stores your face template encrypted, so it can confirm it's you later (for example the first time you share with a new business). You can delete it in **Profile & Settings** → **Linked identity documents** → **Face (liveness)** → **Delete** — you'll need a new face check afterwards. ## 2. Verify NIN 1. Under **Verify NIN**, press **Verify now**. 2. Enter your **First name**, **Middle name** (optional), **Last name** and **Date of birth** exactly as they appear on your NIN record. 3. Press **Verify NIN**. FOID checks them against the national record. Once verified, your name and date of birth are locked to the NIN record. ## 3. Verify BVN Under **Verify BVN**, enter your 11-digit BVN and press **Verify now**. Only digits are accepted. ## 4. Phone number Under **Verify phone number**, enter your phone number and press **Add number** to add it to your profile. ## What "verified" means When the steps are done, your FOID credential is **active**: businesses you approve can rely on the confirmations without asking you to repeat KYC. Your credential can change state, and FOID **always notifies you** when it does, with the reason: | State | What it means | |---|---| | **Active** | In good standing. | | **Under review** | Something needs a closer look (for example, a change in activity on a bank account you connected). Nothing is taken away yet. | | **Downgraded** | Part of your credential is no longer confirmed. You'll see what changed and what you can do. | | **Revoked** | The credential is not valid. | FOID never gives you a credit score and never decides whether you get a loan — that decision always stays with the business. ## Your identity summary On **My Identity** or **Verification Credentials**, **Download Identity Summary** gives you a PDF of what you've verified. # Your FOID code Your FOID code lets someone — a bank teller, a landlord, a shop — check that you're verified **without you reading out your NIN or BVN**. ## Show your code 1. Go to **Overview** and press **View my FOID code**. 2. A QR code and a link appear. Let the other person scan the QR code, or press **Copy link** and send it. 3. The code **lasts 10 minutes**. When it says **Expired**, open it again to get a fresh one. ## What the other person sees A **Credential check** page with: - your first name and the first letter of your last name (for example *Ada O.*), - your credential state (**Active**, **Under review**, **Downgraded** or **Revoked**) and what it means, - ticks for **Face (liveness) verified**, **NIN verified**, **BVN verified**, **Email confirmed**, and **Over 18** (only when your NIN is verified — your date of birth itself is never shown), - the time of the check. They **never** see your NIN, BVN, date of birth, email, phone or address. ## Is it safe to share? Yes, for the 10 minutes it's valid. After that the link stops working (*"This FOID code has expired or isn't valid. Ask for a fresh one."*). If you think someone kept it, simply wait — it expires on its own. # Continue with FOID Some banks and apps have a **Continue with FOID** button — like "Sign in with Google", except you arrive already verified, so you don't repeat KYC. ## What happens when you press it 1. **A FOID window opens.** Enter your email and press **Send code** — no password needed. 2. **Enter the 6-digit code** we email you. (If you've turned on two-factor authentication, you'll also enter the code from your authenticator app.) 3. **Check what's being shared.** The screen shows *"\ wants to see:"* and a list, for example your name, your NIN/BVN check results, liveness, or your email and phone. 4. **The first time you share with a business**, FOID asks for a 5-second face check (**Confirm it's you**). This stops someone who can read your email from sharing your identity. Next time with the same business, you skip it. 5. Press **Allow** to share, or **Cancel** to share nothing (*"Cancelled — nothing was shared."*). The window closes and you're signed in to the app. ## What the business gets Only what you approved. They get **confirmations** — for example *"NIN verified, last 4 digits 8901"* — **never your full NIN or BVN**. You can revoke access any time in **Consent Requests** (see [Connected apps](http://localhost:5177/help/connected-apps.md)). | They ask for | They get | |---|---| | Your name (always) | First, middle and last name | | Identity | NIN / BVN check results and the name and date of birth on record | | Liveness | That your face check passed, and when | | Contact | Your email address and phone number | | Financial | Your credential state and trust report (FOID account holders only) | ## "Test app — not checked by FOID" You'll see this label when an app is still being built and tested. A test app gets your **real first and last name only** — everything else it receives is made-up sample data. FOID hasn't reviewed test apps, so only use them if you trust who sent you. ## No FOID yet? You can still continue. The window says *"You don't have FOID yet — let's verify you for \."* You enter your name exactly as on your NIN, then do the NIN check and face check right there. At the end you'll see **Save this to FOID**: - **Save this to FOID** creates your FOID with the email you just confirmed, so next time any app using FOID takes seconds. Set a password later with **Forgot password**. - **No thanks, just continue** finishes the sign-in for that app only. Nothing is saved to a FOID account. Saving is always your choice — it's never ticked for you. ## Something went wrong? - *"This sign-in has already finished. Start again from the app."* — go back to the app and press the button again. - *"Approved, but the app's window was closed."* — the app was closed while you were approving. Go back and try again. - The window didn't open — your browser may have blocked the popup. Allow popups for that site. # Connected apps and consent Every business you've shared with is listed in FOID, and you can take access back at any time. ## See who has access Two pages show this: - **Consent Requests** — every business that asked for your data, with **Pending**, **Approved** or **Revoked**, what they can see, and when they asked. - **Verification Credentials** — one credential per business you've shared with (**Shared With**), plus your **FOID Master Credential**. Test apps show *(sandbox)* after the name. Use **Search by platform** or the **All / Active / Expiring / Revoked** tabs to filter. ## Revoke access 1. Open **Consent Requests** or **Verification Credentials**. 2. Find the business and press **Revoke**. 3. Confirm: *"Revoke \'s access? They will stop receiving any of your data."* ## What revoking does Straight away: - the business **can't read your data any more** — every request they make gets refused; - any **updates they hadn't received yet are cancelled**, and they get no new ones about you; - the row shows **Revoked**. What they already received before you revoked stays with them (just like any document you handed over), and they may have their own legal duty to keep records. Revoking stops anything new. To share with them again later, go through their **Continue with FOID** or verification flow again. # Keep your account safe All of these are in **Profile & Settings** → **Security**. ## Your password Your password needs **at least 8 characters**, with an **uppercase letter**, a **lowercase letter**, a **number** and a **special character** (like `!` or `#`). A checklist shows each rule as you type, and the button stays off until every rule passes and both passwords match. **Change it:** **Profile & Settings** → **Change password** → enter your **Current password**, a **New password** and **Confirm new**, then **Update password**. **Forgot it:** on the log-in page choose **Forgot password**, press **Send reset code**, enter the code from your email, then set a new password. ## Two-factor authentication Adds a second step when you log in: a 6-digit code from an authenticator app (Google Authenticator, Authy, Microsoft Authenticator, …). **Turn it on** 1. Switch on **Two-factor authentication**. 2. Scan the QR code with your authenticator app. 3. Enter the 6-digit code the app shows and press **Turn on**. From then on, after your password you'll see **Authenticator code** — open the app and enter the current code, then **Verify and log in**. **Turn it off:** switch it off, then confirm with your **Password** and a **Code from your app**. > **Keep your phone safe** If you lose the phone with your authenticator app, you can't complete the second step. Turn two-factor off before you change phones, then set it up again on the new one. ## Fingerprint or face sign-in (Biometric unlock) Log in with Face ID, your fingerprint or Windows Hello instead of typing your password — on **this device**. 1. Switch on **Biometric unlock**. 2. Confirm with your fingerprint, face or device PIN when your device asks. Next time, press **Log in with fingerprint or face** on the log-in page. If it says *"Not available — this device has no fingerprint or face unlock"*, your device doesn't support it. Your fingerprint or face never leaves your device — FOID only stores a key your device creates. Turning **Biometric unlock** off removes it. ## Login alerts With **Login alerts** on (the default), FOID emails you and adds a notification when someone signs in to your account from a **new device**. The first device you use is remembered, so you won't get an alert for it. Got an alert you don't recognise? **Change your password straight away** and turn on two-factor authentication. ## Face data Your face template is stored encrypted. To delete it: **Profile & Settings** → **Linked identity documents** → **Face (liveness)** → **Delete**. You'll need a new liveness check on **My Identity** before you can share a verified FOID again. # FAQ ## About FOID **What is FOID?** A verified identity you build once and reuse. You prove who you are one time — NIN, BVN and a face check — and share that proof with banks and apps only when you approve. **Who owns my FOID?** You do. Nothing is shared without your approval on a consent screen, and you can [revoke access](http://localhost:5177/help/connected-apps.md) at any time. **Is FOID a bank? Does it hold my money?** No. FOID never holds money and never makes payments. It confirms who you are, with your consent. **Does FOID give me a credit score?** No. FOID never scores you and never decides whether you get a loan. A business may see your credential state (for example **Active** or **Under review**) if you allow it, but the decision is always theirs. ## Privacy **Do businesses see my NIN or BVN?** No. They get confirmations — "NIN verified", "BVN verified", the last 4 digits — never the full number. They only see what you approved. **What does FOID store about me?** Your profile, your NIN and BVN (encrypted), your verification results and an encrypted face template. If you connect a bank account, FOID reads your transactions to keep your credential up to date — businesses never get your raw transactions or statements. **Can I delete my face data?** Yes — **Profile & Settings** → **Linked identity documents** → **Face (liveness)** → **Delete**. **A business I shared with — can they keep reading my data forever?** Only until you revoke them. After you press **Revoke**, every request they make is refused and pending updates to them are cancelled. See [Connected apps](http://localhost:5177/help/connected-apps.md). ## Verifying **The NIN check says my details don't match.** Enter your names and date of birth **exactly** as they appear on your NIN record — including the order and spelling of your middle name. **Why must I do the face check before the NIN check?** The face check proves a real person is there before FOID looks up a NIN. It stops people verifying someone else's NIN. **The face check keeps failing.** Use good, even light; remove sunglasses or hats; keep only your face in view; follow each prompt promptly. If your computer camera is poor, choose **Continue on my phone**. **My credential says "Under review" or "Downgraded". What now?** FOID notifies you with the reason when your credential changes. Usually it follows a change on a bank account you connected. Check your notifications for what changed and what you can do. ## Signing in and security **I didn't get my email code.** Check spam and promotions folders, wait a minute, then use **Resend code**. Codes expire — request a new one if it's been a while. **I lost my phone with the authenticator app.** You can't finish two-factor sign-in without it. Contact FOID support to recover your account. Next time, turn two-factor off before changing phones. **I got a login alert I don't recognise.** Change your password straight away and turn on [two-factor authentication](http://localhost:5177/help/security.md#two-factor-authentication). **Why is an app labelled "Test app — not checked by FOID"?** It's still being built. It gets your real name only — everything else it sees is sample data. See [Continue with FOID](http://localhost:5177/help/continue-with-foid.md#test-app-not-checked-by-foid). ## Other features **What is Connected Banks?** Link a bank account (through Mono, a licensed open-banking provider) so your credential stays current from your real banking activity. You can see your transactions in FOID. **What is the AI Credit Wallet?** Credits for the AI assistant in FOID, which answers questions about your own account and finances. You get free credits every month and can buy more. The assistant only ever talks about your account. # Playground This is the real `` button from [`@foid/react`](http://localhost:5177/sdks/react.md), wired to a FOID **test app** that FOID runs for these docs. Change the options on the left, click the button, and go through the real FOID window. The result you see is exactly what **your server** would get back from `foid.oauth.exchange()`. > Interactive "Continue with FOID" playground (needs a browser): http://localhost:5177/playground ## What you're seeing - **The popup** is FOID's real sign-in: your FOID email, a one-time code, then a consent screen listing what's being shared. It's labelled *Test app — not checked by FOID* because this is test mode. - **The result** has your **real first and last name** and **sandbox data for everything else** (NIN/BVN status, last 4 digits, date of birth, liveness, contact). That's how every test app works — see [Test and live mode](http://localhost:5177/guide/test-and-live.md). - **Errors** — press *Cancel* (`access_denied`) or close the window (`popup_closed`) to see what your `onError` receives. - **No FOID account yet?** The popup offers to verify you as a new person and, if you want, save it as a FOID. Or [create one first](http://localhost:5177/help/getting-started.md). The docs app appears in your FOID app under **Connected apps** like any business you share with — you can remove it there. > **Build it yourself** The snippets on the right are complete. For a full working app (React front end + Python or Node back end, every SDK feature) see the [Harbor example app](http://localhost:5177/sdks/example-app.md). > **Running these docs locally** The playground talks to a FOID backend. From the repo: start `backend` (port 8000) and `frontend` (port 5173), then `cd docs && pnpm dev`. The docs dev server proxies `/api` to the backend. The backend creates the docs test app on first use; switch it off with `DOCS_PLAYGROUND=false`. # FOID docs FOID (Fintech Open Identity) lets people in Nigeria verify their identity once (NIN, BVN and a live face check) and share that proof with businesses only when they approve. Businesses integrate with "Continue with FOID" (OAuth 2.0 + PKCE sign-in), a hosted verification page, or server-to-server NIN/BVN checks, using the Python, Node and React SDKs. Start with the quickstart: http://localhost:5177/guide/quickstart.md # Changelog Changes to the FOID API, SDKs and docs. Dates are when the change shipped. ## 2026-11-03 **Continue with FOID** - Sign-in apps in the console (Sign-in apps page): client ID, client secrets (shown once, hashed, 24-hour overlap when rotated) and key pairs (`private_key_jwt`). - Popup sign-in with email code, consent screen, fresh face check per new business (live), and **Save to FOID** for people without an account. - Token endpoint `POST /api/open/v1/oauth/token` — PKCE required, codes single use and valid 60 seconds; a replayed code revokes the grant. **SDKs (0.1.0)** - `foid` (Python), `@foid/node`, `@foid/react` — code exchange, key pairs, webhook verification, Open API helpers, the button and hook. - The button now uses the FOID mark. **Webhooks** - Webhook URLs are checked when saved and at every delivery: https only in live, no private or internal addresses, no redirects, localhost only for test endpoints. See [Webhooks](http://localhost:5177/guide/webhooks.md#allowed-urls). **AI tools** - `llms.txt`, `llms-full.txt` and a markdown copy of every page; a **Copy page** menu (copy, or open in ChatGPT / Claude / Perplexity). - A read-only MCP server at `/mcp` that searches and reads these docs. See [Use FOID docs with AI](http://localhost:5177/guide/ai.md). **Docs** - This site, with a live [playground](http://localhost:5177/playground.md) and an [API reference](http://localhost:5177/api/reference.md) generated from the backend.