Konzepte
Was für jeden Endpunkt gilt. Lesen Sie das, bevor Sie eine Anbindung in Betrieb nehmen.
Fehler
Ein Fehler hat immer dieselbe Form, application/problem+json nach RFC 9457:
{
"type": "https://developers.ledenboek.nl/errors/insufficient_scope",
"title": "Unzureichende Rechte",
"status": 403,
"detail": "Diesem Schlüssel fehlt das Recht leden:schrijven. Ein Administrator kann einen Schlüssel mit diesem Recht erstellen.",
"instance": "/v2/members"
}typeist eine feste URL pro Fehlerart und ändert sich nicht. Bauen Sie Ihre Fehlerbehandlung darauf auf. Die URL ist zugleich eine Seite mit Erklärung.titleunddetailsind für Menschen und folgenAccept-Language. Vergleichen Sie sie nicht im Code.statusist derselbe HTTP-Status wie der der Antwort;instanceist der Pfad Ihres Aufrufs.- Bei
validation_failedsteht inerrorspro Feld, was falsch ist, mitfield,codeundmessage.
# -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 Fehlercodes
| Code | HTTP | Titel |
|---|---|---|
api_key_in_url |
400 | Schlüssel in der URL |
invalid_request |
400 | Ungültige Anfrage |
invalid_api_key |
401 | Ungültiger Schlüssel |
missing_api_key |
401 | Schlüssel fehlt |
subscription_required |
402 | Abonnement erforderlich |
insufficient_scope |
403 | Unzureichende Rechte |
module_disabled |
404 | Modul deaktiviert |
not_found |
404 | Nicht gefunden |
already_in_group |
409 | Bereits in der Gruppe |
already_registered |
409 | Bereits angemeldet |
event_full |
409 | Nicht genug Plätze |
event_requires_payment |
409 | Zahlung erforderlich |
group_follows_subscription |
409 | Gruppe folgt den Abonnements |
idempotency_in_progress |
409 | Anfrage wird noch verarbeitet |
invoice_not_sendable |
409 | Rechnung kann nicht versendet werden |
membership_already_ended |
409 | Mitgliedschaft bereits beendet |
recipient_without_email |
409 | Keine E-Mail-Adresse |
registration_closed |
409 | Anmeldung geschlossen |
idempotency_key_reused |
422 | Idempotency-Key bereits verwendet |
validation_failed |
422 | Daten ungültig |
rate_limited |
429 | Zu viele Aufrufe |
internal_error |
500 | Interner Fehler |
Paginierung
Eine Liste liefert ein Objekt mit data, has_more und next_cursor. Sie blättern mit zwei Parametern:
limit: wie viele Datensätze pro Seite, von 1 bis 100. Standard 25.starting_after: dernext_cursorder vorigen Seite.
Datensätze kommen in der Reihenfolge der id. Kommen während des Blätterns Datensätze hinzu, überspringen Sie nichts und sehen nichts doppelt; mit Seitenzahlen geht das nicht. Auf der letzten Seite ist has_more falsch und next_cursor null.
Ein Beispiel steht im Schnellstart.
Synchronisieren mit updated_since
Führen Sie eine eigene Kopie, holen Sie nicht jedes Mal alles ab. Mit updated_since bekommen Sie nur Datensätze, die zu oder nach diesem Zeitpunkt geändert wurden. Der Zeitpunkt ist ISO 8601, zum Beispiel 2026-10-01T00:00:00Z; ein anderes Format liefert invalid_request. Sie können es mit der Paginierung kombinieren.
Halten Sie fest, wann Ihre Synchronisierung beginnt, und verwenden Sie diesen Zeitpunkt beim nächsten Mal als updated_since. So verpassen Sie nichts, was sich während des Abholens geändert hat.
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_atEin vollständig gelöschter Datensatz kommt mit updated_since nicht mehr zurück. Gleichen Sie deshalb ab und zu die vollständige Liste mit Ihrer Kopie ab.
Idempotenz
Ein POST akzeptiert den Header Idempotency-Key: einen eindeutigen Wert von höchstens 255 Zeichen, etwa eine UUID. Senden Sie dieselbe Anfrage mit demselben Schlüssel erneut, wird nichts doppelt angelegt.
- Dieselbe Anfrage mit demselben Schlüssel liefert die ursprüngliche Antwort, mit dem Header
Idempotent-Replayed: true. - Derselbe Schlüssel mit einer anderen Anfrage liefert
idempotency_key_reused. - Läuft die erste Anfrage noch, bekommen Sie
idempotency_in_progress. Versuchen Sie es nach ein paar Sekunden erneut. - Eine Antwort mit 5xx-Status speichern wir nicht; nach einem Fehler auf unserer Seite können Sie es also gefahrlos mit demselben Schlüssel erneut versuchen.
Schlüssel werden 24 Stunden aufbewahrt. Speichern Sie den Schlüssel bei Ihrem eigenen Datensatz, bevor Sie die Anfrage senden; dann verwendet ein neuer Versuch automatisch denselben.
Limits
Pro Schlüssel sind 600 Aufrufe pro Minute erlaubt, mit einem Testschlüssel 120. Das Fenster ist eine feste Minute. Jede Antwort sagt, wie viel Spielraum noch bleibt:
| Header | Bedeutung |
|---|---|
RateLimit-Limit | Aufrufe pro Minute für diesen Schlüssel. |
RateLimit-Remaining | Was in dieser Minute noch übrig ist. |
RateLimit-Reset | Sekunden bis zur nächsten Minute. |
Retry-After | Nur bei 429: wie viele Sekunden Sie warten müssen. |
Über dem Limit bekommen Sie rate_limited mit Status 429. Warten Sie dann die Sekunden aus Retry-After ab.
# 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 responseVersionen
Die Version steht im Pfad: /v2. Dafür gilt:
/v2bleibt bestehen. Es kommen Felder, Endpunkte und Fehlercodes hinzu; lassen Sie Ihren Code unbekannte Felder ignorieren.- Ein Feld verschwindet nicht und ändert seine Bedeutung nicht ohne neue Version.
- Kommt eine neue Version, funktioniert die alte noch mindestens 12 Monate. In diesem Zeitraum senden die alten Endpunkte die Header
DeprecationundSunsetmit dem Datum, an dem sie abgeschaltet werden. - Jede Änderung steht im Changelog, auch als RSS.