An official developer portal of the Government of the Republic of Zambia · Test environmentVerify a certificate
Pamodzi Developer HubBuild, integrate and learn the e-Services framework

The Partner API and OAuth client credentials

In this lesson you connect a system to the platform's Partner API: you learn how a client is issued, get a token with the client credentials grant, call endpoints, and handle errors and limits the way the platform expects.

What the Partner API is for

The Partner API, at /api/v1 on the platform's API host, is the door for other systems: a ministry's own system, a bank, a border post, a register harvester. It offers the service catalogue, permit and document verification, National Data Register reads, application status, the event catalogue, the audit trail and geodata feeds.

Two design rules shape everything you will see:

  • Identifiers and statuses, not personal data. A case status answer has a case number, a status word and dates,

    never the applicant's name or answers.

  • Everything is scoped. A client sees only what its scopes, its MDA scope and its register scope allow. Something

    outside your scope answers exactly like something that does not exist.

How a client is issued

You cannot create your own credentials. Access takes two steps, done by different people:

  1. You submit an access request on the Developer Hub: purpose, legal basis, data items, volume, retention and

    safeguards. A reviewer at the owning MDA recommends; a data steward decides. Nobody decides their own request, and the reviewer who recommends does not decide.

  2. Once approved, an administrator of the owning MDA registers the client on the platform, in the back office

    under Governance, API clients. They set the client ID, scopes, MDA scope, register scope, IP allow-list, rate limit and daily quota.

The secret is shown once, at creation. The platform stores only its hash. The hub never issues credentials; it records which client the MDA issued for your request.

Getting a token

The token endpoint is POST /api/v1/oauth/token with grant_type=client_credentials. Authenticate with HTTP Basic (client ID and secret) or with form fields.

API=https://platform.example/api/v1

curl -s -X POST "$API/oauth/token" \
  -u "EXAMPLE01:$CLIENT_SECRET" \
  -d grant_type=client_credentials \
  -d "scope=services:read cases:read"
{ "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 900, "scope": "services:read cases:read" }

Points to remember:

  • A token lasts 15 minutes. There is no refresh token. Cache the token and get a new one shortly before it

    expires; do not fetch a token for every call.

  • scope is optional. Without it you get every scope the client holds. A scope you do not hold is left out silently,

    so read the scope field of the answer.

  • Failures use the OAuth error shape: unsupported_grant_type, invalid_client (wrong ID or secret, suspended,

    revoked or expired client), access_denied (address not allowed) and slow_down (20 failed attempts a minute from one address).

  • The token is a partner token. Every staff and citizen endpoint refuses it.

Calling the API

TOKEN=$(curl -s -X POST "$API/oauth/token" -u "EXAMPLE01:$CLIENT_SECRET" \
  -d grant_type=client_credentials | jq -r .access_token)

curl -s "$API/me" -H "Authorization: Bearer $TOKEN"
curl -s "$API/cases/EXAMPLE-2026-000123/status" -H "Authorization: Bearer $TOKEN"

Start every new set-up with GET /me: it shows the client, its environment label, the scopes the token carries, its MDA and register scope, and its limits.

A few behaviours to build for:

  • Conditional requests. GET answers carry an ETag. Send it back in If-None-Match and you get

    304 Not Modified while nothing changed. Answers depend on your scopes, so keep them out of shared caches.

  • Idempotency keys. POST /permits/{code}/uses needs an Idempotency-Key. Reuse the same key when you retry;

    the same key with a different body is refused with 422 IDEMPOTENCY_KEY_REUSED.

  • No identifiers in URLs. Calls that need an NRC or phone number take it in the JSON body.

Scopes

Each scope opens a family of endpoints. The common ones:

ScopeOpens
services:readThe catalogue
permits:verifyPermit and document checks by code or number
documents:validateBulk PDF seal checks
registers:readNational Data Register reads, within the register scope
cases:readApplication status, within the MDA scope
events:subscribeWebhooks and the event catalogue
audit:readThe audit trail of the MDA scope's applications
geo:readGeoJSON feeds
permits:consumeRecording the use of a certificate

Scopes are checked against what the client holds now. When the MDA removes a scope, calls needing it fail at once, even with a token issued before the change.

Errors

Every error is an RFC 7807 problem document with stable fields:

{ "status": 403, "code": "INSUFFICIENT_SCOPE", "detail": "This call needs the \"cases:read\" scope.",
  "requestId": "7f1c...", "instance": "/api/v1/cases/EXAMPLE-2026-000123/status", "requiredScope": "cases:read" }

Program against code, never against the wording. Log requestId and quote it to support. The codes you will meet most: TOKEN_REQUIRED, INVALID_TOKEN (get a new token), CLIENT_INACTIVE, CLIENT_EXPIRED, INSUFFICIENT_SCOPE, IP_NOT_ALLOWED, MDA_SCOPE_REQUIRED, NOT_FOUND, VALIDATION_FAILED, RATE_LIMITED, QUOTA_EXCEEDED.

Limits

Each client has its own per-minute limit (600 by default) and, optionally, a daily quota (reset at 00:00 UTC), counted across all API replicas. Answers carry RateLimit-Limit and RateLimit-Remaining. Over a limit you get 429 with Retry-After in seconds: wait that long. An IP allow-list, when set, applies to the token endpoint and to every call.

Rotating the secret

The MDA's administrator rotates the secret with an overlap of 0 to 14 days (7 by default). During the overlap both secrets work, and a token obtained with the old one carries a warning. Deploy the new secret everywhere, then watch your logs: when the warning stops appearing, every instance has switched. An overlap of 0 is for a leaked secret.

A small client

let cached = null;
async function token() {
  if (cached && cached.expiresAt - Date.now() > 60_000) return cached.value;
  const basic = Buffer.from(`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`).toString('base64');
  const res = await fetch(`${process.env.GSB_API}/oauth/token`, {
    method: 'POST',
    headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ grant_type: 'client_credentials' }),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`${body.error} (${body.requestId})`);
  if (body.warning) console.warn(body.warning);
  cached = { value: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 };
  return cached.value;
}

Exercise

Using a sandbox client: get a token with only services:read, call GET /me, then call GET /cases/{number}/status. Note the problem document you receive, and which field tells you what is missing.

Check your understanding

  • Which grant does a partner system use, and how long does its token last?
  • Who registers your client on the platform, and what is the hub's part in it?
  • Why should your code act on code rather than on detail?
  • What happens to an existing token when the MDA removes one of its scopes?
  • What does a warning field in a token answer tell you?
  • How should your client react to 429 RATE_LIMITED?

Sign in to record your progress.

Next: Webhooks and events