Skip to content

Identity verification

The verification API checks a real-world identifier against an authoritative source and hands you back a token recording what it found. One endpoint runs every check; a check key selects which.

POST /api/v1/kyc/verify/{check} → lookup_id (the token)
POST /api/v2/account_kycs → "kyc_tokens": { … }

Three families of check are available, plus the tokens they produce:

Page What it covers
Identity & business Is this ID, passport or tax PIN real, and whose is it?
Phone numbers Who is this line registered to, and is it active?
Bank accounts Whose name is on this account number?
Tokens Reusing what an earlier check proved

Verification lives on the Axxa Connect host, not the payments host:

https://connect.payhero.africa

Every endpoint takes the same HTTP Basic API key as the rest of the PayHero API. The merchant is derived from your API key — you never send an account id, and a token belonging to another merchant reads as 404, never 403.

Terminal window
export PH_CONNECT_URL=https://connect.payhero.africa

Identity checks are off until you turn them on. In your Axxa Connect settings, either connect your own Prembly API key or switch on the PayHero platform key. Until you do, every verify call returns:

412 Precondition Failed
{ "error": "identity checks are not enabled: turn them on in Settings" }

Which key you use decides who pays — see Billing below.

Every check takes a flat JSON object of string fields and returns the same envelope, whichever provider answered it. The fields differ per check; the response does not.

Terminal window
curl -X POST "$PH_CONNECT_URL/api/v1/kyc/verify/kra_pin" \
-u "$PH_API_USERNAME:$PH_API_PASSWORD" \
-H "Content-Type: application/json" \
-d '{ "number": "P000000000X" }'
200 OK
{
"lookup_id": "a1b2c3d4e5f6a7b8c9d0e1f2",
"check": "kra_pin",
"category": "tax_id",
"country": "KE",
"actor_type": "C",
"subject_name": "ACME TRADING LIMITED",
"status": "verified",
"verification_status": "VERIFIED",
"reference": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"detail": "KRA PIN Checker (Kenya) verification successful",
"response_code": "00",
"data": {
"PINNo": "P000000000X",
"TaxpayerName": "ACME TRADING LIMITED",
"Trading_Business_Name": "Acme Trading Limited",
"Business_Certificate_Id": "PVT-ABCDEFG",
"Vat": "Y"
},
"duration_ms": 0,
"source": "cache",
"cached": true,
"fetched_at": "2026-09-18T14:26:17.958134Z",
"expires_at": "2027-01-09T22:26:17.958134Z",
"charged_amount": 20,
"charge_currency": "KES",
"charge_reference": "cmp_a1b2c3d4e5f6a7b8c9d0e1f2",
"charge_status": "charged"
}
Field Type Notes
lookup_id string The token. Attach it to a KYC submission or resolve it later.
check string The check key you called.
category string national_id, tax_id, phone, passport, bank_account, bank_id or company.
country string The country the check actually ran against — the input country for global checks.
status string verified, not_verified or error.
verification_status string The provider’s own verdict, e.g. VERIFIED.
actor_type string C for a company, I for a natural person. Absent when the check cannot tell.
subject_name string The registered name, normalised out of the provider payload.
reference string The provider’s reference for the check.
detail string Human-readable outcome.
data object The raw provider payload. Shape varies per check — read subject_name instead.
source string prembly for a live call, cache for a replay.
cached boolean Whether this answer came from cache.
expires_at string When the cached result lapses.
charged_amount number What this call cost. 0 on your own provider key.

data is whatever the provider sent, and it differs by check, by country and occasionally by record — TaxpayerName here, first_name/last_name there. Two fields are normalised for you and are the ones to build on:

  • subject_name — the registered name, wherever it appeared in the payload.
  • actor_type — C or I.

actor_type is only meaningful where the check can actually distinguish. A tax ID or company registration can; an ID card or a phone line always belongs to a person and reports I. For a Kenyan KRA PIN the taxpayer class comes from the PIN itself — P… is a company, A… is an individual.

A verified result is cached and replayed on an identical request. Cached replays are cheaper but not free, and they still mint a fresh lookup_id, so every call is separately auditable.

How long a result stays cached is configured per environment — read expires_at on the response rather than assuming a window.

Force a live call with ?fresh=true:

Terminal window
curl -X POST "$PH_CONNECT_URL/api/v1/kyc/verify/national_id_ke?fresh=true" \
-u "$PH_API_USERNAME:$PH_API_PASSWORD" \
-H "Content-Type: application/json" \
-d '{ "number": "28200002" }'

