U-verify

Documentation

Hosted Verification

The fastest way to add identity checks to your product: send your user's browser to a page we host, and get a signed result back at a URL of your choosing. You never see a raw NIN or BVN unless you explicitly fetch the full record afterward. No frontend code to write.

Who this is for

Teams without a dedicated backend developer, teams that want to ship in an afternoon, or anyone who'd rather not have raw NIN/BVN numbers pass through their own servers at all. If you need document upload or facial liveness inside your own app's UI, use the Full API instead — hosted verification only covers ID-number checks.

The flow, end to end

  • 1. You redirect out. Send the browser to our hosted page with your client_key, which checks to run, and where to send them back.
  • 2. Your user verifies. They enter their ID on our page. We run the check, debit your wallet, and build a signed result.
  • 3. We redirect back. The browser lands on your redirect_url with the outcome and a signature appended as query parameters.
  • 4. You verify the signature server-side before trusting the result, then let the user continue.

Step 1 — send the user to us

Redirect (a plain link or 302 works — no signing needed for this hop) to:

https://uverify.uselodger.com/verify/hosted
  ?client_key=ck_live_xxxx
  &checks[]=nin
  &checks[]=bvn
  &reference=order-42
  &redirect_url=https://yourapp.com/kyc/callback
FieldTypeNotes
client_keyrequiredstringYour project's public identifier.
checks[]requiredarrayOne or more of nin, bvn, drivers_license, voters_card, passport, cac.
referencestringYour own order/user ID — echoed back so you can match the result to your record.
redirect_urlrequiredurlMust be on a domain you've registered against this client_key, or the hosted page rejects the session.

Register your domain first

redirect_url is checked against a project_url you register on your account — including your staging domain, if you test there. An unregistered domain fails with INVALID_REDIRECT_URL. Update it from your dashboard's Profile page, or ask support to add it for you.

Step 2 — the redirect back

Your redirect_url receives:

FieldTypeNotes
outcomestringapproved, rejected, manual_review, or inconclusive.
referencestringWhatever you passed in step 1.
session_idstringThis hosted session's unique ID.
verified_atISO 8601When the check completed.
sighex stringHMAC-SHA256 proving this result came from U-verify unmodified.

Step 3 — verify the signature

Recompute the signature over client_key|session_id|reference|outcome|verified_at using your api_secret, and compare it to sig with a constant-time comparison — never a plain === or ==, which leaks timing information an attacker can use to forge a valid signature byte-by-byte.

import crypto from "node:crypto";

function isValidCallback({ clientKey, apiSecret, sessionId, reference, outcome, verifiedAt, sig }) {
  const signingString = [clientKey, sessionId, reference, outcome, verifiedAt].join("|");
  const expected = crypto.createHmac("sha256", apiSecret).update(signingString).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(sig, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// In your callback route handler, reading the query string U-verify redirected back with:
const url = new URL(request.url);
const ok = isValidCallback({
  clientKey: process.env.VERIFYNG_CLIENT_KEY,
  apiSecret: process.env.VERIFYNG_API_SECRET,
  sessionId: url.searchParams.get("session_id"),
  reference: url.searchParams.get("reference"),
  outcome: url.searchParams.get("outcome"),
  verifiedAt: url.searchParams.get("verified_at"),
  sig: url.searchParams.get("sig"),
});

A valid signature is not a login

It proves the result came from U-verify unmodified — it does not prove the browser hitting your callback right now is the same person who started the flow. Keep your own session or auth check on that route; don't unlock anything sensitive purely because sig matched.

Fetch the full record

The redirect query string only carries the outcome — not the actual name, date of birth, address, or photo. Every production integration we've shipped fetches the full record from the backend as the very next step of the callback handler, before doing anything with the result. There are two ways to do this; pick based on what your callback route already has available.

Recommended — signed, no login required

Call this straight from your callback handler with the same client_key/api_secret pair you already use to sign every other request. No JWT, no prior login — just an HMAC over client_key|session_id, exactly like the callback signature above but with the session ID standing in for the full field list.

POST/hosted/result
Returns the same data shape you'd get from a Quick Verify call — outcome, verified_at, and a checks object keyed by check type, each with the full provider payload (name, DOB, gender, address, photo where available).
FieldTypeNotes
client_keyrequiredstringYour project's identifier.
session_idrequiredstringFrom the callback query string.
sigrequiredhex stringhash_hmac('sha256', client_key + '|' + session_id, api_secret).

/hosted/result-by-reference is the same endpoint shape, keyed on the reference you originally passed in Step 1 instead of session_id (signed the same way, with reference in place of the session ID). Use it to build a "refresh verification status" action that re-checks a user's latest result on demand, without sending them through a fresh hosted redirect.

import crypto from "node:crypto";

async function fetchResultBySession(sessionId) {
  const clientKey = process.env.VERIFYNG_CLIENT_KEY;
  const apiSecret = process.env.VERIFYNG_API_SECRET;
  const sig = crypto.createHmac("sha256", apiSecret)
    .update(`${clientKey}|${sessionId}`)
    .digest("hex");

  const res = await fetch("https://api.uverify.uselodger.com/api/v1/hosted/result", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ client_key: clientKey, session_id: sessionId, sig }),
  });
  const body = await res.json();
  return body.success ? body.data : null; // data.checks.nin, data.outcome, ...
}

Re-fetch before you trust it — always

The outcome in the redirect query string is for your UI only. Before you flip any verified flag in your own database, call /hosted/result and use that response's outcome as the source of truth. A signature proves the query string wasn't tampered with in transit, but re-fetching server-to-server closes the separate case where the browser never reaches your callback at all (closed tab, dropped connection, a user who edits the URL bar) — you don't want a "refresh status" retry silently trusting stale or absent query params.

Alternative — from an authenticated admin session

If the code path already has a logged-in client JWT handy (an admin/staff panel reviewing a specific verification, for instance) and doesn't want to touch the raw api_secret, this does the same lookup under your existing session instead:

GET/verifications/session/{session_id}
Returns every field the provider returned for each requested check, scoped to checks run under your client account. Treat the response as sensitive — this is one of the few places raw PII reaches your server.

Session lifetime

  • The session token from step 1 is single-use and expires after 30 minutes.
  • Rate limits: 30/min on validate, 10/min on verify, per IP address.
  • Treat an expired session as a normal "please try again" state, not an error worth alarming your user over.