API
Read out your passports and your supply chain register, add suppliers, and have us tell you as soon as something changes. The API is part of Plus. You create the key in My Ketenpas under API; it is shown once.
Create a key in My KetenpasAuthentication
Every request carries the key as a bearer token. Responses are json, including errors.
curl https://ketenpas.nl/api/v1/paspoorten \
-H "Authorization: Bearer kp_uw_sleutel"Limits
120 requests per minute per organisation, across all routes together. Invitations count separately on top of that: 25 per hour, the same bucket the screen uses.
Entities
A key belongs to one organisation. If you work with entities, you create a key per entity; the parent's API does not show a subsidiary's data.
Routes
GET /api/v1/paspoorten
All passports of your organisation, with score, validity and the shareable link.
POST /api/v1/paspoorten/{id}/intrekken
Withdraws a passport. The shared link stops working immediately, including for anyone who already had it.
GET /api/v1/keten
Your supply chain register: every supplier with status, score and validity.
POST /api/v1/keten
Adds a supplier. With "uitnodigen": true the invitation goes out straight away.
POST /api/v1/keten/{id}/uitnodiging
Sends the invitation (again) to a supplier already in the register.
DELETE /api/v1/keten/{id}
Removes a supplier from the register. The invitation link stops working.
A passport
| Field | Type | Means |
|---|---|---|
| id | uuid | The id you pass when withdrawing. |
| label | string | null | The name you gave the passport. |
| status | "geldig" | "verlopen" | "ingetrokken" | The link only works while this is geldig. |
| score | number | 0 to 100, over the full questionnaire. |
| niveau | string | The wording that belongs to the score. |
| beoordeeldOp | string | ISO 8601, the date of the underlying scan. |
| geldigTot | string | ISO 8601. |
| ingetrokkenOp | string | null | ISO 8601, empty while the passport runs. |
| url | string | null | The shareable link; empty once the passport is no longer valid. |
A supplier
| Field | Type | Means |
|---|---|---|
| id | uuid | The id for the routes below. |
| naam | string | |
| string | Where the invitation is sent. | |
| contactpersoon | string | null | |
| status | "uitgenodigd" | "gekoppeld" | "vervallen" | Uitgenodigd is also the state of someone who has not been mailed yet. |
| score | number | null | Filled as soon as a valid passport is linked. |
| niveau | string | null | |
| beoordeeldOp | string | null | ISO 8601. |
| geldigTot | string | null | ISO 8601. |
| notitie | string | null | Your own note about this supplier. |
Adding a supplier
naam and email are required, contactpersoon and notitie are optional. If the address is already in your register, no second row is added; you get a 409 with the id of the existing one, so a nightly sync does not build up duplicates.
curl -X POST https://ketenpas.nl/api/v1/keten \
-H "Authorization: Bearer kp_uw_sleutel" \
-H "Content-Type: application/json" \
-d '{"naam":"Van Dijk Techniek","email":"security@vandijk.nl","uitnodigen":true}'Errors
Every error is json with one field: fout, containing a sentence you can log. The status code is what you branch on.
| Code | Means |
|---|---|
| 400 | The body is not json, or a required field is missing. |
| 401 | Missing or unknown key. |
| 403 | The key is valid but the plan is not, or your chain limit is reached. |
| 404 | The id does not exist, or belongs to another organisation. |
| 409 | The action no longer applies: already withdrawn, or the address is already in your register. |
| 429 | Too many requests. See the limits above. |
| 502 | We could not send the invitation. The supplier was still added. |
Webhooks
Instead of fetching the register every minute, you can give us one https address to call. You set that under API in My Ketenpas, where you also find the secret and the outcome of the last attempt.
| Event | When |
|---|---|
| leverancier.gekoppeld | A supplier has shared their passport with you. |
| leverancier.vervallen | A supplier's linked passport has expired or been withdrawn. Sent once, in the nightly run. |
| test | The test message from the button in My Ketenpas. |
POST https://uw-systeem.nl/ketenpas
X-Ketenpas-Gebeurtenis: leverancier.gekoppeld
X-Ketenpas-Handtekening: t=1785926400,v1=9f2c...
{
"gebeurtenis": "leverancier.gekoppeld",
"verstuurdOp": "2026-08-05T09:20:00.000Z",
"gegevens": {
"leverancier": {
"id": "8f1d...",
"naam": "Van Dijk Techniek",
"email": "security@vandijk.nl"
}
}
}Verifying the signature
Every message carries the X-Ketenpas-Handtekening header with a timestamp and an hmac-sha256 over timestamp, dot, and the literal body. Recompute it with your secret and compare in constant time. Reject a message whose timestamp is more than five minutes old.
const [t, v1] = kop.split(",").map((deel) => deel.split("=")[1]);
const verwacht = crypto
.createHmac("sha256", geheim)
.update(`${t}.${ruweBody}`)
.digest("hex");
const klopt =
verwacht.length === v1.length &&
crypto.timingSafeEqual(Buffer.from(verwacht), Buffer.from(v1)) &&
Math.abs(Date.now() / 1000 - Number(t)) < 300;What we promise and what we do not
We try three times: immediately, after two seconds and after eight seconds, waiting five seconds per attempt. Answer with 2xx and we are done; with 4xx we stop straight away, because that will not be better in eight seconds. If your side is down for longer, that message is gone: we keep no queue. Use GET /api/v1/keten as the floor under your records and the webhook to hear about it sooner.
Versions
The path carries the version. Within v1 fields are only added, never removed; a field that disappears or changes meaning becomes v2. So write your side to ignore fields it does not know.