Verification API guide
A self-guided handbook for connecting any authorized external system to practitioner and firm verification. Follow the steps, copy the examples, then try a sandbox call.
What this API does
Authorized government and enterprise systems can confirm whether a COREN registration number belongs to a real practitioner or firm, and whether that record is currently allowed to practice.
Quick start
Work through these five actions once. After that, every verification call follows the same pattern.
- Ask COREN to register your organization as an API consumer, then create a sandbox application and a production application.
- Generate credentials on the application. Copy the secret immediately. It is shown only once.
-
Start in sandbox. Use
https://portal.coren.gov.ng/api/sandbox/v1and keys that begin withpk_test_/sk_test_. - Sign every request with HMAC-SHA256. Unsigned or replayed requests are rejected.
-
Send the license number as
reference— the practitioner number or firm number you want to verify.
Environments and base URLs
Sandbox and production are separate. A key from one environment is rejected by the other.
| Environment | Base URL | Key prefix | Use for |
|---|---|---|---|
| Sandbox | https://portal.coren.gov.ng/api/sandbox/v1 |
pk_test_ / sk_test_ |
Integration testing |
| Production | https://portal.coren.gov.ng/api/v1 |
pk_live_ / sk_live_ |
Live verification |
Current version: v1. Future versions will be introduced as /api/v2 without removing v1.
API credentials
Each application receives a public key (identifier) and a secret key (signing key).
Public key: pk_test_ab12cd34ef56gh78ij90klmnopqr Secret key: sk_test_zw9y8x7w6v5u4t3s2r1q0ponmlkjihgfedcba98
- Rotating a key issues a new pair and retires the old one.
- Revoking a key stops it immediately.
- Expired keys return
expired_credentials.
Authentication
Every request must identify the application and prove possession of the secret.
Required headers
| Header | Required | Meaning |
|---|---|---|
X-Api-Key | Yes | Public key |
X-Api-Signature | Yes | hex HMAC-SHA256 of the canonical string |
X-Api-Timestamp | Yes | Unix seconds, within ±300 seconds |
X-Api-Nonce | Yes | Unique value. Reuse is rejected as a replay |
X-Idempotency-Key | Optional | Caller-defined request id |
Content-Type | Yes | application/json |
Canonical string
Join these five lines with a single newline (\n), then HMAC-SHA256 them with the secret key:
timestamp nonce METHOD /path sha256(raw_json_body)
Worked example for practitioner verification:
1710000000 nonce-8f1c2a POST /api/v1/verify/practitioner e3b0c44298fc1c149afbf4c8996fb924... ← SHA-256 of the exact JSON body
/api/sandbox/v1/verify/firm.
Practitioner verification
POST /api/v1/verify/practitioner · scope practitioners.verify
Request
{
"reference": "R12345"
}| Field | Required | Notes |
|---|---|---|
reference | Yes | Practitioner license number (COREN number). |
Successful response
{
"success": true,
"message": "Practitioner verified successfully.",
"data": {
"reference": "R12345",
"registration_number": "R12345",
"full_name": "Aisha Bello",
"gender": "Female",
"cadre": "Engineer",
"engineering_field": "Civil Engineering",
"registration_status": "Active",
"registration_status_code": "enabled",
"is_active": true,
"license_status": {
"is_valid": true,
"is_licensed_to_practice": true,
"label": "Licensed to practice",
"is_expired": false
},
"passport_url": "https://portal.coren.gov.ng/storage/passports/R12345.jpg",
"qualifications": [
{
"qualification": "B.Eng",
"institution": "Ahmadu Bello University",
"certificate": "Bachelor of Engineering",
"start_date": "2010",
"end_date": "2015"
}
]
},
"meta": {
"request_id": "9f2c1a6e-4b77-4d21-9c0a-1e8f0b2d3c4a",
"timestamp": "2026-09-05T11:10:00+01:00"
}
}Firm verification
POST /api/v1/verify/firm · scope firms.verify
Request
{
"reference": "F12345"
}Successful response
{
"success": true,
"message": "Firm verified successfully.",
"data": {
"reference": "F12345",
"registration_number": "F12345",
"full_name": "Greenfield Engineering Ltd",
"firm_type": "Limited Liability",
"category": "Consulting",
"size": "Medium",
"engineering_fields": ["Civil Engineering", "Structural Engineering"],
"year_established": 2008,
"registration_status": "Active",
"registration_status_code": "active",
"is_active": true,
"license_status": {
"is_valid": true,
"is_licensed_to_practice": true,
"label": "Licensed to practice",
"is_expired": false
}
},
"meta": {
"request_id": "b71d0e22-88aa-4f01-91c3-2a44e19d8c10",
"timestamp": "2026-09-05T11:12:00+01:00"
}
}How to read the response
Your application should decide presentation. Do not expect COREN to return colours.
| Field | Meaning | Suggested UI |
|---|---|---|
registration_status |
Account standing from the COREN portal (Active, Suspended, Deregistered, Deceased, Inactive). | Show the label as-is. |
is_active |
The record may currently renew / practice from an account-status perspective. | Gate the main “eligible” badge. |
license_status.is_licensed_to_practice |
Wallet-based license rule used on the public verification page. Expired when balance is missing or negative. | Green if true, red if false. |
license_status.is_valid |
License is current and the account is allowed to practice. | Use this for a single pass/fail decision. |
meta.request_id |
Unique id for this call. Quote it when contacting COREN support. | Store with your case record. |
Errors
Failed calls still return JSON. Read error_code, message, and meta.request_id.
{
"success": false,
"message": "No practitioner was found with the provided reference.",
"data": { "error_code": "not_found" },
"error_code": "not_found",
"meta": {
"request_id": "c0ffee00-1111-2222-3333-444455556666",
"timestamp": "2026-09-05T11:15:00+01:00"
}
}| HTTP | error_code | What to do |
|---|---|---|
| 401 | missing_credentials | Send X-Api-Key. |
| 401 | invalid_credentials | Check the public key or secret. |
| 401 | expired_credentials / revoked_credentials | Generate or rotate a new key. |
| 401 | invalid_signature | Rebuild the canonical string from the exact body and path. |
| 401 | invalid_timestamp | Sync server time. Window is ±300 seconds. |
| 401 | replay_detected | Use a new nonce on every request. |
| 403 | suspended_consumer / suspended_application | Ask COREN to reactivate access. |
| 403 | environment_mismatch | Use sandbox keys only on sandbox URLs. |
| 403 | insufficient_scope | Ask COREN to assign the product/scope. |
| 403 | ip_not_allowed | Call from an allowlisted address. |
| 404 | not_found | The registration number is not on the register. |
| 422 | invalid_request | Send a reference (license number). |
| 429 | rate_limit_exceeded | Wait for Retry-After seconds. |
| 500 | internal_error | Retry later and share the request id. |
Rate limits and IP restrictions
Default limits apply unless COREN sets custom values on the application.
| Sandbox | Production | |
|---|---|---|
| Per minute | 30 | 60 |
| Per hour | 500 | 2,000 |
| Per day | 2,000 | 10,000 |
| Per month | 20,000 | 200,000 |
When limited, the response is HTTP 429 and includes Retry-After, X-RateLimit-Limit, and X-RateLimit-Remaining.
If an IP allowlist is enabled, only listed addresses or CIDR ranges can call the API. Ask COREN to add your egress IPs before go-live.
Integration playbook
Any approved consumer follows the same contract. The API is not tailored to a single organization or product.
- Complete your own authorization step first (payment, eligibility check, or internal approval). Your system is the record for that step.
- Only then call COREN with HMAC credentials.
- Send
referenceas the practitioner or firm license number. - Render
registration_statusandlicense_statusin your own interface. - Store
meta.request_idwith your case record.
reference is the license number to look up. It does not authorize the call. Credentials, signature, timestamp, and nonce are always required. Repeating the same reference is safe: verification is read-only and idempotent.
Code examples
These samples sign a sandbox practitioner request. Replace the keys and license number in reference.
BODY='{"reference":"R12345"}'
TS=$(date +%s)
NONCE=$(uuidgen)
PATH_URL="/api/sandbox/v1/verify/practitioner"
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $2}')
CANON=$(printf '%s\n%s\n%s\n%s\n%s' "$TS" "$NONCE" "POST" "$PATH_URL" "$BODY_HASH")
SIG=$(printf '%s' "$CANON" | openssl dgst -sha256 -hmac "$SECRET_KEY" -hex | awk '{print $2}')
curl -sS -X POST "https://portal.coren.gov.ng/api/sandbox/v1/verify/practitioner" \
-H "Content-Type: application/json" \
-H "X-Api-Key: $PUBLIC_KEY" \
-H "X-Api-Signature: $SIG" \
-H "X-Api-Timestamp: $TS" \
-H "X-Api-Nonce: $NONCE" \
--data "$BODY"<?php
$body = json_encode([
'reference' => 'R12345',
], JSON_UNESCAPED_SLASHES);
$path = '/api/sandbox/v1/verify/practitioner';
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$canonical = implode("\n", [$timestamp, $nonce, 'POST', $path, hash('sha256', $body)]);
$signature = hash_hmac('sha256', $canonical, $secretKey);
$ch = curl_init('https://portal.coren.gov.ng/api/sandbox/v1/verify/practitioner');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Api-Key: '.$publicKey,
'X-Api-Signature: '.$signature,
'X-Api-Timestamp: '.$timestamp,
'X-Api-Nonce: '.$nonce,
],
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($ch);import crypto from 'crypto';
const body = JSON.stringify({
reference: 'R12345',
});
const path = '/api/sandbox/v1/verify/practitioner';
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomUUID();
const canonical = [timestamp, nonce, 'POST', path, crypto.createHash('sha256').update(body).digest('hex')].join('\n');
const signature = crypto.createHmac('sha256', secretKey).update(canonical).digest('hex');
const res = await fetch('https://portal.coren.gov.ng/api/sandbox/v1/verify/practitioner', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': publicKey,
'X-Api-Signature': signature,
'X-Api-Timestamp': timestamp,
'X-Api-Nonce': nonce,
},
body,
});
console.log(await res.json());import hashlib, hmac, json, time, uuid, urllib.request
body = json.dumps({
"reference": "R12345",
}, separators=(",", ":"))
path = "/api/sandbox/v1/verify/practitioner"
timestamp = str(int(time.time()))
nonce = str(uuid.uuid4())
body_hash = hashlib.sha256(body.encode()).hexdigest()
canonical = "\n".join([timestamp, nonce, "POST", path, body_hash])
signature = hmac.new(secret_key.encode(), canonical.encode(), hashlib.sha256).hexdigest()
req = urllib.request.Request(
"https://portal.coren.gov.ng/api/sandbox/v1/verify/practitioner",
data=body.encode(),
headers={
"Content-Type": "application/json",
"X-Api-Key": public_key,
"X-Api-Signature": signature,
"X-Api-Timestamp": timestamp,
"X-Api-Nonce": nonce,
},
method="POST",
)
print(urllib.request.urlopen(req).read().decode())Try it in the browser
This console signs the request in your browser and calls the API directly. Keys stay in this page and are not saved on the server.
The response will appear here.
Versioning and changelog
External URLs include the version. v1 remains available when v2 is introduced. Deprecated versions will be announced before retirement.
| Version | Date | Notes |
|---|---|---|
| v1 | 5 September 2026 | Lookup field is reference (practitioner or firm license number). HMAC authentication, sandbox/production split, request logging, and admin portal. |
Machine-readable contract: openapi-v1.yaml
FAQ
Can I send the secret as a Bearer token instead of HMAC?
Production applications require HMAC. You may also send X-Api-Secret or Authorization: Bearer sk_..., but the signature, timestamp, and nonce are still required when HMAC is enabled.
Why is my signature invalid?
Almost always the body or path used for signing differs from the request. Sign the exact JSON string and the full path including /api/v1 or /api/sandbox/v1.
Does sandbox use dummy practitioners?
Sandbox is a separate credential environment. Verification still reads the live register (read-only) so you can test with real numbers without production keys.
Will more APIs be added?
Yes. License, certificate, and other products can be added without changing how consumers, applications, or credentials work.