Skip to main content
TRCN

Developers · Partner integration

Inter-Agency Verification API

The authenticated, signed REST API for partner agencies — WAEC, NECO, JAMB, federal ministries, and approved employers — to verify a Nigerian teacher’s registration, certificate, and license status, with webhook callbacks for status changes.

Before you start

This API is restricted to partner agencies onboarded by TRCN — examination bodies (WAEC, NECO, JAMB), federal ministries, and approved employers. Provisioning is handled by TRCN; this guide assumes you have already received:

  • A partner ID (your audience, e.g. waec)
  • An API key (and, for federal partners, an mTLS client certificate)
  • A set of scopes and allowed purposes that gate which endpoints you may call and why
  • A webhook signing secret (whsec_…) if you subscribed to status-change callbacks

Treat the API key and signing secret as production secrets. If either is exposed, contact TRCN to rotate it immediately.

Quick reference

Base URL

https://api.trcn.gov.ng/v1/inter-agency

Authentication

Bearer API key, x-trcn-api-key, or mTLS

Response format

Signed JSON envelope (RS256 JWS)

Rate limit

Per-partner, per-minute + per-day

Status

Production

JWKS

/v1/inter-agency/.well-known/jwks.json

Authentication

Every request (except the public /health and /.well-known/jwks.json) must authenticate. Three methods are accepted, checked in this order:

  1. mTLS client certificate — federal partners present a certificate at the TLS layer (the recommended posture for this data class). Resolved by certificate fingerprint.
  2. Bearer API key Authorization: Bearer <key>
  3. Header API key x-trcn-api-key: <key>

A request with no valid credential returns 401 Unauthorized. Even with mTLS, continue to verify the JWS signature on every response — defence in depth.

Scopes and purpose-of-use

Each endpoint requires a scope on your partner record. The eligibility and batch endpoints additionally require a purpose, and that purpose must be in your allowed purposes. Calling outside your grants returns 403 Forbidden — request additional scopes/purposes from TRCN rather than working around them.

Scopes → endpoints

ScopeEndpoint
eligibilityPOST /eligibility-check
lookup_by_reg_noGET /lookup/by-reg-no/{regNo}
lookup_by_tidGET /lookup/by-tid/{tId}
lookup_by_ninPOST /lookup/by-nin
batchPOST /batch

Purpose-of-use values

Pass one of these as purpose on eligibility and batch checks. You will be granted only the purposes relevant to your agency.

WAEC_INVIGILATOR_ELIGIBILITYWAEC_EXAMINER_CREDENTIAL_CHECKNECO_INVIGILATOR_ELIGIBILITYNECO_EXAMINER_CREDENTIAL_CHECKJAMB_CBT_STAFF_VERIFICATIONJAMB_INVIGILATOR_ELIGIBILITYJAMB_ENDORSEMENT_ELIGIBILITYSCHOOL_EMPLOYMENT_VERIFICATIONEMPLOYER_BACKGROUND_CHECKGENERAL_PUBLIC_VERIFICATION

Quick start

A first eligibility check. Set TRCN_API_KEY in your environment, then:

bash
curl -X POST "https://api.trcn.gov.ng/v1/inter-agency/eligibility-check" \
  -H "Authorization: Bearer $TRCN_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 2f1c9a7e-checker-001" \
  -d '{
    "identifier": { "regNo": "TRCN/ABJ/2024/123456" },
    "purpose": "WAEC_INVIGILATOR_ELIGIBILITY",
    "context": { "examBatch": "WASSCE-2026-MAY", "correlationId": "req-8842" }
  }'

The response is a signed envelope (see below). Two response headers echo the identifiers for logging: X-Verification-Id and X-Signature-Kid.

Endpoints

POST
/v1/inter-agency/eligibility-check
scope: eligibility

Resolves a teacher and evaluates eligibility against the supplied purpose. Body:

  • identifier — one of regNo, tId, or nin
  • purpose — a purpose-of-use value (required)
  • context — optional examBatch, employerName, correlationId

Supports Idempotency-Key.

GET
/v1/inter-agency/lookup/by-reg-no/{regNo}
scope: lookup_by_reg_no

Status lookup by TRCN registration number (URL-encode the slashes).

bash
curl "https://api.trcn.gov.ng/v1/inter-agency/lookup/by-reg-no/TRCN%2FABJ%2F2024%2F123456" \
  -H "x-trcn-api-key: $TRCN_API_KEY"
GET
/v1/inter-agency/lookup/by-tid/{tId}
scope: lookup_by_tid

