VerifyNG API
Nigerian Identity Verification — v1
VerifyNG is a unified Nigerian identity verification API. One integration connects your application to NIMC (NIN), CBN (BVN), FRSC (Driver's Licence), INEC (Voter's Card), NIS (Passport) and CAC (Business Registration) — with intelligent caching that makes repeat lookups free.
Base URL
https://api.verifyn.ng/api/v1
Production
Sandbox URL
https://sandbox.verifyn.ng/api/v1
Testing
Auth Method
HMAC-SHA256 + JWT
Per request
Authentication
VerifyNG uses a two-step authentication process. Every request is signed with HMAC-SHA256
using your api_secret,
and then a short-lived JWT is used to authorise API calls.
This prevents replay attacks and keeps your secret key off the wire.
Sign your request with HMAC-SHA256
Build a signing string from your client_key, timestamp, HTTP method, URL path and SHA-256 hash of the request body. Sign it with your api_secret using HMAC-SHA256.
Exchange for a JWT
POST to /auth/token with your HMAC headers. The API returns a JWT valid for 60 minutes. Cache this token — do not request a new one on every API call.
Call any endpoint
Include both the HMAC headers AND the JWT Bearer token in every subsequent request. The API verifies both the signature and the token.
Required headers on every request
X-Client-Key: ck_live_xxxxxxxxxxxx
X-Timestamp: 1783440000 # Unix timestamp (seconds)
X-Signature: a3f8c2... # HMAC-SHA256 signature
Authorization: Bearer eyJhbGci... # JWT (after /auth/token)
Content-Type: application/json
Accept: application/json
Request Signing (HMAC-SHA256)
The signing string is a pipe-delimited string of 5 components. Build it exactly as shown
and sign with HMAC-SHA256
using your api_secret.
client_key | unix_timestamp | HTTP_METHOD | /full/path | sha256(body)
# Example signing string:
ck_live_abc123|1783440000|POST|/api/v1/auth/token|4f53cda18c2b...
# Sign it:
signature = HMAC-SHA256(signing_string, api_secret)
# Notes:
# - HTTP_METHOD must be UPPERCASE (POST, GET, DELETE)
# - /full/path includes /api/v1 prefix
# - body = empty string "" for GET/DELETE requests
# - sha256(body) = hash of the raw JSON body string
# - Timestamp must be within 5 minutes of server time
// VerifyNG HMAC signing — PHP
function buildHmacHeaders(string $clientKey, string $apiSecret,
string $method, string $path, string $body): array {
$timestamp = time();
$bodyHash = hash('sha256', $body);
$signingString = implode('|', [
$clientKey, $timestamp,
strtoupper($method),
'/api/v1' . $path,
$bodyHash,
]);
$signature = hash_hmac('sha256', $signingString, $apiSecret);
return [
'X-Client-Key' => $clientKey,
'X-Timestamp' => $timestamp,
'X-Signature' => $signature,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
];
}
// Step 1: Get JWT token
$body = '{}';
$headers = buildHmacHeaders($clientKey, $apiSecret, 'POST', '/auth/token', $body);
$response = Http::withHeaders($headers)
->withBody($body, 'application/json')
->post('https://api.verifyn.ng/api/v1/auth/token');
$jwt = $response->json('data.access_token');
// Step 2: Verify a NIN
$payload = json_encode([
'checks' => ['nin'],
'nin' => '12345678901',
'first_name' => 'Adaeze',
'last_name' => 'Okonkwo',
]);
$headers = buildHmacHeaders($clientKey, $apiSecret, 'POST', '/verify', $payload);
$headers['Authorization'] = "Bearer {$jwt}";
$result = Http::withHeaders($headers)
->withBody($payload, 'application/json')
->post('https://api.verifyn.ng/api/v1/verify')
->json('data');
if ($result['outcome'] === 'approved') {
// User identity verified ✅
}
// VerifyNG HMAC signing — Node.js
const crypto = require('crypto');
const axios = require('axios');
function buildHmacHeaders(clientKey, apiSecret, method, path, body) {
const timestamp = Math.floor(Date.now() / 1000);
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const signingString = [clientKey, timestamp, method.toUpperCase(),
`/api/v1${path}`, bodyHash].join('|');
const signature = crypto.createHmac('sha256', apiSecret)
.update(signingString).digest('hex');
return { 'X-Client-Key': clientKey,
'X-Timestamp': timestamp,
'X-Signature': signature,
'Content-Type': 'application/json',
'Accept': 'application/json' };
}
async function verifyNIN(nin, firstName, lastName) {
// Get JWT
const tokenRes = await axios.post(
'https://api.verifyn.ng/api/v1/auth/token',
{}, { headers: buildHmacHeaders(CLIENT_KEY, API_SECRET, 'POST', '/auth/token', '{}') }
);
const jwt = tokenRes.data.data.access_token;
// Verify NIN
const body = JSON.stringify({ checks: ['nin'], nin, first_name: firstName, last_name: lastName });
const headers = { ...buildHmacHeaders(CLIENT_KEY, API_SECRET, 'POST', '/verify', body),
Authorization: `Bearer ${jwt}` };
const res = await axios.post('https://api.verifyn.ng/api/v1/verify', JSON.parse(body), { headers });
return res.data.data;
}
# VerifyNG HMAC signing — Python
import hmac, hashlib, time, json, requests
def build_hmac_headers(client_key, api_secret, method, path, body):
timestamp = int(time.time())
body_hash = hashlib.sha256(body.encode()).hexdigest()
signing_string = "|".join([client_key, str(timestamp),
method.upper(), f"/api/v1{path}", body_hash])
signature = hmac.new(api_secret.encode(), signing_string.encode(),
hashlib.sha256).hexdigest()
return { "X-Client-Key": client_key, "X-Timestamp": str(timestamp),
"X-Signature": signature, "Content-Type": "application/json",
"Accept": "application/json" }
# Get JWT
headers = build_hmac_headers(CLIENT_KEY, API_SECRET, "POST", "/auth/token", "{}")
jwt = requests.post("https://api.verifyn.ng/api/v1/auth/token",
data="{}", headers=headers).json()["data"]["access_token"]
# Verify NIN
payload = json.dumps({"checks": ["nin"], "nin": "12345678901",
"first_name": "Adaeze", "last_name": "Okonkwo"})
headers = build_hmac_headers(CLIENT_KEY, API_SECRET, "POST", "/verify", payload)
headers["Authorization"] = f"Bearer {jwt}"
result = requests.post("https://api.verifyn.ng/api/v1/verify",
data=payload, headers=headers).json()["data"]
# Step 1 — Get JWT (replace values with your credentials)
# First compute the signature (see signing string docs)
curl -X POST https://api.verifyn.ng/api/v1/auth/token \
-H "X-Client-Key: ck_live_xxxx" \
-H "X-Timestamp: 1783440000" \
-H "X-Signature: your_hmac_signature" \
-H "Content-Type: application/json" \
-d '{}'
# Response:
{
"success": true,
"data": {
"access_token": "eyJhbGci...",
"expires_in": 3600
}
}
# Step 2 — Verify a NIN
curl -X POST https://api.verifyn.ng/api/v1/verify \
-H "X-Client-Key: ck_live_xxxx" \
-H "X-Timestamp: 1783440001" \
-H "X-Signature: new_hmac_for_this_body" \
-H "Authorization: Bearer eyJhbGci..." \
-H "Content-Type: application/json" \
-d '{"checks":["nin"],"nin":"12345678901","first_name":"Adaeze","last_name":"Okonkwo"}'
// VerifyNG HMAC signing — Go
package main
import (
"crypto/hmac"; "crypto/sha256"; "encoding/hex"
"fmt"; "strings"; "time"; "bytes"; "net/http"
)
func buildHmacHeaders(clientKey, apiSecret, method, path, body string) map[string]string {
timestamp := fmt.Sprintf("%d", time.Now().Unix())
h := sha256.Sum256([]byte(body))
bodyHash := hex.EncodeToString(h[:])
signing := strings.Join([]string{clientKey, timestamp,
strings.ToUpper(method), "/api/v1"+path, bodyHash}, "|")
mac := hmac.New(sha256.New, []byte(apiSecret))
mac.Write([]byte(signing))
sig := hex.EncodeToString(mac.Sum(nil))
return map[string]string{
"X-Client-Key": clientKey, "X-Timestamp": timestamp,
"X-Signature": sig, "Content-Type": "application/json",
}
}
API Endpoints
Every endpoint starts with https://api.verifyn.ng/api/v1.
Each entry below shows the full expected payload, which fields are required vs optional, and a real example response.
No body fields needed. The HMAC headers identify you.
Request Body Fields
| Field | Required | Notes |
|---|---|---|
—
|
optional | Empty JSON body required: {} |
Example Request Body
{}
Example Response
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 3600
}
}
Only the ID field for your selected check type is required. All personal details (first_name, last_name, etc.) are optional — providing them improves the confidence score.
Request Body Fields
| Field | Required | Notes |
|---|---|---|
checks
|
required | At least one check type: nin, bvn, drivers_license, voters_card, passport, cac |
nin
|
if checks includes nin | 11 digits, no spaces. Example: 39506861494 |
bvn
|
if checks includes bvn | 11 digits. Example: 22369534677 |
license_number
|
if checks includes drivers_license | FRSC licence number. Example: AAA00000AA00 |
voter_id
|
if checks includes voters_card | INEC voter ID number from PVC |
passport_number
|
if checks includes passport | Letter + 8 digits. Example: A12345678 |
rc_number
|
if checks includes cac | CAC registration number. Example: RC1234567 |
first_name
|
optional | Optional. Cross-validates against government record — raises confidence score. |
last_name
|
optional | Optional. Cross-validates against government record. |
middle_name
|
optional | Optional. |
date_of_birth
|
required for passport | Format: YYYY-MM-DD. Required for passport. Optional for NIN/BVN (raises confidence). |
gender
|
optional | Optional. Values: male, female |
phone
|
optional | Optional. Your applicant's phone number. |
email
|
optional | Optional. Your applicant's email address. |
reference
|
optional | Optional. Your internal ID for this applicant. Stored for correlation. |
Example Request Body
{
"checks": ["nin"],
"nin": "39506861494",
"first_name": "Adewale",
"last_name": "Jamiu",
"reference": "user_8827"
}
Example Response
{
"success": true,
"data": {
"session_id": "01KWY41Z...",
"outcome": "approved",
"reference": "user_8827",
"verified_at": "2026-07-08T10:00:00.000Z",
"overall_confidence": 95,
"checks": {
"nin": {
"status": "passed",
"source": "provider",
"provider": "dojah",
"matched_name": "ADEWALE JAMIU",
"first_name": "ADEWALE",
"last_name": "JAMIU",
"middle_name": "ADESHOLA",
"date_of_birth": "1990-04-31",
"gender": "male",
"phone_number": "07012345678",
"state_of_origin":"Kwara",
"photo": "/9j/4AAQSkZJRg...",
"confidence": 95,
"cost": 200,
"wallet_balance_after": 4800
}
}
}
}
GET request — no body. No query parameters needed.
Example Response
{
"success": true,
"data": {
"wallet": {
"balance": 4800.00,
"currency": "NGN",
"cost_per_nin": 200,
"cost_per_bvn": 150,
"cost_per_dl": 150,
"cost_per_pvc": 150,
"cost_per_passport":200,
"cost_per_cac": 250,
"low_balance_threshold": 2000
}
}
}
All query parameters are optional.
Query Parameters
| Field | Required | Notes |
|---|---|---|
page
|
optional | Page number. Default: 1 |
per_page
|
optional | Results per page. Default: 25. Max: 100 |
type
|
optional | Filter: debit or credit |
check_type
|
optional | Filter by check: nin, bvn, etc. |
from
|
optional | Start date. Format: YYYY-MM-DD |
to
|
optional | End date. Format: YYYY-MM-DD |
Example Response
{
"success": true,
"data": {
"data": [
{
"ulid": "01KWY41Z...",
"type": "debit",
"amount": 200,
"balance_after": 4800,
"description":"NIN verification — ref: 01KWA12...",
"created_at": "2026-07-08T10:00:00.000Z"
}
],
"meta": { "total": 42, "per_page": 25, "current_page": 1, "last_page": 2 }
}
}
Amount is in Naira (not kobo). Minimum ₦1,000.
Request Body Fields
| Field | Required | Notes |
|---|---|---|
amount
|
required | Amount in Naira (NGN). Minimum: 1000. Maximum: 10,000,000 |
callback_url
|
required | Your URL that Paystack redirects to after payment. Must be HTTPS in production. |
Example Request Body
{
"amount": 10000,
"callback_url": "https://yourdomain.com/wallet/funded"
}
Example Response
{
"success": true,
"data": {
"reference": "VNG-ABC123DEF456",
"authorization_url": "https://checkout.paystack.com/...",
"access_code": "abc123...",
"amount": 10000
}
}
Pass the Paystack reference as a URL segment. No body needed.
Query Parameters
| Field | Required | Notes |
|---|---|---|
{reference}
|
required | URL parameter. The Paystack reference from the callback URL query string. |
Example Response
{
"success": true,
"data": {
"status": "success",
"amount": 10000,
"new_balance": 14800,
"message": "₦10,000.00 has been added to your wallet."
}
}
All query parameters are optional.
Query Parameters
| Field | Required | Notes |
|---|---|---|
page
|
optional | Page number. Default: 1 |
check_type
|
optional | Filter by type: nin, bvn, drivers_license, voters_card, passport, cac |
source
|
optional | Filter by source: provider, database, redis |
Example Response
{
"success": true,
"data": {
"data": [
{
"ulid": "01KWY41Z...",
"check_type": "nin",
"source": "provider",
"cost_charged": 200,
"looked_up_at": "2026-07-08T10:00:00.000Z",
"data": {
"first_name": "DAUDA",
"last_name": "JAMIU",
"gender": "male",
"date_of_birth":"2000-06-01"
}
}
],
"stats": {
"total_lookups": 42,
"provider_calls": 8,
"cache_hits": 34,
"total_spent": 1600
}
}
}
All query parameters optional.
Query Parameters
| Field | Required | Notes |
|---|---|---|
page
|
optional | Page number. Default: 1 |
search
|
optional | Search by name or reference |
kyc_status
|
optional | Filter: approved, rejected, pending |
Example Response
{
"success": true,
"data": {
"data": [
{
"ulid": "01KWY41Z...",
"reference": "user_8827",
"kyc_status": "approved",
"created_at": "2026-07-08T10:00:00.000Z"
}
]
}
}
All query parameters optional.
Query Parameters
| Field | Required | Notes |
|---|---|---|
page
|
optional | Default: 1 |
outcome
|
optional | Filter: approved, rejected, manual_review, inconclusive |
reference
|
optional | Your internal reference to find a specific session |
Example Response
{
"success": true,
"data": {
"data": [
{
"session_id": "01KWY41Z...",
"outcome": "approved",
"reference": "user_8827",
"verified_at": "2026-07-08T10:00:00.000Z",
"checks": ["nin"]
}
]
}
}
Managed by Paystack. Do not call manually.
Request Body Fields
| Field | Required | Notes |
|---|---|---|
(Paystack payload)
|
N/A | Sent automatically by Paystack. Verified using HMAC-SHA512 against your Paystack secret key. |
Example Response
{ "message": "Webhook processed" }
Check Types
All checks go through POST /api/v1/verify.
The checks array
tells the API which lookups to run.
Example value
12345678901
Format
11 digits
Request body
{"checks":["nin"],"nin":"12345678901","first_name":"Adaeze","last_name":"Okonkwo"}
Example value
12345678901
Format
11 digits
Request body
{"checks":["bvn"],"bvn":"12345678901"}
Example value
AAA00000AA00
Format
Alpha-numeric
Request body
{"checks":["drivers_license"],"license_number":"AAA00000AA00"}
Example value
VIN00000000
Format
Alpha-numeric
Request body
{"checks":["voters_card"],"voter_id":"VIN00000000"}
Example value
A00000000
Format
Letter + 8 digits
Request body
{"checks":["passport"],"passport_number":"A00000000","date_of_birth":"1990-01-15"}
Example value
RC1234567
Format
RC + digits
Request body
{"checks":["cac"],"rc_number":"RC1234567"}
Hosted Redirect Flow
The hosted flow lets you verify a user's identity without building your own form or touching raw NIN/BVN data at all. You send the user to a VerifyNG-hosted page with a link, VerifyNG runs the checks, and the user is redirected back to your app with a signed result. It works the same way regardless of what your app is built with — Laravel, Express, Django/Flask, Next.js, a mobile app, or anything else that can redirect a browser and receive a callback.
This is not a one-way integration. The hosted page is the fastest path to "ship it today," but everything it does is backed by the same authenticated API described elsewhere in these docs. You can drop down to the raw endpoints at any point — to build a fully custom UI, to re-check a session server-side, to combine results with webhooks, or to chain the redirect into other parts of your own product. See Integration Ideas below.
Privacy
No PII on your server
Raw NIN/BVN never touch your backend unless you choose to fetch it
Portability
Any stack, any framework
It is just a redirect + a signed query string callback
Efficiency
Cacheable + chainable
checks[] can request multiple check types in one session
Redirect Architecture
There are two request/response hops on top of a browser redirect. Nothing here is Laravel- or PHP-specific — it's plain HTTP.
┌────────────────┐ 1. Browser redirect ┌───────────────────────┐
│ Your App │ ────────────────────────▶│ VerifyNG Hosted Page │
│ (any stack) │ ?client_key=&checks[]= │ GET /verify/hosted │
└────────────────┘ &reference=&redirect_url └───────────┬───────────┘
│ 2. POST /hosted/validate
▼
┌───────────────────────┐
│ VerifyNG API │
│ validates client_key │
│ + redirect_url domain│
└───────────┬───────────┘
│ session_token
▼
User fills the form
(NIN / BVN / etc.)
│ 3. POST /hosted/verify
▼
┌───────────────────────┐
│ VerifyNG API │
│ NIMC / CBN / FRSC / │
│ INEC / NIS / CAC │
└───────────┬───────────┘
│ signed callback_url
▼
┌────────────────┐ 4. Browser redirect ┌───────────────────────┐
│ Your App │◀────────────────────────── │ VerifyNG Hosted Page │
│ /callback │ outcome, reference, │ redirects the browser │
│ │ session_id, verified_at, └───────────────────────┘
│ │ sig
└───────┬─────────┘
│ 5. Verify HMAC signature with your own api_secret
▼
┌─────────────────────────────────────────────┐
│ 6. (optional) GET /verifications/session/id │
│ → full record: names, DOB, address, │
│ photo, company data, etc. │
└─────────────────────────────────────────────┘
You redirect the browser
A plain <a href> or server-side redirect to the VerifyNG hosted URL with client_key, checks[], reference, and redirect_url as query params. No signing needed for this hop.
VerifyNG validates
The hosted page calls the unauthenticated /hosted/validate endpoint, which checks your client_key is active, your redirect_url domain matches what is registered on your account, and that your wallet has balance. It returns a short-lived session_token.
User submits, VerifyNG verifies
The user enters their ID (NIN, BVN, etc.) on the hosted form. /hosted/verify runs the check against the relevant government source (or serves it from cache, for free), debits your wallet, and builds a signed callback_url.
VerifyNG redirects back
The browser is sent to your redirect_url with outcome, reference, session_id, verified_at, and sig appended as query params. The session_token is invalidated — one use only.
You verify the signature
Recompute the HMAC-SHA256 over client_key|session_id|reference|outcome|verified_at using your api_secret, and compare it to sig with a constant-time comparison. This proves the result was not tampered with in the browser.
Optionally fetch the full record
If you need more than outcome/reference — name, DOB, address, company data, photo — call GET /verifications/session/{session_id} from your backend using your authenticated JWT. This is entirely optional and stack-agnostic.
Multi-Stack Examples
The same three operations — build the link, verify the callback signature, and (optionally) fetch the full session — implemented in four common stacks. Swap in your own env/config lookups.
1. Build the hosted link
$url = rtrim(config('services.verifyng.hosted_url'), '/') . '/verify/hosted'
. '?client_key=' . urlencode(config('services.verifyng.client_key'))
. '&checks[]=nin'
. '&reference=' . urlencode($user->id)
. '&redirect_url=' . urlencode(route('kyc.callback'));
2. Verify the callback signature
$signingString = implode('|', [
config('services.verifyng.client_key'),
$request->input('session_id'),
$request->input('reference'),
$request->input('outcome'),
$request->input('verified_at'),
]);
$expected = hash_hmac('sha256', $signingString, config('services.verifyng.secret_key'));
if (! hash_equals($expected, $request->input('sig'))) {
abort(400, 'Signature mismatch.');
}
3. (Optional) Fetch the full session
$response = Http::withToken($jwt)
->get("{$apiBase}/verifications/session/{$sessionId}", [
'include_photo' => 'true',
]);
$data = $response->json('data');
1. Build the hosted link
const params = new URLSearchParams();
params.set('client_key', process.env.VERIFYNG_CLIENT_KEY);
params.append('checks[]', 'nin');
params.set('reference', user.id);
params.set('redirect_url', `${process.env.APP_URL}/kyc/callback`);
const hostedUrl = `${process.env.VERIFYNG_HOSTED_URL}/verify/hosted?${params}`;
2. Verify the callback signature
const crypto = require('crypto');
app.get('/kyc/callback', (req, res) => {
const { outcome, reference, session_id, verified_at, sig } = req.query;
const signingString = [
process.env.VERIFYNG_CLIENT_KEY, session_id, reference, outcome, verified_at,
].join('|');
const expected = crypto
.createHmac('sha256', process.env.VERIFYNG_API_SECRET)
.update(signingString)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(sig || '');
const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!valid) return res.status(400).send('Signature mismatch.');
// outcome: approved | rejected | manual_review | inconclusive
res.redirect(outcome === 'approved' ? '/kyc/success' : '/kyc/failed');
});
3. (Optional) Fetch the full session
const res = await fetch(
`${apiBase}/verifications/session/${sessionId}?include_photo=true`,
{ headers: { Authorization: `Bearer ${jwt}` } }
);
const { data } = await res.json();
1. Build the hosted link
from urllib.parse import urlencode
params = {
'client_key': CLIENT_KEY,
'reference': user.id,
'redirect_url': f'{APP_URL}/kyc/callback',
}
hosted_url = f"{VERIFYNG_HOSTED_URL}/verify/hosted?{urlencode(params)}&checks[]=nin"
2. Verify the callback signature
import hmac, hashlib
from flask import request, redirect, abort
@app.route('/kyc/callback')
def kyc_callback():
outcome, reference, session_id, verified_at, sig = (
request.args.get(k) for k in
('outcome', 'reference', 'session_id', 'verified_at', 'sig')
)
signing_string = '|'.join([CLIENT_KEY, session_id, reference, outcome, verified_at])
expected = hmac.new(
API_SECRET.encode(), signing_string.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, sig or ''):
abort(400, 'Signature mismatch.')
return redirect('/kyc/success' if outcome == 'approved' else '/kyc/failed')
3. (Optional) Fetch the full session
import requests
resp = requests.get(
f'{API_BASE}/verifications/session/{session_id}',
params={'include_photo': 'true'},
headers={'Authorization': f'Bearer {jwt}'},
)
data = resp.json()['data']
1. Build the hosted link
const params = new URLSearchParams({
client_key: process.env.VERIFYNG_CLIENT_KEY!,
reference: user.id,
redirect_url: `${process.env.NEXT_PUBLIC_APP_URL}/kyc/callback`,
});
params.append('checks[]', 'nin');
const hostedUrl = `${process.env.VERIFYNG_HOSTED_URL}/verify/hosted?${params}`;
2. Verify the callback signature (Route Handler)
// app/kyc/callback/route.ts
import crypto from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
const sp = new URL(req.url).searchParams;
const outcome = sp.get('outcome')!;
const reference = sp.get('reference')!;
const sessionId = sp.get('session_id')!;
const verifiedAt = sp.get('verified_at')!;
const sig = sp.get('sig')!;
const signingString = [
process.env.VERIFYNG_CLIENT_KEY, sessionId, reference, outcome, verifiedAt,
].join('|');
const expected = crypto
.createHmac('sha256', process.env.VERIFYNG_API_SECRET!)
.update(signingString)
.digest('hex');
const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
if (!valid) return NextResponse.json({ error: 'Signature mismatch' }, { status: 400 });
const dest = outcome === 'approved' ? '/kyc/success' : '/kyc/failed';
return NextResponse.redirect(new URL(dest, req.url));
}
3. (Optional) Fetch the full session
const res = await fetch(
`${apiBase}/verifications/session/${sessionId}?include_photo=true`,
{ headers: { Authorization: `Bearer ${jwt}` }, cache: 'no-store' }
);
const { data } = await res.json();
Integration Ideas
The redirect is one building block, not the whole system. It can feed into — or be fed by — every other part of the API, regardless of your project's stack, size, or maturity.
Treat the redirect as a hint, not the truth
The query string is visible in the browser. For anything sensitive (granting paid access, unlocking a wallet, approving a loan), re-confirm the outcome server-side via GET /verifications/session/{id} before acting on it.
Layer in webhooks for slow outcomes
"manual_review" can resolve minutes or hours later. Register a webhook endpoint (see Webhooks) so your backend is notified the moment a review completes, instead of leaving the user on a stale "pending" screen.
Chain checks in one session
Pass multiple values in checks[] — e.g. nin and bvn — to run a fuller identity check in a single hosted session and a single redirect round-trip.
Reuse the cache across your own flows
A second lookup on the same NIN/BVN anywhere in your product (a different feature, a different user role, a repeat signup attempt) is served from cache and does not re-charge your wallet.
Go headless when you outgrow the hosted page
Once your team is ready to own the UI, call /hosted/validate and /hosted/verify (or the fully authenticated endpoints) directly from your own frontend. The signing and callback contract stays identical — only the form is yours.
Pre-empt failures with the balance flag
has_balance comes back from /hosted/validate before the user does anything. Use it to show your own "top up required" state instead of letting the user hit a dead end mid-flow.
Deep-link from mobile
The exact same query-string contract works from a native app: open the hosted URL in an in-app browser / custom tab, and set redirect_url to a universal link or custom URL scheme your app can intercept.
Use reference as your join key
Pass your own internal user ID, order ID, or application ID as reference. Every downstream lookup — the callback, the webhook, GET /verifications/session/{id} — carries it back, so results always map cleanly to your own records.
Multi-party verification
Marketplaces, landlords, or B2B platforms can run two independent hosted sessions — one NIN check for a buyer, one CAC check for a seller's business — and tie both back to a single transaction via a shared reference prefix.
Progressive verification
Start with a cheap/fast check (e.g. NIN) to unlock a limited tier, and trigger a second hosted redirect later for a stronger check (e.g. BVN or CAC) only when the user needs a higher trust tier — no need to front-load everything on day one.
Redirect Security & Edge Cases
Always compare the signature with a constant-time function — hash_equals() in PHP, crypto.timingSafeEqual() in Node, hmac.compare_digest() in Python. A plain === comparison leaks timing information.
redirect_url must resolve to a domain that matches (or is a subdomain of) the project URL registered on your VerifyNG account, or /hosted/validate rejects the request outright — register every domain you use, including staging.
session_token / session_id are single-use and expire after 30 minutes. Treat an expired session as a normal "please restart" state in your UI, not an error worth alarming the user over.
api_secret must live only on your backend — env var, secrets manager, whatever your stack uses. It should never reach frontend JavaScript, mobile app bundles, or client-side code of any kind.
Rate limits apply per IP to both /hosted/validate (30/min) and /hosted/verify (10/min). Build your retry/backoff UX with that ceiling in mind, especially for kiosk-style or shared-IP deployments.
The signed callback proves the result came from VerifyNG unmodified — it does not prove the browser reaching your callback route is the same user who started the flow. Keep your own session/auth check on the callback route for anything tied to a logged-in account.
Rotate api_secret from your VerifyNG profile periodically, and immediately if it is ever exposed (committed to a repo, logged, etc.) — old signatures signed with a rotated secret will simply stop verifying.
Response Format
All responses follow the same envelope structure.
✓ Successful verification
{
"success": true,
"data": {
"session_id": "01KWY41Z...",
"outcome": "approved", // approved | rejected | manual_review | inconclusive
"reference": "your-user-id-123",
"verified_at": "2026-07-08T10:00:00.000Z",
"overall_confidence": 95,
"checks": {
"nin": {
"status": "passed", // passed | failed | skipped | unavailable
"source": "provider", // provider | database | redis
"provider": "dojah",
"matched_name": "ADEWALE JAMIU",
"first_name": "ADEWALE",
"last_name": "JAMIU",
"middle_name": "ADESHOLA",
"date_of_birth": "1990-04-31",
"gender": "male",
"phone_number": "07012345678",
"state_of_origin":"Kwara",
"photo": "/9j/4AAQSkZJRg...", // base64 NIMC photo
"confidence": 95,
"cost": 200, // ₦200 (0 if from cache)
"wallet_balance_after": 4800
}
}
}
}
✗ Error response
{
"success": false,
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "Wallet balance is too low. Please top up.",
"balance": 50,
"cost": 200
}
}
Error Codes
| Code | HTTP | Meaning & Fix |
|---|---|---|
MISSING_HEADERS
|
401 | HMAC headers missing. Add X-Client-Key, X-Timestamp, X-Signature to every request. |
INVALID_CLIENT_KEY
|
401 | client_key not found or account suspended. Check your credentials. |
INVALID_SIGNATURE
|
401 | HMAC signature mismatch. Check signing string order and api_secret. |
TIMESTAMP_DRIFT
|
401 | Request timestamp too old (>5 min). Ensure your server clock is synced (NTP). |
INSUFFICIENT_BALANCE
|
402 | Wallet has insufficient funds. Top up at /wallet/fund/initialize. |
QUOTA_EXCEEDED
|
429 | Monthly verification quota exceeded. Contact support to increase. |
TOO_MANY_ATTEMPTS
|
429 | Rate limit hit. Slow down requests — max 60/min per client. |
INVALID_CREDENTIALS
|
401 | Wrong email or password (portal login). |
ACCOUNT_SUSPENDED
|
403 | Account suspended. Contact VerifyNG support. |
INVALID_RESPONSE
|
500 | Upstream provider returned unexpected data. Non-billable — retry. |
CONNECTION_ERROR
|
502 | Could not reach government database. Non-billable — retry in 30s. |
Webhooks
VerifyNG fires webhooks to your registered webhook_url
when important events occur. Verify the payload using your api_secret.
wallet.low_balance
Fired when wallet drops below your configured threshold. Act immediately to avoid verification failures.
{
"event": "wallet.low_balance",
"client_id": "01KWY41Z...",
"balance": 1800,
"threshold": 2000,
"cost_per_nin":200,
"checks_left": 9,
"timestamp": "2026-07-08T10:00:00.000Z"
}
Pricing & Caching
Cache-first saves money
The same NIN, BVN or any ID verified before is stored permanently in VerifyNG's database. Repeat lookups — even from different client projects — return instantly from cache at ₦0. Only the very first lookup hits the government database and costs money.
| Check Type | Provider Cost (first lookup) | Cache Hit Cost | Source Database |
|---|---|---|---|
| NIN | ₦200 | ₦0 ⚡ | NIMC |
| BVN | ₦150 | ₦0 ⚡ | CBN |
| Driver's Licence | ₦150 | ₦0 ⚡ | FRSC |
| Voter's Card | ₦150 | ₦0 ⚡ | INEC |
| International Passport | ₦200 | ₦0 ⚡ | NIS |
| CAC Business | ₦250 | ₦0 ⚡ | CAC |
Prices shown are defaults. Your account may have custom pricing set by the VerifyNG
administrator.
Check your wallet summary (GET /wallet) for your current
per-check costs.
Rate Limits
/auth/token
10 requests/minute
Per client. Prevents token hammering.
/verify
60 requests/minute
Per client. For high volume contact us.
All endpoints
300 requests/minute
Per IP address across all endpoints.
Portal login
5 attempts/minute
Per IP. Blocks brute force attempts.
Rate limit headers are returned on every response:
X-RateLimit-Limit,
X-RateLimit-Remaining,
X-RateLimit-Reset.
NDPR Compliance
No NIN stored in plain text
Every NIN, BVN and sensitive ID is stored as an irreversible SHA-256 hash. The actual ID is AES-256 encrypted and only accessible to authorised VerifyNG administrators.
Consent via payment
The public ID search feature requires explicit payment before returning any identity data — satisfying NDPR's consent requirement.
Right to erasure
Contact support to request deletion of any data VerifyNG holds. Cache entries can be invalidated on request.
Data minimisation
Only data directly returned by the government database is stored. VerifyNG never stores additional profiling data.
Immutable audit trail
Every access to identity data is logged with timestamp, client ID, and source. Logs cannot be modified.
Data residency
All VerifyNG data is stored in Nigeria. No data is transferred outside Nigerian jurisdiction.
Frequently Asked Questions
No. The JWT is valid for 60 minutes. Cache it and reuse it. Only request a new token when you receive a 401 response or when the token expires.
VerifyNG uses multi-provider fallback (Prembly → Smile Identity → Dojah). If all providers fail, you receive a CONNECTION_ERROR — no charge is applied. Retry after 30 seconds.
Yes. Pass multiple values in the checks array: {"checks":["nin","bvn"],"nin":"...","bvn":"..."}. Each check is independent — partial success is possible.
Yes. The photo field contains a base64-encoded JPEG image from NIMC. On cache hits, the photo is returned from the VerifyNG database at no additional cost.
"provider" means we called the government database this time and charged your wallet. "database" means we served from our permanent cache — free. "redis" means ultra-fast in-memory cache — also free.
Catch the 402 status code and show a user-friendly message like "Verification temporarily unavailable" — do not expose the wallet error to your end users. Alert your admin to top up.
Yes. Use https://sandbox.verifyn.ng/api/v1 with sandbox credentials. Test NIN: 70123456789 (returns John Doe Adamu). Test BVN: 12345678901.
Any language that can make HTTP requests and compute HMAC-SHA256. We provide examples for PHP, Node.js, Python, Go and cURL. The signing algorithm is industry-standard.
No. Your api_secret must NEVER be exposed in frontend code. Always call VerifyNG from your backend server. Your backend then returns the verification result to your frontend.
Ready to integrate?
Register your project and get sandbox credentials in under 2 minutes.
Get API Access — Free Sandbox