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 |
Base URL and authentication
Section titled “Base URL and authentication”Verification lives on the Axxa Connect host, not the payments host:
https://connect.payhero.africaEvery 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.
export PH_CONNECT_URL=https://connect.payhero.africaEnable checks first
Section titled “Enable checks first”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:
{ "error": "identity checks are not enabled: turn them on in Settings" }Which key you use decides who pays — see Billing below.
The shape of a check
Section titled “The shape of a check”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.
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" }'{ "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"}Response fields
Section titled “Response fields”| 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. |
Normalised vs raw
Section titled “Normalised vs raw”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—CorI.
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.
Caching
Section titled “Caching”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:
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.
Billing
Section titled “Billing”- Your own Prembly key — PayHero charges nothing.
charged_amountis0andcharge_statusisfree. - The PayHero platform key — each check is billed to the Team’s service
wallet, and the response carries
charged_amount,charge_currencyandcharge_reference.
Refunds
Section titled “Refunds”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_verifiedresult 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:
curl "$PH_CONNECT_URL/api/v1/kyc/pricing" \ -u "$PH_API_USERNAME:$PH_API_PASSWORD"{ "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.
Prices
Section titled “Prices”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.
Discover the available checks
Section titled “Discover the available checks”The catalogue is served by the API, so a new check appears to your integration without a release on your side.
curl "$PH_CONNECT_URL/api/v1/kyc/checks" \ -u "$PH_API_USERNAME:$PH_API_PASSWORD"[ { "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 }.
Availability
Section titled “Availability”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:
{ "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 |
Errors
Section titled “Errors”| 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:
{ "error": "insufficient service wallet balance", "reason": "insufficient_balance"}Audit trail
Section titled “Audit trail”Every check — live, cached or failed — is recorded. Read the history back with:
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.