Status lookup by numeric Teacher ID. Non-numeric tId returns 400.

POST
/v1/inter-agency/lookup/by-nin
scope: lookup_by_nin

Status lookup by 11-digit NIN. The NIN is sent in the request body, never the URL, so it never lands in access logs.

bash
# NIN goes in the body, NEVER the URL (NIMC Act s.26 / NDPA s.30)
curl -X POST "https://api.trcn.gov.ng/v1/inter-agency/lookup/by-nin" \
  -H "Authorization: Bearer $TRCN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "nin": "12345678901" }'
POST
/v1/inter-agency/batch
scope: batch

Up to 100 eligibility checks in one call. Returns { count, results } where each result is its own signed envelope. Supports Idempotency-Key.

bash
curl -X POST "https://api.trcn.gov.ng/v1/inter-agency/batch" \
  -H "Authorization: Bearer $TRCN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "identifier": { "tId": 1234567 }, "purpose": "NECO_INVIGILATOR_ELIGIBILITY" },
      { "identifier": { "regNo": "TRCN/LAG/2023/998877" }, "purpose": "NECO_INVIGILATOR_ELIGIBILITY" }
    ]
  }'
GET
/v1/inter-agency/.well-known/jwks.json/v1/inter-agency/health
public

No authentication. The JWKS document holds the public keys for verifying response signatures; /health returns the issuer and active key id.

The signed response envelope

Every authenticated response is a JSON envelope. The signature field is a compact RS256 JWS (JWT) signed by TRCN over the same result, with claims iss, aud (your partner ID), sub (the verification ID), and iat. This gives you non-repudiation: you can prove to a third party that TRCN attested a status at a point in time.

json
{
  "verificationId": "ver_7f3a1c9e8b2d4f60",
  "verifiedAt": "2026-06-07T10:42:11.880Z",
  "issuer": "https://api.trcn.gov.ng",
  "audience": "waec",
  "result": {
    "found": true,
    "source": "new_system",
    "status": "VALID",
    "eligible": true,
    "reasons": [],
    "teacher": {
      "name": "Adeyemi John Olumide",
      "tId": 1234567,
      "state": "Lagos",
      "category": "Professional"
    },
    "license": {
      "status": "VALID",
      "issueDate": "2024-01-15",
      "expiryDate": "2027-01-14",
      "daysRemaining": 587
    },
    "certificate": {
      "status": "VERIFIED",
      "regNo": "TRCN/ABJ/2024/123456",
      "issueDate": "2024-01-15"
    }
  },
  "signature": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRyY24taWEtMjAyNi0wNiJ9.eyJzdWIiО...",
  "signatureKid": "trcn-ia-2026-06"
}

Verify the signature

Fetch the JWKS (cache it; respect kid rotation), verify the JWS with the key whose kid matches the JOSE header, and confirm iss and aud. Trust the verified result claim, not the unsigned envelope copy.

Node.js

javascript
// npm i jose
import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL('https://api.trcn.gov.ng/v1/inter-agency/.well-known/jwks.json'),
);

// envelope = the JSON body you received
const { payload } = await jwtVerify(envelope.signature, JWKS, {
  issuer: 'https://api.trcn.gov.ng',
  audience: 'YOUR_PARTNER_ID', // must equal envelope.audience
});

// payload.result is what TRCN cryptographically attested.
// Trust payload.result over envelope.result if they ever differ.
if (payload.result.status !== 'VALID') {
  // not currently licensed
}

Python

python
# pip install "pyjwt[crypto]"
import jwt
from jwt import PyJWKClient

JWKS_URL = "https://api.trcn.gov.ng/v1/inter-agency/.well-known/jwks.json"
jwks = PyJWKClient(JWKS_URL)

signing_key = jwks.get_signing_key_from_jwt(envelope["signature"])
claims = jwt.decode(
    envelope["signature"],
    signing_key.key,
    algorithms=["RS256"],
    issuer="https://api.trcn.gov.ng",
    audience="YOUR_PARTNER_ID",  # must equal envelope["audience"]
)
result = claims["result"]  # the attested result

Result fields

FieldMeaning
foundWhether a teacher matched. When false, other fields are null.
statusVALID · EXPIRED · REVOKED · NOT_FOUND. Treat anything but VALID as not currently licensed.
eligibleBoolean verdict for the supplied purpose; reasons[] explains a false.
sourcenew_system (certified) or legacy.
teachername, tId, state, category.
licensestatus, issueDate, expiryDate, daysRemaining.
certificatestatus (VERIFIED / CLAIMED / LEGACY_RECORD / NOT_FOUND), regNo, issueDate. Use regNo as the stable identifier.
profileFull profile (qualifications, employment) for new-system lookups. Raw NIN is never returned — only ninVerified.

