Concepten
Wat voor elk endpoint geldt. Lees dit voordat je een koppeling in productie neemt.
Fouten
Een fout komt altijd in dezelfde vorm, als application/problem+json volgens RFC 9457:
{
"type": "https://developers.ledenboek.nl/errors/insufficient_scope",
"title": "Onvoldoende rechten",
"status": 403,
"detail": "Deze sleutel heeft het recht leden:schrijven niet. Een beheerder kan een sleutel met dat recht aanmaken.",
"instance": "/v2/members"
}typeis een vaste URL per soort fout en verandert niet. Bouw je foutafhandeling hierop. De URL is ook een pagina met uitleg.titleendetailzijn voor mensen en volgenAccept-Language. Vergelijk er niet op in je code.statusis dezelfde HTTP-status als die van het antwoord;instanceis het pad van je aanroep.- Bij
validation_failedstaat inerrorsper veld wat er mis is, metfield,codeenmessage.
# -i shows the status line and headers; errors come as application/problem+json
curl -i https://api.ledenboek.nl/v2/members \
-H "Authorization: Bearer $LEDENBOEK_API_KEY"const response = await fetch("https://api.ledenboek.nl/v2/members", { headers });
if (!response.ok) {
const problem = await response.json(); // application/problem+json
// problem.type is stable; branch on it, not on the title
if (problem.type.endsWith("/insufficient_scope")) {
// ask an administrator for a key with the right scope
}
throw new Error(`${problem.status} ${problem.title}: ${problem.detail ?? ""}`);
}<?php
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
$problem = json_decode($body, true); // application/problem+json
$code = basename($problem["type"]); // for example "insufficient_scope"
throw new RuntimeException("{$problem["status"]} {$code}: " . ($problem["detail"] ?? $problem["title"]));
}response = session.get("https://api.ledenboek.nl/v2/members")
if not response.ok:
problem = response.json() # application/problem+json
code = problem["type"].rsplit("/", 1)[-1] # for example "insufficient_scope"
for error in problem.get("errors", []): # only for validation_failed
print(error["field"], error["message"])
raise RuntimeError(f"{problem['status']} {code}: {problem.get('detail', problem['title'])}")Alle foutcodes
| Code | HTTP | Titel |
|---|---|---|
api_key_in_url |
400 | Sleutel in de URL |
invalid_request |
400 | Ongeldig verzoek |
invalid_api_key |
401 | Ongeldige sleutel |
missing_api_key |
401 | Sleutel ontbreekt |
subscription_required |
402 | Abonnement nodig |
insufficient_scope |
403 | Onvoldoende rechten |
module_disabled |
404 | Onderdeel staat uit |
not_found |
404 | Niet gevonden |
already_in_group |
409 | Al in de groep |
already_registered |
409 | Al ingeschreven |
event_full |
409 | Niet genoeg plekken |
event_requires_payment |
409 | Betaling nodig |
group_follows_subscription |
409 | Groep volgt de abonnementen |
idempotency_in_progress |
409 | Verzoek wordt nog verwerkt |
invoice_not_sendable |
409 | Factuur kan niet verstuurd worden |
membership_already_ended |
409 | Lidmaatschap al beëindigd |
recipient_without_email |
409 | Geen e-mailadres |
registration_closed |
409 | Inschrijving gesloten |
idempotency_key_reused |
422 | Idempotency-Key al gebruikt |
validation_failed |
422 | Gegevens kloppen niet |
rate_limited |
429 | Te veel aanroepen |
internal_error |
500 | Interne fout |
Paginering
Een lijst geeft een object met data, has_more en next_cursor. Je bladert met twee parameters:
limit: hoeveel records per pagina, van 1 tot 100. Standaard 25.starting_after: denext_cursorvan de vorige pagina.
Records komen in volgorde van id. Komen er tijdens het bladeren records bij, dan sla je niets over en zie je niets dubbel; met paginanummers lukt dat niet. Op de laatste pagina is has_more onwaar en next_cursor null.
Een voorbeeld staat in de quickstart.
Synchroniseren met updated_since
Houd je een eigen kopie bij, haal dan niet elke keer alles op. Met updated_since krijg je alleen records die op of na dat tijdstip zijn gewijzigd. Het tijdstip is ISO 8601, bijvoorbeeld 2026-10-01T00:00:00Z; een ander formaat geeft invalid_request. Je kunt het combineren met paginering.
Leg het moment vast waarop je synchronisatie begint en gebruik dat de volgende keer als updated_since. Zo mis je niets wat tijdens het ophalen veranderde.
curl "https://api.ledenboek.nl/v2/members?updated_since=2026-10-01T00:00:00Z&limit=100" \
-H "Authorization: Bearer $LEDENBOEK_API_KEY"// Remember when this run started; that is the next run's updated_since.
const startedAt = new Date().toISOString();
let cursor = null;
do {
const url = new URL("https://api.ledenboek.nl/v2/members");
url.searchParams.set("updated_since", lastSync);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("starting_after", cursor);
const page = await (await fetch(url, { headers })).json();
for (const member of page.data) await upsert(member);
cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
lastSync = startedAt;<?php
// Remember when this run started; that is the next run's updated_since.
$startedAt = gmdate("Y-m-d\TH:i:s\Z");
$cursor = null;
do {
$query = http_build_query(array_filter([
"updated_since" => $lastSync,
"limit" => 100,
"starting_after" => $cursor,
]));
$page = getJson("https://api.ledenboek.nl/v2/members?" . $query);
foreach ($page["data"] as $member) {
upsert($member);
}
$cursor = $page["has_more"] ? $page["next_cursor"] : null;
} while ($cursor !== null);
$lastSync = $startedAt;from datetime import datetime, timezone
# Remember when this run started; that is the next run's updated_since.
started_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
cursor = None
while True:
params = {"updated_since": last_sync, "limit": 100}
if cursor:
params["starting_after"] = cursor
page = session.get("https://api.ledenboek.nl/v2/members", params=params).json()
for member in page["data"]:
upsert(member)
if not page["has_more"]:
break
cursor = page["next_cursor"]
last_sync = started_atEen record dat helemaal is verwijderd, komt met updated_since niet meer terug. Vergelijk daarom af en toe de volledige lijst met je eigen kopie.
Idempotentie
Een POST accepteert de header Idempotency-Key: een unieke waarde van hooguit 255 tekens, bijvoorbeeld een UUID. Stuur je hetzelfde verzoek opnieuw met dezelfde sleutel, dan wordt er niets dubbel aangemaakt.
- Hetzelfde verzoek met dezelfde sleutel geeft het oorspronkelijke antwoord terug, met de header
Idempotent-Replayed: true. - Dezelfde sleutel met een ander verzoek geeft
idempotency_key_reused. - Is het eerste verzoek nog bezig, dan krijg je
idempotency_in_progress. Probeer het na een paar seconden opnieuw. - Een antwoord met een 5xx-status bewaren we niet, dus na een fout aan onze kant kun je veilig opnieuw proberen met dezelfde sleutel.
Sleutels worden 24 uur bewaard. Bewaar de sleutel bij je eigen record voordat je het verzoek stuurt, dan gebruikt een nieuwe poging vanzelf dezelfde.
Limieten
Per sleutel mag je 600 aanroepen per minuut doen, met een testsleutel 120. Het venster is een vaste minuut. Elk antwoord vertelt hoeveel ruimte er nog is:
| Header | Betekenis |
|---|---|
RateLimit-Limit | Aanroepen per minuut voor deze sleutel. |
RateLimit-Remaining | Wat er in deze minuut nog over is. |
RateLimit-Reset | Seconden tot de volgende minuut begint. |
Retry-After | Alleen bij 429: hoeveel seconden je moet wachten. |
Boven de limiet krijg je rate_limited met status 429. Wacht dan het aantal seconden uit Retry-After.
# The headers show how much room is left in this minute
curl -s -D - -o /dev/null https://api.ledenboek.nl/v2/organization \
-H "Authorization: Bearer $LEDENBOEK_API_KEY" | grep -i ratelimitasync function request(url, options, attempt = 0) {
const response = await fetch(url, options);
if (response.status === 429 && attempt < 3) {
const seconds = Number(response.headers.get("Retry-After") ?? 1);
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
return request(url, options, attempt + 1);
}
return response;
}<?php
function request(string $url, array $headers, int $attempt = 0): array {
$ch = curl_init($url);
$retryAfter = 1;
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) {
if (stripos($line, "Retry-After:") === 0) {
$retryAfter = (int) trim(substr($line, 12));
}
return strlen($line);
},
]);
$body = curl_exec($ch);
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) === 429 && $attempt < 3) {
sleep($retryAfter);
return request($url, $headers, $attempt + 1);
}
return json_decode($body, true);
}import time
def request(url, attempt=0, **kwargs):
response = session.get(url, **kwargs)
if response.status_code == 429 and attempt < 3:
time.sleep(int(response.headers.get("Retry-After", "1")))
return request(url, attempt + 1, **kwargs)
return responseVersies
De versie staat in het pad: /v2. Daarvoor geldt:
/v2blijft bestaan. Er komen velden, endpoints en foutcodes bij, dus laat je code onbekende velden negeren.- Een veld verdwijnt niet en verandert niet van betekenis zonder nieuwe versie.
- Komt er een nieuwe versie, dan blijft de oude minstens 12 maanden werken. In die periode sturen we op de oude endpoints de headers
DeprecationenSunsetmee, met de datum waarop ze stoppen. - Elke wijziging staat in de changelog, ook als RSS.