Developer documentation
IDEX API reference
One REST API over the same verification engine as the dashboard, the same wallet and the same pricing. Authenticate with a bearer key, post JSON, receive JSON.
Base URL
All requests must use HTTPS. Plain HTTP is refused, not redirected.
At a glance
Verifications are prepaid from your IDEX wallet. Reading your balance or your history costs nothing.
Introduction
The IDEX API is a REST interface to the identity verification services listed in the catalogue below. It is the same engine the dashboard uses: the same pricing, the same wallet, the same audit trail. A verification submitted by your server and one submitted by a colleague through the web form are the same record, visible in the same history.
Every endpoint accepts and returns application/json encoded as UTF-8, is
reached over HTTPS only, and is authenticated with a bearer key issued from your
dashboard. There is no SDK to install and no handshake to perform — a single
POST with three headers is a complete integration.
Base URL
All paths in this reference are relative to
https://idex.com.ng/api/v1. Requests over plain HTTP are refused rather
than redirected, because a redirect would have already put your key on the wire in
clear text.
Versioning
The version is in the path. Within v1 the contract is additive: new fields
may appear in a response object, and new optional parameters may be accepted. Nothing
that exists is renamed, retyped or removed. A change that would break a working
integration ships as a new path, and v1 keeps answering.
Write your client so that an unknown field is ignored rather than fatal, and treat
every member of data as optional — identity records are not uniform,
and a field that is present for one subject may be absent for the next.
What you can call today
Verification services are awaiting activation
Service catalogue
The service parameter on a verification request takes one of these codes.
Availability is read live from the platform, so this table is the current state, not a
snapshot.
| Service code | Service | Endpoint | Availability |
|---|---|---|---|
| NIN_BASIC | NIN Basic Verification | POST /nin/verify | Coming soon |
| NIN_ADVANCED | NIN Advanced Verification | POST /nin/verify | Coming soon |
| NIN_PHONE | Phone Number to NIN | POST /nin/verify | Coming soon |
| BVN_BASIC | BVN Verification | POST /bvn/verify | Coming soon |
| BVN_ADVANCED | BVN Advanced Verification | POST /bvn/verify | Coming soon |
| BVN_VALIDATION | BVN Validation | POST /bvn/verify | Coming soon |
| IDENTITY_MATCH | Identity Matching | Dashboard only | Coming soon |
| BANK_NIN | Bank Account to NIN | Dashboard only | Coming soon |
Identity matching and bank-account resolution are
available through the dashboard. They are not exposed on v1 yet; when
they are, they arrive as new paths and nothing on this page changes.
Conventions in this document
- Money is a JSON number in naira, to two decimal places. There are no kobo integers anywhere in this API.
- Timestamps are ISO 8601 with the West Africa Time offset, for example
2026-08-18T11:04:23+01:00. - Sample keys such as
idex_live_a1b2c3d4e5f6a7b8_REPLACE_WITH_YOUR_SECRETare deliberately invalid. Replace the whole string, not just the tail. - Identifiers such as a NIN or a BVN are always sent as strings. A leading zero is significant and a JSON number would eat it.
Quick start
Four steps from nothing to a working call. The first three take about five minutes and happen in the dashboard; the fourth is one request.
Create an account. Register at idex.com.ng/register and confirm your email address. Your account type — individual, agent, business or enterprise — determines the price band applied to every call you make.
Fund your wallet. Verifications are prepaid: each one debits your wallet at your effective rate when it is submitted, and the charge is reversed automatically where the refund policy covers it. The minimum funding amount is ₦500.00. Top up from Dashboard → Wallet, and see pricing for the current bands.
Create an API key. Go to Dashboard → API keys, name the key after the system that will hold it, choose its environment and tick only the scopes that system needs. The secret is displayed once, at creation. Store it in your server's secret manager or environment before you close the panel.
Make your first call. Start with the balance endpoint. It proves the key, the header format and your network path in one request, and it costs nothing.
curl "https://idex.com.ng/api/v1/balance" \
-H "Authorization: Bearer idex_live_a1b2c3d4e5f6a7b8_REPLACE_WITH_YOUR_SECRET" \
-H "Accept: application/json"{
"success": true,
"request_id": "b7f2c81d9a4e4c05",
"status": "ok",
"data": {
"balance": 4820.50,
"currency": "NGN",
"account_type": "business"
},
"timestamp": "2026-08-18T11:04:23+01:00"
}A 200 with your balance means the key is valid, active and
carries the wallet.read scope. Anything else is answered by the
error codes table.
Authentication
Every request carries one header. There is no session, no OAuth dance and no per-request signature to compute.
Authorization: Bearer idex_live_a1b2c3d4e5f6a7b8_REPLACE_WITH_YOUR_SECRETHow a key is built
An IDEX key is two halves joined by an underscore. The left half identifies the key and travels in our logs; the right half is the secret and is never stored anywhere in a form we can read back.
| Half | Example | What it is |
|---|---|---|
| Key ID | idex_live_a1b2c3d4e5f6a7b8 | Public. Prefix, environment and sixteen hex characters. It appears in your API activity log so you can tell which credential made which call. Safe to quote in a support ticket. |
| Secret | 43 URL-safe characters | Private. Stored only as a peppered SHA-256 hash. There is no endpoint, no admin screen and no database query that can return it. |
Because the secret is only ever stored as a hash, it is shown exactly once — on the panel that creates it. A key that has been lost cannot be recovered; it can only be replaced. That is a property of the design rather than a policy, and it is the reason a stolen database dump does not yield a working key.
Keys belong on your server
When authentication fails
A missing, malformed, unknown or revoked key is answered with 401 and an
error object. IDEX takes the same amount of time to reject an unknown key ID as it does
to reject a known key ID with the wrong secret, so response timing does not enumerate
valid keys.
{
"success": false,
"request_id": "5c0a71e4b8d34f92",
"status": "error",
"error": {
"code": "invalid_key",
"message": "The API key is invalid."
},
"timestamp": "2026-08-18T11:06:02+01:00"
}Practical rules
- Read the key from an environment variable or a secret manager. A key in source control is a key in every fork, every backup and every laptop that ever cloned it.
- Give each system its own key. Shared keys cannot be revoked without an outage somewhere you did not expect.
- Send the header exactly as
Authorization: Bearer <key>. The scheme is case-insensitive; the key is not. - Do not log the header. Log the
request_idfrom the response body instead — it identifies the call without identifying the credential.
API keys and scopes
Keys are managed entirely from Dashboard → API keys. You may hold up to ten active keys at a time; revoking one frees a slot immediately.
Environments
A key is issued as either live or test, and the environment is
baked into the key ID — idex_live_… or
idex_test_…. The environment is recorded on every call the key makes,
so staging traffic and production traffic are distinguishable in your API activity log,
and either can be revoked without touching the other.
Test keys are not free calls
Scopes
A scope is permission to reach a family of endpoints. Tick only what the holding system needs: a key that only reads history cannot be used to spend your wallet if it leaks.
| Scope | Grants | Endpoints |
|---|---|---|
| nin.verify | Perform NIN verifications | POST /nin/verify |
| bvn.verify | Perform BVN verifications | POST /bvn/verify |
| verification.read | Read verification history and status | GET /verifications |
| wallet.read | Read wallet balance | GET /balance |
A request whose key lacks the required scope is refused with
403 insufficient_scope before any pricing or wallet logic runs, so it is
never charged. Creating a key with no scopes ticked grants all of them — a key
that can do nothing would only produce confusing failures later.
Rotation without downtime
Regenerating a key does not edit it in place. It revokes the existing credential and issues a new one, which means the old key ID stays attached to its history: what that credential did remains readable after it is retired.
- Create a second key with the same scopes, named for the same system.
- Deploy the new secret to your servers and let it take traffic.
- Confirm the new key ID appears in your API activity log.
- Revoke the old key. Revocation takes effect on the next request.
Rotate on a schedule you can actually keep — quarterly is realistic for most teams — and immediately if a secret has been in a log file, a screenshot, a chat message or a repository.
Revocation
Revocation is immediate and final: the next request made with that key is answered with
401 key_revoked. Verifications already completed are unaffected, remain in
your history and are still readable through
the status endpoint with any key that holds
verification.read.
Making requests
Required headers
| Header | Type | Required | Description |
|---|---|---|---|
Authorization | string | Always | Bearer <your key>. See Authentication. |
Content-Type | string | On POST | application/json. POST bodies are JSON; a form-encoded body is refused with unsupported_media_type. |
Accept | string | Recommended | application/json. Responses are JSON regardless, but sending this makes your intent explicit to proxies in between. |
User-Agent | string | Recommended | Identify your integration, for example acme-onboarding/1.4. It makes support conversations far shorter. |
A complete request
POST /api/v1/nin/verify HTTP/1.1
Host: idex.com.ng
Authorization: Bearer idex_live_a1b2c3d4e5f6a7b8_REPLACE_WITH_YOUR_SECRET
Content-Type: application/json
Accept: application/json
User-Agent: acme-onboarding/1.4
{
"service": "NIN_BASIC",
"nin": "12345678901",
"consent": true
}Bodies and encoding
- The body of a
POSTis a single JSON object. Arrays, bare values and form encodings are refused. - Send identifiers as JSON strings.
"01234567890"survives the round trip;01234567890does not. - Whitespace, dashes and non-breaking spaces are stripped from digit fields before validation, so a value pasted out of a spreadsheet is accepted.
- Unknown members are ignored. Sending your own correlation field does no harm, but it
is not echoed back — use the
referenceIDEX returns as your key.
Every response carries a request ID
Successful or not, every response includes request_id. Log it next to your
own record. It is the fastest way for support to find one call among millions, and
unlike the key or the identifier it is safe to paste into an email.
Timeouts
A verification is a synchronous call to an upstream data source. Most answer in a few seconds; some take considerably longer under load. Set your client timeout to at least 90 seconds. A client that gives up after five seconds will report failures that did not happen, and IDEX will still have performed — and charged for — the lookup.
Idempotency and retries
A retry is a second billable lookup
The safe pattern is short:
- Generate your own idempotency key on your side and store it with the row you are verifying, before you call.
- Call
/nin/verifyor/bvn/verify. Persist the returnedreferenceagainst that row as soon as you have it, before you do anything else with the response. - If the call times out or the connection drops, do not repeat it.
Query
GET /verificationsfiltered to the last few minutes and look for your record. A verification that was created will be there, with its reference and its status. - Only submit again once you have established that no verification was created.
Read-only endpoints — /balance and /verifications —
are naturally idempotent, cost nothing and may be retried freely, subject to the
rate limit.
NIN verification
Submits a National Identification Number — or, for NIN_PHONE, a
registered phone number — and returns the identity record held against it by the
data provider. Requires the nin.verify scope.
The call is synchronous and prepaid. Your wallet is debited at your effective rate when
the verification is recorded, before the provider is called. If the provider gives a
definite negative answer and the platform's refund policy covers it, the debit is
reversed automatically and the response says so. If the provider cannot be reached at
all, nothing is refunded and the verification stays
pending for reconciliation.
Lawful basis and consent
Request parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
service | string | Optional | One of NIN_BASIC, NIN_ADVANCED or NIN_PHONE. Defaults to NIN_BASIC. |
nin | string | Conditional | The 11-digit National Identification Number. Required for NIN_BASIC and NIN_ADVANCED. |
phone | string | Conditional | The registered Nigerian phone number, for example 08012345678. Required for NIN_PHONE, and ignored by the other two. |
consent | boolean | Required | Your confirmation that you hold a lawful basis for this lookup. Recorded against the verification. |
Response parameters
The full envelope is documented under
response parameters. On a NIN verification the
data object carries the identity record.
| Field | Type | Present | Description |
|---|---|---|---|
reference | string | Always | The IDEX reference for this verification, for example IDX-260818-4B7E1C9A02. Store it. |
status | string | Always | successful, failed, refunded or pending. See transaction status. |
amount | number | Always | What this verification cost, in naira, at your effective rate. |
balance | number | Always | Your wallet balance after the call settled. |
refunded | boolean | Always | True when the charge for this verification has been reversed. |
data.nin | string | When available | The National Identification Number the record belongs to. Returned in full to the caller who paid for the lookup; stored masked. |
data.first_name | string | When available | Given name as held on the record. |
data.middle_name | string | When available | Middle name, where the record has one. |
data.last_name | string | When available | Surname as held on the record. |
data.date_of_birth | string | When available | ISO date, YYYY-MM-DD. |
data.gender | string | When available | male or female, as recorded. |
data.phone_number | string | When available | The phone number attached to the record. |
data.state_of_origin | string | When available | State recorded against the identity. |
data.lga_of_origin | string | NIN_ADVANCED | Local government area of origin. |
data.residential_address | string | NIN_ADVANCED | Address as held on the record. |
data.nationality | string | NIN_ADVANCED | Nationality as held on the record. |
data.photo | string | NIN_ADVANCED | Base64-encoded JPEG of the record photograph, where the provider supplies one. Often several hundred kilobytes — do not log it. |
Nothing in data is guaranteed
Examples
curl -X POST "https://idex.com.ng/api/v1/nin/verify" \
-H "Authorization: Bearer idex_live_a1b2c3d4e5f6a7b8_REPLACE_WITH_YOUR_SECRET" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--max-time 90 \
-d '{
"service": "NIN_BASIC",
"nin": "12345678901",
"consent": true
}'<?php
$apiKey = getenv('IDEX_API_KEY'); // never hard-code the key
$payload = json_encode([
'service' => 'NIN_BASIC',
'nin' => '12345678901',
'consent' => true,
]);
$ch = curl_init('https://idex.com.ng/api/v1/nin/verify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$failed = curl_errno($ch) !== 0;
curl_close($ch);
// A transport failure is NOT a failed verification. Reconcile, do not resubmit.
if ($failed) {
reconcileWithIdex(); // see "Full examples"
exit;
}
$body = json_decode((string) $raw, true);
if ($status === 200 && !empty($body['success'])) {
// Persist the reference first, then use the record.
saveReference($body['reference']);
echo $body['data']['first_name'] . ' ' . $body['data']['last_name'];
} else {
$code = $body['error']['code'] ?? 'internal_error';
echo 'Verification not completed: ' . $code;
}// Server-side only. This file must never reach a browser bundle.
const IDEX_KEY = process.env.IDEX_API_KEY;
async function verifyNin(nin) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 90000);
let response;
try {
response = await fetch('https://idex.com.ng/api/v1/nin/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${IDEX_KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ service: 'NIN_BASIC', nin, consent: true }),
signal: controller.signal
});
} finally {
clearTimeout(timeout);
}
const body = await response.json();
if (!response.ok || !body.success) {
const error = new Error(body.error?.message ?? 'Verification failed');
error.code = body.error?.code ?? 'internal_error';
error.requestId = body.request_id;
error.reference = body.reference ?? null;
throw error;
}
return body;
}import os
import requests
BASE_URL = "https://idex.com.ng/api/v1"
API_KEY = os.environ["IDEX_API_KEY"]
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "acme-onboarding/1.4",
})
def verify_nin(nin: str, service: str = "NIN_BASIC") -> dict:
response = session.post(
f"{BASE_URL}/nin/verify",
json={"service": service, "nin": nin, "consent": True},
timeout=90,
)
body = response.json()
if response.status_code != 200 or not body.get("success"):
error = body.get("error", {})
raise RuntimeError(f"{error.get('code', 'internal_error')}: {error.get('message', '')}")
return body
record = verify_nin("12345678901")
print(record["reference"], record["data"].get("first_name"))A successful response
{
"success": true,
"request_id": "9f1c2d7a4b6e48f0",
"status": "successful",
"reference": "IDX-260818-4B7E1C9A02",
"service": "NIN_BASIC",
"message": "Verification completed.",
"amount": 150.00,
"currency": "NGN",
"balance": 4670.50,
"refunded": false,
"data": {
"nin": "12345678901",
"first_name": "ADAEZE",
"middle_name": "NGOZI",
"last_name": "OKONKWO",
"date_of_birth": "1991-04-17",
"gender": "female",
"phone_number": "08012345678",
"state_of_origin": "ANAMBRA"
},
"timestamp": "2026-08-18T11:04:23+01:00"
}When no record is found
A definite negative from the provider is a 404. It is a completed
verification, not a broken request: it has a reference and it appears in your history.
Whether it is refunded depends on the platform's refund policy, and the response tells
you which happened.
{
"success": false,
"request_id": "1d4e77a0c9b24e31",
"status": "refunded",
"reference": "IDX-260818-7C21A0FE93",
"service": "NIN_BASIC",
"amount": 150.00,
"currency": "NGN",
"balance": 4820.50,
"refunded": true,
"error": {
"code": "not_found",
"message": "No record was found for that National Identification Number."
},
"timestamp": "2026-08-18T11:09:55+01:00"
}When the service is not activated
Every service is checked for a mapped, enabled provider before any money moves. A service that is awaiting activation is refused up front, with no reference and no charge.
{
"success": false,
"request_id": "3ab90f1e5c7d4a68",
"status": "error",
"error": {
"code": "service_unavailable",
"message": "NIN Basic Verification is not yet available. No charge has been made."
},
"timestamp": "2026-08-18T11:11:07+01:00"
}BVN verification
Submits a Bank Verification Number and returns the record held against it. Requires the
bvn.verify scope. Pricing, debiting, refunds and reconciliation behave
exactly as they do for NIN verification — it is
the same engine with a different service code.
Three service codes are available. Choose the narrowest one that answers your question:
BVN_VALIDATION— confirms only that the BVN exists. Returns no personal data at all. If all you need is "is this a real BVN", this is the correct and cheapest choice, and it is the one to prefer under data minimisation.BVN_BASIC— the identity fields held against the BVN.BVN_ADVANCED— the extended record, including enrolment detail and the record photograph where the provider supplies one.
Ask for the least you need
Request parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
service | string | Optional | One of BVN_BASIC, BVN_ADVANCED or BVN_VALIDATION. Defaults to BVN_BASIC. |
bvn | string | Required | The 11-digit Bank Verification Number, sent as a string. |
consent | boolean | Required | Your confirmation that you hold a lawful basis for this lookup. Recorded against the verification. |
Response parameters
| Field | Type | Present | Description |
|---|---|---|---|
reference | string | Always | The IDEX reference for this verification. Store it before you use the record. |
status | string | Always | successful, failed, refunded or pending. |
amount | number | Always | What this verification cost, in naira, at your effective rate. |
balance | number | Always | Your wallet balance after the call settled. |
refunded | boolean | Always | True when the charge for this verification has been reversed. |
data.valid | boolean | BVN_VALIDATION | Whether the BVN exists. The only field BVN_VALIDATION returns besides the number itself. |
data.bvn | string | When available | The Bank Verification Number the record belongs to. |
data.first_name | string | When available | Given name as held on the record. |
data.middle_name | string | When available | Middle name, where the record has one. |
data.last_name | string | When available | Surname as held on the record. |
data.date_of_birth | string | When available | ISO date, YYYY-MM-DD. |
data.gender | string | When available | male or female, as recorded. |
data.phone_number | string | When available | The phone number attached to the record. |
data.nationality | string | When available | Nationality as held on the record. |
data.enrollment_bank | string | BVN_ADVANCED | The institution where the BVN was enrolled. |
data.enrollment_branch | string | BVN_ADVANCED | The branch where the BVN was enrolled. |
data.registration_date | string | BVN_ADVANCED | ISO date the record was created. |
data.residential_address | string | BVN_ADVANCED | Address as held on the record. |
data.photo | string | BVN_ADVANCED | Base64-encoded JPEG of the record photograph, where the provider supplies one. |
Examples
curl -X POST "https://idex.com.ng/api/v1/bvn/verify" \
-H "Authorization: Bearer idex_live_a1b2c3d4e5f6a7b8_REPLACE_WITH_YOUR_SECRET" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--max-time 90 \
-d '{
"service": "BVN_BASIC",
"bvn": "22123456789",
"consent": true
}'<?php
/**
* Confirm a BVN exists without pulling any personal data.
* BVN_VALIDATION is the data-minimising choice for an onboarding gate.
*/
function idexValidateBvn(string $bvn): bool
{
$ch = curl_init('https://idex.com.ng/api/v1/bvn/verify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
CURLOPT_POSTFIELDS => json_encode([
'service' => 'BVN_VALIDATION',
'bvn' => $bvn,
'consent' => true,
]),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('IDEX_API_KEY'),
'Content-Type: application/json',
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$body = json_decode((string) $raw, true);
// 404 means the provider answered: no such BVN. That is an answer, not an error.
if ($status === 404) {
return false;
}
if ($status !== 200 || empty($body['success'])) {
throw new RuntimeException('BVN validation unavailable: ' . ($body['error']['code'] ?? 'network_error'));
}
return (bool) ($body['data']['valid'] ?? false);
}// Server-side only.
const IDEX_KEY = process.env.IDEX_API_KEY;
async function verifyBvn(bvn, service = 'BVN_BASIC') {
const response = await fetch('https://idex.com.ng/api/v1/bvn/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${IDEX_KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ service, bvn, consent: true })
});
const body = await response.json();
// Persist the reference whenever there is one, success or not:
// it is how the call is reconciled later.
if (body.reference) {
await saveReference(bvn, body.reference, body.status);
}
if (!response.ok || !body.success) {
throw Object.assign(new Error(body.error?.message ?? 'BVN verification failed'), {
code: body.error?.code ?? 'internal_error',
requestId: body.request_id
});
}
return body.data;
}import os
import requests
BASE_URL = "https://idex.com.ng/api/v1"
API_KEY = os.environ["IDEX_API_KEY"]
def verify_bvn(bvn: str, service: str = "BVN_BASIC") -> dict:
response = requests.post(
f"{BASE_URL}/bvn/verify",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
},
json={"service": service, "bvn": bvn, "consent": True},
timeout=90,
)
body = response.json()
reference = body.get("reference")
if reference:
store_reference(bvn, reference, body.get("status"))
if response.status_code == 404:
return {"found": False, "reference": reference}
if response.status_code != 200 or not body.get("success"):
error = body.get("error", {})
raise RuntimeError(f"{error.get('code', 'internal_error')}: {error.get('message', '')}")
return {"found": True, "reference": reference, "record": body["data"]}A successful response
{
"success": true,
"request_id": "c40b6e19f2a74d83",
"status": "successful",
"reference": "IDX-260818-A19D3F04B7",
"service": "BVN_BASIC",
"message": "Verification completed.",
"amount": 145.00,
"currency": "NGN",
"balance": 4525.50,
"refunded": false,
"data": {
"bvn": "22123456789",
"first_name": "IBRAHIM",
"middle_name": "SANI",
"last_name": "MUSA",
"date_of_birth": "1988-11-02",
"gender": "male",
"phone_number": "08098765432",
"nationality": "Nigeria"
},
"timestamp": "2026-08-18T11:22:40+01:00"
}A validation-only response
BVN_VALIDATION answers the question and nothing more. There is no name, no
date of birth and no photograph in the payload — which is precisely the point.
{
"success": true,
"request_id": "77e0aa3b1c5f4e26",
"status": "successful",
"reference": "IDX-260818-6E48B2C155",
"service": "BVN_VALIDATION",
"message": "Verification completed.",
"amount": 80.00,
"currency": "NGN",
"balance": 4445.50,
"refunded": false,
"data": {
"bvn": "22123456789",
"valid": true
},
"timestamp": "2026-08-18T11:26:12+01:00"
}Verification status
Reads verifications already on your account. Requires the
verification.read scope. This endpoint is free and does not touch your
wallet, so it may be polled within the rate limit.
It has two modes. Supply reference and you get that one verification.
Supply no reference and you get a page of your history, newest first, filtered by
whatever else you send.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
reference | string | Optional | An IDEX reference. When present, every other filter is ignored and a single verification is returned. |
status | string | Optional | Filter by successful, failed, refunded or pending. |
service | string | Optional | Filter by service code, for example BVN_BASIC. |
from | string | Optional | Start date, YYYY-MM-DD, inclusive. |
to | string | Optional | End date, YYYY-MM-DD, inclusive. |
page | integer | Optional | Page number, starting at 1. Defaults to 1. |
per_page | integer | Optional | Results per page, 1 to 100. Defaults to 20. |
History is scoped to your account
Examples
# One verification by reference
curl "https://idex.com.ng/api/v1/verifications?reference=IDX-260818-4B7E1C9A02" \
-H "Authorization: Bearer idex_live_a1b2c3d4e5f6a7b8_REPLACE_WITH_YOUR_SECRET" \
-H "Accept: application/json"
# Everything still pending, most recent first
curl "https://idex.com.ng/api/v1/verifications?status=pending&per_page=50" \
-H "Authorization: Bearer idex_live_a1b2c3d4e5f6a7b8_REPLACE_WITH_YOUR_SECRET" \
-H "Accept: application/json"{
"success": true,
"request_id": "e21f8b0c6d3a4917",
"status": "ok",
"data": {
"reference": "IDX-260818-4B7E1C9A02",
"service": "NIN_BASIC",
"status": "successful",
"channel": "api",
"identifier": "123****8901",
"amount": 150.00,
"currency": "NGN",
"refunded": false,
"summary": "ADAEZE NGOZI OKONKWO",
"data_available": true,
"data": {
"nin": "12345678901",
"first_name": "ADAEZE",
"last_name": "OKONKWO",
"date_of_birth": "1991-04-17",
"gender": "female"
},
"created_at": "2026-08-18T11:04:21+01:00",
"completed_at": "2026-08-18T11:04:23+01:00"
},
"timestamp": "2026-08-18T12:00:00+01:00"
}{
"success": true,
"request_id": "0b93de4a7c1f42a8",
"status": "ok",
"data": {
"verifications": [
{
"reference": "IDX-260818-7C21A0FE93",
"service": "NIN_BASIC",
"status": "refunded",
"identifier": "555****1234",
"amount": 150.00,
"refunded": true,
"created_at": "2026-08-18T11:09:53+01:00"
},
{
"reference": "IDX-260818-A19D3F04B7",
"service": "BVN_BASIC",
"status": "successful",
"identifier": "221****6789",
"amount": 145.00,
"refunded": false,
"created_at": "2026-08-18T11:22:38+01:00"
}
],
"pagination": {
"page": 1,
"per_page": 20,
"pages": 4,
"total": 68
}
},
"timestamp": "2026-08-18T12:01:14+01:00"
}Result retention
Identity payloads are not kept indefinitely. 30 days after a
verification completes, the result data is purged: the record keeps its reference,
service, status, amount and timestamps, but data_available becomes
false and data is omitted. The masked identifier remains; the
full identifier never appears in history at all.
If you need the record beyond that window, store it in your own system when you receive it, under your own retention policy.
Wallet balance
Returns the current balance of the wallet that funds your verifications. Requires the
wallet.read scope. Free, and safe to call on a schedule.
Response parameters
| Field | Type | Present | Description |
|---|---|---|---|
data.balance | number | Always | Available balance in naira, to two decimal places. |
data.currency | string | Always | Always NGN. Present so a client does not have to assume it. |
data.account_type | string | Always | Your pricing tier: individual, agent, business or enterprise. It determines the rate charged for each verification. |
data.updated_at | string | Always | When the wallet last changed. |
Examples
curl "https://idex.com.ng/api/v1/balance" \
-H "Authorization: Bearer idex_live_a1b2c3d4e5f6a7b8_REPLACE_WITH_YOUR_SECRET" \
-H "Accept: application/json"import os
import requests
BASE_URL = "https://idex.com.ng/api/v1"
LOW_WATER_MARK = 5000.00
def balance() -> float:
response = requests.get(
f"{BASE_URL}/balance",
headers={
"Authorization": f"Bearer {os.environ['IDEX_API_KEY']}",
"Accept": "application/json",
},
timeout=30,
)
response.raise_for_status()
return float(response.json()["data"]["balance"])
# Run this on a schedule so a verification never fails for want of funding.
if balance() < LOW_WATER_MARK:
alert_finance_team("IDEX wallet is running low")Checking the balance before every verification is unnecessary — a
call that cannot be afforded is refused with 402 insufficient_funds and is
never charged. Poll it on a schedule instead, and raise an internal alert while there is
still time to fund the account.
Response parameters
Every endpoint answers with the same envelope. Only the contents of data
change from one endpoint to the next, and error replaces it when something
went wrong. A client that can read this envelope can read the whole API.
{
"success": true,
"request_id": "9f1c2d7a4b6e48f0",
"status": "successful",
"reference": "IDX-260818-4B7E1C9A02",
"service": "NIN_BASIC",
"message": "Verification completed.",
"amount": 150.00,
"currency": "NGN",
"balance": 4670.50,
"refunded": false,
"data": {},
"error": null,
"timestamp": "2026-08-18T11:04:23+01:00"
}| Field | Type | Present | Description |
|---|---|---|---|
success |
boolean | Always | true only when the request did what it was asked to do. Test this
and the HTTP status; never infer success from the presence of
data. |
request_id |
string | Always | A 16-character identifier for this HTTP request. Log it. It is what support searches on, and it contains nothing sensitive. |
status |
string | Always | For a verification: successful, failed,
refunded or pending. For a read-only endpoint:
ok. For a request that never became a verification:
error. See transaction status. |
reference |
string | Verifications | The IDEX reference, in the form IDX-YYMMDD-XXXXXXXXXX. Present
whenever a verification record was created — including on a
404 or a 504. Its absence means nothing was recorded
and nothing was charged. |
service |
string | Verifications | The service code that was run, echoed back so a queued job can be matched to its result without re-reading its own payload. |
message |
string | Usually | A human-readable summary. Safe to show to your own staff; never branch on
it — branch on status and error.code, which are
stable. |
amount |
number | Verifications | What the verification cost, in naira, at your effective rate. 0
when nothing was charged. |
currency |
string | Verifications | Always NGN. |
balance |
number | Verifications | Your wallet balance after this call settled, including any automatic refund. Cheaper than a separate balance call after every verification. |
refunded |
boolean | Verifications | Whether the charge for this verification has been reversed. A
pending verification is always false — the
question is not settled yet. |
data |
object | On success | The payload for the endpoint: the identity record, the balance, or the
verification list. Empty object rather than null when there is
nothing to return. Every member is optional. |
error |
object | On failure | null on success. Otherwise an object carrying
code, message and sometimes fields. |
error.code |
string | On failure | A stable machine-readable code from the error codes table. This is the value your integration should branch on. |
error.message |
string | On failure | A plain-English explanation. The wording may change; the code will not. |
error.fields |
object | validation_failed | Field name to message, for example
{"nin": "Enter the 11-digit NIN."}. Map these onto your own form
rather than showing a generic failure. |
timestamp |
string | Always | ISO 8601 with the West Africa Time offset, for example
2026-08-18T11:04:23+01:00. |
The error envelope
{
"success": false,
"request_id": "b81f0c37e2a54d16",
"status": "error",
"error": {
"code": "validation_failed",
"message": "Please correct the highlighted fields.",
"fields": {
"nin": "Enter the 11-digit National Identification Number."
}
},
"timestamp": "2026-08-18T11:31:09+01:00"
}Error codes
Branch on error.code. The HTTP status tells you the class of problem; the
code tells you exactly which one, and it does not change between releases.
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
| missing_key | 401 | No Authorization header was sent, or it was empty. | Send Authorization: Bearer <your key>. Check that your HTTP client is not stripping the header on redirect. |
| invalid_key | 401 | The key is malformed, unknown, or the secret does not match. | Confirm you copied the whole key, including the key ID prefix. If it cannot be found, issue a new one and revoke the old. |
| key_revoked | 401 | The key was revoked. | Deploy the replacement key. This is expected traffic during a rotation and needs no alert. |
| key_expired | 401 | The key passed its expiry date. | Issue a new key from the dashboard and deploy it. |
| account_suspended | 403 | The account behind the key is not active. | Contact support. Keys cannot be used while an account is suspended. |
| insufficient_scope | 403 | The key is valid but does not hold the scope this endpoint requires. | Tick the missing scope on a new key and rotate to it. Scopes cannot be added to an existing key. |
| not_found | 404 | The provider answered definitively that no such record exists — or the reference you asked for is not on your account. | For a verification, this is a completed result: record it and move on. Do not retry; the answer will not change. |
| validation_failed | 422 | The request body failed validation. error.fields says which field and why. | Correct the field and resubmit. Nothing was charged. |
| insufficient_funds | 402 | The wallet balance does not cover this verification. | Fund the wallet and resubmit. Nothing was charged. Poll /balance on a schedule to avoid meeting this in production. |
| rate_limited | 429 | Too many requests in the current window. | Wait for the interval in Retry-After, then retry with exponential backoff. See rate limits. |
| service_unavailable | 503 | The service is not activated, or its provider is disabled. | Nothing was charged. Check the service catalogue, and treat this as a signal to pause the queue rather than to retry in a tight loop. |
| pricing_unavailable | 503 | The service is activated but has no price configured for your account. | Nothing was charged. Contact support — this is a configuration fault on our side, not on yours. |
| provider_timeout | 504 | The provider did not answer in time. The verification exists and is pending. | Do NOT resubmit. Store the reference and reconcile with GET /verifications. See transaction status. |
| unsupported_media_type | 415 | The POST body was not JSON. | Send Content-Type: application/json and a JSON object as the body. |
| method_not_allowed | 405 | The HTTP method is wrong for that path. | Verifications are POST; balance and history are GET. |
| internal_error | 500 | Something failed on our side. | Retry once after a short delay. If it persists, send us the request_id — it is the fastest route to a diagnosis. |
Which errors cost money
Only one class of failure leaves a charge in place. Everything rejected before the provider is called — authentication, scope, validation, funding, availability and pricing — is free, and no verification record is created.
- Never charged:
missing_key,invalid_key,key_revoked,key_expired,account_suspended,insufficient_scope,validation_failed,insufficient_funds,rate_limited,service_unavailable,pricing_unavailable,unsupported_media_type,method_not_allowed. - Charged, then refunded where policy allows:
not_found. The response'srefundedfield is authoritative. - Charged and unsettled:
provider_timeout. The lookup may have been performed upstream, so nothing is refunded automatically. IDEX reconciles it; see below.
Transaction status
Every verification carries exactly one of four statuses. They are not severity levels — they describe what happened to your money and to the lookup, and your integration should treat each one differently.
| Status | What happened | Your wallet | What to do |
|---|---|---|---|
| Successful | The provider returned a record and it is in data. |
Debited. | Store the record under your own retention policy. Nothing further to do. |
| Failed | The provider gave a definite negative: no such record, or the record could not be released. This is an answer, not a fault. | Debited, and not refunded — the lookup was performed. | Record the outcome against your subject. Resubmitting the same identifier will return the same answer and charge you again. |
| Refunded | A definite negative that the platform's refund policy covers. | Debited, then automatically credited back. balance in the response
already reflects the refund. |
Same as failed. Do not raise a refund request — it has already happened. |
| Pending | The provider did not answer in time. Whether the lookup was performed upstream is genuinely unknown. | Debited. Nothing has been refunded. | Do not resubmit. Store the reference and reconcile. |
Pending, in detail
Pending is not failure
A timeout is the one outcome where the honest answer is "we do not know". The provider may have performed the lookup and lost the response on the way back — in which case it will invoice us for it, and refunding you immediately would be refunding a lookup that really happened. It may equally have never received the request at all.
IDEX resolves that ambiguity rather than guessing:
- The verification is recorded as
pendingwith its reference, and the debit stands. - IDEX re-queries the provider for the authoritative outcome of that specific lookup.
- If it completed upstream, the record becomes
successfuland the result becomes readable through the status endpoint. - If it demonstrably never happened, the charge is reversed and the record becomes
refunded.
What your integration must do is simple: persist the reference, then poll. A sensible schedule is once a minute for the first five minutes, then every fifteen minutes for an hour. Verifications that are still pending after that are worth a support ticket quoting the reference — not a resubmission.
The same logic applies when your client times out and you never saw a response at all. You may have a verification with no reference on your side. Do not resubmit: list your recent verifications, find it by service, identifier and time, and adopt its reference.
Webhooks
Not yet enabled
A webhook is how you learn about something that happens after your request has already been answered — principally a pending verification being reconciled minutes or hours later. Everything a webhook tells you can also be discovered by polling; a webhook simply saves you the polling.
Events
| Event | Sent when |
|---|---|
| verification.completed | A verification that was pending has been reconciled and is now
successful or failed. |
| verification.refunded | A charge has been reversed — automatically by policy, or by an administrator. |
| wallet.credited | A funding payment has been confirmed and the balance increased. |
| wallet.low_balance | The balance has fallen below your configured threshold. |
Delivery headers
| Header | Type | Sent | Description |
|---|---|---|---|
X-IDEX-Event | string | Always | The event name, for example verification.completed. |
X-IDEX-Delivery | string | Always | A unique id for this delivery attempt of this event. Retries of the same event repeat the event id, not the delivery id. |
X-IDEX-Timestamp | string | Always | Unix seconds at which the signature was computed. Part of the signed payload. |
X-IDEX-Signature | string | Always | Lowercase hex HMAC-SHA512 of "<timestamp>.<raw body>", keyed with your webhook secret. |
Payload
The body is a JSON object. data carries the same field names the API uses
elsewhere, and never carries the identity record itself — only the masked identifier. A
webhook is a notification, not a delivery mechanism for a record. Fetch the record with
GET /verifications if you need it.
{
"id": "evt_9a3f1c0d7b2e4856",
"event": "verification.completed",
"created_at": "2026-08-18T11:41:07+01:00",
"data": {
"reference": "IDX-260818-4B7E1C9A02",
"service": "NIN_BASIC",
"status": "successful",
"previous_status": "pending",
"identifier": "123****8901",
"amount": 150.00,
"currency": "NGN",
"refunded": false,
"completed_at": "2026-08-18T11:41:05+01:00"
}
}Verifying the signature
Verify before you parse. Compute the HMAC over the raw request body — the exact bytes that arrived. Re-encoding a decoded body produces different bytes and a signature that will never match.
Compare with hash_equals(), not ==. A plain comparison returns
as soon as two characters differ, and the time it takes leaks how much of the signature
was correct, which is enough to forge one byte at a time.
<?php
/**
* IDEX webhook receiver.
* Point your dashboard webhook URL at this file.
*/
$secret = (string) getenv('IDEX_WEBHOOK_SECRET'); // from Dashboard > Settings
$raw = (string) file_get_contents('php://input');
$given = (string) ($_SERVER['HTTP_X_IDEX_SIGNATURE'] ?? '');
$sentAt = (int) ($_SERVER['HTTP_X_IDEX_TIMESTAMP'] ?? 0);
// 1. Reject a replay before doing any work.
if ($sentAt === 0 || abs(time() - $sentAt) > 300) {
http_response_code(400);
exit;
}
// 2. Recompute the signature over the exact bytes received.
$expected = hash_hmac('sha512', $sentAt . '.' . $raw, $secret);
// 3. Constant-time comparison. Never use == here.
if ($secret === '' || !hash_equals($expected, $given)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
if (!is_array($event) || empty($event['id'])) {
http_response_code(400);
exit;
}
// 4. Delivery is at-least-once. Deduplicate on the event id.
if (alreadyProcessed($event['id'])) {
http_response_code(200);
exit;
}
// 5. Acknowledge quickly, then do the slow work out of band.
markProcessed($event['id']);
enqueue($event);
http_response_code(200);
echo 'ok';Retries and what we expect of your endpoint
- Answer with any
2xxwithin 10 seconds. The body is ignored. Do the real work after you have acknowledged — a webhook receiver that waits on your own database is a webhook receiver that times out. - Anything that is not a
2xx, and any timeout, is retried with growing gaps: roughly one minute, five minutes, thirty minutes, two hours, then six hours. After the final attempt the delivery is marked failed and left visible in your dashboard. - Delivery is at least once. Network partitions produce duplicates.
Treat
idas an idempotency key and make reprocessing harmless. - Order is not guaranteed. Use
created_atand the record's own status rather than assuming the order of arrival. - Your endpoint must be HTTPS with a valid certificate, and must not require authentication of its own beyond the signature.
- Rotate the webhook secret from the dashboard if it is ever exposed. During a rotation, accept either the old or the new signature for a short window.
Rate limits
Requests are counted per API key in a rolling one-minute window. The default allowance is 60 requests per minute. Read-only calls count towards the same allowance as verifications.
If your integration needs a higher ceiling — a bulk onboarding run, a migration, a nightly batch — ask us before you run it. A raised limit arranged in advance is free; a run that trips the limit for an hour is an incident for both of us.
Headers on every response
| Header | Type | Sent | Description |
|---|---|---|---|
X-RateLimit-Limit | integer | Always | Requests permitted in the current window for this key. |
X-RateLimit-Remaining | integer | Always | Requests left in the current window. Slow down as this approaches zero rather than waiting for a 429. |
X-RateLimit-Reset | integer | Always | Unix seconds at which the window resets. |
Retry-After | integer | On 429 | Seconds to wait before retrying. Honour this value over your own backoff schedule. |
A throttled response
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 24
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1786534884
{
"success": false,
"request_id": "6d1a94c70b8e42f3",
"status": "error",
"error": {
"code": "rate_limited",
"message": "Too many requests. Retry in 24 seconds."
},
"timestamp": "2026-08-18T11:47:36+01:00"
}Backing off properly
Retry 429 and 500. Do not retry 402,
403, 404, 422 or 503 — the
answer will not change, and in the case of 503 a tight retry loop is
actively unhelpful. Never retry 504:
reconcile it instead.
Wait Retry-After when it is present. Otherwise double the delay each
attempt and add a little randomness, so that a fleet of workers throttled at the same
moment does not come back in step and throttle itself again.
<?php
/**
* Send a request, honouring Retry-After and backing off with jitter.
*
* $send must return [int $httpStatus, array $body, array $headers].
*/
function idexWithBackoff(callable $send, int $maxAttempts = 5): array
{
$attempt = 0;
while (true) {
$attempt++;
[$status, $body, $headers] = $send();
$retryable = $status === 429 || $status === 500;
if (!$retryable || $attempt >= $maxAttempts) {
return [$status, $body];
}
// Honour the server's own instruction when it gives one.
$wait = isset($headers['retry-after'])
? (float) $headers['retry-after']
: (float) (2 ** $attempt);
// Jitter: without it, throttled workers retry in lockstep for ever.
$wait += random_int(0, 1000) / 1000;
usleep((int) round($wait * 1000000));
}
}If you are processing a queue, a worker pool sized to your allowance is better than any retry strategy: at 60 requests per minute, one request per second per key never trips the limit at all.
Full examples
Two complete integrations. Both do the four things a production integration has to do: persist the reference before anything else, distinguish a definite answer from an unsettled one, back off on a throttle, and reconcile rather than resubmit.
PHP
<?php
declare(strict_types=1);
/**
* A complete IDEX client.
*
* The distinction that matters is between IdexException — a definite answer we
* did not like — and IdexPending, which means the outcome is genuinely unknown
* and the wallet has been debited. The first may be acted on. The second must
* be reconciled, and must never be resubmitted.
*/
class IdexException extends RuntimeException
{
public function __construct(
public readonly string $errorCode,
string $message,
public readonly ?string $reference = null,
public readonly ?string $requestId = null
) {
parent::__construct($message);
}
}
final class IdexPending extends IdexException {}
final class IdexClient
{
private const BASE = 'https://idex.com.ng/api/v1';
public function __construct(private readonly string $apiKey) {}
public function verifyNin(string $nin, string $service = 'NIN_BASIC'): array
{
return $this->send('POST', '/nin/verify', [
'service' => $service,
'nin' => $nin,
'consent' => true,
]);
}
public function verifyBvn(string $bvn, string $service = 'BVN_BASIC'): array
{
return $this->send('POST', '/bvn/verify', [
'service' => $service,
'bvn' => $bvn,
'consent' => true,
]);
}
public function verification(string $reference): array
{
return $this->send('GET', '/verifications?reference=' . rawurlencode($reference));
}
public function balance(): float
{
return (float) $this->send('GET', '/balance')['data']['balance'];
}
private function send(string $method, string $path, ?array $payload = null, int $attempt = 1): array
{
$headers = [];
$ch = curl_init(self::BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/json',
'Accept: application/json',
'User-Agent: acme-onboarding/1.4',
],
CURLOPT_HEADERFUNCTION => function ($handle, string $line) use (&$headers): int {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$transport = curl_errno($ch) !== 0;
curl_close($ch);
// No response at all: the lookup may still have been performed upstream.
if ($transport) {
throw new IdexPending('provider_timeout', 'No response from IDEX. Reconcile before resubmitting.');
}
$body = json_decode((string) $raw, true);
$body = is_array($body) ? $body : [];
// 429 and 500 are the only statuses worth repeating.
if (($status === 429 || $status === 500) && $attempt < 4) {
$wait = (float) ($headers['retry-after'] ?? 2 ** $attempt);
usleep((int) round(($wait + random_int(0, 750) / 1000) * 1000000));
return $this->send($method, $path, $payload, $attempt + 1);
}
if ($status === 504) {
throw new IdexPending(
'provider_timeout',
(string) ($body['error']['message'] ?? 'The provider did not answer in time.'),
$body['reference'] ?? null,
$body['request_id'] ?? null
);
}
if ($status !== 200 || empty($body['success'])) {
throw new IdexException(
(string) ($body['error']['code'] ?? 'internal_error'),
(string) ($body['error']['message'] ?? 'The request could not be completed.'),
$body['reference'] ?? null,
$body['request_id'] ?? null
);
}
return $body;
}
}<?php
$idex = new IdexClient((string) getenv('IDEX_API_KEY'));
try {
$result = $idex->verifyNin($applicant->nin);
// The reference is the first thing to persist, always.
$applicant->idex_reference = $result['reference'];
$applicant->verified_name = trim(
($result['data']['first_name'] ?? '') . ' ' . ($result['data']['last_name'] ?? '')
);
$applicant->status = 'verified';
$applicant->save();
} catch (IdexPending $pending) {
// Charged, unsettled, and an answer is coming. Queue it. Never resubmit.
$applicant->idex_reference = $pending->reference;
$applicant->status = 'awaiting_verification';
$applicant->save();
queueReconcile($applicant->id, 60);
} catch (IdexException $error) {
match ($error->errorCode) {
'not_found' => $applicant->markUnverified('No record found for that NIN'),
'validation_failed' => $applicant->markInvalid('That NIN is not well formed'),
'insufficient_funds' => alertOps('IDEX wallet needs funding'),
'service_unavailable'=> pauseOnboardingQueue('IDEX service not activated'),
default => logError('IDEX ' . $error->errorCode, $error->requestId),
};
}
/**
* Run from the queue. Idempotent, so it is safe on any schedule:
* every minute for the first five, then every fifteen for an hour.
*/
function reconcile(IdexClient $idex, string $reference): string
{
$record = $idex->verification($reference)['data'];
return match ($record['status']) {
'successful' => storeResult($reference, $record['data'] ?? []),
'failed', 'refunded' => markUnverified($reference, $record['status']),
default => 'still_pending',
};
}Python
"""A complete IDEX client: verify, back off, reconcile."""
import os
import random
import time
import requests
BASE_URL = "https://idex.com.ng/api/v1"
RETRYABLE = {429, 500}
class IdexError(RuntimeError):
"""A definite answer we did not like."""
def __init__(self, code, message, reference=None, request_id=None):
super().__init__(f"{code}: {message}")
self.code = code
self.reference = reference
self.request_id = request_id
class IdexPending(IdexError):
"""Charged and unsettled. Reconcile — never resubmit."""
class IdexClient:
def __init__(self, api_key: str, base_url: str = BASE_URL):
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "acme-onboarding/1.4",
})
def verify_nin(self, nin: str, service: str = "NIN_BASIC") -> dict:
return self._send("POST", "/nin/verify", {"service": service, "nin": nin, "consent": True})
def verify_bvn(self, bvn: str, service: str = "BVN_BASIC") -> dict:
return self._send("POST", "/bvn/verify", {"service": service, "bvn": bvn, "consent": True})
def verification(self, reference: str) -> dict:
return self._send("GET", f"/verifications?reference={reference}")
def balance(self) -> float:
return float(self._send("GET", "/balance")["data"]["balance"])
def _send(self, method: str, path: str, payload=None, attempt: int = 1) -> dict:
try:
response = self.session.request(
method, f"{self.base_url}{path}", json=payload, timeout=90
)
except requests.RequestException as exc:
# No response at all: the lookup may still have happened upstream.
raise IdexPending("provider_timeout", "No response from IDEX") from exc
if response.status_code in RETRYABLE and attempt < 4:
wait = float(response.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait + random.uniform(0, 0.75))
return self._send(method, path, payload, attempt + 1)
try:
body = response.json()
except ValueError:
body = {}
if response.status_code == 504:
raise IdexPending(
"provider_timeout",
"The provider did not answer in time",
body.get("reference"),
body.get("request_id"),
)
if response.status_code != 200 or not body.get("success"):
error = body.get("error", {})
raise IdexError(
error.get("code", "internal_error"),
error.get("message", "The request could not be completed."),
body.get("reference"),
body.get("request_id"),
)
return bodyidex = IdexClient(os.environ["IDEX_API_KEY"])
def onboard(applicant) -> str:
try:
result = idex.verify_nin(applicant.nin)
except IdexPending as pending:
# Charged, unsettled. Queue it and reconcile; do not call again.
applicant.idex_reference = pending.reference
applicant.status = "awaiting_verification"
applicant.save()
schedule_reconcile(applicant.id, delay=60)
return "pending"
except IdexError as error:
if error.code == "not_found":
return applicant.mark_unverified("No record found for that NIN")
if error.code == "validation_failed":
return applicant.mark_invalid("That NIN is not well formed")
if error.code == "insufficient_funds":
alert_finance_team("IDEX wallet needs funding")
raise
# Persist the reference before anything else.
applicant.idex_reference = result["reference"]
record = result["data"]
applicant.verified_name = " ".join(
part for part in (record.get("first_name"), record.get("last_name")) if part
)
applicant.status = "verified"
applicant.save()
return "verified"
def reconcile(reference: str) -> str:
"""Idempotent. Every minute for five minutes, then every fifteen for an hour."""
record = idex.verification(reference)["data"]
if record["status"] == "successful":
store_result(reference, record.get("data", {}))
return "verified"
if record["status"] in ("failed", "refunded"):
mark_unverified(reference, record["status"])
return "not_verified"
return "still_pending"Before you go live
- The key is read from the environment, not from source control, and never reaches a browser or a mobile binary.
- The reference is persisted before the record is used, on every path including the failures.
- A timeout is queued for reconciliation. Nothing in your code resubmits a verification automatically.
- Your client timeout is at least 90 seconds.
- Identity data you store has a retention period and a reason for being kept, and the photograph is never written to a log.
- Someone is alerted when the wallet balance falls below a level that would interrupt onboarding.
Ready to build?
Create an account, issue a key, and integrate against the contract on this page while the services complete provider activation.