InstantIPE

Developer reference

InstantIPE Verification API

Two verification services — IPE Clearance and NIN Validation — over a REST API billed against your InstantIPE wallet. Bearer authentication, a sandbox that never charges, idempotent submissions and signed webhooks.

Prices below are platform defaults. Sign in to see the rates on your account and to generate keys.

Introduction

The InstantIPE Verification API is a REST API over HTTPS. It exposes instant NIN and BVN slips plus two specialist services — IPE Clearance and Validation — and bills each request against your InstantIPE wallet at the price your account is on.

Every response is JSON. Requests are authenticated with a bearer API key you generate yourself, and every response carries an X-Request-Id you can quote to support.

Base URL https://instantipe.com.ng/api/v1
Protocol HTTPS only. Plain HTTP is redirected.
Content type application/json for requests and responses
Authentication Authorization: Bearer <api key>
Currency NGN (₦). All amounts are strings with two decimals.

NIN and BVN slips are synchronous: the slip PDF comes back in the same response. IPE Clearance and Validation are asynchronous: a submission is accepted, your wallet is debited, and a specialist completes the job — typically within 24 hours on business days. You then either poll the request by reference or, better, receive a signed webhook.

Start here: call GET /api/v1/ping (no key needed) to confirm connectivity, then GET /api/v1/me with your key to confirm authentication.

Authentication

Generate keys in the API Keys tab of this console. A key is shown in full exactly once, at creation — only a SHA-256 hash is stored, so it cannot be recovered later.

Request header
Authorization: Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The header X-API-Key: <key> is accepted as an alternative. Revoking a key takes effect immediately on the next request.

Scopes. A key may be restricted to a subset of the API. A request that needs a scope the key does not hold is refused with 403 insufficient_scope.

Scope Grants
ipe:read Read IPE Clearance requests
ipe:write Submit IPE Clearance requests
validation:read Read Validation requests
validation:write Submit Validation requests
nin:read Read NIN slip requests
nin:write Generate NIN slips (all slip types)
bvn:read Read BVN slip requests
bvn:write Generate BVN slips and cards
wallet:read Read wallet balance and account details
webhooks:manage Create and manage webhook endpoints

IP allowlist. A key can be locked to specific addresses or CIDR blocks. Calls from anywhere else are refused with 403 ip_not_allowed.

Never expose a key in browser or mobile client code — every call spends real wallet balance. Call the API from your server.

Live & Test mode

Every key belongs to one environment, identified by its prefix.

Prefix Environment Behaviour
ipe_live_ Live Real submissions. Your wallet is debited and a specialist processes the job.
ipe_test_ Test (sandbox) Identical validation, envelope, rate limits and webhooks — but no wallet is ever charged and no live job is created. Requests settle automatically on a timer.

Sandbox requests settle deterministically, so you can exercise every branch of your integration without waiting on a human:

Submitted NIN / Tracking ID ends with Sandbox outcome
0000 Settles to failed with a "record could not be verified" reason.
1111 Stays pending indefinitely — use it to test your timeout handling.
anything else Settles to done with a simulated result.
Sandbox references are prefixed TEST_ and are only visible to test keys. A live key cannot read them, and a test key cannot read live records.

Making requests

Send JSON, receive JSON. Every response uses the same envelope.

A successful response carries status: "success" and a data object:

Success
{
    "status": "success",
    "data": {
        "reference": "IPE_A1B2C3D4E5",
        "service": "ipe",
        "state": "pending",
        "amount": "500.00",
        "currency": "NGN"
    },
    "request_id": "2f1c8f0e-9a4c-4c2f-9b1a-7e6d5c4b3a21"
}

A failure carries status: "error", a stable machine-readable code, and a human message:

Error
{
    "status": "error",
    "code": "insufficient_funds",
    "message": "Insufficient wallet balance. You need 500.00 (balance 120.00).",
    "request_id": "2f1c8f0e-9a4c-4c2f-9b1a-7e6d5c4b3a21"
}
status is the transport verdict; state is the job lifecycle. A request can be status: "success" while its state is still pending. Branch your code on state — never on status alone.

