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.
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.
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.
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. |
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:
{
"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:
{
"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:
{
"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:
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.
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.
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
{
"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 · fullDefault: full |
| slip_type | enum |
optional | Slip format issued on success. One of: basic · regular · standard · premiumDefault: 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
{
"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
{
"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.
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 |
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"]}'
{
"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"
}
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&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.
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.
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.
reference and compare state against what you already stored.{
"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"
}
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:
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:
X-InstantIPE-Event: ipe.completed
X-InstantIPE-Delivery: 5512
X-InstantIPE-Signature: t=1755500000,v1=6f2b…c91a
Content-Type: application/json
{
"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.