Every route below requires a consumer bearer token, and the pattern is identical for nin and bvn (just swap the path segment and body field name). Enrollment (Epic 3) is not a separate endpoint — it’s a side effect of a passing verify-face + link, see Architecture.

POST /identity/{nin|bvn}/validate

string
11 digits. bvn field name for the BVN route.
200 OK — new validation
The registry photo is never included here — matchedName is masked (first letter of each word kept, rest starred) even though the full name is known internally. The photo stays server-side, cached against validationToken, until a passing verify-face.
200 OK — already linked to a different account
Only linking date + a coarse device hint — never the other account’s identity, contact details, or anything else.
404 Not Found
400 Bad Request (registry mismatch)
502 Bad Gateway

POST /identity/{nin|bvn}/verify-face

multipart/form-data.
string
required
file
Required unless reuseCapture=true.
string
"true" or "false" (string, not boolean — multipart fields are always strings). Reuses the most recent passing capture from a NIN verify-face within the capture-reuse window when linking BVN right after NIN — comparison still always runs fresh against the new registry photo, only the re-capture step is skipped.
Rate-limited per validationToken (a brute-force-identity-theft control).
200 OK — match
422 — liveness failed
422 — face doesn't match the registry photo
422 — face doesn't match this account's OTHER registry
cross_registry_mismatch only triggers when linking a second registry (BVN after NIN, or vice versa) — the live face is checked against the account’s existing canonical embedding, not just the registry photo just fetched. Logged at elevated severity; policy (hard-block vs. flag-and-continue) is CROSS_REGISTRY_MISMATCH_POLICY in src/config/constants.ts.
On match, the returned linkToken is single-use and expires in LINK_TOKEN_TTL_SECONDS (default 5 min) — it’s the API’s proof that this exact comparison actually happened.

POST /identity/{nin|bvn}/link

200 OK
Creates the NinLink/BvnLink row, and — the enrollment side effect — upserts the live-capture embedding (carried in the linkToken payload, never re-fetched or re-computed) into canonical_embeddings. Replaying a linkToken (already consumed, expired, or never issued) returns 404 not_found.