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.

1

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.

2

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.

3

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

Request Headers
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.

Signing String Format
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

JSON POST /api/v1/auth/token
{}

Example Response

200 OK
{
  "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

JSON POST /api/v1/verify
{
  "checks":     ["nin"],
  "nin":        "39506861494",
  "first_name": "Adewale",
  "last_name":  "Jamiu",
  "reference":  "user_8827"
}

Example Response

200 OK
{
  "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

200 OK
{
  "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

200 OK
{
  "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

JSON POST /api/v1/wallet/fund/initialize
{
  "amount":       10000,
  "callback_url": "https://yourdomain.com/wallet/funded"
}

Example Response

200 OK
{
  "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

200 OK
{
  "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

200 OK
{
  "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

200 OK
{
  "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

200 OK
{
  "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

200 OK
{ "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.               │
└─────────────────────────────────────────────┘
1

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.

2

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.

3

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.

4

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.

5

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.

6

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