Identity Verification
Per-kind KYC endpoints (BVN, NIN, liveness, face-match, account-name) with audit trail + envelope-encrypted PII.
PlatformXe ships per-kind identity verification endpoints that wrap the underlying Identity Resolution Service with audit-grade persistence, envelope-encrypted PII, and scoped events. Five capabilities are exposed:
| Endpoint | What it verifies |
|---|---|
POST /v1/identity/verify-bvn | Bank Verification Number against a name. |
POST /v1/identity/verify-nin | National Identification Number against a name. |
POST /v1/identity/liveness | Live person, not a printed photo / video / mask. |
POST /v1/identity/face-match | Selfie similarity to an ID-document reference photo. |
POST /v1/identity/verify-account | Bank account name resolution (NIBSS-equivalent). |
| Property | Value |
|---|---|
| Scope | identity:verify |
| Plan gate | Detection Pack addon |
| Rate limit | 1,000 requests / hour per API key (per endpoint) |
| Idempotency | All endpoints honour Idempotency-Key |
Each call is persisted to identity_verifications with the raw provider response envelope-encrypted under your tenant's KMS key. The metadata summary stays in the clear for fast querying; the encrypted payload is only ever decrypted under explicit reviewer authorisation.
Country support today: The per-kind verification endpoints on this page (BVN, NIN, account-name, liveness, face-match) currently dispatch through Nigeria's provider chain. The country-pluggable engine that backs them — NG, KE, GH, and ZA plugins, per-(country, provider) circuit breakers, latency observations, and a dead-letter queue — is live as of Phase 6F.5b. See Identity Resilience for the supported countries, capability matrix, and provider health surface.
For multi-country use today, call POST /api/v1/identity/resolve or /verify (the generic endpoints) with the country-appropriate identifier kind — NIN maps to each country's primary national identifier (Huduma Number in KE, Ghana Card in GH, SAID in ZA). The per-kind endpoints below will accept an explicit country parameter in a follow-up release.
Verify BVN / NIN
curl -X POST https://api.platformxe.com/api/v1/identity/verify-bvn \
-H "Content-Type: application/json" \
-H "x-api-key: pxk_live_..." \
-d '{
"subjectId": "usr_001",
"bvn": "12345678901",
"matchAgainst": {
"firstName": "Adebayo",
"lastName": "Ogunde",
"dateOfBirth": "1980-04-15"
}
}'
POST /v1/identity/verify-nin is identical — substitute nin for bvn.
| Field | Type | Required | Description |
|---|---|---|---|
subjectId | string | Yes | Tenant-defined user / merchant id. Persisted on the verification row for audit linkage. |
bvn / nin | string | Yes | 11-digit identifier. Server validates the format. |
matchAgainst.firstName / lastName | string | Yes | Expected name to compare against the resolved profile. |
matchAgainst.dateOfBirth | string | No | ISO-8601. Used as a soft signal when the upstream returns DOB. |
Response:
{
"success": true,
"data": {
"verificationId": "iv_2j7yhq8wcs9...",
"kind": "bvn",
"status": "verified",
"matchScore": 95,
"profile": { "firstName": "Adebayo", "lastName": "Ogunde", "dateOfBirth": "1980-04-15", "...": "..." }
}
}
Statuses: verified (match), failed (no match or upstream returned verified=false), provider_down (every provider in the chain unavailable).
When the chain returns provider_down, the request is also captured to the dead-letter queue so operators can replay it later. See Identity Resilience → Dead-letter queue.
Liveness
curl -X POST https://api.platformxe.com/api/v1/identity/liveness \
-H "Content-Type: application/json" \
-H "x-api-key: pxk_live_..." \
-d '{
"subjectId": "usr_001",
"image": { "url": "https://your-storage.com/selfie.jpg" }
}'
| Field | Type | Required | Description |
|---|---|---|---|
subjectId | string | Yes | Tenant-defined user id. |
image.url | string (https) | Either url or base64 | HTTPS-only URL to the captured selfie. |
image.base64 | string | Either url or base64 | Raw base64 (no data: prefix). Mutually exclusive with url. |
image.contentType | "image/jpeg" | "image/png" | "image/webp" | When base64 is supplied | MIME hint. |
Response:
{
"success": true,
"data": {
"verificationId": "iv_2j7yhq8...",
"kind": "liveness",
"status": "verified",
"score": 92,
"provider": "primary"
}
}
When liveness fails, status: "failed" ships with an optional reason (spoof, occluded, low_quality, …). The provider field is the internal provider label — never a vendor name.
Face match
curl -X POST https://api.platformxe.com/api/v1/identity/face-match \
-H "Content-Type: application/json" \
-H "x-api-key: pxk_live_..." \
-d '{
"subjectId": "usr_001",
"selfie": { "url": "https://your-storage.com/selfie.jpg" },
"reference": { "url": "https://your-storage.com/id-photo.jpg" },
"threshold": 70
}'
| Field | Type | Required | Description |
|---|---|---|---|
subjectId | string | Yes | |
selfie | BiometricImage | Yes | The "live" capture. |
reference | BiometricImage | Yes | The reference image — typically the photo extracted from an ID document. |
threshold | number (0–100) | No | Similarity threshold for verified. Defaults to 65, clamped to [0, 100]. |
Response:
{
"success": true,
"data": {
"verificationId": "iv_2j7y...",
"kind": "face_match",
"status": "verified",
"similarity": 78,
"provider": "primary"
}
}
Verify account (NIBSS-equivalent)
curl -X POST https://api.platformxe.com/api/v1/identity/verify-account \
-H "Content-Type: application/json" \
-H "x-api-key: pxk_live_..." \
-d '{
"subjectId": "usr_001",
"accountNumber": "1234567890",
"bankCode": "044",
"expectedName": { "firstName": "Adebayo", "lastName": "Ogunde" },
"threshold": 70
}'
| Field | Type | Required | Description |
|---|---|---|---|
subjectId | string | Yes | |
accountNumber | string | Yes | Account number in the tenant's bank. Format depends on the country. |
bankCode | string | Yes | Bank code (NIBSS sort code in NG). |
expectedName.firstName / lastName | string | Yes | Expected name to match against the resolved account name. |
threshold | number (0–100) | No | Token-overlap match threshold. Defaults to 70. |
Response:
{
"success": true,
"data": {
"verificationId": "iv_2j7y...",
"kind": "account",
"status": "verified",
"matchScore": 100,
"resolvedName": "ADEBAYO OGUNDE",
"provider": "primary"
}
}
Match scoring uses the same canonicalisation pipeline as screening (lowercase, diacritic-stripped, honorifics removed, whitespace collapsed) — Adébáyò Ógúndé resolves to the same canonical form as Adebayo Ogunde, so honest spelling differences don't sink the match.
Privacy + storage
- Every successful or failed call writes one append-only row to
identity_verificationskeyed by(orgId, subjectId, kind, verifiedAt). - The raw provider response is envelope-encrypted: per-row 32-byte DEK, AES-256-GCM payload encryption, KEK held by the configured KMS provider. Wire format is
<keyId>|<wrappedDEK>|<iv>|<authTag>|<ciphertext>(base64). metadatais non-PII (scores, thresholds, bank code, matched-fields list). The PII (full name, DOB, address, photos) lives only inside the encrypted payload.- Decryption requires explicit reviewer authorisation. A dedicated audit-decryption endpoint that surfaces raw payloads under that authorisation ships in a follow-up release; encryption itself is live.
- When the KMS provider is unconfigured (the documented null adapter), persistence still writes the row but
rawResponseEncryptedstays null. Audit reproducibility is partial in that case but the verification trail isn't lost.
Events
| Event | Fires when |
|---|---|
IDENTITY_BVN_VERIFIED | BVN match against the expected name. |
IDENTITY_NIN_VERIFIED | NIN match. |
IDENTITY_LIVENESS_PASSED | Liveness check returned passed: true. |
IDENTITY_LIVENESS_FAILED | Liveness check returned passed: false. Carries reason. |
IDENTITY_FACE_MATCHED | Similarity ≥ threshold. |
IDENTITY_ACCOUNT_VERIFIED | Account name match ≥ threshold. |
IDENTITY_VERIFICATION_FAILED | Any kind failed. Carries kind, optional reason. |
All events carry the verificationId so you can drill into the audit trail.
Errors
| HTTP | Code | Cause |
|---|---|---|
| 400 | BAD_REQUEST | Validation failure — non-11-digit BVN/NIN, missing subjectId / expectedName, malformed image (both url+base64, http: URL), invalid threshold. |
| 401 | UNAUTHORIZED | Missing or invalid API key. |
| 402 | DETECTION_PACK_REQUIRED | Detection Pack addon not enabled. |
| 403 | FORBIDDEN | API key has no organisation context, or wrong scope. |
| 429 | RATE_LIMITED | 1,000/hr per endpoint exceeded. |
| 500 | INTERNAL_ERROR | Unexpected service failure. (Provider unavailability is a documented status: "provider_down" response, NOT a 500.) |