Job states are pending, processing, done and failed. Only done and failed are terminal. A failed job is refunded to your wallet by the operator.

Errors

HTTP status tells your proxy what happened; code tells your code what to do. Codes are stable — new ones may be added, existing ones will not change meaning.

Code HTTP Meaning
unauthorized 401 Invalid or missing API key. Send it as: Authorization: Bearer <key>
forbidden 403 This request is not permitted.
insufficient_scope 403 This API key does not carry the scope required for this endpoint.
ip_not_allowed 403 This API key may not be used from this IP address.
service_disabled 403 This service is currently disabled.
not_found 404 The requested resource does not exist.
method_not_allowed 405 That HTTP method is not supported on this endpoint.
validation_error 400 One or more request parameters are invalid.
service_unpriced 400 This service is not priced yet. Please contact support.
insufficient_funds 402 Insufficient wallet balance for this request.
duplicate_request 409 This request has already been submitted.
idempotency_conflict 409 This Idempotency-Key was already used with a different request body.
request_in_progress 409 A request with this Idempotency-Key is still being processed.
payload_too_large 413 The request could not be completed.
rate_limited 429 Rate limit exceeded. Please slow down and try again.
wallet_error 502 The wallet could not be debited. No charge was made.
service_unavailable 503 The service is temporarily unavailable. Please retry shortly.
server_error 500 An unexpected error occurred. Quote the request_id when contacting support.

Field-level failures also return an errors object keyed by field name, ready to render straight into a form:

Validation failure
{
    "status": "error",
    "code": "validation_error",
    "message": "One or more request parameters are invalid: nin, tracking_id.",
    "errors": {
        "nin": "Nin is not in the expected format (11 digits).",
        "tracking_id": "Tracking id is required."
    },
    "request_id": "2f1c8f0e-9a4c-4c2f-9b1a-7e6d5c4b3a21"
}

Rate limits

Limits are enforced per API key and per IP address in fixed windows.

Window Default limit Applies to
1 minute 60 requests Per API key (burst)
1 hour 1000 requests Per API key (volume)
1 minute 60 requests Per source IP address

Every authenticated response carries the current budget:

Response headers
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1755500460

Exceeding a limit returns 429 rate_limited with a Retry-After header and a retry_after field (both in seconds). Back off and retry — a rate-limited request is never charged.

Need a higher ceiling for a bulk migration? A per-key limit can be raised for your account — contact support with the key name.

Idempotency

Submissions spend money, so retries must be safe. Send an Idempotency-Key header on any POST and the API guarantees the request is executed at most once.

Request header
Idempotency-Key: order-8842-attempt-1
Situation Result
Same key, same body, request already completed The stored response is replayed verbatim with Idempotency-Replayed: true. Nothing is charged again.
Same key, same body, first request still running 409 request_in_progress. Retry after a moment.
Same key, different body 409 idempotency_conflict. The key is bound to the first body it saw.
No key sent No replay protection. A retried request submits — and charges — again.

Keys are scoped per API key and per endpoint, may be up to 255 characters, and are retained for 24 hours. Use something unique to the business event — an order id, not a timestamp.

IPE Clearance

Clear an Ineligible Person Error (IPE) on a NIMC enrolment and obtain the issued NIN and new Tracking ID.

Endpoint POST /api/v1/ipe
Your price ₦500.00 per request
Scopes ipe:read ipe:write
Processing Typically resolved within 24 hours on business days.
Webhook events ipe.completed ipe.failed
Availability Accepting requests

Request body

Field Type Description
tracking_id string required Tracking ID printed on the NIMC enrolment slip. The only required field. Duplicates are rejected.
old_tracking_id string optional Previous Tracking ID, when the enrolment has been re-issued before.
nin string optional The 11-digit National Identification Number, when it is already known. Normally left out — the clearance resolves it.
Pattern: ^\d{11}$
full_name string optional Holder name, forwarded to the processing team. Not needed to clear an enrolment.
phone_number string optional Holder phone number, forwarded to the processing team. Not needed to clear an enrolment.
note string optional Free-text note passed through to the processing team.

Example

