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
On this page

Partner API

The Partner API is how another system talks to the Pamodzi e-Services platform: a ministry's own system, a bank, a border post, a register harvester or a vendor's application. It is a JSON API at /api/v1 on the platform's API host. It answers with identifiers and statuses, never with an applicant's personal data, and every call is made by a registered client with its own credentials, scopes and limits.

This guide covers getting a client, getting a token, calling endpoints, handling errors and limits, rotating the secret and how the API is versioned. The full list of operations and schemas is in the API reference: Partner API v1. The same document is served by every platform instance at /api/openapi.json (OpenAPI 3.1).

What the API offers

AreaEndpoints (relative to /api/v1)Scope
Your own clientGET /meany valid token
Service catalogueGET /services, GET /services/{serviceId}services:read
Permits and documentsGET /permits/verify/{code}, GET /verify/{documentNumber}, POST /verify/documentspermits:verify, documents:validate
Certificate use (customs, ports, checkpoints)GET and POST /permits/{code}/usespermits:consume
National Data RegisterGET /registers, /registers/{code}/lookup, /registers/{code}/entries, /registers/{code}/changes, /parties/{type}/linksregisters:read
Application statusGET /cases/{caseNumber}/statuscases:read
EventsGET /events (the catalogue), GET /webhooks (your subscriptions)events:subscribe
Audit trailGET /audit/eventsaudit:read
GeodataGET /geo/feeds, GET /geo/feeds/{key}geo:read
Legacy consumersPOST /parties/associated-entities, POST /fines/search, POST /ussd/cases/lookupparties:read, fines:read, ussd:gateway

Anything else under /api/v1 answers 404 NOT_FOUND as a problem document.

Step 1: get a client

The platform does not hand out credentials to anyone who asks. Access goes through two steps, and the two are kept apart on purpose.

  1. Request access on the hub. Sign in to the Pamodzi Developer Hub and submit an access request: the kind

    (api_sandbox or api_production), the system or register you need, your organisation, the purpose, the legal basis, the data items, the expected volume, how long you keep the data and your safeguards. A reviewer at the MDA that owns the data checks it and recommends a decision; a data steward decides it. The same person never recommends and decides, and nobody decides their own request. The hub never issues credentials.

  2. The owning MDA registers your client on the platform. Once your request is approved, an administrator of the

    owning MDA registers your system in the back office (Governance, API clients). They set:

    • the client ID (4 to 10 letters, digits, - or _; the platform stores it in capitals);
    • the scopes your system may hold;
    • the MDA scope: the ministries whose applications, events and audit trail you may see;
    • the register scope: the National Data Register registers you may read;
    • an IP allow-list (exact addresses or IPv4 CIDR blocks; up to 50 entries);
    • a rate limit per minute (default 600) and an optional daily quota;
    • an optional expiry date, a contact and the environment label (sandbox or production). The secret is shown once, when the client is created. Only a hash of it is stored, so nobody can show it to you again. The hub request records which client was issued for it.

Every change to a client (scopes, limits, allow-list, suspension, rotation) is audited and appears in the change history with the reason the administrator gave.

Keep the secret in your secret store, never in source code, a ticket or a chat. If it leaks, ask the MDA to rotate it (see below) or to suspend the client at once.

Step 2: get a token

The token endpoint implements the OAuth 2.0 client credentials grant (RFC 6749 section 4.4). Send your client ID and secret with HTTP Basic (preferred) or as form fields.

# API is the address the owning MDA gives you; the secret comes from your secret store
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": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "services:read cases:read"
}
  • Tokens last 15 minutes (expires_in: 900). There is no refresh token: ask for a new one shortly before the old

    one expires, and reuse a token for every call until then. Do not request a token per call.

  • scope is optional. Leave it out to get every scope your client holds; name a subset to get only those. A scope you

    do not hold is silently left out, so check the scope field of the answer.

  • grant_type must be client_credentials; anything else is 400 unsupported_grant_type.
  • A wrong ID or secret is 401 invalid_client. Twenty failed attempts in a minute from one address are answered

    429 slow_down with Retry-After: 60.

  • A suspended, revoked or expired client gets 401 invalid_client; a call from an address outside the allow-list

    gets 403 access_denied.

  • Token errors use the OAuth error shape: { "error", "error_description", "requestId" }.

