Documentation
Integration recipes
The Hosted Verification and Full API guides cover individual endpoints. This page covers three complete, opinionated patterns, assembled from production integrations that have run real traffic against U-verify, for problems that show up around the API call, not in it: proving a verified ID actually belongs to the account claiming it, billing for checks without double-charging, and showing a user their verification result without leaking data they never gave you.
1. Bind a verified ID to an existing account
A successful Quick Verify or hosted check proves the number is real, it does not prove the person typing it in is its owner. Someone can enter a real NIN that belongs to someone else. If the result is going to unlock anything (a "verified" badge, higher limits, host/landlord status), cross-check the provider's payload against whatever the user already told you about themselves at signup, and treat a mismatch as a blocker, not a warning.
- Only compare fields both sides have. A field the provider returned that your profile never collected isn't a mismatch, it's just data you didn't ask for. Comparing against a blank profile field produces false positives that block real users.
- Compare case-insensitively, trimmed. Government data entry is inconsistent about casing and whitespace far more often than it's actually wrong.
- On a mismatch, tell the user exactly which field and why ; "your last name on file doesn't match your NIN record"; so they can fix their profile rather than retry the same failing check.
// After a successful Quick Verify call, before you trust the match:
function findProfileMismatches(user, verified) {
const mismatches = [];
const same = (a, b) => a && b && a.trim().toLowerCase() === b.trim().toLowerCase();
if (verified.first_name && user.firstName && !same(verified.first_name, user.firstName)) {
mismatches.push({ field: "first_name", verified: verified.first_name, profile: user.firstName });
}
if (verified.last_name && user.lastName && !same(verified.last_name, user.lastName)) {
mismatches.push({ field: "last_name", verified: verified.last_name, profile: user.lastName });
}
if (verified.date_of_birth && user.dateOfBirth && verified.date_of_birth !== user.dateOfBirth) {
mismatches.push({ field: "date_of_birth", verified: verified.date_of_birth, profile: user.dateOfBirth });
}
// ...same idea for gender, state/LGA, etc. Skip any field neither side has.
return mismatches;
}
// Only fields BOTH sides already have get compared. A field the user never
// filled in isn't a "mismatch" ; it's just missing, and comparing against it
// would produce a false positive that blocks a real user.Where this fits in the flow
POST /verify ; see the API reference), before you persist anything as verified. It works the same way against a hosted-flow result fetched via /hosted/result.2. Bill per verification, not per API call
If verification is a paid feature you resell to your own users or tenants (a hotel biller checking a walk-in guest, a marketplace charging for a seller's KYC), you need a billing story on top of whatever the API itself costs, and you need it to be idempotent, so a user refreshing the page doesn't trigger a second charge for a check you already ran.
| Field | Type | Notes |
|---|---|---|
Idempotency key | concept | Your own record of (tenant, id_type, id_number, status). Check it before charging anything. |
Wallet billing | path | Debit first, verify second, the debit is your commitment; the provider call is a slow network hop that shouldn't block it. |
Gateway billing | path | Create a pending record, redirect to checkout, run the actual verify call from your payment webhook once the charge clears, never before. |
// Your own ledger of what THIS tenant has already paid to verify,
// keyed on (tenantId, idType, idNumber), never re-derived from VerifyNG.
async function verifyWalkInGuest({ tenantId, idType, idNumber, gateway, ...person }) {
const existing = await db.identityVerifications.findFirst({
where: { tenantId, type: idType, number: idNumber, status: "verified" },
});
if (existing) {
// Same ID, already paid for once; never bill twice for the same lookup.
return { charged: false, verified: true, payload: existing.verifiedPayload };
}
const amount = WALKIN_FEE;
if (gateway === "wallet") {
const debited = await debitWallet(tenantId, amount, `ID check (${idType})`);
if (!debited) throw new InsufficientBalanceError(amount);
// Debit succeeds and is recorded FIRST; the provider call below is a
// slow network hop that shouldn't hold a DB transaction open, and by
// this point the charge is already committed either way.
const result = await verifyNg.quickVerify(idType, idNumber, person);
await db.identityVerifications.create({ tenantId, type: idType, number: idNumber, ...result });
return { charged: true, ...result };
}
// gateway === "paystack": create a pending record + pending payment, kick
// off checkout, and run the actual verifyNg.quickVerify() call from your
// payment webhook/callback once the charge clears, never before.
return initPendingPaystackCharge({ tenantId, idType, idNumber, amount, person });
}Handle a 402 from VerifyNG itself, separately
402 from POST /verify means your U-verify wallet is out of funds; see Errors & status codes. Surface it as "verification temporarily unavailable" to your end user, not as their fault, and alert whoever tops up your wallet.3. Show claimed vs. verified, without over-collecting
A single successful NIN check returns far more than a name, phone number, residential address, state and LGA of origin, sometimes a photo. If you surface all of it back to the user on a "here's what we found" screen, you've turned a verification feature into a way to look up someone's phone number or address from their NIN alone, which is a privacy problem independent of whether the account is genuinely theirs.
The fix: only ever display a verified field next to a claimed value the user already gave you. A field they never provided is withheld entirely; not shown, not hinted at, regardless of what the provider returned for it.
// GET /kyc/compare, side-by-side claimed vs. verified, privacy-safe.
async function getComparison(user) {
const record = await db.nimcVerifications.findLatest({ userId: user.id });
if (!record) return { available: false, fields: {} };
const pairs = {
first_name: [user.firstName, record.verifiedFirstName],
last_name: [user.lastName, record.verifiedLastName],
date_of_birth: [user.dateOfBirth, record.verifiedDob],
state: [user.state, record.verifiedResidenceState],
// NIMC also returns phone, full address, photo, deliberately NOT
// included here, because the user never claimed those values first.
};
const visible = {};
for (const [field, [claimed, verified]] of Object.entries(pairs)) {
// A field the user never filled in is withheld entirely, not shown
// even as "verified: xxx"; so this screen can't be used to pull data
// (phone number, address) off someone's NIN that they didn't already
// give you themselves.
if (!claimed) continue;
visible[field] = { claimed, verified, matches: normalize(claimed) === normalize(verified) };
}
return { available: true, fields: visible };
}The backend, not the browser, decides "verified"
This pattern pairs with the hosted-flow rule from the previous page: your callback route receives an outcome query parameter, but that value should only ever drive what the browser shows next. The record you persist as "verified"; the one this comparison reads from, should come from your backend independently re-fetching /hosted/result and reading its outcome. If that fetch fails or can't be confirmed, leave the user unverified and let a retry (or your /hosted/result-by-reference-backed "refresh status" action) pick it back up later, never mark someone verified on the strength of a query string alone.
Related pages
- Hosted Verification ; the redirect flow and both ways to fetch a full result.
- Full API reference ; Quick Verify, subjects, documents, facial liveness, wallet.
- Errors & status codes ; what a
402or422means and how to recover.