HomeServicesAPIPricingDocumentationAboutContact
LoginGet Started

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

https://idex.com.ng/api/v1

All requests must use HTTPS. Plain HTTP is refused, not redirected.

At a glance

Version v1 JSON only Bearer key 60 req/min

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

https://idex.com.ng/api/v1

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

No verification service is activated yet. Every service in the catalogue is awaiting provider activation, so /nin/verify and /bvn/verify answer with service_unavailable and nothing is debited from your wallet. Key authentication, the balance endpoint and the verification history endpoint behave exactly as documented, so you can build and test the whole integration path now.

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 codeServiceEndpointAvailability
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_SECRET are 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.

1

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.

2

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.

3

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.

4

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
curl "https://idex.com.ng/api/v1/balance" \
  -H "Authorization: Bearer idex_live_a1b2c3d4e5f6a7b8_REPLACE_WITH_YOUR_SECRET" \
  -H "Accept: application/json"
Response 200
{
  "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.

HTTP header
Authorization: Bearer idex_live_a1b2c3d4e5f6a7b8_REPLACE_WITH_YOUR_SECRET

How 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.

HalfExampleWhat 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

Never put an API key in a browser bundle, a mobile app, a public repository or a mobile web page. Anything shipped to a device can be extracted from it, and a key that spends your wallet is worth extracting. Call IDEX from your server, and let your app talk to 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.

Response 401
{
  "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_id from 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

Both environments bill the same wallet at the same rate. IDEX does not run a separate sandbox ledger, so a test key spends real balance on a real lookup. Use the environment to separate systems, not to get 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.

ScopeGrantsEndpoints
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.

  1. Create a second key with the same scopes, named for the same system.
  2. Deploy the new secret to your servers and let it take traffic.
  3. Confirm the new key ID appears in your API activity log.
  4. 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

HeaderTypeRequiredDescription
AuthorizationstringAlwaysBearer <your key>. See Authentication.
Content-TypestringOn POSTapplication/json. POST bodies are JSON; a form-encoded body is refused with unsupported_media_type.
AcceptstringRecommendedapplication/json. Responses are JSON regardless, but sending this makes your intent explicit to proxies in between.
User-AgentstringRecommendedIdentify your integration, for example acme-onboarding/1.4. It makes support conversations far shorter.

A complete request

HTTP
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 POST is a single JSON object. Arrays, bare values and form encodings are refused.
  • Send identifiers as JSON strings. "01234567890" survives the round trip; 01234567890 does 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 reference IDEX 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

IDEX does not de-duplicate identical requests. Two calls with the same NIN are two verifications and two charges. Never retry a verification blindly after a timeout — reconcile it first.

The safe pattern is short:

  1. Generate your own idempotency key on your side and store it with the row you are verifying, before you call.
  2. Call /nin/verify or /bvn/verify. Persist the returned reference against that row as soon as you have it, before you do anything else with the response.
  3. If the call times out or the connection drops, do not repeat it. Query GET /verifications filtered to the last few minutes and look for your record. A verification that was created will be there, with its reference and its status.
  4. 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

POSThttps://idex.com.ng/api/v1/nin/verify

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

By submitting a verification you confirm that you have a lawful basis for it and, where the law requires one, the consent of the person whose record you are looking up. Send consent: true to record that confirmation against the verification.

Request parameters

ParameterTypeRequiredDescription
servicestringOptionalOne of NIN_BASIC, NIN_ADVANCED or NIN_PHONE. Defaults to NIN_BASIC.
ninstringConditionalThe 11-digit National Identification Number. Required for NIN_BASIC and NIN_ADVANCED.
phonestringConditionalThe registered Nigerian phone number, for example 08012345678. Required for NIN_PHONE, and ignored by the other two.
consentbooleanRequiredYour 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.

FieldTypePresentDescription
referencestringAlwaysThe IDEX reference for this verification, for example IDX-260818-4B7E1C9A02. Store it.
statusstringAlwayssuccessful, failed, refunded or pending. See transaction status.
amountnumberAlwaysWhat this verification cost, in naira, at your effective rate.
balancenumberAlwaysYour wallet balance after the call settled.
refundedbooleanAlwaysTrue when the charge for this verification has been reversed.
data.ninstringWhen availableThe National Identification Number the record belongs to. Returned in full to the caller who paid for the lookup; stored masked.
data.first_namestringWhen availableGiven name as held on the record.
data.middle_namestringWhen availableMiddle name, where the record has one.
data.last_namestringWhen availableSurname as held on the record.
data.date_of_birthstringWhen availableISO date, YYYY-MM-DD.
data.genderstringWhen availablemale or female, as recorded.
data.phone_numberstringWhen availableThe phone number attached to the record.
data.state_of_originstringWhen availableState recorded against the identity.
data.lga_of_originstringNIN_ADVANCEDLocal government area of origin.
data.residential_addressstringNIN_ADVANCEDAddress as held on the record.
data.nationalitystringNIN_ADVANCEDNationality as held on the record.
data.photostringNIN_ADVANCEDBase64-encoded JPEG of the record photograph, where the provider supplies one. Often several hundred kilobytes — do not log it.

Nothing in data is guaranteed

Treat every member of data as optional. Identity records are not uniform: a field present for one subject may be absent, empty or null for the next. A client that assumes a field exists will fail on a real record eventually.

Examples

cURL
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 (curl)
<?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;
}
JavaScript (fetch)
// 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;
}
Python (requests)
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

