Integration guide
The consumer contract for the OFIS API, from your first token to signed assertions, enrollment, linking and entitlements. Every example was captured from a live server.
This guide is adapted from the OFIS repository’s INTEGRATION.md, the contract the service is tested against. Where this page and the live schema ever diverge, the API reference generated from the service wins.
Authentication
OFIS uses OAuth 2 client credentials via Keycloak. There is no user login on the API: your service authenticates as itself. The token endpoint is {issuer}/protocol/openid-connect/token; OFIS gives you the exact issuer for your environment with your credentials.
curl -s -X POST "https://<keycloak-host>/realms/ofis/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$OFIS_CLIENT_ID" \
-d "client_secret=$OFIS_CLIENT_SECRET" \
-d "scope=ofis:lookup ofis:assert"
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 300,
"token_type": "Bearer",
"scope": "ofis:lookup ofis:assert"
}
Send it on every call as Authorization: Bearer <access_token>. Treat the token as opaque and re-request it when it expires; do not cache it beyond expires_in.
Member states (public reference data)
GET /api/v1/states/ needs no authentication. Mirror it into your own system on a refresh cycle rather than hard-coding codes; OFIS is the source of truth for the Schedule A allocation.
curl -s "https://sandbox.identity.federal.africa/api/v1/states/?search=Uganda"
{
"count": 1, "next": null, "previous": null,
"results": [{ "id": 113, "name": "Uganda", "official_name": "", "iso_code": "UG",
"state_code": "52", "region": "east", "is_active": true, "flag_emoji": "", "citizen_count": 2 }]
}
Results are paginated at 25 per page and support ?search=, ?region= and ?is_active=.
Lookup: non-personal status
GET /api/v1/citizens/{fa_id}/ requires ofis:lookup.
curl -s "https://sandbox.identity.federal.africa/api/v1/citizens/FA-52-00000002/" \
-H "Authorization: Bearer $TOKEN"
{
"federal_id": "FA-52-00000002",
"status": "active",
"verification_level": 1,
"verified": true,
"state": "Uganda",
"state_code": "52",
"issued_at": "2026-07-06T16:51:09.165411+00:00",
"last_verified": "2026-07-06T16:51:09.165990+00:00",
"membership": { "tier": "basic", "until": "2027-10-26", "source": "sandbox-com" }
}
membership is the member’s effective tier: the highest tier among their active entitlements, whichever app sold it. With no active entitlement, or for an identity that is not active, all three fields are null. verified is true whenever verification_level >= 1; read it together with status, because a revoked or suspended identity keeps its level. last_verified is null for an identity never upgraded past Level 0. Lookup never errors on status, only on a nonexistent Federal ID:
{ "error": { "code": "identity_not_found", "message": "FA-ID not found" } }
Assertions: signed, verified offline
POST /api/v1/assertions/ requires ofis:assert and a consent record covering every requested attribute.
curl -s -X POST "https://sandbox.identity.federal.africa/api/v1/assertions/" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"federal_id": "FA-52-00000002", "requested_attributes": ["verified", "verification_level", "state_code"]}'
{
"assertion": "eyJhbGciOiJFUzI1NiIsImtpZCI6...",
"assertion_id": "assert-26c24992",
"expires_at": "2026-07-06T17:54:26.076428Z"
}
requested_attributes may be any subset of verified, verification_level, state_code, status, country, membership_tier. Errors: identity_not_found, identity_not_active (status is checked before consent), attribute_invalid, scope_not_allowed, and the consent family.
The golden rule: verify assertions locally. Never call OFIS on your hot path.
| Claim | Meaning |
|---|---|
iss |
The OFIS issuer, https://identity.federal.africa |
sub |
The Federal ID the assertion is about |
aud |
Your client_id; always check it matches you |
iat, exp |
Issued at, expiry; assertions are valid for 1 hour |
jti |
The assertion ID |
| the attributes | Only those you asked for and were granted |
Fetch the keys from GET /.well-known/jwks.json, no authentication. The response carries Cache-Control: public, max-age=3600; respect it. On key rotation the old key stays published until assertions signed with it have expired, so select the key by the JWT header’s kid and never assume a single key.
Python, verified end to end against a real sandbox assertion:
import jwt, requests, json
JWKS_URL = "https://sandbox.identity.federal.africa/.well-known/jwks.json"
EXPECTED_ISSUER = "https://identity.federal.africa" # sandbox uses its own
EXPECTED_AUDIENCE = "sandbox-consumer" # your client_id
def get_jwks():
return requests.get(JWKS_URL, timeout=5).json() # cache for up to 1h
def verify_assertion(token: str, jwks_doc: dict) -> dict:
kid = jwt.get_unverified_header(token)["kid"]
jwk = next(k for k in jwks_doc["keys"] if k["kid"] == kid)
public_key = jwt.PyJWK.from_json(json.dumps(jwk)).key
return jwt.decode(token, public_key, algorithms=["ES256"],
audience=EXPECTED_AUDIENCE, issuer=EXPECTED_ISSUER)
Node with jose, same result:
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://sandbox.identity.federal.africa/.well-known/jwks.json"));
export async function verifyAssertion(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://identity.federal.africa",
audience: "sandbox-consumer",
});
return payload;
}
Linking
A member proves which Federal ID is theirs, and grants you consent, through linking. When your product needs that, send their browser to the OFIS-owned linking flow. This is not raw OIDC: OFIS renders its own consent screen and writes the consent record that gates assertions.
GET https://<ofis-host>/portal/link/authorize/?client_id=<your client_id>&redirect_uri=<registered URI>&scope=<space-separated attributes>&state=<your opaque value>
redirect_urimust match a URI pre-registered with OFIS byte for byte. An unknown client or a mismatched URI gets an error page, never a redirect.scopeis a subset of your client’s allowed attributes, for exampleverified verification_level state_code.stateis echoed back unchanged; use it against CSRF.
The member signs in if needed, completes registration if they have no Federal ID yet, and sees the consent screen. OFIS then redirects to your redirect_uri with ?code=<one-time code>&state=… on approval or ?error=access_denied&state=… on denial. Your backend exchanges the code with its ofis:assert token:
curl -s -X POST "https://sandbox.identity.federal.africa/api/v1/link/token/" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"code": "<the code from the callback>"}'
{
"federal_id": "FA-52-00000001",
"assertion": "eyJhbGciOiJFUzI1NiIsImtpZCI6...",
"assertion_id": "assert-126c330f",
"expires_at": "2026-07-06T21:42:14.836899Z"
}
Store federal_id; the first assertion comes in the same call. Codes are single-use and expire in five minutes; every failure collapses to one error, link_code_invalid (400), deliberately, so a consumer cannot probe codes. Treat it as “start the flow over”.
Enrollment: turn a Federal ID login into a Federal ID
POST /api/v1/enrollments/ requires ofis:enroll. Call it from your backend right after a person signs in with Federal ID, with the ID token you received.
curl -s -X POST "https://sandbox.identity.federal.africa/api/v1/enrollments/" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"id_token": "<the citizens-realm ID token>"}'
| Situation | Response |
|---|---|
| The person already has a Federal ID | 200 with it; any details you send are ignored |
No Federal ID yet, and state_code, display_name, country_of_origin sent |
201, issued at Level 0 |
| No Federal ID yet, details missing | 404 identity_not_found: ask for state and country, call again |
date_of_birth under 16 years ago |
403 too_young; nothing is issued |
Aged 16 or 17 without guardian_consent: true |
403 guardian_consent_required |
{
"federal_id": "FA-52-00000001",
"created": true,
"status": "active",
"verification_level": 0,
"verified": false,
"state_code": "52",
"membership": { "tier": null, "until": null, "source": null }
}
state_code must be exactly two digits (05, not 5), display_name at most 200 characters, country_of_origin an ISO 3166-1 alpha-2 code. date_of_birth is stored and never returned. OFIS checks that the ID token is RS256 from the citizens realm, issued to one of your registered login clients, no more than 10 minutes old, and for a verified email. Enrollment does not grant consent: lookups work, assertions still need linking.
Errors: id_token_invalid (400), email_not_verified (403), identity_not_found (404), state_not_found (400), idp_unavailable (503, retry with backoff).
Entitlements: report the memberships you sell
Requires ofis:entitle, restricted to apps that sell plans. An entitlement says: this Federal ID holds this tier (basic or premium) until this date, because your app sold it. OFIS takes the highest live tier across every app and shows it everywhere as membership. OFIS never sees money: nothing about what was charged, or how, is sent or stored.
curl -s -X PUT "https://sandbox.identity.federal.africa/api/v1/entitlements/sub_8123/" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"federal_id": "FA-52-00000001", "tier": "premium", "valid_until": "2027-10-22"}'
{
"reference": "sub_8123",
"federal_id": "FA-52-00000001",
"tier": "premium",
"valid_until": "2027-10-22",
"granted_by": "federalafrica-com",
"granted_at": "2026-10-26T09:14:02.511903Z",
"revoked_at": null,
"effective": { "tier": "premium", "until": "2027-10-22", "source": "federalafrica-com" }
}
reference is your own stable ID, scoped to your client. PUT is idempotent: the first call creates (201), later calls update (200); renewal is the same reference with a later valid_until. GET reads your own entitlement; DELETE with an optional short reason cancels it (204). A reference revoked by OFIS staff is not revived (409 reference_revoked_by_staff); use a new one. A suspended member reports tier: null until reinstated. Honour until locally; a lapse is announced by webhook within a day.
Error model
Every error is {"error": {"code": "...", "message": "..."}}. Build on code; message may change.
| Code | Status | Meaning |
|---|---|---|
identity_not_found |
404 | No Federal ID with that number |
identity_not_active |
409 | Exists but suspended, revoked or deceased |
state_not_found |
400 | state_code is not an active member state |
duplicate_external_ref |
409 | external_ref already linked to another Federal ID |
too_young, guardian_consent_required |
403 | The age rules |
reference_conflict, reference_revoked_by_staff |
409 | Entitlement reference problems |
tier_unknown, valid_until_past |
400 | Entitlement field problems |
entitlement_not_found |
404 | No entitlement with that reference for your client |
attribute_invalid |
400 | An unknown attribute was requested |
scope_not_allowed, consent_missing, consent_revoked, consent_expired |
403 | Consent |
client_unknown, not_authenticated, authentication_failed |
401 | Credentials |
permission_denied |
403 | Token lacks the scope |
invalid |
400 | Malformed request |
id_token_invalid, email_not_verified |
400, 403 | Enrollment token problems |
throttled |
429 | Default 600 requests per minute per client; 60 per minute per address unauthenticated. Wait for Retry-After |
idp_unavailable |
503 | Safe to retry with backoff |
internal_error |
500 | Safe to retry with backoff; contact OFIS if persistent |
Versioning
The API is /api/v1/. Breaking changes only ever arrive under a new prefix, and /api/v1/ stays supported for at least six months after /api/v2/ ships. Deprecations are announced directly to registered consumers.
Resilience
- Cache verification state locally. Do not call OFIS on every request that needs to know whether someone is verified.
- Two-second timeout and a circuit breaker on every call. If OFIS is slow or down, fail toward your cached state.
- Re-validate on a slow cycle, about every 30 days with jitter, and immediately before anything security-sensitive.
- Use webhooks to cut latency; they do not replace the polling cycle, because delivery can be dead-lettered.
- Cache the membership tier with its
untildate and honouruntillocally.