Rate limits

Limits are per-partner: a per-minute and a per-day quota set on your record. Every response carries the current window state, and a breach returns 429 with a retryAfterSeconds hint. Back off and retry; do not hammer.

  • X-RateLimit-Limit — your per-minute quota
  • X-RateLimit-Remaining — calls left in the window
  • X-RateLimit-Reset — Unix epoch (seconds) when the window resets
http
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1749291791

{
  "statusCode": 429,
  "message": "Per-minute quota exceeded for partner waec",
  "retryAfterSeconds": 23
}

Idempotency

For POST /eligibility-check and POST /batch, send an Idempotency-Key header (a unique value you generate per logical request). A retry with the same key and same body replays the original response instead of re-running the check — safe for network retries. Reusing a key with a different body is rejected.

Errors

StatusWhen
400Malformed request — missing identifier or purpose, NIN not 11 digits, non-numeric tId, empty or >100-item batch.
401No valid credential (mTLS cert, Bearer key, or x-trcn-api-key).
403Authenticated, but missing the scope for the endpoint or the purpose is not in your allowed purposes.
429Per-minute or per-day quota exceeded. Honour retryAfterSeconds.
5xxTransient — retry with exponential back-off (start 1s, max 3 retries).

Note: a successful lookup that simply finds no teacher is still 200 with result.found = false — not a 404.

Webhooks (status-change callbacks)

If you subscribed, TRCN POSTs a signed event to your endpoint whenever a verified teacher’s status changes — so you don’t have to poll. Subscriptions are provisioned by TRCN; ask to subscribe and you’ll receive a signing secret (whsec_…).

Event types

license.verifiedlicense.expiredlicense.revokedlicense.reinstatedcertificate.verifiedcertificate.revokedteacher.deactivated

Delivery payload

The body carries no raw PII — only the public regNo/tId identifiers and status strings. Headers include X-TRCN-Signature, X-TRCN-Event-Id, X-TRCN-Event-Type, and X-TRCN-Delivery-Id.

http
POST https://your-endpoint.example.gov.ng/trcn/webhooks
Content-Type: application/json
X-TRCN-Signature: t=1749291731,v1=3a9f...c12e
X-TRCN-Event-Id: evt_5b2c8d1a9e4f
X-TRCN-Event-Type: license.revoked
X-TRCN-Delivery-Id: dlv_91aa77c0
User-Agent: TRCN-Webhooks/1.0

{
  "eventId": "evt_5b2c8d1a9e4f",
  "eventType": "license.revoked",
  "occurredAt": "2026-06-07T10:42:11.880Z",
  "data": {
    "tId": 1234567,
    "regNo": "TRCN/ABJ/2024/123456",
    "licenseStatus": "REVOKED",
    "previousStatus": "VALID"
  }
}

Verify the signature

X-TRCN-Signature is a Stripe-style HMAC-SHA256 of `${t}.${rawBody}` using your signing secret. Compute it over the raw request body (before JSON parsing), compare in constant time, and reject events older than 5 minutes (replay protection). Acknowledge with a 2xx as soon as you have persisted the event; process asynchronously. Failed deliveries are retried with exponential back-off and jitter, then dead-lettered.

javascript
import crypto from 'crypto';

const REPLAY_WINDOW_SECONDS = 300; // 5 minutes

// IMPORTANT: verify against the RAW request body, before JSON parsing.
function verifyTrcnWebhook(rawBody, signatureHeader, signingSecret) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((p) => p.split('=')),
  );
  const t = Number(parts.t);
  const provided = parts.v1;
  if (!Number.isFinite(t) || !provided) return false;

  // reject replays
  if (Math.abs(Date.now() / 1000 - t) > REPLAY_WINDOW_SECONDS) return false;

  const expected = crypto
    .createHmac('sha256', signingSecret) // your whsec_... secret
    .update(`${t}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(provided);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Acknowledge fast (2xx) once stored; process asynchronously.

OpenAPI specification

The full contract is published as OpenAPI 3.1 — generate typed clients, drive contract tests, or import into Postman / Insomnia / Bruno.

Need access, more scopes, or a webhook subscription?

Partner credentials, additional scopes/purposes, mTLS certificate enrolment, and webhook subscriptions are provisioned by the TRCN integrations team. Looking for the free, no-auth public lookup instead? See the Teacher Verification API.

Contact the integrations team