Response 200
{
  "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.

Response 404
{
  "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.

Response 503
{
  "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

POSThttps://idex.com.ng/api/v1/bvn/verify

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

A BVN is financial-sector data. Request the extended record only when you have a specific need for the extra fields, retain the response for no longer than your purpose requires, and never log the photograph.

Request parameters

ParameterTypeRequiredDescription
servicestringOptionalOne of BVN_BASIC, BVN_ADVANCED or BVN_VALIDATION. Defaults to BVN_BASIC.
bvnstringRequiredThe 11-digit Bank Verification Number, sent as a string.
consentbooleanRequiredYour confirmation that you hold a lawful basis for this lookup. Recorded against the verification.

Response parameters

FieldTypePresentDescription
referencestringAlwaysThe IDEX reference for this verification. Store it before you use the record.
statusstringAlwayssuccessful, failed, refunded or pending.
amountnumberAlwaysWhat this verification cost, in naira, at your effective rate.
balancenumberAlwaysYour wallet balance after the call settled.
refundedbooleanAlwaysTrue when the charge for this verification has been reversed.
data.validbooleanBVN_VALIDATIONWhether the BVN exists. The only field BVN_VALIDATION returns besides the number itself.
data.bvnstringWhen availableThe Bank Verification Number the record belongs to.
data.first_namestringWhen availableGiven name as held on the record.
data.middle_namestringWhen availableMiddle name, where the record has one.
data.last_namestringWhen availableSurname as held on the record.
data.date_of_birthstringWhen availableISO date, YYYY-MM-DD.
data.genderstringWhen availablemale or female, as recorded.
data.phone_numberstringWhen availableThe phone number attached to the record.
data.nationalitystringWhen availableNationality as held on the record.
data.enrollment_bankstringBVN_ADVANCEDThe institution where the BVN was enrolled.
data.enrollment_branchstringBVN_ADVANCEDThe branch where the BVN was enrolled.
data.registration_datestringBVN_ADVANCEDISO date the record was created.
data.residential_addressstringBVN_ADVANCEDAddress as held on the record.
data.photostringBVN_ADVANCEDBase64-encoded JPEG of the record photograph, where the provider supplies one.

Examples

cURL
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 (curl)
<?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);
}
JavaScript (fetch)
// 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;
}
Python (requests)
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

Response 200
{
  "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.

Response 200
{
  "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

GEThttps://idex.com.ng/api/v1/verifications?reference=IDX-260818-4B7E1C9A02

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

ParameterTypeRequiredDescription
referencestringOptionalAn IDEX reference. When present, every other filter is ignored and a single verification is returned.
statusstringOptionalFilter by successful, failed, refunded or pending.
servicestringOptionalFilter by service code, for example BVN_BASIC.
fromstringOptionalStart date, YYYY-MM-DD, inclusive.
tostringOptionalEnd date, YYYY-MM-DD, inclusive.
pageintegerOptionalPage number, starting at 1. Defaults to 1.
per_pageintegerOptionalResults per page, 1 to 100. Defaults to 20.

History is scoped to your account

A reference belonging to another account is answered with 404 not_found, exactly as an unknown reference is. There is no response that distinguishes the two, so a reference cannot be probed for existence.

Examples

cURL
# 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"
Response 200 — single
{
  "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"
}
Response 200 — list
{
  "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

GEThttps://idex.com.ng/api/v1/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

FieldTypePresentDescription
data.balancenumberAlwaysAvailable balance in naira, to two decimal places.
data.currencystringAlwaysAlways NGN. Present so a client does not have to assume it.
data.account_typestringAlwaysYour pricing tier: individual, agent, business or enterprise. It determines the rate charged for each verification.
data.updated_atstringAlwaysWhen the wallet last changed.

Examples

cURL
curl "https://idex.com.ng/api/v1/balance" \
  -H "Authorization: Bearer idex_live_a1b2c3d4e5f6a7b8_REPLACE_WITH_YOUR_SECRET" \
  -H "Accept: application/json"
Python (requests)
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.

The envelope
{
  "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"
}
FieldTypePresentDescription
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

Response 422
{
  "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.

CodeHTTPMeaningWhat 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's refunded field 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.

StatusWhat happenedYour walletWhat 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

Pending means the provider did not answer in time. It does not mean the verification failed, it does not mean nothing was charged, and it does not mean you may safely try again. Nothing has been refunded yet.

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:

  1. The verification is recorded as pending with its reference, and the debit stands.
  2. IDEX re-queries the provider for the authoritative outcome of that specific lookup.
  3. If it completed upstream, the record becomes successful and the result becomes readable through the status endpoint.
  4. 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

Outbound webhook delivery is not switched on yet. The contract on this page is fixed and safe to build against, and you will register your endpoint from the dashboard once delivery is enabled. Until then, reconcile pending verifications by polling the status endpoint.

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

EventSent 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

HeaderTypeSentDescription
X-IDEX-EventstringAlwaysThe event name, for example verification.completed.
X-IDEX-DeliverystringAlwaysA unique id for this delivery attempt of this event. Retries of the same event repeat the event id, not the delivery id.
X-IDEX-TimestampstringAlwaysUnix seconds at which the signature was computed. Part of the signed payload.
X-IDEX-SignaturestringAlwaysLowercase 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.

Webhook body
{
  "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 — verify and acknowledge
<?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 2xx within 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 id as an idempotency key and make reprocessing harmless.
  • Order is not guaranteed. Use created_at and 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

HeaderTypeSentDescription
X-RateLimit-LimitintegerAlwaysRequests permitted in the current window for this key.
X-RateLimit-RemainingintegerAlwaysRequests left in the current window. Slow down as this approaches zero rather than waiting for a 429.
X-RateLimit-ResetintegerAlwaysUnix seconds at which the window resets.
Retry-AfterintegerOn 429Seconds to wait before retrying. Honour this value over your own backoff schedule.

A throttled response

Response 429
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 — backoff with jitter
<?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 — the client
<?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 — using it
<?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

Python — the client
"""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 body
Python — using it
idex = 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.