curl -X POST https://instantipe.com.ng/api/v1/ipe \
  -H "Authorization: Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: my-unique-request-id" \
  -d '{
         "tracking_id": "ABC1234567890"
     }'
<?php
$payload = array (
  'tracking_id' => 'ABC1234567890',
);

$ch = curl_init('https://instantipe.com.ng/api/v1/ipe');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => json_encode($payload),
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
        'Content-Type: application/json',
        'Idempotency-Key: ' . $myOrderId,
    ],
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($res['status'] === 'success') {
    $reference = $res['data']['reference'];   // store this
} else {
    // branch on the stable code, not the message text
    match ($res['code']) {
        'insufficient_funds' => topUpWallet(),
        'duplicate_request'  => alreadySubmitted(),
        default              => logFailure($res['request_id'], $res['message']),
    };
}
const res = await fetch('https://instantipe.com.ng/api/v1/ipe', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
    'Content-Type': 'application/json',
    'Idempotency-Key': myOrderId,
  },
  body: JSON.stringify({
      "tracking_id": "ABC1234567890"
  }),
});

const body = await res.json();
if (body.status === 'success') {
  console.log('reference', body.data.reference);
} else {
  console.error(body.code, body.message, body.request_id);
}
import requests

res = requests.post(
    'https://instantipe.com.ng/api/v1/ipe',
    headers={
        'Authorization': 'Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
        'Idempotency-Key': my_order_id,
    },
    json={
        'tracking_id': 'ABC1234567890'
    },
    timeout=30,
)
body = res.json()

if body['status'] == 'success':
    reference = body['data']['reference']
else:
    raise RuntimeError(f"{body['code']}: {body['message']} ({body['request_id']})")

Response

201 Created
{
    "status": "success",
    "data": {
        "reference": "IPE_A1B2C3D4E5",
        "service": "ipe",
        "state": "pending",
        "amount": "500.00",
        "currency": "NGN",
        "environment": "live",
        "wallet_balance": "18500.00",
        "tracking_id": "ABC1234567890",
        "nin": "12345678901",
        "message": "Request accepted and queued for processing."
    },
    "request_id": "2f1c8f0e-9a4c-4c2f-9b1a-7e6d5c4b3a21"
}

Fields returned on completion

Field Description
tracking_id The Tracking ID submitted.
old_tracking_id The original Tracking ID (equals tracking_id when none was supplied).
new_tracking_id The cleared Tracking ID issued by NIMC. Present once the state is `done`.
nin The NIN resolved for the enrolment. Present once the state is `done`.

NIN Validation

Validate a National Identification Number against NIMC records and receive the validated slip.

Endpoint POST /api/v1/validation
Your price ₦1,000.00 per request
Scopes validation:read validation:write
Processing Typically resolved within 24 hours on business days.
Webhook events validation.completed validation.failed
Availability Accepting requests

Request body

Field Type Description
nin string required The 11-digit National Identification Number to validate. Duplicates are rejected.
Pattern: ^\d{11}$
validation_type enum optional Depth of the validation to perform.
One of: demographic · biometric · full
Default: full
slip_type enum optional Slip format issued on success.
One of: basic · regular · standard · premium
Default: standard
full_name string optional Holder name, forwarded to the processing team.
phone_number string optional Holder phone number, forwarded to the processing team.
note string optional Free-text note passed through to the processing team.

Example

curl -X POST https://instantipe.com.ng/api/v1/validation \
  -H "Authorization: Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: my-unique-request-id" \
  -d '{
         "nin": "12345678901",
         "validation_type": "full",
         "slip_type": "standard"
     }'
<?php
$payload = array (
  'nin' => '12345678901',
  'validation_type' => 'full',
  'slip_type' => 'standard',
);

$ch = curl_init('https://instantipe.com.ng/api/v1/validation');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => json_encode($payload),
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
        'Content-Type: application/json',
        'Idempotency-Key: ' . $myOrderId,
    ],
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($res['status'] === 'success') {
    $reference = $res['data']['reference'];   // store this
} else {
    // branch on the stable code, not the message text
    match ($res['code']) {
        'insufficient_funds' => topUpWallet(),
        'duplicate_request'  => alreadySubmitted(),
        default              => logFailure($res['request_id'], $res['message']),
    };
}
const res = await fetch('https://instantipe.com.ng/api/v1/validation', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
    'Content-Type': 'application/json',
    'Idempotency-Key': myOrderId,
  },
  body: JSON.stringify({
      "nin": "12345678901",
      "validation_type": "full",
      "slip_type": "standard"
  }),
});

