Concepts
Ce qui vaut pour chaque endpoint. Lisez ceci avant de mettre une intégration en production.
Erreurs
Une erreur a toujours la même forme, application/problem+json selon la RFC 9457 :
{
"type": "https://developers.mitgliederbuch.de/errors/insufficient_scope",
"title": "Droits insuffisants",
"status": 403,
"detail": "Cette clé n'a pas le droit leden:schrijven. Un administrateur peut créer une clé avec ce droit.",
"instance": "/v2/members"
}typeest une URL fixe par type d'erreur et ne change pas. Basez votre gestion des erreurs dessus. L'URL est aussi une page d'explication.titleetdetailsont destinés aux humains et suiventAccept-Language. Ne les comparez pas dans votre code.statusest le même statut HTTP que celui de la réponse ;instanceest le chemin de votre appel.- Avec
validation_failed,errorsindique par champ ce qui ne va pas, avecfield,codeetmessage.
# -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'])}")Tous les codes d'erreur
| Code | HTTP | Titre |
|---|---|---|
api_key_in_url |
400 | Clé dans l'URL |
invalid_request |
400 | Requête invalide |
invalid_api_key |
401 | Clé invalide |
missing_api_key |
401 | Clé manquante |
subscription_required |
402 | Abonnement requis |
insufficient_scope |
403 | Droits insuffisants |
module_disabled |
404 | Module désactivé |
not_found |
404 | Introuvable |
already_in_group |
409 | Déjà dans le groupe |
already_registered |
409 | Déjà inscrit |
event_full |
409 | Pas assez de places |
event_requires_payment |
409 | Paiement requis |
group_follows_subscription |
409 | Le groupe suit les abonnements |
idempotency_in_progress |
409 | Requête en cours |
invoice_not_sendable |
409 | Facture impossible à envoyer |
membership_already_ended |
409 | Adhésion déjà terminée |
recipient_without_email |
409 | Pas d'adresse e-mail |
registration_closed |
409 | Inscriptions fermées |
idempotency_key_reused |
422 | Idempotency-Key déjà utilisée |
validation_failed |
422 | Données invalides |
rate_limited |
429 | Trop d'appels |
internal_error |
500 | Erreur interne |
Pagination
Une liste renvoie un objet avec data, has_more et next_cursor. Vous parcourez avec deux paramètres :
limit: le nombre d'enregistrements par page, de 1 à 100. 25 par défaut.starting_after: lenext_cursorde la page précédente.
Les enregistrements arrivent dans l'ordre de leur id. Si des enregistrements sont ajoutés pendant que vous parcourez, vous n'en sautez aucun et n'en voyez aucun deux fois ; avec des numéros de page, ce n'est pas possible. Sur la dernière page, has_more est faux et next_cursor vaut null.
Un exemple se trouve dans le démarrage rapide.
Synchroniser avec updated_since
Tenez votre propre copie au lieu de tout récupérer à chaque fois. Avec updated_since, vous ne recevez que les enregistrements modifiés à partir de ce moment. Le moment est au format ISO 8601, par exemple 2026-10-01T00:00:00Z ; un autre format renvoie invalid_request. Vous pouvez le combiner avec la pagination.
Notez le moment où votre synchronisation commence et utilisez-le comme updated_since la fois suivante. Ainsi, vous ne manquez rien de ce qui a changé pendant la récupération.
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_atUn enregistrement entièrement supprimé ne revient plus avec updated_since. Comparez donc de temps en temps la liste complète avec votre copie.
Idempotence
Un POST accepte l'en-tête Idempotency-Key : une valeur unique de 255 caractères au plus, par exemple un UUID. Si vous renvoyez la même requête avec la même clé, rien n'est créé deux fois.
- La même requête avec la même clé renvoie la réponse d'origine, avec l'en-tête
Idempotent-Replayed: true. - La même clé avec une autre requête renvoie
idempotency_key_reused. - Si la première requête est encore en cours, vous recevez
idempotency_in_progress. Réessayez après quelques secondes. - Nous ne conservons pas une réponse avec un statut 5xx ; après une erreur de notre côté, vous pouvez donc réessayer sans risque avec la même clé.
Les clés sont conservées 24 heures. Enregistrez la clé avec votre propre enregistrement avant d'envoyer la requête ; une nouvelle tentative utilisera alors automatiquement la même.
Limites
Chaque clé peut faire 600 appels par minute, 120 avec une clé de test. La fenêtre est une minute fixe. Chaque réponse indique la marge restante :
| En-tête | Signification |
|---|---|
RateLimit-Limit | Appels par minute pour cette clé. |
RateLimit-Remaining | Ce qui reste pour cette minute. |
RateLimit-Reset | Secondes jusqu'à la minute suivante. |
Retry-After | Uniquement avec 429 : combien de secondes attendre. |
Au-delà de la limite, vous recevez rate_limited avec le statut 429. Attendez alors le nombre de secondes indiqué par 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 responseVersions
La version figure dans le chemin : /v2. Les règles :
/v2reste. Des champs, endpoints et codes d'erreur s'ajoutent ; faites en sorte que votre code ignore les champs inconnus.- Un champ ne disparaît pas et ne change pas de sens sans nouvelle version.
- Quand une nouvelle version arrive, l'ancienne fonctionne encore au moins 12 mois. Pendant cette période, les anciens endpoints envoient les en-têtes
DeprecationetSunsetavec la date de leur arrêt. - Chaque modification figure dans le journal des modifications, aussi en RSS.