Failed checks are never cached — a retry always re-runs.

  • Your own Prembly key — PayHero charges nothing. charged_amount is 0 and charge_status is free.
  • The PayHero platform key — each check is billed to the Team’s service wallet, and the response carries charged_amount, charge_currency and charge_reference.

You are never billed for a lookup PayHero was not billed for. The charge is taken up front, then reversed automatically whenever the provider did not charge us — which covers more than outright failures:

  • the check was temporarily unavailable upstream
  • the request was rejected before the check ran
  • the provider waived its own fee, which it does for a not_verified result on some checks but not others

charge_status on the response is what to read, not charged_amount:

charge_status Meaning
charged Billed. charged_amount is what it cost.
refunded Reversed — this lookup cost you nothing.
refund_failed Billed, and the reversal did not go through. Contact support.
free Running on your own provider key.

charged_amount keeps the original figure on a refunded lookup so the trail stays auditable, so totalling charged_amount without checking charge_status will overstate your spend.

Prices vary per check and per country, so read them rather than hardcoding:

Terminal window
curl "$PH_CONNECT_URL/api/v1/kyc/pricing" \
-u "$PH_API_USERNAME:$PH_API_PASSWORD"
200 OK
{
"platform": true,
"allowed": true,
"billing_mode": "service_wallet",
"currency": "KES",
"balance": 4250,
"prices": {
"kra_pin": { "live": 50, "cached": 20 },
"national_id_ke": { "live": 40, "cached": 20 }
}
}

When allowed is false, reason says why — an unentitled account, an empty wallet, or billing being unreachable.

Every check is sold at the provider’s own price plus a flat 5 KES margin, so prices vary widely — from 25 KES for a Nigerian bank account to 1,305 KES for a global company search. Read /api/v1/kyc/pricing rather than hardcoding these; they move when the provider’s do.

Check Live Cached Check Live Cached
bank_account_ng 25 20 phone 65 20
bvn_basic 35 20 voter_id_tz 70 20
phone_ng 35 20 phone_status_global 70 20
scuml_ng 35 20 national_id_ci 100 20
national_id_ke 40 20 resident_card_ci 100 20
bvn 45 20 kra_pin_from_id_ke 105 20
tin_global 45 20 passport_ng 105 20
tin_from_nin_ng 45 20 passport_gh 128 20
bank_account_ke 55 20 national_id_gh 148 20
bvn / nin 55 20 company_global 1,305 20
kra_pin 55 20 anything else 50 20

A cached replay is 20 KES whatever it replays.

The catalogue is served by the API, so a new check appears to your integration without a release on your side.

Terminal window
curl "$PH_CONNECT_URL/api/v1/kyc/checks" \
-u "$PH_API_USERNAME:$PH_API_PASSWORD"
200 OK
[
{
"key": "kra_pin",
"label": "KRA PIN",
"country": "KE",
"category": "tax_id",
"description": "Confirms a KRA PIN is registered and active, and returns the taxpayer name.",
"fields": [
{ "name": "number", "label": "KRA PIN", "required": true, "hint": "A123456789B" }
]
}
]

Fields with a fixed set of values — a bank, a country — carry an options array of { value, label }.

A check being in the catalogue does not mean it is answering right now. The upstream registries go down independently of each other, and a check can be temporarily unavailable for days.

When that happens the call fails with 502 rather than returning a verdict, and nothing is charged — a check that never ran is never billed:

502 Bad Gateway
{ "error": "identity provider error: Kenya Phone Verification verification is currently unavailable" }

Treat this as retryable with back-off, and distinct from not_verified:

Outcome HTTP Meaning Charged
status: "verified" 200 Identifier confirmed Yes
status: "not_verified" 200 Check ran, found no match Only if the provider charged us
provider error 502 Check never ran — retry later No — refunded
Status Meaning
400 A required field is missing or malformed.
402 The Team’s service wallet cannot cover the check.
403 Identity checks are not available on this PayHero account.
404 Unknown check key, or an unknown token.
412 Identity checks are not enabled — connect a key first.
502 The identity provider or billing was unreachable. Safe to retry.

Billing-related errors carry a machine-readable reason alongside the message:

402 Payment Required
{
"error": "insufficient service wallet balance",
"reason": "insufficient_balance"
}

Every check — live, cached or failed — is recorded. Read the history back with:

Terminal window
curl "$PH_CONNECT_URL/api/v1/kyc/lookups?limit=50" \
-u "$PH_API_USERNAME:$PH_API_PASSWORD"

Identifiers are never stored in the clear: each entry carries a masked identifier_masked such as P00•••••00X.