U-verify

Documentation

Authentication

Two layers protect every request that touches real identity data: an HMAC signature proving the request came from you and wasn't altered in transit, and a short-lived JWT access token proving you're currently logged in. The Hosted Verification and Public Search endpoints skip both; see Hosted Verification if that's all you need.

Sandbox first, production by request

Registering gets you a ck_test_ sandbox project capped at 100 checks/month, active once you verify your email. Production access (10,000 checks/month, live traffic) is a separate, manually reviewed request from your dashboard , where your current usage against that cap is always visible, once you know what you're building. See the Acceptable Use Policy for what we look for.

Why two layers, not one secret key like Paystack?

Paystack trusts whoever holds its secret key because every call is already self-describing; a specific amount, a specific reference, so a leak mostly risks money moving through your own account, which you'd notice fast. A U-verify call carries someone else's NIN or BVN, with no amount to flag misuse. So the two layers solve two different leak scenarios: the HMAC signature means api_secret itself is never sent over the wire, only a derived signature is, so it can't be lifted from a proxy log or packet capture the way a bearer key attached to every request could be. And the JWT layer means that even if a bearer token does leak, it's only useful for an hour, not forever.

The three signature headers

Every signed request carries these three headers:

FieldTypeNotes
X-Client-KeyrequiredstringYour project's public identifier.
X-Timestamprequiredunix secondsMust be within 300 seconds of the server's clock, in either direction.
X-Signaturerequiredhex stringHMAC-SHA256 of the signing string below, keyed with your api_secret.

Building the signing string

Join five parts with a pipe character, in this exact order:

client_key|timestamp|METHOD|path|sha256(body)
  • METHOD is uppercase : GET, POST, PATCH, or DELETE.
  • path is the full request path including /api/v1, and excluding any query string; e.g. /api/v1/verifications/01ABC.../nin, not the full URL and not ?status=pending.
  • sha256(body) is the hex-encoded SHA-256 hash of the exact raw request body you're about to send, an empty string for a GET request with no body, not {}.

Then compute HMAC-SHA256(signing_string, api_secret), hex-encoded, that's your X-Signature.

Worked example

Submitting a NIN inside an existing verification session, a signed, JWT-authenticated POST with a body:

session_id is dynamic, not a constant

Every sample below uses a placeholder for the session id in the path. That value comes from the session_id field in the response to POST /verifications , never hardcode a fixed string here. The signature is computed over the exact path you send, so if the id in your signed string doesn't match the id in the request URL, you'll get INVALID_SIGNATURE with no other clue why.
BODY='{"nin":"12345678901"}'
TS=$(date +%s)
HASH=$(printf '%s' "$BODY" | shasum -a 256 | cut -d' ' -f1)
# SESSION_ULID is NOT a literal string, substitute the real session_id
# returned by POST /verifications (see "Start a verification session").
# Sign and send the EXACT same path, or the signature will never match.
PATH="/api/v1/verifications/$SESSION_ULID/nin"
SIGN="$CLIENT_KEY|$TS|POST|$PATH|$HASH"
SIG=$(printf '%s' "$SIGN" | openssl dgst -sha256 -hmac "$API_SECRET" | cut -d' ' -f2)

curl -X POST "https://api.uverify.uselodger.com$PATH" \
  -H "X-Client-Key: $CLIENT_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d "$BODY"

Getting and using the access token

POST/auth/token
Signed like any other request, but doesn't need a bearer token yet, this call is how you get one.

"client_key and api_secret go in the JSON body"

They don't; the body is empty. client_key and api_secret never travel in the request at all; they're only inputs to the X-Client-Key and X-Signature headers from the section above. Posting { "client_key": "...", "api_secret": "..." } as JSON; the natural shape if you've used a typical OAuth client-credentials exchange before, gets you 401 MISSING_HEADERS: the server never reads the body on this endpoint, it's checking for three headers and finding none.

So the full request is: an empty body, the three signature headers, signed with sha256("") as the body-hash component, since there's nothing to hash, and no Authorization header yet, because you don't have a token until this call returns one:

curl -X POST https://api.uverify.uselodger.com/api/v1/auth/token \
  -H "X-Client-Key: $CLIENT_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -H "Content-Type: application/json"

Full signing code for this exact call, in four languages, is on the quickstart page. A successful response:

{
  "success": true,
  "data": {
    "access_token": "eyJ0eXAiOiJKV1Qi...",
    "token_type": "bearer",
    "expires_in": 3600,
    "scope": "client"
  }
}

Attach access_token as Authorization: Bearer <token> on every subsequent request in addition to the three signature headers, both layers are checked on every authenticated endpoint.

Tokens last expires_in seconds (currently one hour). The simplest pattern: cache the token and its expiry when you get it, and proactively call POST /auth/token/refresh (also signed, using your current token) somewhere around the 50–55 minute mark, before it lapses. /auth/token/refresh also works for up to two weeks after a token has expired, so if you do hit a 401 TOKEN_EXPIRED on some other call because a refresh was missed, retry with /auth/token/refresh first rather than jumping straight back to a full /auth/token exchange. Only fall back to /auth/token from scratch if that refresh itself fails, meaning the token is more than two weeks stale, or was explicitly logged out.

Either way, you only need one of these calls per hour of traffic, not one per verification. Every individual verification request still needs its own fresh HMAC signature (timestamp changes every call), it just reuses whatever access token is already cached.

Test this before writing any code

See Testing in Postman for a copy-paste collection script that signs every request automatically, confirm the whole flow works against a real sandbox client in a few minutes before touching your own backend.

Common mistakes

"The keys are the bearer token"

They're not. client_key/api_secret are signing material for the HMAC headers above; pasting api_secret directly into Authorization: Bearer fails, both because the JWT guard rejects a non-JWT value and because the request is still missing the three signature headers every endpoint requires, including /auth/token itself. The bearer value only ever comes from calling /auth/token first.

Signature mismatch on every request

Almost always one of: signing the pretty-printed JSON instead of the exact bytes you send, including the query string in path, lower-casing the HTTP method, or on any endpoint with a {ulid} segment signing a hardcoded placeholder instead of the real id you're actually sending, so the path you signed and the path you requested silently diverge. That last one fails every single time, with no other symptom, which makes it easy to mistake for a broken secret or a broken signing implementation when the real request path is the only thing wrong. Log your signing string server-side and compare it character-by-character against what you'd expect.

"Timestamp drift" errors

Your server's clock is more than 5 minutes off. Sync it with NTP, this is the single most common cause of intermittent auth failures in production, since it often works in local dev and fails on a container with a drifted clock.

Never sign from the browser or a mobile app

If api_secret ships inside client-side JavaScript or an app binary, anyone can extract it. Sign requests from your own backend, or use Hosted Verification instead, which needs no secret on the client at all.