Zum Hauptinhalt springen

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

VerbBedeutungAntwort
GETlesen200 mit data (Objekt oder Liste)
POSTanlegen oder Vorgang auslösen201 mit dem angelegten Objekt
PUTändern (teilweise, siehe unten) bzw. eine Menge vollständig setzen200 mit dem geänderten Objekt
DELETElöschen200 mit { "data": { "id": "…", "deleted": true } }
PUT ändert nur die mitgeschickten Felder

Ein 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:

ParameterBedeutungStandard
pageSeite, ab 11
per_pageEinträge je Seite, höchstens 20050
sortSortierfeld; führendes Minus kehrt um (-created_at)je Ressource
qVolltextsuche über die sinnvollen Felder der Ressource
updated_sinceISO-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"
Wenn eine Liste kein Zeitfeld führt, gibt es 400 – nicht „0 Treffer“

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 '' neben NULL.
  • Geprüft wird streng. Schlägt die Prüfung fehl, kommt 400 mit dem Code validation_failed; details nennt 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" statt 12); Beträge auch mit Komma.

Zeitangaben

ArtFormatBeispiel
DatumJJJJ-MM-TT2026-08-14
ZeitpunktISO 8601, UTC2026-08-14T09:00:00Z
VeranstaltungszeitenISO oder JJJJ-MM-TT hh:mm:ss2026-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:

EndpunktInhalt
GET /spenden/{id}/pdfZuwendungsbestätigung
GET /fahrtkosten/{id}/pdfausgefülltes Fahrtkostenformular
GET /veranstaltungen/{id}/teilnehmer/{tid}/bescheinigung/{vorlagenId}Teilnahmebescheinigung
GET /bank/belege/{id}/inhaltBelegdatei
GET /mails/{id}/anhaenge/{anhangId}/inhaltMail-Anhang
GET /dokumente/{id}/inhaltDatei 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:

EndpunktGrenzeAblage
PUT /dokumente/upload?ordner_id=…&name=…2 GBCloudspeicher (Stream, nichts läuft durch den Arbeitsspeicher)
PUT /bank/belege/{id}/inhalt?name=…20 MBDatenbank
PUT /mails/{id}/anhaenge?name=…10 MBDatenbank
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?