Konventionen
Die interne Schnittstelle der Anwendung ist auf die eigene Oberfläche zugeschnitten und
darf sich mit ihr ändern. /api/v1 ist ein Versprechen an fremde Werkzeuge: eine feste
Hülle um die Antwort, feste Fehlercodes, feste Listenparameter. Was auf dieser Seite
steht, gilt für jeden Endpunkt.
Antwortform
Einzelobjekt:
{ "data": { "id": "8f2c…", "first_name": "Maja", "last_name": "Beispiel" } }
Liste:
{
"data": [ { "id": "…" }, { "id": "…" } ],
"meta": { "page": 1, "per_page": 50, "total": 214 }
}
total ist immer die Gesamtzahl der Treffer über alle Seiten, nicht die Länge der
aktuellen Seite – damit lässt sich die Zahl der Seiten ausrechnen.
Fehler:
{ "error": { "code": "insufficient_scope", "message": "Für diesen Aufruf fehlt der Bereich „material:write“." } }
Manche Endpunkte hängen ein meta an eine Einzelantwort, wenn es dazu etwas zu sagen gibt
– etwa meta.missing bei den Spenden-Einstellungen oder meta.mailed beim Anlegen eines
Nachweises. Das steht jeweils beim Endpunkt.
Verben und ihre Bedeutung
| Verb | Bedeutung | Antwort |
|---|---|---|
GET | lesen | 200 mit data (Objekt oder Liste) |
POST | anlegen oder Vorgang auslösen | 201 mit dem angelegten Objekt |
PUT | ändern (teilweise, siehe unten) bzw. eine Menge vollständig setzen | 200 mit dem geänderten Objekt |
DELETE | löschen | 200 mit { "data": { "id": "…", "deleted": true } } |
PUT ändert nur die mitgeschickten FelderEin PUT verhält sich wie ein PATCH: Felder, die im Rumpf fehlen, bleiben unangetastet.
Ein Aufruf mit {"city":"Musterstadt"} ändert den Wohnort und löscht nicht den Rest
des Datensatzes. Ein Feld ausdrücklich leeren geht mit null oder "".
Wo ein PUT doch eine ganze Menge ersetzt, ist das der Sinn des Endpunkts und steht
dabei – etwa PUT /gruppen/verteiler/{id}/mitglieder (Empfängerliste) oder
PUT /abrechnung/{id}/belege (Beleg-Auswahl).
Einige Unterlisten antworten auf POST/DELETE mit der kompletten Liste statt mit dem
einzelnen Eintrag – etwa Familien-Verknüpfungen oder die Fahrzeuge einer Packliste. Das ist
in der Regel das, was ein Werkzeug danach anzeigen will.
Listen: Blättern, Sortieren, Suchen
Diese Parameter gelten für jede Liste, auch für Unterlisten wie
/veranstaltungen/{id}/teilnehmer oder /bank/{accountId}/buchungen:
| Parameter | Bedeutung | Standard |
|---|---|---|
page | Seite, ab 1 | 1 |
per_page | Einträge je Seite, höchstens 200 | 50 |
sort | Sortierfeld; führendes Minus kehrt um (-created_at) | je Ressource |
q | Volltextsuche über die sinnvollen Felder der Ressource | – |
updated_since | ISO-8601-Zeitpunkt – nur seither Geändertes | – |
Unsinnige Werte für page und per_page werden geräuschlos auf sinnvolle gesetzt: an
einem Tippfehler in der URL soll ein Werkzeug nicht scheitern, sondern die erste Seite
bekommen. per_page=5000 liefert also 200 Einträge, nicht einen Fehler.
Welche Felder sich sortieren lassen, steht beim jeweiligen Endpunkt (und in der OpenAPI-Beschreibung). Ein unbekanntes Sortierfeld fällt auf die Standardsortierung zurück – frei wählbare Felder wären eine SQL-Injektion durch die Hintertür.
Zwei Listen haben eine feste Reihenfolge und kennen deshalb kein sort: das Postfach
(/mails, neueste zuerst) und das Aktivitätsprotokoll (/stammdaten/protokoll, ebenfalls
neueste zuerst).
Ressourcenspezifische Filter (gruppe_id, status, archiviert, von/bis …) stehen
bei den Endpunkten.
Inkrementeller Abgleich mit updated_since
Ein Werkzeug merkt sich den Zeitpunkt seines letzten Laufs und holt beim nächsten Mal nur noch die Änderungen:
curl -H "Authorization: Bearer $KEY" \
"$BASIS/mitglieder?updated_since=2026-07-01T00:00:00Z&per_page=200"
Der Sachstand der Nachweise etwa entsteht aus mehreren Tabellen und trägt keinen eigenen
Zeitstempel. Eine leere Liste wäre hier eine Falle: Ein Werkzeug, das aus „0 Treffer“
schließt, es habe sich nichts geändert, würde den Abgleich stillschweigend für immer
einstellen. Deshalb antwortet die Liste mit 400 bad_request und sagt, dass
updated_since hier nicht anwendbar ist.
Beim Aktivitätsprotokoll heißt updated_since sinngemäß seither angelegt – ein
Protokolleintrag ändert sich nie. Genau das braucht ein Werkzeug, das laufend mitliest.
Eingaben
- Rumpf ist JSON (
Content-Type: application/json) – außer beim Datei-Upload, siehe unten. - Leere Strings werden zu
null. Damit steht in der Datenbank nicht''nebenNULL. - Geprüft wird streng. Schlägt die Prüfung fehl, kommt
400mit dem Codevalidation_failed;detailsnennt Feld für Feld, was nicht passte:
{
"error": {
"code": "validation_failed",
"message": "Ungültige Eingabe.",
"details": { "formErrors": [], "fieldErrors": { "email": ["Ungültige E-Mail-Adresse"] } }
}
}
- Unbekannte Felder werden ignoriert. Ein Feld, das es nicht gibt, verschwindet stillschweigend – deshalb lohnt der Blick in die Feldtabellen bei den Endpunkten.
- Zahlen dürfen als Text kommen (
"12"statt12); Beträge auch mit Komma.
Zeitangaben
| Art | Format | Beispiel |
|---|---|---|
| Datum | JJJJ-MM-TT | 2026-08-14 |
| Zeitpunkt | ISO 8601, UTC | 2026-08-14T09:00:00Z |
| Veranstaltungszeiten | ISO oder JJJJ-MM-TT hh:mm:ss | 2026-08-01T09:00 wird angenommen |
Ein tagesgenauer Anmeldeschluss schließt den Tag vollständig ein: registration_end als
reines Datum wird serverseitig auf 23:59:59 gesetzt – wie in der Oberfläche.
Dateien
Einige Endpunkte liefern keine JSON-Antwort, sondern eine Datei; erkennbar am
Content-Type:
| Endpunkt | Inhalt |
|---|---|
GET /spenden/{id}/pdf | Zuwendungsbestätigung |
GET /fahrtkosten/{id}/pdf | ausgefülltes Fahrtkostenformular |
GET /veranstaltungen/{id}/teilnehmer/{tid}/bescheinigung/{vorlagenId} | Teilnahmebescheinigung |
GET /bank/belege/{id}/inhalt | Belegdatei |
GET /mails/{id}/anhaenge/{anhangId}/inhalt | Mail-Anhang |
GET /dokumente/{id}/inhalt | Datei aus dem Cloudspeicher |
Upload
Hochgeladen wird immer der rohe Dateiinhalt als Rumpf, mit dem Dateityp als
Content-Type. Kein JSON, kein multipart/form-data. Name und Ziel kommen als
Query-Parameter:
| Endpunkt | Grenze | Ablage |
|---|---|---|
PUT /dokumente/upload?ordner_id=…&name=… | 2 GB | Cloudspeicher (Stream, nichts läuft durch den Arbeitsspeicher) |
PUT /bank/belege/{id}/inhalt?name=… | 20 MB | Datenbank |
PUT /mails/{id}/anhaenge?name=… | 10 MB | Datenbank |
curl -X PUT -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/pdf" \
--data-binary @quittung.pdf \
"$BASIS/bank/belege/8f2c…/inhalt?name=quittung.pdf"
Zu große Dateien antworten mit 413 too_large. Geprüft wird zweimal – vorab am
Content-Length (das spart die Übertragung) und noch einmal beim Lesen, damit ein falscher
oder fehlender Header die Grenze nicht aushebelt. Der Dateiname darf keine Pfadtrenner
enthalten.
Grenzen
Die Anfragen sind je Schlüssel begrenzt (Standard 120 pro Minute, einstellbar unter Einstellungen → API). Bewusst nicht je IP-Adresse: mehrere Werkzeuge sitzen oft hinter derselben Adresse und würden sich sonst gegenseitig ausbremsen.
Jede Antwort trägt die aktuellen Werte:
RateLimit-Limit: 120
RateLimit-Remaining: 118
RateLimit-Reset: 42
Beim Überschreiten kommt 429 rate_limited. RateLimit-Reset nennt die Sekunden bis zum
nächsten Fenster – ein Werkzeug wartet diese Zeit ab, statt sofort erneut anzuklopfen.
CORS
/api/v1 erlaubt Anfragen von jeder Herkunft, aber ohne Cookies
(kein Access-Control-Allow-Credentials). Das ist sicher und nicht großzügig: die einzige
Authentifizierung ist der Authorization-Kopf, den ein fremdes Skript nicht aus dem
Browser eines Opfers heraus setzen kann. Ein CSRF-Risiko wie bei Cookie-Sitzungen entsteht
dadurch nicht.
Die RateLimit-*-Kopfzeilen sind freigegeben, damit ein Browser-Werkzeug sein eigenes
Limit sehen kann.
Protokollierung
Jeder schreibende Zugriff landet im Aktivitätsprotokoll (Einstellungen → Protokoll) –
mit demselben Objekttyp und demselben Vorher/Nachher-Vergleich wie eine Änderung aus der
Oberfläche. Statt eines Benutzernamens steht dort api:<Name des Schlüssels>; die Details
nennen Schlüssel-ID und IP-Adresse.
Damit lässt sich auch Monate später beantworten, welches angebundene Werkzeug eine Änderung ausgelöst hat. Aus demselben Grund bleiben Felder wie „angelegt von“ leer: Ein Schlüssel gehört keinem Konto – auch nicht dem, das ihn erzeugt hat.
Versionierung
/api/v1 ist ein Versprechen. Felder können hinzukommen, und ein Werkzeug sollte
unbekannte Felder ignorieren statt daran zu scheitern. Änderungen, die bestehende Aufrufe
brechen würden, kämen unter /api/v2.
Hat dies deine Frage beantwortet?