const body = await res.json();
if (body.status === 'success') {
  console.log('reference', body.data.reference);
} else {
  console.error(body.code, body.message, body.request_id);
}
import requests

res = requests.post(
    'https://instantipe.com.ng/api/v1/validation',
    headers={
        'Authorization': 'Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
        'Idempotency-Key': my_order_id,
    },
    json={
        'nin': '12345678901',
        'validation_type': 'full',
        'slip_type': 'standard'
    },
    timeout=30,
)
body = res.json()

if body['status'] == 'success':
    reference = body['data']['reference']
else:
    raise RuntimeError(f"{body['code']}: {body['message']} ({body['request_id']})")

Response

201 Created
{
    "status": "success",
    "data": {
        "reference": "VAL_A1B2C3D4E5",
        "service": "validation",
        "state": "pending",
        "amount": "1000.00",
        "currency": "NGN",
        "environment": "live",
        "wallet_balance": "18500.00",
        "nin": "12345678901",
        "message": "Request accepted and queued for processing."
    },
    "request_id": "2f1c8f0e-9a4c-4c2f-9b1a-7e6d5c4b3a21"
}

Fields returned on completion

Field Description
nin The NIN that was validated.
validation_type The validation depth performed.
slip_type The slip format issued.

NIN & BVN slips

Generate a verification slip instantly. The wallet is debited, the provider is queried, and the PDF is returned in the same response — no polling, no webhook. A provider failure is refunded automatically.

NIN endpoint POST /api/v1/nin.php
BVN endpoint POST /api/v1/bvn.php
Scopes nin:write nin:read bvn:write bvn:read
Processing Synchronous — typically under 10 seconds.
Keys Live keys only. There is no sandbox for slips, so a test key is rejected.

Slip types and your prices

slip_type Lookup by Your price Status
NIN_BASIC NIN (search_value) ₦150.00 on
NIN_REGULAR NIN ₦150.00 on
NIN_STANDARD NIN ₦150.00 on
NIN_PREMIUM NIN ₦180.00 on
VNIN_SLIP NIN ₦180.00 on
NIN_REGULAR_PHONE Phone number (search_value) ₦180.00 on
NIN_STANDARD_PHONE Phone number ₦150.00 on
NIN_PREMIUM_PHONE Phone number ₦200.00 on
NIN_REGULAR_DEMO Demographics (first_name surname gender dob) ₦180.00 on
NIN_STANDARD_DEMO Demographics ₦150.00 on
NIN_PREMIUM_DEMO Demographics ₦200.00 on
BVN_BASIC BVN (search_value) ₦150.00 on
BVN_CARD BVN ₦200.00 on
BVN_PHONE_BASIC Phone number ₦150.00 off
BVN_PHONE_CARD Phone number ₦200.00 off

Request body

Field Type Description
slip_type enum required One of the slip types above.
search_value string required unless demo 11-digit NIN or BVN, or an 11-digit phone number (080…) for the _PHONE types.
first_name string demo only Holder first name (NIN_*_DEMO).
surname string demo only Holder surname.
gender string demo only male or female.
dob string demo only Date of birth, YYYY-MM-DD.
curl -X POST https://instantipe.com.ng/api/v1/nin.php \
  -H 'Authorization: Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
  -H 'Content-Type: application/json' \
  -d '{"slip_type":"NIN_STANDARD","search_value":"12345678901"}'
$ch = curl_init('https://instantipe.com.ng/api/v1/nin.php');
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ['Authorization: Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type: application/json'],
  CURLOPT_POSTFIELDS => json_encode(['slip_type' => 'NIN_STANDARD', 'search_value' => '12345678901']),
]);
$res = json_decode(curl_exec($ch), true);
file_put_contents($res['data']['download_name'], base64_decode($res['data']['pdf_base64']));
const res = await fetch('https://instantipe.com.ng/api/v1/nin.php', {
  method: 'POST',
  headers: { Authorization: 'Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type': 'application/json' },
  body: JSON.stringify({ slip_type: 'NIN_STANDARD', search_value: '12345678901' }),
});
const { data } = await res.json();
require('fs').writeFileSync(data.download_name, Buffer.from(data.pdf_base64, 'base64'));

