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:
- 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.
- 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.
scopeis optional. Without it you get every scope the client holds. A scope you do not hold is left out silently,so read the
scopefield 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) andslow_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.
GETanswers carry anETag. Send it back inIf-None-Matchand you get304 Not Modifiedwhile nothing changed. Answers depend on your scopes, so keep them out of shared caches. - Idempotency keys.
POST /permits/{code}/usesneeds anIdempotency-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:
| Scope | Opens |
|---|---|
services:read | The catalogue |
permits:verify | Permit and document checks by code or number |
documents:validate | Bulk PDF seal checks |
registers:read | National Data Register reads, within the register scope |
cases:read | Application status, within the MDA scope |
events:subscribe | Webhooks and the event catalogue |
audit:read | The audit trail of the MDA scope's applications |
geo:read | GeoJSON feeds |
permits:consume | Recording 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
coderather than ondetail? - What happens to an existing token when the MDA removes one of its scopes?
- What does a
warningfield in a token answer tell you? - How should your client react to
429 RATE_LIMITED?
Sign in to record your progress.