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
| Area | Endpoints (relative to /api/v1) | Scope |
|---|---|---|
| Your own client | GET /me | any valid token |
| Service catalogue | GET /services, GET /services/{serviceId} | services:read |
| Permits and documents | GET /permits/verify/{code}, GET /verify/{documentNumber}, POST /verify/documents | permits:verify, documents:validate |
| Certificate use (customs, ports, checkpoints) | GET and POST /permits/{code}/uses | permits:consume |
| National Data Register | GET /registers, /registers/{code}/lookup, /registers/{code}/entries, /registers/{code}/changes, /parties/{type}/links | registers:read |
| Application status | GET /cases/{caseNumber}/status | cases:read |
| Events | GET /events (the catalogue), GET /webhooks (your subscriptions) | events:subscribe |
| Audit trail | GET /audit/events | audit:read |
| Geodata | GET /geo/feeds, GET /geo/feeds/{key} | geo:read |
| Legacy consumers | POST /parties/associated-entities, POST /fines/search, POST /ussd/cases/lookup | parties: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.
- Request access on the hub. Sign in to the Pamodzi Developer Hub and submit an access request: the kind
(
api_sandboxorapi_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. - 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 (
sandboxorproduction). 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.
- the client ID (4 to 10 letters, digits,
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 oldone expires, and reuse a token for every call until then. Do not request a token per call.
scopeis optional. Leave it out to get every scope your client holds; name a subset to get only those. A scope youdo not hold is silently left out, so check the
scopefield of the answer.grant_typemust beclient_credentials; anything else is400 unsupported_grant_type.- A wrong ID or secret is
401 invalid_client. Twenty failed attempts in a minute from one address are answered429 slow_downwithRetry-After: 60. - A suspended, revoked or expired client gets
401 invalid_client; a call from an address outside the allow-listgets
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
| Scope | What it allows |
|---|---|
services:read | Read the service catalogue |
permits:verify | Verify permits and licences |
documents:validate | Check the electronic seal of PDF documents in bulk (files are not kept) |
registers:read | Read National Data Register entries in the client's register scope |
cases:read | Read the status of applications in the client's MDA scope |
events:subscribe | Receive webhooks for events in the client's MDA scope |
audit:read | Read the audit trail of applications in the client's MDA scope, from a sequence number on (no personal data) |
geo:read | Read the GeoJSON feeds the platform publishes (for example licensed areas; no personal data) |
permits:consume | Record the use of a certificate (quantity drawn, place, consignment) and read what remains, for certificates of ministries in the client's MDA scope |
parties:read | Read the organisations a person (by NRC) is an approved representative of |
fines:read | Verify fines and read a person's fine history from the fines register in the client's register scope |
ussd:gateway | Look 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:notify | The 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.
| Status | Code | Meaning and what to do |
|---|---|---|
| 400 | VALIDATION_FAILED | The request is not valid. Fix it; do not retry as is. |
| 401 | TOKEN_REQUIRED | No bearer token. |
| 401 | INVALID_TOKEN | Invalid or expired token. Get a new token and retry once. |
| 401 | CLIENT_INACTIVE, CLIENT_EXPIRED | The client is suspended, revoked or past its expiry. Contact the owning MDA. |
| 403 | INSUFFICIENT_SCOPE | The token lacks the scope named in requiredScope. |
| 403 | IP_NOT_ALLOWED | The calling address is not on the client's allow-list. |
| 403 | MDA_SCOPE_REQUIRED | The client has no MDA scope, so it may not read cases or the audit trail. |
| 404 | NOT_FOUND | Not found, or outside your scope (the two are not distinguished). |
| 422 | IDEMPOTENCY_KEY_REUSED | The same Idempotency-Key was sent with a different body. |
| 429 | RATE_LIMITED, QUOTA_EXCEEDED | Wait Retry-After seconds. |
| 500 | SERVER_ERROR | Something failed on the platform. Retry later with back-off; quote requestId. |
| 503 | AUTH_UNAVAILABLE | Client 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-LimitandRateLimit-Remaining. - Over the per-minute limit you get
429 RATE_LIMITEDwithRetry-Afterin 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):
- The administrator rotates the secret and passes you the new one (shown once).
- You deploy the new secret. During the overlap both secrets get tokens. A token obtained with the old one carries a
warningfield: "Signed in with the previous secret, which stops working when the rotation overlap ends." - When the overlap ends the old secret stops working. Watch for the
warningfield 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 ininfo.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
codelike its HTTP status. - When an old interface is retired, the platform marks it with
DeprecationandSunsetheaders before it goes (as itdoes today for the old register keys). Watch for those headers in your logs.
- A formal deprecation timetable for
/api/v1itself 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;requestIdis logged. 429and503are retried afterRetry-Afteror with back-off;4xxvalidation 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.