Response

200 OK
{
    "status": "success",
    "data": {
        "reference": "NIN-20260923-8F3K2Q",
        "state": "done",
        "amount": "150.00",
        "currency": "NGN",
        "download": "https://instantipe.com.ng/uploads/results/nin_standard_12345678901_….pdf",
        "download_name": "NIN_STANDARD_12345678901.pdf",
        "pdf_base64": "JVBERi0xLjQK…",
        "details": {
            "first_name": "AISHA",
            "surname": "BELLO",
            "dob": "1990-05-14",
            "…": "…"
        }
    }
}

Re-download a past slip with GET /api/v1/nin.php?reference=… (or /api/v1/bvn.php?reference=…), and list your slips with a bare GET. Both need the matching :read scope.

A failed provider lookup returns 502 with "refunded": true — the charge is reversed to your wallet in the same request. An insufficient balance returns 402 before anything is charged.

Bulk submission

Both services accept a batch in a single call. Each entry is priced, deduplicated and submitted independently — a bad entry never blocks the rest.

Service Field Max per request Entry format
IPE Clearance entries 200 Tracking ID or 11-digit NIN, 6–20 alphanumeric characters
Validation nins 100 11-digit NIN
Bulk IPE Clearance
curl -X POST https://instantipe.com.ng/api/v1/ipe \
  -H "Authorization: Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"entries": ["ABC1234567890", "12345678901", "DEF2233445566"]}'
Bulk response
{
    "status": "success",
    "data": {
        "summary": {
            "requested": 3,
            "submitted": 2,
            "skipped": 1,
            "failed": 0,
            "not_reached": 0,
            "charged": "1000.00",
            "currency": "NGN"
        },
        "items": [
            {
                "label": "ABC1234567890",
                "state": "pending",
                "reference": "BLK_A1B2C3D4E5"
            },
            {
                "label": "12345678901",
                "state": "pending",
                "reference": "BLK_F6G7H8I9J0"
            },
            {
                "label": "DEF2233445566",
                "state": "skipped",
                "reason": "Tracking ID DEF2233445566 has already been submitted."
            }
        ],
        "wallet_balance": "17500.00",
        "price_each": "500.00"
    },
    "request_id": "2f1c8f0e-9a4c-4c2f-9b1a-7e6d5c4b3a21"
}
A batch stops at the first line it cannot afford. Anything after that is reported as not_reached and is not charged — resubmit it after topping up. Always reconcile against summary.submitted and summary.charged, never against the number of entries you sent.

Check status

Read one request, a whole batch, or only what has changed since your last poll. Every GET below accepts the same parameters, so /ipe, /validation and /requests behave identically.

Request Returns
GET /api/v1/ipe?reference=IPE_A1B2C3 One IPE Clearance request
GET /api/v1/validation?reference=VAL_A1B2C3 One Validation request
GET /api/v1/requests?reference=… Any request, either service
GET /api/v1/requests?references=A,B,C Up to 100 named requests in one call
GET /api/v1/requests?updated_since=… Only requests that changed
GET /api/v1/requests?service=ipe&amp;state=done Filtered, paginated list

List queries accept state, service, from, to, limit (max 100) and offset. The meta block carries total and has_more so you can page reliably.

Two fields make a poll self-contained: terminal is true once a job has finished (stop tracking it), and on a failure reason explains why while refund states exactly what went back to the wallet — a Validation failure is refunded minus a processing fee, so the amount is not always the amount you were charged.

Tracking many jobs at once

Polling one reference at a time costs 12 calls an hour per job, so a bulk batch would exhaust any rate limit long before it finished. Use one of these two instead and tracking any number of jobs costs a flat ~12 calls an hour.

