Concepts
What applies to every endpoint. Read this before you put an integration into production.
Errors
An error always has the same shape, application/problem+json as described in RFC 9457:
{
"type": "https://developers.mitgliederbuch.de/errors/insufficient_scope",
"title": "Insufficient permissions",
"status": 403,
"detail": "This key lacks the permission leden:schrijven. An administrator can create a key with that permission.",
"instance": "/v2/members"
}typeis a fixed URL per kind of error and does not change. Build your error handling on it. The URL is also a page with an explanation.titleanddetailare for humans and followAccept-Language. Do not compare them in your code.statusis the same HTTP status as the response;instanceis the path of your request.- For
validation_failed,errorslists what is wrong per field, withfield,codeandmessage.
# -i shows the status line and headers; errors come as application/problem+json
curl -i https://api.mitgliederbuch.de/v2/members \
-H "Authorization: Bearer $LEDENBOEK_API_KEY"const response = await fetch("https://api.mitgliederbuch.de/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.mitgliederbuch.de/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'])}")All error codes
| Code | HTTP | Title |
|---|---|---|
api_key_in_url |
400 | API key in URL |
invalid_request |
400 | Invalid request |
invalid_api_key |
401 | Invalid API key |
missing_api_key |
401 | Missing API key |
subscription_required |
402 | Subscription required |
insufficient_scope |
403 | Insufficient permissions |
module_disabled |
404 | Module disabled |
not_found |
404 | Not found |
already_in_group |
409 | Already in group |
already_registered |
409 | Already registered |
event_full |
409 | Not enough spots |
event_requires_payment |
409 | Payment required |
group_follows_subscription |
409 | Group follows subscriptions |
idempotency_in_progress |
409 | Request still in progress |
invoice_not_sendable |
409 | Invoice cannot be sent |
membership_already_ended |
409 | Membership already ended |
recipient_without_email |
409 | No email address |
registration_closed |
409 | Registration closed |
idempotency_key_reused |
422 | Idempotency-Key already used |
validation_failed |
422 | Validation failed |
rate_limited |
429 | Too many requests |
internal_error |
500 | Internal error |
Pagination
A list returns an object with data, has_more and next_cursor. You page with two parameters:
limit: how many records per page, from 1 to 100. Default 25.starting_after: thenext_cursorof the previous page.
Records come in order of id. If records are added while you page, you skip nothing and see nothing twice, which page numbers cannot guarantee. On the last page has_more is false and next_cursor is null.
There is an example in the quickstart.
Syncing with updated_since
If you keep your own copy, do not fetch everything each time. With updated_since you only get records changed at or after that moment. The timestamp is ISO 8601, for example 2026-10-01T00:00:00Z; any other format returns invalid_request. You can combine it with pagination.
Record the moment your sync starts and use that as updated_since next time. That way you miss nothing that changed while you were fetching.
curl "https://api.mitgliederbuch.de/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.mitgliederbuch.de/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.mitgliederbuch.de/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.mitgliederbuch.de/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_atA record that has been deleted entirely does not come back with updated_since. Compare the full list with your copy now and then.
Idempotency
A POST accepts the Idempotency-Key header: a unique value of at most 255 characters, such as a UUID. If you send the same request again with the same key, nothing is created twice.
- The same request with the same key returns the original response, with the header
Idempotent-Replayed: true. - The same key with a different request returns
idempotency_key_reused. - If the first request is still running, you get
idempotency_in_progress. Try again after a few seconds. - A response with a 5xx status is not stored, so after an error on our side you can safely retry with the same key.
Keys are kept for 24 hours. Store the key with your own record before you send the request, so a retry uses the same one automatically.
Rate limits
Each key may make 600 requests per minute, a test key 120. The window is a fixed minute. Every response tells you how much room is left:
| Header | Meaning |
|---|---|
RateLimit-Limit | Requests per minute for this key. |
RateLimit-Remaining | What is left in this minute. |
RateLimit-Reset | Seconds until the next minute starts. |
Retry-After | Only with 429: how many seconds to wait. |
Above the limit you get rate_limited with status 429. Wait the number of seconds in Retry-After.
# The headers show how much room is left in this minute
curl -s -D - -o /dev/null https://api.mitgliederbuch.de/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 responseVersioning
The version is in the path: /v2. This applies:
/v2stays. Fields, endpoints and error codes will be added, so make your code ignore unknown fields.- A field is not removed and does not change meaning without a new version.
- When a new version arrives, the old one keeps working for at least 12 months. During that period the old endpoints send the
DeprecationandSunsetheaders, with the date they stop. - Every change is listed in the changelog, also as RSS.