U-verify

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

Run this check right after a Quick Verify call succeeds (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.

FieldTypeNotes
Idempotency keyconceptYour own record of (tenant, id_type, id_number, status). Check it before charging anything.
Wallet billingpathDebit first, verify second, the debit is your commitment; the provider call is a slow network hop that shouldn't block it.
Gateway billingpathCreate 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

Your own billing layer and U-verify's wallet are two different balances. A 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