Batch — up to 100 references
GET /api/v1/requests?references=BLK_A1B2C3D4E5,BLK_F6G7H8I9J0,VAL_K1L2M3N4O5
Authorization: Bearer ipe_live_…

The reply is a list, and meta.not_found names any reference that is not on your account, so nothing is silently dropped.

Change feed
GET /api/v1/requests?updated_since=2026-08-18T09:00:00%2B01:00
→ meta.next_cursor = "djE6MTc4NzEyOTA2MjoxOTA0OA"

GET /api/v1/requests?cursor=djE6MTc4NzEyOTA2MjoxOTA0OA          (5 minutes later)
→ only what changed since; replay the new next_cursor next time

Seed the feed once with updated_since (ISO-8601, a plain date, or a Unix timestamp), then keep replaying meta.next_cursor. The cursor is a position over (updated_at, row id), not a timestamp watermark, so requests that change within the same second are never skipped and never repeated. An empty page hands the same cursor back, so an idle poll is free to repeat. Follow meta.has_more immediately to drain a backlog; otherwise wait meta.poll_interval_seconds.

The change feed reports every change to a request, not only a state transition, so treat it as at-least-once: key on reference and compare state against what you already stored.
Completed IPE Clearance
{
    "status": "success",
    "data": {
        "reference": "IPE_A1B2C3D4E5",
        "service": "ipe",
        "state": "done",
        "status_label": "Successful",
        "terminal": true,
        "amount": "500.00",
        "currency": "NGN",
        "created_at": "2026-08-18 09:14:02",
        "updated_at": "2026-08-18 11:40:55",
        "reason": null,
        "refund": null,
        "tracking_id": "ABC1234567890",
        "old_tracking_id": "ABC1234567890",
        "new_tracking_id": "NEWTRK9988",
        "nin": "12345678901",
        "result": {
            "text": "New Tracking ID: NEWTRK9988 | NIN: 12345678901",
            "download": "https://instantipe.com.ng/uploads/results/ipe_IPE_A1B2C3D4E5.pdf",
            "expired": false
        }
    },
    "request_id": "2f1c8f0e-9a4c-4c2f-9b1a-7e6d5c4b3a21"
}
Downloadable slips are retained for a limited period. Once purged, result.download becomes null and result.expired becomes true — fetch and store the file when the webhook arrives rather than linking to it long-term.

Polling etiquette. These jobs take minutes to hours. A five-minute cadence on cursor (or on references for a known batch) keeps you inside the rate limit no matter how many jobs are open. Polling a single reference in a loop does not, and webhooks are still the cheapest way to learn that a job finished.

Webhooks

Register an HTTPS endpoint and InstantIPE posts a signed JSON event the moment a request reaches a terminal state. This is the recommended way to consume results.

Event Fires when
ipe.completed An IPE Clearance request completed successfully
ipe.failed An IPE Clearance request failed (and was refunded)
validation.completed A Validation request completed successfully
validation.failed A Validation request failed (and was refunded)

Manage endpoints in the Webhooks tab of this console, or over the API with a key holding webhooks:manage:

Register an endpoint
curl -X POST https://instantipe.com.ng/api/v1/webhooks \
  -H "Authorization: Bearer ipe_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-app.example.com/hooks/instantipe",
       "events": ["ipe.completed", "ipe.failed"]}'

Each delivery is a POST with these headers:

Delivery headers
X-InstantIPE-Event: ipe.completed
X-InstantIPE-Delivery: 5512
X-InstantIPE-Signature: t=1755500000,v1=6f2b…c91a
Content-Type: application/json
Delivery body
{
    "id": "evt_8841",
    "event": "ipe.completed",
    "environment": "live",
    "created_at": "2026-08-18 11:40:55",
    "data": {
        "reference": "IPE_A1B2C3D4E5",
        "service": "ipe",
        "state": "done",
        "status_label": "Successful",
        "terminal": true,
        "amount": "500.00",
        "currency": "NGN",
        "reason": null,
        "refund": null,
        "tracking_id": "ABC1234567890",
        "new_tracking_id": "NEWTRK9988",
        "nin": "12345678901",
        "result": {
            "text": "New Tracking ID: NEWTRK9988 | NIN: 12345678901",
            "download": null
        }
    }
}