The token is a partner token only. The platform refuses it on every staff or citizen endpoint, so a leaked partner token cannot open a back-office session.

Step 3: call the API

Send the token as a bearer token:

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/services?limit=50&offset=0" -H "Authorization: Bearer $TOKEN"
curl -s "$API/cases/EXAMPLE-2026-000123/status" -H "Authorization: Bearer $TOKEN"

GET /me is the quickest check of a new set-up: it returns your client ID, environment, the scopes this token carries, your MDA and register scope, your rate limit and your quota.

A small Node.js client that keeps one token until shortly before it expires:

// Node 18+ (global fetch). CLIENT_ID and CLIENT_SECRET come from your secret store.
const API = process.env.GSB_API; // e.g. https://platform.example/api/v1
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(`${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(`token: ${body.error} (${body.requestId})`);
  if (body.warning) console.warn(body.warning); // you signed in with the previous secret
  cached = { value: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 };
  return cached.value;
}

async function call(path) {
  const res = await fetch(`${API}${path}`, { headers: { Authorization: `Bearer ${await token()}` } });
  if (res.status === 401) cached = null; // expired or revoked: get a new token next time
  if (!res.ok) {
    const p = await res.json();
    throw Object.assign(new Error(`${p.code}: ${p.detail}`), { status: res.status, code: p.code, requestId: p.requestId });
  }
  return res.json();
}

Caching and conditional requests

Every GET answer carries an ETag and Cache-Control: private, no-cache. When you poll the catalogue, a service or a register, send the ETag back in If-None-Match: while nothing changed the platform answers 304 Not Modified with no body. Answers depend on your scopes, so never put them in a shared cache. Answers about one request (a permit check, a case status) are no-store.

Idempotency

POST /permits/{code}/uses requires an Idempotency-Key header (8 to 80 letters, digits or . _ : -; a UUID is ideal). Keep the same key when you retry: the same key with the same body is recorded once and a repeat answers 200 with replayed: true; the same key with a different body is refused with 422 IDEMPOTENCY_KEY_REUSED.

Personal identifiers stay out of URLs

Where a call needs an NRC or a phone number (/parties/associated-entities, /fines/search, /ussd/cases/lookup) it travels in the JSON body, never in the path or query string. Do the same in your own logs: never log a request body that carries an identifier.

Scopes

ScopeWhat it allows
services:readRead the service catalogue
permits:verifyVerify permits and licences
documents:validateCheck the electronic seal of PDF documents in bulk (files are not kept)
registers:readRead National Data Register entries in the client's register scope
cases:readRead the status of applications in the client's MDA scope
events:subscribeReceive webhooks for events in the client's MDA scope
audit:readRead the audit trail of applications in the client's MDA scope, from a sequence number on (no personal data)
geo:readRead the GeoJSON feeds the platform publishes (for example licensed areas; no personal data)
permits:consumeRecord the use of a certificate (quantity drawn, place, consignment) and read what remains, for certificates of ministries in the client's MDA scope
parties:readRead the organisations a person (by NRC) is an approved representative of
fines:readVerify fines and read a person's fine history from the fines register in the client's register scope
ussd:gatewayLook up the status and invoice of an application for the phone number of its applicant, in the client's MDA scope
legacy:documents, legacy:data-exchange, legacy:payment-callback, legacy:notifyThe legacy ZamPoint contracts kept for older ministry systems (HTTP Basic, only on those routes)

Scopes are checked on every call against what the client holds now: when the MDA removes a scope, it stops working at once, not when your current token expires. Ask only for the scopes you need; a reviewer will ask why you need each one.

Scope is not the only limit. A client with cases:read sees only applications of ministries in its MDA scope, and a client without an MDA scope is refused with 403 MDA_SCOPE_REQUIRED. Something outside your scope answers exactly like something that does not exist (404 NOT_FOUND), so the API never confirms that a record exists.

Errors: problem documents

Every error on /api/v1 (apart from the token endpoint) is an RFC 7807 problem document with Content-Type: application/problem+json, plus the platform's own fields:

{
  "type": "https://platform.example/problems/insufficient-scope",
  "title": "Forbidden",
  "status": 403,
  "code": "INSUFFICIENT_SCOPE",
  "detail": "This call needs the \"cases:read\" scope.",
  "message": "This call needs the \"cases:read\" scope.",
  "requestId": "7f1c2d9e-...",
  "instance": "/api/v1/cases/EXAMPLE-2026-000123/status",
  "requiredScope": "cases:read"
}

Act on code, never on the text. The wording may change; the codes are stable. Quote requestId when you contact support. instance names the path of the request, never its query string.

StatusCodeMeaning and what to do
400VALIDATION_FAILEDThe request is not valid. Fix it; do not retry as is.
401TOKEN_REQUIREDNo bearer token.
401INVALID_TOKENInvalid or expired token. Get a new token and retry once.
401CLIENT_INACTIVE, CLIENT_EXPIREDThe client is suspended, revoked or past its expiry. Contact the owning MDA.
403INSUFFICIENT_SCOPEThe token lacks the scope named in requiredScope.
403IP_NOT_ALLOWEDThe calling address is not on the client's allow-list.
403MDA_SCOPE_REQUIREDThe client has no MDA scope, so it may not read cases or the audit trail.
404NOT_FOUNDNot found, or outside your scope (the two are not distinguished).
422IDEMPOTENCY_KEY_REUSEDThe same Idempotency-Key was sent with a different body.
429RATE_LIMITED, QUOTA_EXCEEDEDWait Retry-After seconds.
500SERVER_ERRORSomething failed on the platform. Retry later with back-off; quote requestId.
503AUTH_UNAVAILABLEClient authentication is briefly unavailable. Retry with back-off.

Rate limits and quotas

Each client has its own per-minute limit (default 600 calls) and, optionally, a daily quota. Both are counted across every API replica, so spreading calls over servers does not raise them.

  • Every authenticated answer carries RateLimit-Limit and RateLimit-Remaining.
  • Over the per-minute limit you get 429 RATE_LIMITED with Retry-After in seconds.
  • Over the daily quota you get 429 QUOTA_EXCEEDED; the quota resets at 00:00 UTC.
  • Honour Retry-After. Retrying in a tight loop only keeps you limited.

Public endpoints that people use without a client (such as the portal's verification pages) have their own limits per address. They are not meant for system-to-system traffic: use the Partner API.

IP allow-lists

When the client has an allow-list, both the token endpoint and every API call check the caller's address. Entries are exact IPv4 or IPv6 addresses or IPv4 CIDR blocks such as 198.51.100.0/24; an empty list allows any address. Ask for an allow-list for any production client that calls from fixed addresses, and tell the MDA before your outbound addresses change.

Rotating the secret

Rotate the secret on a schedule and whenever someone who knew it leaves. Rotation is done by the owning MDA's administrator and keeps the old secret working for an overlap of 0 to 14 days (default 7):

  1. The administrator rotates the secret and passes you the new one (shown once).
  2. You deploy the new secret. During the overlap both secrets get tokens. A token obtained with the old one carries a

    warning field: "Signed in with the previous secret, which stops working when the rotation overlap ends."

  3. When the overlap ends the old secret stops working. Watch for the warning field in your logs: if it still appears,

    some instance has not been updated.

An overlap of 0 stops the old secret at once; use it when the secret has leaked.

Versioning

  • The major version is in the path: /api/v1. The OpenAPI document carries the version of the contract in

    info.version (1.0.0 today).

  • The platform's integration tests check that every route is documented and every documented operation exists, so the

    reference cannot drift from the server.

  • Within v1, changes are additive: new endpoints, new optional fields, new event types, new error codes. Write clients

    that ignore fields they do not know and that treat an unknown code like its HTTP status.

  • When an old interface is retired, the platform marks it with Deprecation and Sunset headers before it goes (as it

    does today for the old register keys). Watch for those headers in your logs.

  • A formal deprecation timetable for /api/v1 itself has not been published yet.

Checklist before you go live

  • The client was registered for production by the owning MDA after an approved hub access request.
  • The secret is in a secret store; it is not in source code, logs or tickets.
  • Tokens are cached and renewed before expiry, not fetched per call.
  • Errors are handled by code; requestId is logged.
  • 429 and 503 are retried after Retry-After or with back-off; 4xx validation errors are not retried.
  • The allow-list names your production addresses.
  • You know how to get the secret rotated, and you have tested a rotation in the sandbox.

Related guides: Webhooks and events, Document verification. The course Integration developer walks through all of this with exercises.