A .failed delivery carries the same record with reason set and refund describing what went back to the wallet — identical to what Check Status returns, so one parser serves both.

Verifying the signature

The signature header is t=<unix timestamp>,v1=<hex hmac-sha256>, where the HMAC is computed over <timestamp>.<raw request body> using your endpoint secret. Compare in constant time and reject anything older than your tolerance window.

<?php
$secret = 'whsec_…';                       // from the Webhooks tab
$body   = file_get_contents('php://input'); // RAW body — do not re-encode
$header = $_SERVER['HTTP_X_INSTANTIPE_SIGNATURE'] ?? '';

$t = $v1 = null;
foreach (explode(',', $header) as $part) {
    [$k, $v] = array_pad(explode('=', trim($part), 2), 2, null);
    if ($k === 't')  $t  = (int) $v;
    if ($k === 'v1') $v1 = $v;
}

if (!$t || !$v1 || abs(time() - $t) > 300) {
    http_response_code(400); exit;              // stale or malformed
}
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
if (!hash_equals($expected, $v1)) {
    http_response_code(401); exit;              // not from InstantIPE
}

$event = json_decode($body, true);
// ... process, then acknowledge
http_response_code(200);
echo 'ok';
const crypto = require('crypto');

// mount with a RAW body parser: express.raw({ type: 'application/json' })
app.post('/hooks/instantipe', (req, res) => {
  const secret = process.env.INSTANTIPE_WEBHOOK_SECRET;
  const header = req.get('X-InstantIPE-Signature') || '';
  const parts  = Object.fromEntries(header.split(',').map(p => p.trim().split('=')));

  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!parts.t || !parts.v1 || age > 300) return res.sendStatus(400);

  const expected = crypto.createHmac('sha256', secret)
    .update(`${parts.t}.${req.body}`).digest('hex');

  const a = Buffer.from(expected), b = Buffer.from(parts.v1);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  // ... process
  res.sendStatus(200);
});
import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split('=', 1) for p in header.split(','))
    t, v1 = parts.get('t'), parts.get('v1')
    if not t or not v1 or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(),
        f'{t}.'.encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, v1)
Rule Detail
Respond fast Return any 2xx within 15 seconds. Queue the work; do not process inline.
Retries A non-2xx or timeout is retried with exponential backoff (30s, 1m, 2m, 4m, 8m…) up to 6 attempts.
Idempotency Deduplicate on id — a retry re-sends the same event id.
HTTPS only Endpoints must be HTTPS and must not resolve to a private or reserved address.
Ordering Not guaranteed. Treat data.state as the current truth.

Endpoint reference

Everything the API exposes, in one table.

Method Path Scope Purpose
GET /api/v1/ping none Liveness and service availability
GET /api/v1/me wallet:read Account, wallet, key scopes, rate budget
GET /api/v1/wallet wallet:read Balance, optionally with recent ledger entries
GET /api/v1/services any Self-describing catalogue with your prices
POST /api/v1/ipe ipe:write Submit IPE Clearance (single or bulk)
GET /api/v1/ipe ipe:read Fetch or list IPE Clearance requests
POST /api/v1/validation validation:write Submit Validation (single or bulk)
GET /api/v1/validation validation:read Fetch or list Validation requests
POST /api/v1/nin.php nin:write Generate a NIN slip (11 slip types, instant PDF)
GET /api/v1/nin.php nin:read Re-download or list NIN slips
POST /api/v1/bvn.php bvn:write Generate a BVN slip or card (instant PDF)
GET /api/v1/bvn.php bvn:read Re-download or list BVN slips
GET /api/v1/requests ipe:read / validation:read Fetch or list across both services
GET /api/v1/webhooks webhooks:manage List endpoints, or delivery attempts
POST /api/v1/webhooks webhooks:manage Create an endpoint, or queue a test delivery
PUT /api/v1/webhooks webhooks:manage Update an endpoint or rotate its secret
DELETE /api/v1/webhooks webhooks:manage Delete an endpoint

A machine-readable OpenAPI 3.1 document is available at /docs/openapi.yaml.