MDT-API

Alle Statistiken dieser Instanz sind über eine schlanke HTTP-API abrufbar – ideal für Dashboards, Tabellen, Automationen und eigene Auswertungen. Diagramme werden serverseitig als PNG gerendert und lassen sich direkt einbetten.

Basis-URL: https://mdt.npdev.eu/api/v1

Bot zum eigenen Server hinzufügen: https://mdt.npdev.eu/invite

Authentifizierung

Externe Integrationen senden einen instanz-spezifischen API-Schlüssel als Bearer-Token:

Authorization: Bearer stmp_dein-schluessel

Einen Schlüssel erstellst du im Discord mit /apikey create – er wird nur einmal angezeigt. Weitere Schlüssel verwaltest du mit /apikey list und /apikey delete. Jeder Schlüssel ist an genau einen Server (Guild) gebunden; die API liefert ausschließlich Daten dieses Servers.

Die Web-Oberfläche verwendet stattdessen eine Discord-Session und den x-guild-id-Header. API-Key und Session sind alternative Authentifizierungswege; ein Browser muss keinen Schlüssel speichern. Ohne Authentifizierung erreichbar sind GET /api/v1/health, GET /docs, GET /api/v1/docs, /invite sowie die öffentlichen Branding-Endpunkte GET /api/v1/branding/:guildId und GET /api/v1/branding/:guildId/icon.png.

Wenn eine Guild rolePermissionsEnabled aktiviert, enthält GET /access neben dem Kompatibilitäts-Level auch capabilities, roleSystem und suspended. REST-Routen und Realtime-Themen werden dann mit den jeweils passenden Capability-Schlüsseln geprüft. API-Schlüssel behalten Vollzugriff auf ihre eigene Guild; die Rang- und Suspendierungsprüfung gilt für Browser-Sessions.

GET /access beantwortet außerdem drei Sichtbarkeitsfragen bereits verrechnet, weil die rohen Schalter dahinter Admin-Routen sind: versicherungVisible sagt, ob diese Person den Versicherungen-Bereich sieht, citizensRolloutEnabled, ob das Bürgerregister überhaupt eine Serverantwort hat — die Routen unter /platform/citizens hängen am Rollout-Flag canonicalCitizensEnabled und antworten sonst mit 404 —, und sharedDispatchRolloutEnabled dasselbe für sharedIncidentsUnitsEnabled, hinter dem /platform/units/board-catalog liegt. Ein Client, der diese Felder ignoriert, zeigt Reiter, die nur eine Fehlermeldung ausgeben können, und fragt Routen ab, die nur 404 kennen.

Fehlt der Header oder ist der Schlüssel ungültig, antwortet die API mit 401 und einem JSON-Body { "error": "..." }.

Mehrere Instanzen und Kooperationen

Jede Guild ist eine eigene, vollständig getrennte Instanz. Der Onboarding-Assistent unterstützt die Vorlagen rettungsdienst, polizei, feuerwehr und neutral. Terminologie, Branding, Badge-Format und die dreizehn Module werden pro Guild gespeichert.

Die Seed-Route schreibt Beispieldaten nur in Bereiche, die unmittelbar vor dem Schreiben noch leer sind. Bereits vorhandene Guild-Daten werden nicht ersetzt. Das aktuelle Rollen- und Suspendierungsmodell ist zusätzlich in 0 beschrieben. Kooperationen arbeiten mit den Stufen none, redacted, read und write je Modul. Redaktion und Sichtbarkeit werden serverseitig erzwungen; ein Patiententransfer ist ein eigener, expliziter Handover-Schritt.

DELETE /api/v1/admin/instance ist die einzige Instanzlöschung. Sie steht nur dem live geprüften Discord-Serverbesitzer offen, löscht die Guild-Datenbank samt abhängigen Datensätzen, versucht Bot-Nachrichten zu entfernen und lässt den Bot die Guild verlassen. Nachrichten anderer Nutzer werden nicht gelöscht.

Umbenannte Pfade

facility und courses sind die kanonischen, service-neutralen URL-Pfade. Die bisherigen Pfade /hospital und /firstaid bleiben als Abwärtskompatibilitäts-Aliasse erhalten — das sind die einzigen beiden Aliasse der HTTP-API.

Aus dem fest verdrahteten Blatt „Stammkunden" sind mit Migration 0066 frei konfigurierbare Listen geworden. Hier wurde nicht nur die Adresse umbenannt, sondern der Schlüssel selbst, und der benennt zugleich Modul, Berechtigung und API.

Ein Stammkunde war eine Zeile in dem einen Blatt; jetzt ist er eine Zeile in einer Liste. Die Ebene verschiebt sich dadurch: /lists/:id ist die Liste selbst, die Kunden liegen darunter unter /entries.

Bis 0066Ab 0066
GET /stammkundenGET /lists/:listId/entries
POST /stammkundenPOST /lists/:listId/entries
PATCH /stammkunden/:idPATCH /lists/:listId/entries/:id
DELETE /stammkunden/:idDELETE /lists/:listId/entries/:id
POST /stammkunden/:id/visitsPOST /lists/:listId/entries/:id/counts/:fieldId
GET /stammkunden/:id/visits/countGET /lists/:listId/entries/:id/counts
DELETE /stammkunden/:id/visits/latestDELETE /lists/:listId/entries/:id/counts/:fieldId/latest

Die Migration verliert dabei nichts und erfindet auch keine neuen Ids. Sie legt je Gilde die Liste list_stammkunden_{guildId} mit dem Namen „Stammkunden" an, übernimmt jeden Stammkunden als Zeile unter seiner bisherigen Id und jeden gezählten Besuch als Zählereignis. Die drei Spalten heißen field_stammkunden_name_{guildId}, field_stammkunden_note_{guildId} und field_stammkunden_visits_{guildId} — die letzte ist der :fieldId der Besuchszähler. Eine Integration kann :listId und :fieldId damit ohne Nachschlagen bilden; GET /lists nennt beide ohnehin.

Für den alten Namensraum gibt es keinen API-Alias. Eine Integration, die noch /api/v1/stammkunden aufruft, bekommt seitdem 404. Gespeicherte Modullisten, Rangrechte (stammkunden.view/.write → lists.view/.write) und Kooperationsfreigaben hat die Migration mit umgezogen; neu ist nur der Aufrufpfad. Als gespeicherter Modulschlüssel wird stammkunden weiterhin als Altschreibweise gelesen, damit ein zurückgespieltes Backup den Reiter nicht verliert — das ist aber kein Pfad-Alias.

Die Browser-Routen unter /dispatch/{guildId}/… sprechen inzwischen durchgehend dienstneutrales Englisch. Anders als bei der API bleiben die alten Segmente dort erreichbar: ein gesetztes Lesezeichen oder ein in einen Discord-Kanal geschriebener Link wird auf das neue Segment umgeleitet.

Altes SegmentNeues Segment
/dispatch/{guildId}/einsatz/dispatch/{guildId}/incident
/dispatch/{guildId}/kalendar/dispatch/{guildId}/calendar
/dispatch/{guildId}/stammkunden/dispatch/{guildId}/lists
/dispatch/{guildId}/hospital/dispatch/{guildId}/facility
/dispatch/{guildId}/firstaid/dispatch/{guildId}/courses

Beim Einsatzblatt ist ausschließlich die Adresse umbenannt: Modulschlüssel, Berechtigung und API heißen weiterhin einsatz, und GET /einsatz bleibt der API-Pfad.

Endpunkte

Die Pfade sind relativ zur Basis-URL https://mdt.npdev.eu/api/v1. Ausgenommen sind /docs und /dispatch: beide liefert derselbe Server unter der Wurzel aus.

Methode & PfadBeschreibungFormat
GET /healthStatusprüfung (ohne Auth)JSON
GET /docsDiese Dokumentation im Browser (ohne Auth)HTML
GET /dispatchLeitstellenblatt im Browser (ohne Auth)HTML
GET /branding/:guildIdÖffentliches Erscheinungsbild einer Instanz (inkl. modules und tabOrder)JSON
GET /branding/:guildId/icon.pngÖffentliches Instanz-IconPNG
GET /uploads/:idAuthentifiziertes DokumentbildBild
POST /uploadsBild als multipart/form-data (Feld file) hochladen, höchstens 5 MBJSON
GET /admin/cooperationsKooperationen der Instanz auflistenJSON
POST /admin/cooperationsKooperation vorschlagenJSON
POST /admin/cooperations/:id/acceptKooperation annehmenJSON
PUT /admin/cooperations/:id/grantsEigene Kooperationsfreigaben ändernJSON
POST /admin/cooperations/:id/revokeKooperation widerrufenJSON
POST /admin/cooperations/:id/suspendKooperation mit Begründung aussetzenJSON
POST /admin/cooperations/:id/resumeAusgesetzte Kooperation mit Begründung fortsetzenJSON
POST /admin/cooperations/:id/supersedeKooperation mit Begründung ablösenJSON
PUT /admin/cooperations/:id/metadataEigene Kontakt-, Protokoll- und Capability-Metadaten ändernJSON
POST /admin/cooperations/:id/health-ackErfolgreiche eigene Verbindung zur API bestätigen (Admin-Session; kein Body oder {})JSON
PUT /admin/brandingInstanzname, Badge-Format, Begriffe, Module und Reiterfolge (tabOrder) ändernJSON
POST /admin/branding/iconInstanz-Icon hochladenJSON
DELETE /admin/instanceInstanz vollständig löschen (nur Serverbesitzer)JSON
GET /onboarding/:guildId/seedableLeere Einrichtungsbereiche ermittelnJSON
POST /onboarding/:guildId/seedVorlagen für leere Bereiche anwendenJSON
GET /accessZugriffsstatus und freigegebene PartnerbereicheJSON
GET /platform/configPlattform-Konfiguration lesenJSON
PUT /platform/configPlattform-Konfiguration versionsgesichert ändernJSON
GET /platform/organization/unitsOrganisationseinheiten auflistenJSON
POST /platform/organization/unitsOrganisationseinheit anlegenJSON
PATCH /platform/organization/units/:idOrganisationseinheit versionsgesichert ändernJSON
POST /platform/organization/units/:id/archiveOrganisationseinheit archivierenJSON
GET /platform/organization/membershipsZuordnungen von Mitarbeitern zu Organisationseinheiten auflistenJSON
POST /platform/organization/membershipsMitarbeiter einer Organisationseinheit zuordnenJSON
POST /platform/organization/memberships/:id/endOrganisationszuordnung beendenJSON
GET /platform/organization/clearancesFreigabestufen auflistenJSON
POST /platform/organization/clearancesFreigabestufe anlegenJSON
PATCH /platform/organization/clearances/:idFreigabestufe versionsgesichert ändernJSON
POST /platform/organization/clearances/:id/archiveFreigabestufe archivierenJSON
GET /platform/organization/clearances/employees/:employeeIdFreigaben eines Mitarbeiters auflistenJSON
POST /platform/organization/clearances/employees/:employeeIdFreigabe einem Mitarbeiter erteilenJSON
POST /platform/organization/clearances/assignments/:id/revokeMitarbeiterfreigabe widerrufenJSON
GET /platform/network/policiesEigene Freigaberichtlinien auflistenJSON
GET /platform/network/catalogVersionierten Aktions-, Ressourcen- und Feldkatalog lesenJSON
POST /platform/network/policiesFreigaberichtlinie anlegenJSON
PATCH /platform/network/policies/:idFreigaberichtlinie versionsgesichert ändernJSON
POST /platform/network/policies/:id/archiveFreigaberichtlinie archivierenJSON
POST /platform/network/access/explainZugriff auf eine hypothetische Ressource simulierenJSON
GET /platform/network/directoryExplizit freigegebene Partner-Metadaten lesenJSON
GET /platform/dispatchers/:ownerGuildIdExplizit freigegebene Leitstellenbesetzung einer Instanz lesenJSON
POST /citizen-import/uploadsWiederaufnehmbaren Bürgerimport beginnenJSON
PUT /citizen-import/uploads/:uploadId/chunks/:indexGehashten Import-Chunk hochladenBinär
POST /citizen-import/uploads/:uploadId/finalizeVollständigen Import prüfen und versiegelnJSON
POST /citizen-import/uploads/:uploadId/abortUnvollständigen Import abbrechenJSON
GET /integrations/ingame/capabilitiesVerfügbare InGame-Integrationsfunktionen lesenJSON
PUT /integrations/ingame/employees/:userId/ranksInGame-Ränge einer Discord-Person synchronisierenJSON
GET /admin/apikeysAPI-Schlüssel auflistenJSON
DELETE /admin/apikeys/:idAPI-Schlüssel widerrufenJSON
GET /admin/auditÄnderungsprotokoll lesenJSON
GET /admin/callsign-prefixesRufnamen-Präfixe lesenJSON
PUT /admin/callsign-prefixesRufnamen-Präfixe setzen ({ "prefixes": [...] }, ersetzt die Liste); baut das Panel neu aufJSON
GET /admin/channelsDiscord-Kanäle auflistenJSON
GET /admin/dispatch-codesStatuscodes lesenJSON
PUT /admin/dispatch-codesStatuscodes setzen ({ "codes": [...], "pauseCode": ... }, ersetzt die Liste)JSON
GET /admin/gebieteEinsatzgebiete lesenJSON
PUT /admin/gebieteEinsatzgebiete setzen ({ "gebiete": [...] }, ersetzt die Liste); nur Board, kein Panel-NeuaufbauJSON
GET /admin/members/searchMitglieder nach Server-Nickname, Account-Name oder Discord-ID suchenJSON
PUT /admin/on-duty/:userId/codeDienstcode setzenJSON
POST /admin/panelStempeluhr-Panel veröffentlichenJSON
GET /admin/rolesDiscord-Rollen auflistenJSON
GET /admin/settingsInstanz-Einstellungen lesenJSON
GET /admin/standbyBereitschaftsliste lesenJSON
POST /admin/standby/callBereitschaft rufenJSON
POST /admin/standby/revokeBereitschaftsruf zurücknehmenJSON
GET /admin/stempeluhrStempeluhr-Einstellungen lesenJSON
GET /ai/accessKI-Zugriff lesenJSON
POST /ai/einsatz-reportKI-Lagemeldung erzeugenJSON
GET /ai/modelsVerfügbare KI-Modelle lesenJSON
POST /ai/reportKI-Bericht erzeugenJSON
GET /ai/settingsKI-Einstellungen lesenJSON
PUT /ai/settingsKI-Einstellungen setzen (Schlüssel, Modell, Prompts); ein falsch typisiertes Feld schreibt nichts und antwortet mit 400JSON
GET /ai/templatesKI-Vorlagen lesenJSON
POST /ai/templatesKI-Vorlage anlegen (Name, Inhalt, Typ, Triage-Freigabe)JSON
PUT /ai/templatesVorlagenreihenfolge setzen ({ "ids": [...] }); eine veraltete Liste wird mit 409 abgelehntJSON
PATCH /ai/templates/:idKI-Vorlage ändernJSON
GET /ai/usageKI-Nutzung lesenJSON
GET /calendarKalendertermine lesenJSON
PATCH /calendar/:idKalendertermin ändernJSON
DELETE /calendar/:idKalendertermin löschen; ?scope=series löscht die ganze Serie, alles andere nur diesen TerminJSON
GET /calendar/categoriesKalenderkategorien lesenJSON
PUT /calendar/categoriesKalenderkategorien setzen (ersetzt die Liste; eine ungültige Kategorie lässt die gesamte Anfrage scheitern, statt Zeilen zu entfärben)JSON
GET /calendar/remindersKalender-Erinnerungen lesenJSON
GET /calendar/settingsKalendereinstellungen lesenJSON
PUT /calendar/settingsKalendereinstellungen setzenJSON
GET /documentsDokumente lesenJSON
GET /documents/:idDokument lesenJSON
GET /documents/:id/pdfDokument als PDF abrufenPDF
POST /documents/:id/readDokument als gelesen markierenJSON
POST /documents/categoriesDokumentenkategorie anlegenJSON
PATCH /documents/categories/:idDokumentenkategorie ändernJSON
DELETE /documents/categories/:idDokumentenkategorie löschenJSON
PUT /documents/categories/orderDokumentenkategorien sortierenJSON
PUT /documents/orderDokumente sortierenJSON
GET /documents/unreadUngelesene Dokumente lesenJSON
GET /documents/:id/revisionsVersionsverlauf eines Dokuments lesen (neueste zuerst, ohne Text)JSON
GET /documents/:id/revisions/:revEine Version vollständig lesen (mit body)JSON
POST /documents/:id/revisions/:rev/restoreVersion als neue Version wiederherstellen (Admin)JSON
POST /documents/:id/restoreDokument aus dem Papierkorb zurückholen (Admin)JSON
POST /documents/:id/duplicateDokument als Kopie anlegen (Admin)JSON
POST /documents/:id/acknowledgeAktuelle Version als „Gelesen und verstanden" bestätigenJSON
GET /documents/:id/acknowledgementsBestätigungen und noch offene Personen lesen (Admin)JSON
GET /documents/templatesDokumentvorlagen lesen (eingebaute zuerst)JSON
POST /documents/templatesDokumentvorlage anlegen (Admin)JSON
PATCH /documents/templates/:idDokumentvorlage ändern (Admin)JSON
DELETE /documents/templates/:idDokumentvorlage löschen (Admin)JSON
PUT /documents/templates/orderDokumentvorlagen sortieren (Admin)JSON
GET /letterheadsBriefköpfe lesenJSON
PATCH /letterheads/:idBriefkopf ändernJSON
PUT /letterheads/orderBriefköpfe sortierenJSON
GET /tv/presentationsTV-Präsentationen samt Folien und Bildschirm-Links lesen (Admin)JSON
POST /tv/presentationsTV-Präsentation anlegen (Admin)JSON
PATCH /tv/presentations/:idTV-Präsentation umbenennen (Admin)JSON
DELETE /tv/presentations/:idTV-Präsentation samt Folien und Links löschen (Admin)JSON
POST /tv/presentations/:id/slidesFolie als multipart/form-data (Feld file, PNG/JPEG/WebP, höchstens 7 MB) anhängen (Admin)JSON
DELETE /tv/presentations/:id/slides/:slideIdFolie löschen (Admin)JSON
PUT /tv/presentations/:id/slides/orderFolien sortieren (Admin)JSON
GET /tv/slides/:id/imageFolienbild für die Vorschau (Admin, Session-Cookie)Bild
POST /tv/presentations/:id/screensNeuen Bildschirm-Link anlegen (Admin)JSON
PATCH /tv/screens/:idBildschirm-Link umbenennen (Admin)JSON
DELETE /tv/screens/:idBildschirm-Link löschen; offene Streams enden (Admin)JSON
POST /tv/screens/:id/showFolie wechseln: { "step": 1 }, { "step": -1 } oder { "slideId": "…" } (Admin)JSON
POST /me/clock-inSelbst einstempelnJSON
POST /me/clock-outSelbst ausstempelnJSON
PUT /me/dispatcherSelbst als Leitstelle setzenJSON
GET /medic/citizens/:idMedic-Person lesenJSON
PUT /medic/citizens/:id/stateMedic-Personstatus setzenJSON
POST /medic/citizens/resolveMedic-Person auflösenJSON
POST /medic/eventsMedic-Ereignisse übernehmenJSON
GET /medic/patients/:idMedic-Patient lesenJSON
GET /medic/qualificationsMedic-Qualifikationen lesenJSON
GET /patients/:pid/injuriesVerletzungen lesenJSON
PUT /patients/:pid/injuries/:regionVerletzung setzenJSON
PATCH /patients/:pid/injuries/:regionVerletzung ändernJSON
DELETE /patients/:pid/injuries/:regionVerletzung entfernenJSON
PUT /personal/ranksRangliste ändernJSON
GET /listsListen samt ihrer Spalten lesenJSON
PATCH /lists/:idListe ändern (Name, Beschreibung, wer darin schreiben darf)JSON
PUT /lists/orderListen sortierenJSON
POST /lists/:id/fieldsSpalte anlegenJSON
PATCH /lists/:id/fields/:fieldIdSpalte ändernJSON
DELETE /lists/:id/fields/:fieldIdSpalte löschenJSON
PUT /lists/:id/fields/orderSpalten sortierenJSON
GET /lists/:id/fields/:fieldId/usageWie viele Zellen und Zählereignisse an einer Spalte hängenJSON
GET /lists/:id/entries[?period=&date=]Zeilen einer Liste lesen; Zählerstände gelten für den gefragten ZeitraumJSON
PATCH /lists/:id/entries/:entryIdZellen einer Zeile ändernJSON
GET /lists/:id/entries/:entryId/countsGesamtzahl der Zählereignisse einer ZeileJSON
POST /lists/:id/entries/:entryId/counts/:fieldIdZähler erhöhen (stempelt immer jetzt)JSON
DELETE /lists/:id/entries/:entryId/counts/:fieldId/latestNeuestes Zählereignis im gezeigten Zeitraum zurücknehmenJSON
GET /stats/heatmapAktivitäts-Heatmap lesen, mit UnterbesetzungJSON
GET /stats/leaderboardRangliste des ServersJSON
GET /stats/user/:userIdStatistik eines einzelnen NutzersJSON
GET /stats/shiftsEinzelne Schichten im Zeitraum (Team oder userId)JSON
GET /stats/inactiveAktive Mitarbeiter mit ihrer letzten SchichtJSON
GET /stats/einsatzEinsatz-Kennzahlen im ZeitraumJSON
GET /stats/filtersRänge und Organisationseinheiten für die FilterJSON
GET /me/statsEigene Statistik (nur Lesezugriff nötig)JSON
GET /stats/leaderboard/chart/:type.pngRanglisten-DiagrammPNG
GET /stats/user/:userId/chart/:type.pngNutzer-DiagrammPNG
POST /admin/clock-inPerson per Dienstmarke einstempelnJSON
POST /admin/clock-outPerson per Dienstmarke ausstempelnJSON
GET /admin/clock-status/:badgeIst die Dienstmarke im Dienst?JSON
GET /admin/on-dutyAlle offenen Schichten (im Dienst)JSON
GET /admin/membersAlle Mitarbeiter (Rolle + Dienstmarke)JSON
PUT /admin/dispatchersLeitstelle / Sek. Leitstelle / Höchstrangiger setzenJSON
POST /admin/unitsEinheit anlegenJSON
GET /admin/unitsEinheiten auflistenJSON
PUT /admin/units/:callsignEinheit aktualisieren (leere Besatzung löscht sie)JSON
DELETE /admin/units/:callsignEinheit löschenJSON
GET /personalPersonalblatt lesen (Mitarbeiter + Spalten)JSON
GET /personal/employees/:id/ranksRangzuweisungen einer Mitarbeiterzeile lesenJSON
PUT /personal/columnsSpalten des Personalblatts ersetzenJSON
PUT /personal/employees/:id/ranksManuelle oder InGame-Rangzuweisungen einer Mitarbeiterzeile ersetzenJSON
PATCH /personal/employees/:id/discord-ranksExplizite Discord-Rangänderungen anwendenJSON
POST /personal/employeesMitarbeiter manuell anlegenJSON
PATCH /personal/employees/:idZellen eines Mitarbeiters aktualisierenJSON
POST /personal/employees/:id/archiveMitarbeiter archivierenJSON
POST /personal/employees/:id/restoreArchivierten Mitarbeiter reaktivierenJSON
GET /personal/syncVorschläge aus dem Discord-Abgleich lesenJSON
POST /personal/sync/applyVorschläge annehmen/verwerfenJSON
GET /personal/employees/:id/notesNotizen zur Personalakte lesenJSON
POST /personal/employees/:id/notesNotiz anlegenJSON
PATCH /personal/employees/:id/notes/:noteIdNotiz ändernJSON
DELETE /personal/employees/:id/notes/:noteIdNotiz löschenJSON
GET /coursesKurse und ihre Anmeldungen lesenJSON
POST /coursesAnmeldung anlegenJSON
PATCH /courses/:idFelder einer Anmeldung bearbeitenJSON
POST /courses/:id/passAnmeldung abschließen (erste complete-Aktion)JSON
POST /courses/:id/unpassAbschluss zurücknehmen (erste reopen-Aktion)JSON
POST /courses/:id/actions/:actionIdEine konfigurierte Aktion ausführenJSON
DELETE /courses/:idAnmeldung löschenJSON
POST /courses/definitionsKurs anlegenJSON
PATCH /courses/definitions/:courseIdKurs umbenennen/einstellenJSON
PUT /courses/definitions/:courseId/layoutFelder und Aktionen eines Kurses setzenJSON
PUT /courses/definitions/orderKurse sortierenJSON
DELETE /courses/definitions/:courseIdLeeren Kurs löschenJSON
GET /board-noticesAushänge des Schwarzen Bretts lesenJSON
POST /board-noticesAushang veröffentlichenJSON
PATCH /board-notices/:idAushang ändernJSON
DELETE /board-notices/:idAushang entfernenJSON
GET /einsatz?archived=1Einsatzblätter lesen (ohne archived nur laufende)JSON
GET /einsatz/:idEinzelnes Einsatzblatt lesen; ?ownerGuildId= liest ein freigegebenes Partnerblatt, bei Freigabestufe redacted geschwärztJSON
POST /einsatzEinsatzblatt anlegenJSON
PATCH /einsatz/:idTitel, Ort, Kartenmarkierung oder Einsatzleiter setzen — Titel nur mit Admin-RechtenJSON
POST /einsatz/:id/closeEinsatz abschließen (ins Archiv)JSON
POST /einsatz/:id/reopenAbgeschlossenen Einsatz wieder öffnenJSON
DELETE /einsatz/:idAbgeschlossenes Einsatzblatt löschen (nur Archiv; ein offener Einsatz wird mit 409 abgelehnt)JSON
POST /einsatz/:id/patientsPatient erfassenJSON
PATCH /einsatz/:id/patients/:pidName, Zugehörigkeit, Triage, Ort oder Schritte ändernJSON
DELETE /einsatz/:id/patients/:pidPatient entfernenJSON
GET /einsatz/:id/entriesVerlauf des Einsatzes lesen (Einsatz- und Patienteneinträge)JSON
POST /einsatz/:id/entriesNotiz eintragen (patientId: null = Lagemeldung)JSON
DELETE /einsatz/:id/entries/:eidEigene Notiz zurücknehmenJSON
POST /einsatz/:id/patients/:pid/transferPatient in die Einrichtung verlegen — optionales Feld buildingId wählt die Zieleinrichtung; ohne Angabe die ersteJSON
POST /einsatz/:id/patients/:pid/announcePatient der Einrichtung ankündigen (erscheint dort ausgegraut über dem Warteraum) — optionales Feld buildingId; die Verlegung löscht die AnkündigungJSON
DELETE /einsatz/:id/patients/:pid/announceAnkündigung zurücknehmenJSON
GET /facility/buildingsEinrichtungen auflistenJSON
POST /facility/buildingsEinrichtung anlegenJSON
PATCH /facility/buildings/:idEinrichtung umbenennen oder Position ändernJSON
DELETE /facility/buildings/:idEinrichtung löschen — 409, solange Patienten darin liegen oder es das letzte istJSON
GET /facility/settingsHinweis-Schwellen der Einrichtung lesen: stayWarnMinutes (Verweildauer-Warnung), fileReminderHours (Akten-Erinnerung); null = ausJSON
`GET /facility/stats?days=1\7\30[&buildingId=][&tz=]`Kennzahlen der Einrichtung: Aufnahmen, Entlassungen, Ø/Median-Verweildauer, Belegung im Verlauf, Entlassungen pro Tag und je Früh/Spät/Nacht, offene Akten je BetreuerJSON
PUT /facility/settingsHinweis-Schwellen setzen (Admin) — Teil-Update, Minuten 1–10080 oder nullJSON
`GET /facility[?view=active\pending\discharged]`Board der Einrichtung lesen — ohne Parameter die aktiven Patienten, pending die Entlassenen ohne geschriebene Akte (mit involved: wer beteiligt war), discharged das Archiv. ?discharged=1 bleibt als Alias für view=discharged, liefert seitdem aber nur noch Patienten mit geschriebener Akte — vor pending waren das alle Entlassenen, jetzt strikt weniger. Antwort enthält buildings (alle Einrichtungen der Guild); jeder Raum und jeder Patient trägt buildingId. Gefiltert wird im Client. Das Archiv (discharged) nimmt zusätzlich from/to (ISO-Zeitpunkte, Entlassung), triage, betreuer (Teil des Namens) und limit (1–100) an. Die aktive Ansicht trägt settings (Hinweis-Schwellen) und incoming (angekündigte Patienten).JSON
POST /facility/patientsPatient direkt in der Einrichtung anlegen (Direktaufnahme, landet im Warteraum). Optionales Feld buildingId wählt die Einrichtung; ohne Angabe die erste.JSON
GET /facility/patients/:idEinzelnen Einrichtung-Patienten lesen; ?ownerGuildId= liest einen freigegebenen PartnerpatientenJSON
PATCH /facility/patients/:idOrt (buildingId + Raum/Bett — alle drei Felder gemeinsam), Triage, Zugehörigkeit oder Schritte eines Patienten ändernJSON
POST /facility/patients/:id/handoverPatient mit einer aktiven Partnerfreigabe an die Partner-Einrichtung übergebenJSON
GET /facility/patients/:id/entriesVerlauf eines Einrichtung-Patienten lesenJSON
GET /facility/patients/:id/citizenBürgerakte zum Patienten: Stammdaten (Blutgruppe, Allergien, Vorerkrankungen) und die letzten früheren Aufenthalte; citizen: null ohne verknüpften BürgerJSON
POST /facility/patients/:id/entriesNotiz zu einem Einrichtung-Patienten eintragenJSON
DELETE /facility/patients/:id/entries/:eidEigene Notiz eines Einrichtung-Patienten zurücknehmen (Leitstelle: jede)JSON
GET /facility/roomsEinrichtungen mit ihren Räumen und Betten auflisten (Antwort: { buildings: [{ id, name, position, rooms: [...] }] })JSON
POST /facility/roomsRaum anlegen (Felder buildingId, name)JSON
PATCH /facility/rooms/:idRaum umbenennen oder Position ändernJSON
DELETE /facility/rooms/:idRaum löschen (Patienten fallen in den Warteraum zurück)JSON
POST /facility/rooms/:id/bedsBett in einem Raum anlegenJSON
PATCH /facility/beds/:idBett umbenennen oder Position ändernJSON
DELETE /facility/beds/:idBett löschen (Patient fällt in den Streifen „ohne Bett" zurück)JSON
GET /lagerLager auflisten (Bestandsübersicht) — Admin-Rechte auch zum LesenJSON
GET /lager/search?q=&lagerId=Artikel über alle Lager des Servers suchen (lagerId optional, für Vorrang)JSON
GET /lager/:id[?date=YYYY-MM-DD]Inventur eines Tages lesen (ohne date der heutige)JSON
POST /lagerLager anlegenJSON
PATCH /lager/:idLager umbenennen und/oder Artikelsortierung ändernJSON
DELETE /lager/:idLager löschen (mit allen Artikeln, Tagen und Zählungen)JSON
PUT /lager/:id/dayHeutige Inventur erfassen/aktualisieren (kein Datum im Body)JSON
POST /lager/:id/itemsArtikel anlegenJSON
PATCH /lager/:id/items/:itemIdArtikel bearbeitenJSON
DELETE /lager/:id/items/:itemIdArtikel archivieren (keine Löschung)JSON
PUT /lager/:id/items/orderEigene Artikelreihenfolge speichernJSON
GET /versicherung/catalogPreisliste lesen: Tarife, Modul×Tarif-Matrix, Rabattstaffel — Admin-Rechte auch zum LesenJSON
PATCH /versicherung/tariffs/:idTarifname und/oder Grundpreis ändernJSON
POST /versicherung/modulesModul anlegenJSON
PATCH /versicherung/modules/:idModul umbenennen, Abrechnungsart setzen (perPerson: Preis je Person statt einmalig) und/oder Preise setzen (`prices: { tariffId: Betrag \null }, null` löscht den Preis)JSON
DELETE /versicherung/modules/:idModul archivieren (bleibt bei Kunden gebucht)JSON
PUT /versicherung/modules/orderReihenfolge der Module setzenJSON
POST /versicherung/discountsRabattstufe anlegen oder überschreiben (eine je Personenzahl)JSON
DELETE /versicherung/discounts/:idRabattstufe löschenJSON
GET /versicherung/customersVersicherte auflisten, je mit Personenzahl und gerechneter PrämieJSON
POST /versicherung/customersVersicherten anlegen (name, tariffId, optional note)JSON
GET /versicherung/customers/:idEin Versicherter samt seiner PersonenJSON
PATCH /versicherung/customers/:idName, Tarif, Notiz, gebuchte Plätze (bookedSlots, null = nach eingetragenen Personen rechnen), Vertragslaufzeit (termFrom/termTo, "YYYY-MM-DD", null = entfernen), Medikit-Zähler und -Kontingent (medkitsUsed, medkitLimit) und/oder gebuchte Module (moduleIds) ändernJSON
DELETE /versicherung/customers/:idVersicherten löschenJSON
PUT /versicherung/customers/orderReihenfolge der Versicherten setzenJSON
POST /versicherung/customers/:id/personsVersicherte Person hinzufügenJSON
PATCH /versicherung/customers/:id/persons/:personIdPerson umbenennen oder Notiz ändernJSON
DELETE /versicherung/customers/:id/persons/:personIdPerson entfernenJSON
PUT /versicherung/customers/:id/persons/orderReihenfolge der Personen setzenJSON
GET /versicherung/kasse/personsWer als Person einer Kassenzeile in Frage kommt: Admins dieser Gilde (Discord-Administratoren und die in den Einstellungen zusätzlich eingetragenen), geschnitten mit dem Personalblatt. partial: true heißt, der Mitglieder-Cache war noch nicht warm (kurz nach dem Start) — es wird bewusst nie pro Anfrage abgerufenJSON
GET /versicherung/kasse[?month=YYYY-MM]Kassenbuch lesen (ohne month alles); keine SummenzeileJSON
POST /versicherung/kasseKassenzeile anlegen (date, employeeId, purpose, amount vorzeichenbehaftet, optional note, paidOut)JSON
PATCH /versicherung/kasse/:idKassenzeile ändernJSON
DELETE /versicherung/kasse/:idKassenzeile löschenJSON
GET /versicherung/expiringVerträge, deren Vertragsende in den eingestellten Vorlauf fällt — für das Band auf dem LeitstellenblattJSON
GET /versicherung/settingsVorlauf der Ablauf-Erinnerung und Freigabe der Übersicht lesen (expiryLeadDays, overviewPublic)JSON
PUT /versicherung/settingsBeides setzen, jedes für sich (expiryLeadDays, ganze Zahl 0–90; overviewPublic, Ja/Nein). Nur mitgeschickte Felder werden geschriebenJSON
GET /versicherung/overviewVersicherungsnehmer, die gebuchten Module, gerechneter Medikit-Stand und Vertragsende — die einzige Ausnahme im Namensraum: ohne Admin-Rechte lesbar, aber nur wenn die Gilde die Übersicht freigegeben hat (overviewPublic); sonst 403. Für Admins immer lesbarJSON

Die folgenden Schreib- und Detailrouten gehören ebenfalls zur aktuellen API (die ausführlichen Regeln stehen in den jeweiligen Abschnitten bzw. im Quellcode der Route):

Methode & PfadZweckFormat
POST /admin/apikeysAPI-Key erstellen; Klartext nur in der AntwortJSON
PUT /admin/settings, /admin/stempeluhr, /admin/brandingGuild-, Stempeluhr- und Branding-Einstellungen ändernJSON
GET /admin/dispatchers, /admin/statusesAktuelle Leitstellen-Slots und Statusdefinitionen lesenJSON
GET /overview, /alarm, /me/alarmDashboard-, Alarm- und persönlichen Alarmstatus lesenJSON
PATCH /documents/:id, DELETE /documents/:idDokument ändern oder in den Papierkorb legen (?permanent=1: endgültig löschen)JSON
POST /documents, POST /documents/categoriesDokument oder Kategorie anlegenJSON
PATCH /einsatz/:id/entries/:eidEigene Verlaufsnotiz ändernJSON
PATCH /facility/patients/:pid, PATCH /facility/patients/:pid/entries/:eidEinrichtungpatient oder Verlauf ändernJSON
GET /uploads, DELETE /uploads/:idEigene Uploads verwaltenJSON
GET /lager/searchArtikel guildweit suchen (q, optional lagerId)JSON
GET /versicherung/kasse, POST /versicherung/kasseKassenbuch lesen oder Zeile anlegenJSON
DELETE /ai/templates/:idKI-Vorlage löschenJSON
POST /calendarKalendertermin anlegenJSON
POST /letterheads, DELETE /letterheads/:idBriefkopf anlegen oder löschenJSON
POST /lists, DELETE /lists/:idListe anlegen oder löschenJSON
POST /lists/:id/entries, DELETE /lists/:id/entries/:entryIdZeile anlegen oder löschenJSON

:userId ist die numerische Discord-User-ID. :type ist einer der unten gelisteten Diagramm-Typen.

Das Leitstellenblatt unter 0 nutzt ausschließlich die hier dokumentierten Endpunkte. Die Seite verlangt Discord- Login. Nach der Anmeldung wird die Guild über /dispatch/{guildId} gewählt; der Browser sendet Session-Cookie und x-guild-id. API-Schlüssel bleiben für externe Integrationen und die FiveM-Resource vorgesehen.

Kooperations-Gesundheitsbestätigung

health-ack akzeptiert nur eine authentifizierte Admin-Session der eigenen Kooperationsseite und eine aktive direkte Kooperation. Kein Request-Body oder genau {} ist erlaubt; Client-Zeitstempel und ownerGuildId werden abgewiesen. Der Server setzt den eigenen lastHealthAcknowledgedAt-Wert und aktualisiert dadurch automatisch updatedAt; Kontakt-, Protokoll-, Capability- und Verifikationsfelder bleiben unverändert. Das ist die ausdrückliche Bestätigung eines Admins, dass diese Seite die API erfolgreich erreicht hat, kein automatischer Poll und kein Beweis für die Erreichbarkeit des Partners. partnerMetadata.lastHealthAcknowledgedAt zeigt dessen letzte eigene Bestätigung. verifiedAt bestätigt dagegen nur beschreibende Kontakt-, Protokoll- und Capability-Metadaten. Ein alter Zeitstempel bleibt nach Aussetzung oder Widerruf als Verlauf sichtbar, ist aber kein aktueller Verbindungsstatus; beim neuen Vorschlag beginnt er wieder als null. Gesundheitsbestätigungen ändern weder Freigaben noch Zugriffsrechte.

Scoped InGame-Schlüssel und Capability-Versionen

Neue scoped Schlüssel verwenden Credential-Katalog 2 (stmp_sc2 im Klartext, sc2 im gespeicherten Verifier). Katalog 1 bleibt unverändert dekodierbar; neue Bits werden ausschließlich in Katalog 2 vergeben. Die Capability-Route akzeptiert Vertrag 1 und 2, lehnt andere oder malformed X-MDT-*-Header ab und ergänzt bei Vertrag 2 scopeCatalogVersion. scopes enthält nur die hashgebundenen Grants des aufgelösten Schlüssels. Legacy-Keys behalten ihre alte Liste und erhalten keine neue Plattformberechtigung.

Ausstellbar sind die bestehenden Rang-, Organisationsmitgliedschafts- und Mitarbeiterfreigabe-Scopes sowie die owner-lokalen Organisationseinheiten-Scopes orgUnits:read, orgUnits:platform.organization_unit_create, orgUnits:platform.organization_unit_update und orgUnits:platform.organization_unit_archive. Die API bestimmt die Owner-Guild aus dem Schlüssel; Request-Header können sie nicht ersetzen. Methode, Pfad und Audit-Aktion müssen außerdem mit der zentralen Route übereinstimmen.

Katalog 2 reserviert exakte Namen für operative Einheiten/Status, Incidents, Assignments, Bürger und Gewahrsam, stellt sie aber nicht aus. Die vorhandenen Routen verlangen eine Discord-Sitzung und bauen Actor-Home, Benutzerattribute und Feldprojektionen im Policy-v2-Dienst auf; dieser Dienst weist den noch unverifizierten scoped-service-Principal explizit ab. Ohne eine serververifizierte Übersetzung des Keys in owner-, action- und field-begrenzte Policy-Claims blieben clientseitige Owner-/Actor-/Action-/Field-Angaben eine unsichere Autoritätsquelle. Deshalb antworten diese Familien scoped Bearern weiterhin mit 403.

Query-Parameter

ParameterWerteStandardGilt für
perioddaily, weekly, monthly, alltime, customweeklyalle Stats-Endpunkte
dateYYYY-MM-DDheutealle Stats-Endpunkte außer inactive
from, toYYYY-MM-DD–nur mit period=custom (beide Tage eingeschlossen, max. 366 Tage)
comparetrue, falsefalseRangliste, Einzelstatistik
detailtrue, falsefalseEinzelstatistik, /me/stats
rankId, orgUnitIdId aus /stats/filters–Rangliste, Heatmap, Schichten, Inaktive
limit1–50010Rangliste (JSON)
chartstrue, falsefalseStats-JSON-Endpunkte
maxPixelsGanzzahl ≥ 50000990000alle Diagramme
includeArchivedtrue, falsefalseRangliste, Heatmap, Ranglisten-Diagramme

period

eingeschlossen); Verlauf pro Tag, ab 63 Tagen pro ISO-Woche. Verglichen wird mit dem gleich langen Zeitraum direkt davor.

date

Wählt den Tag, die Woche oder den Monat, der ausgewertet wird — angegeben als Berliner Kalendertag, der irgendwo im gewünschten Zeitraum liegt. Ohne date gilt der laufende Zeitraum.

Ein abgeschlossener Zeitraum wird bis zu seinem eigenen Ende ausgewertet, der laufende bis jetzt. Ein Datum in der Zukunft wird auf heute begrenzt, ein ungültiges mit 400 abgelehnt. Das gilt auch für die Diagramm-Endpunkte (.png).

rankId und orgUnitId

Grenzen die serverweiten Zahlen auf einen Rang bzw. eine Organisationseinheit ein; beide zusammen ergeben die Schnittmenge. Wie includeArchived wird vor dem Zählen gefiltert, sodass Summen, Verlauf, Anteil, Heatmap und Vergleichswerte zueinander passen. Die Einsatz-Kennzahlen werden nicht gefiltert.

compare

Liefert zusätzlich baseline (Durchschnitt der vergleichbaren Zeiträume, jeweils bis zum selben Zeitpunkt), baselineTrend (derselbe Durchschnitt als Verlauf über die ganzen Vergleichszeiträume) und bei der Rangliste previousRanks (Platz je Nutzer im letzten Vergleichszeitraum).

includeArchived

Im Personalblatt archivierte Personen („Ehemalige") bleiben mit ihren Schichten in der Historie, zählen aber standardmäßig in keiner serverweiten Statistik mehr mit — weder in den Summen noch im Verlauf, im Anteil oder in der Heatmap. Mit includeArchived=true werden ihre Schichten wieder mitgerechnet. Die Einzelstatistik /stats/user/:userId ist davon nicht betroffen: Sie liefert immer die Schichten der angefragten Person.

charts und maxPixels

Mit charts=true werden die Diagramme als Base64-PNG direkt in die JSON-Antwort eingebettet (Objekt charts). Ohne den Parameter bleibt die Antwort schlank.

maxPixels ist das Pixel-Budget pro Diagramm. Viele Einbettungs-Ziele begrenzen Bilder auf 1 Mio. Pixel bzw. 2 MB. Die Diagramme werden normalerweise mit 2×-Skalierung für scharfe Kanten gerendert; überschreitet das Ergebnis das Budget, wird die Skalierung so weit reduziert, dass es gerade darunter bleibt. Setze maxPixels kleiner, um für ein Ziel mit strengerem Limit zu rendern:

https://mdt.npdev.eu/api/v1/stats/leaderboard?charts=true&maxPixels=1000000

Diagramm-Typen

Rangliste (/stats/leaderboard/chart/:type.png):

Nutzer (/stats/user/:userId/chart/:type.png):

In der JSON-Antwort (mit charts=true) erscheinen dieselben Schlüssel im Objekt charts, jeweils als Base64-kodiertes PNG.

Antwort-Schemas

Alle Zeiten sind Millisekunden (*Ms), Zeitpunkte ISO-8601-Strings in UTC.

GET /stats/leaderboard

{
  "period": "weekly",
  "generatedAt": "2026-07-25T02:15:10.000Z",
  "window": { "start": "2026-07-19T22:00:00.000Z", "end": "2026-07-25T02:15:10.000Z" },
  "totals": {
    "totalMs": 518400000, "totalShifts": 42, "activeCount": 9,
    "totalBreakMs": 7200000, "autoClosedShifts": 2
  },
  "rows": [
    {
      "userId": "123456789012345678",
      "badgeNumber": "01",
      "nickname": "[MD-01] Mustermann",
      "totalMs": 144000000,
      "shiftCount": 12,
      "avgMs": 12000000,
      "breakMs": 1800000,
      "autoClosedCount": 1
    }
  ],
  "trend": [{ "label": "Mo", "totalMs": 72000000 }],
  "charts": { "bars": "<base64>", "share": "<base64>", "trend": "<base64>" }
}

Das Feld charts erscheint nur bei charts=true und wenn Daten vorhanden sind.

window ist der tatsächlich ausgewertete Zeitraum (start ist null bei period=alltime) — halboffen, also start eingeschlossen und end nicht.

GET /stats/user/:userId

{
  "period": "weekly",
  "generatedAt": "2026-07-25T02:15:10.000Z",
  "window": { "start": "2026-07-19T22:00:00.000Z", "end": "2026-07-25T02:15:10.000Z" },
  "userId": "123456789012345678",
  "row": {
    "userId": "123456789012345678",
    "badgeNumber": "01",
    "nickname": "[MD-01] Mustermann",
    "totalMs": 144000000,
    "shiftCount": 12,
    "avgMs": 12000000
  },
  "trend": [{ "label": "Mo", "totalMs": 72000000 }],
  "charts": { "shifts": "<base64>", "trend": "<base64>" }
}

row ist null, wenn der Nutzer im Zeitraum keine Schichten hatte.

Mit detail=true kommen heatmap.cells (Wochentag × Stunde), shifts (die einzelnen Schichten, auf den Zeitraum zugeschnitten) und activity (led: geleitete Einsätze, patientsTakenOver: übernommene Patienten) dazu. GET /me/stats liefert dieselbe Antwort für die aufrufende Person.

GET /stats/heatmap

Neben cells, users und entries enthält die Antwort minStaffing (die in den Stempeluhr-Einstellungen hinterlegte Mindestbesetzung, 0 = aus) und, wenn sie gesetzt ist, staffing.occurrences bzw. staffing.understaffed: je Wochentag × Stunde, wie oft diese Stunde im Zeitraum vorkam und wie oft dabei weniger Personen eingestempelt waren. Bei period=alltime wird die Besetzung über die letzten 365 Tage geprüft.

GET /stats/shifts

shifts ist die Liste der Schichten, die den Zeitraum berühren, neueste zuerst (höchstens 2000, dann truncated: true). workedMs und breakMs sind auf den Zeitraum zugeschnitten. Mit userId die Schichten einer Person, mit autoClosed=true nur die automatisch beendeten.

Beispiele

Rangliste der Woche inklusive eingebetteter Diagramme:

curl -H "Authorization: Bearer $KEY" \
  "https://mdt.npdev.eu/api/v1/stats/leaderboard?period=weekly&charts=true"

Balkendiagramm als PNG herunterladen:

curl -H "Authorization: Bearer $KEY" \
  "https://mdt.npdev.eu/api/v1/stats/leaderboard/chart/bars.png?period=weekly" -o bars.png

Einzelnen Nutzer über den gesamten Zeitraum abfragen:

curl -H "Authorization: Bearer $KEY" \
  "https://mdt.npdev.eu/api/v1/stats/user/123456789012345678?period=alltime"

Ein eingebettetes Diagramm aus der JSON-Antwort dekodieren (Shell):

curl -s -H "Authorization: Bearer $KEY" \
  "https://mdt.npdev.eu/api/v1/stats/leaderboard?charts=true" \
  | jq -r '.charts.trend' | base64 -d > trend.png

Rollout-Flags

Die Plattformflächen sind einzeln freischaltbar. Jede Instanz entscheidet über featureFlags in ihrer Plattformkonfiguration (GET/PUT /platform/config), welche Bereiche sie überhaupt betreibt. Der Schalter ist kein reiner Oberflächenhinweis: er hängt vor der eigentlichen Route.

Es entscheidet immer die besitzende Instanz, nicht die fragende. Bei den owner-bezogenen Bereichen ist das die Guild im Pfad (/platform/<bereich>/:ownerGuildId/…), sonst die eigene Instanz des Aufrufers aus x-guild-id bzw. x-actor-home-guild-id. Pfadsegmente, die keine Guild benennen — inbox, sources, board-sources, board-catalog, discovery, attachments, policy — adressieren die eigene Instanz.

Eine Instanz ohne Plattformkonfiguration hat sich in nichts eingewählt. Ist der Schalter aus, antwortet die Route mit 404 Nicht gefunden — bewusst ununterscheidbar von einer unbekannten Route, damit eine abgeschaltete Fläche nichts über ihre Existenz verrät. Kann die Konfiguration nicht gelesen werden, gilt sie als aus; ein Rollout-Schalter fällt nie nach offen.

FlagGesperrte Pfade
canonicalCitizensEnabled/platform/citizens, /platform/citizen-matching
citizenImportsEnabled/citizen-registry, /citizen-import
custodyEnabled/platform/custody
documentsEvidenceEnabled/platform/documents, /platform/evidence
emergencyAccessEnabled/platform/emergency
networkV2Enabled/platform/network (außer den beiden Policy-v2-Pfaden)
organizationsEnabled/platform/organization
policyV2Enabled/platform/network/policies, /platform/network/access
sharedIncidentsUnitsEnabled/platform/incidents, /platform/units, /platform/dispatchers

Der längere Präfix gewinnt: /platform/network/policies hängt an policyV2Enabled, der Rest von /platform/network an networkV2Enabled.

/platform/config und /platform/outbox hängen an keinem Flag — die Konfiguration muss lesbar bleiben, um sie überhaupt zu setzen. Zwei Flags sperren absichtlich keinen Pfad: allowMultipleClearances ist eine Datenregel (darf eine zweite aktive Freigabestufe bestehen?), und platformAccessEnabled ist die Berechtigungsmaschine selbst, die bei jeder Entscheidung im Dienst gelesen wird statt an einer Routengrenze.

Plattform-Netzwerk

Die Plattform-Verwaltungsrouten akzeptieren ausschließlich eine angemeldete Browser-Sitzung aus der eigenen Instanz. API-Schlüssel und Partnerkontexte sind dafür nicht zulässig. Die Admin-Prüfung wird bei jeder Anfrage frisch aus der aktuellen Discord-Mitgliedschaft, konfigurierten Administratoren und – bei aktiviertem Rangsystem – der effektiven Fähigkeit settings.manage abgeleitet.

POST /platform/network/access/explain ist ausschließlich eine hypothetische Ressourcen-Simulation. Die Antwort kennzeichnet dies mit simulation.mode: "hypothetical-resource" und simulation.authoritativeResource: false. Der Aufrufer darf Zielbenutzer und dessen Actor-Home angeben; Rollen-, Rang-, Freigabestufen- und Owner-Claims werden abgelehnt. Ressourcenmetadaten beschreiben nur das simulierte Szenario und bestätigen ausdrücklich nicht den Zugriff auf einen realen Datensatz. Ein späterer Erklärmodus für reale Datensätze muss deren Descriptor serverseitig laden.

GET /platform/network/directory verwendet ausschließlich den separaten Ressourcentyp platform-directory. Der direkte Kooperations- und Policy-Zugriff wird geprüft, bevor Partnerdaten geladen werden. Danach lädt und projiziert der Server nur die explizit angefragten und freigegebenen Felder panelRanks, organizationalUnits und clearanceLevels. panelRanks enthält alle im Panel konfigurierten Ränge, aber keine Discord-Rollen-IDs. Eine Freigabe für Bürger-, Patienten- oder andere Fachdaten gewährt daher keinen Zugriff auf dieses Verzeichnis.

GET /platform/network/catalog liefert catalog.version, den stabilen Aktionskatalog und die bereits tatsächlich integrierten Ressourcentypen mit ihren Feldern. Clients dürfen unbekannte freie Ressourcen/Felder nicht als integriert oder abrufbar behandeln. Neue Domänen werden einzeln ergänzt und erhöhen die Katalogversion; es gibt bewusst keinen globalen Bereitschaftsschalter, der unfertige Domänen als einsatzbereit ausgibt.

GET /platform/dispatchers/:ownerGuildId ist ein Policy-v2-Ressourcenabruf. Er verlangt eine Browser-Sitzung sowie x-actor-home-guild-id; API-Schlüssel und der alte Partnerkontext werden nicht verwendet. Die Quelle wird aus dem Pfad serverseitig festgelegt. Mit ?fields=a,b können ausschließlich die im Katalog veröffentlichten Felder angefragt werden. Die Ressourcenfreigabe wird vor dem Laden geprüft, anschließend werden nur die pro Betrachter lesbaren Felder ausgegeben. Ein gildenweiter WebSocket-Snapshot wird dafür bewusst noch nicht veröffentlicht, weil dessen Inhalt je Betrachter unterschiedlich ist.

Bürgerimport-Upload

Die Upload-Endpunkte akzeptieren ausschließlich eine frische menschliche Administrator-Sitzung der eigenen Instanz; API-Schlüssel sind ausgeschlossen. Ein Import beginnt mit einem strikten JSON-Manifest samt erwarteter Größe, Chunk-Anzahl, SHA-256 und Idempotency-Key. Jeder maximal 4 MiB große Chunk wird als application/octet-stream mit x-chunk-sha256 übertragen. finalize und abort erwarten jeweils {}. Die Gesamtgröße ist standardmäßig auf 64 MiB begrenzt. Das Versiegeln stellt nur ein unveränderliches Importartefakt bereit; die fachliche Validierung und das transaktionale Anwenden sind getrennte Arbeitsschritte.

Quellenkatalog und MedicNet-Mapping-Versionen

GET /citizen-registry/source-catalog listet die registrierbaren Quellenzuordnungen mit adapterVersion, mappingVersion, imagesPolicy und isCurrent. isCurrent markiert die Zuordnung, die eine neu registrierte Quelle verwenden sollte; ältere Einträge bleiben gelistet und bytegenau festgeschrieben, damit eine bestehende Quelle unverändert weiterimportiert. POST /citizen-registry/sources akzeptiert für MedicNet ausschließlich medicnet-adapter-v1 zusammen mit einer der drei katalogisierten Mapping-Versionen.

MappingStandVerhalten bei gang, gewalt, tot
medicnet-map-v1Bestandkeine Zustandsansprüche
medicnet-map-v2Bestandkeine Zustandsansprüche
medicnet-map-v3aktuell"1" = angekreuzt, 0/false/no/nein/off = nicht angekreuzt

medicnet-map-v3 legt für jeden erkannten Wert einen owner-medizinischen BOOLEAN-Anspruch der Klassifizierung RESTRICTED an (medical.medicnet.gang, …gewalt, …tot). Jede andere Schreibweise bleibt ungelöst und wird als ambiguous-field zur Prüfung gemeldet, statt geraten zu werden; der Rohwert bleibt in allen Fällen erhalten. Es entsteht dabei kein Polizeifeld, keine Polizeikategorie und kein polizeilicher Anspruch. Eine Klassifizierung ist keine Sichtbarkeit: jeder Lesezugriff braucht weiterhin eine ausdrückliche Feldfreigabe des Eigentümers.

TV-Präsentationen

Jeder Bildschirm-Link einer Präsentation hat zwei öffentliche Adressen außerhalb von /api/v1, geschützt allein durch den zufälligen Token im Pfad:

(multipart/x-mixed-replace) aus verlustfreien 1920×1080-PNGs; Folien von vor der Umstellung bleiben JPEG. Browser, auch der DUI-Browser eines FiveM- Fernsehers, ersetzen das Bild, sobald eine neue Folie gezeigt wird. Solange sich nichts ändert, wird die aktuelle Folie alle 50 s erneut geschickt.

bei alten Folien JPEG), nicht zwischenspeicherbar, für Clients, die selbst neu laden.

Jede offene Verbindung zählt als Zuschauer; ein Link mit Zuschauern oder einem Abruf in den letzten 90 s gilt als live.

Admin-Endpunkte

Diese Endpunkte schreiben Daten und aktualisieren das Panel sofort. Alle Aktionen werden über die Dienstmarke adressiert – im Request als Zahl oder numerischer String (z. B. 99 oder "01"), 01 und 1 sind dieselbe Marke. In allen JSON-Antworten erscheint die Dienstmarke dagegen immer als zweistelliger, mit führender Null aufgefüllter String (1 → "01", 99 → "99").

Die Auflösung Dienstmarke → Person läuft dreistufig, von billig nach teuer:

  1. Offene Schicht – ist die Marke eingestempelt, steht alles schon in der DB.
  2. Mitarbeiter-Cache – alle Mitglieder mit einer Dienstmarke im

konfigurierten Nickname-Format werden in der Datenbank gehalten und über Gateway-Events aktuell gehalten.

  1. Live-Abruf der Server-Mitglieder – langsam und rate-limitiert, daher nur

als letzte Instanz (z. B. brandneues Mitglied, das der Cache noch nicht kennt). Nur dieser Schritt braucht den Server-Members-Intent.

Der Cache wird beim Start des Bots einmal vollständig befüllt, danach über GuildMemberAdd/Update/Remove gepflegt und alle 6 Stunden sicherheitshalber neu synchronisiert. Im Normalbetrieb erreicht damit keine Anfrage mehr Stufe 3.

POST /admin/clock-in

Body { "badge": 99 }. Stempelt die Person mit Dienstmarke 99 im konfigurierten Format ein (im Rettungsdienst-Bestand z. B. MD-99). 404 wenn keine Person diese Marke im Nickname trägt, 409 wenn sie bereits eingestempelt ist. Antwort 201:

{ "badgeNumber": "99", "userId": "…", "nickname": "[MD-99] Dr. Pearce",
  "clockInAt": "2026-07-25T10:00:00.000Z", "status": "OPEN" }

POST /admin/clock-out

Body { "badge": 99 }. Beendet die offene Schicht der Person mit Dienstmarke 99 (wie der „Ausstempeln"-Knopf im Panel). Räumt zusätzlich auf: hält die Person einen Dispatcher-Slot, werden alle drei geleert; außerdem wird sie aus allen Einheiten entfernt (leere Einheiten werden gelöscht). 404 wenn die Marke keine offene Schicht hat. Diese Route löst keine Dienstmarke über Server-Mitglieder auf, benötigt also den Server-Members-Intent nicht. Antwort 200:

{ "badgeNumber": "99", "userId": "…", "clockOutAt": "2026-07-25T18:00:00.000Z",
  "workedMs": 27000000, "breakMs": 1800000, "status": "CLOSED" }

GET /admin/clock-status/:badge

{ "badgeNumber": "99", "clockedIn": true, "status": "OPEN", "since": "2026-07-25T10:00:00.000Z" }

clockedIn ist false (mit status/since = null), wenn die Marke keine offene Schicht hat.

GET /admin/on-duty

Alle aktuell offenen Schichten (OPEN oder PAUSED), früheste Einstempelung zuerst. since ist immer die Einstempelzeit (clockInAt), unabhängig vom Status. onDuty ist [], wenn niemand im Dienst ist.

{
  "generatedAt": "2026-07-25T19:20:00.000Z",
  "onDuty": [
    { "badgeNumber": "99", "userId": "…", "nickname": "[MD-99] Aiden Pearce",
      "status": "OPEN", "since": "2026-07-25T10:00:00.000Z" },
    { "badgeNumber": "01", "userId": "…", "nickname": "[MD-01] Jermaine Prince",
      "status": "PAUSED", "since": "2026-07-25T18:45:00.000Z" }
  ]
}

GET /admin/members

Alle Mitarbeiter, deren Nickname eine Dienstmarke im konfigurierten Format trägt, nach Dienstmarke sortiert. Quelle ist der Cache – die Antwort kostet keinen Discord-Abruf. members ist [], solange der erste Sync nach dem Bot-Start noch nicht durchgelaufen ist.

Mitarbeiter ist, wer die konfigurierte Mitarbeiter-Rolle trägt; ohne konfigurierte Rolle zählen nur Server-Admins und die in den Einstellungen hinterlegten zusätzlichen Admins. Die Dienstmarke im Nickname liefert nur die Nummer – sie macht niemanden zum Mitarbeiter, denn jeder kann sich selbst umbenennen.

{
  "members": [
    { "badgeNumber": "01", "userId": "…", "name": "Jermaine Prince" },
    { "badgeNumber": "99", "userId": "…", "name": "Aiden Pearce" }
  ]
}

name ist der Nickname ohne Marken-Präfix.

PUT /admin/dispatchers

Body mit optionalen Feldern leitstelle, sekLeitstelle, hoechstrangiger (jeweils Dienstmarke). Nur mitgesendete Felder werden gesetzt; weggelassene bleiben unverändert. Die Slots werden geleert, sobald die zugewiesene Person sich ausstempelt. Antwort 200 mit dem aktuellen Stand aller drei Slots.

Einheiten (/admin/units)

Eine Einheit hat ein eindeutiges Rufzeichen (callsign), einen freien status, eine freie info und bis zu 3 Mitglieder (Dienstmarken).

Beim Ausstempeln wird die Person automatisch aus allen Einheiten entfernt; eine Einheit ohne Mitglieder wird gelöscht.

Einsatzzuordnung

einsatzId setzt die Einheit auf ein offenes eigenes Einsatzblatt; null hebt die Zuordnung auf. Der Freitext in info bleibt dabei unangetastet — die Zuordnung steht neben ihm, nicht an seiner Stelle. War die Einheit schon einem anderen Einsatz zugeordnet, wird diese Zuordnung beendet und die neue angelegt; dieselbe Auswahl ein zweites Mal ändert nichts.

Jede Zuordnung und jede Aufhebung schreibt eine Systemzeile in das Protokoll des ganzen Einsatzes (GET /einsatz/:id/entries), nicht in eine Patientenakte: Einheit „Adam-01" zugeordnet bzw. Zuordnung von Einheit „Adam-01" aufgehoben. Wird der Einsatz abgeschlossen oder die Einheit aufgelöst (letztes Mitglied entfernt, ausgestempelt, gelöscht), endet die Zuordnung von selbst und wird ebenso protokolliert. Die Zuordnungshistorie bleibt erhalten; GET /admin/units nennt nur currentAssignment als Zeiger und keinen Einsatzinhalt.

409 bei einem abgeschlossenen oder unbekannten Einsatz, bei einer archivierten Einheit und wenn ein Besatzungsmitglied keine aktive Personalakte mit Dienstnummer in dieser Instanz hat.

Statusmeldungen (/admin/statuses)

Das Vokabular, das das Board für den status einer Einheit anbietet. Pro Server konfigurierbar; wurde nie etwas gespeichert, gelten die Standardmeldungen (Status 1–Status 6, Status 9).

Ein Eintrag hat ein label, eine color aus der festen Palette (green, orange, blue, red, grey) und alarm – eine so markierte Meldung bekommt im Board und im Discord-Panel das 🚨.

Einträge werden getrimmt, leere und zu lange (> 40 Zeichen) verworfen, Labels case-insensitiv dedupliziert, die Liste bei 20 Einträgen gekappt; eine unbekannte Farbe wird zu grey.

PUT /admin/units/:callsign bleibt davon unberührt und akzeptiert weiterhin beliebigen status-Freitext. Nur die Selbstbedienungsroute unten ist auf die konfigurierten Meldungen beschränkt.

PUT /me/unit-status

Body { "callsign": "Adam-01", "status": "Status 3" }. Setzt den Status einer Einheit, die die aufrufende Person selbst besetzt – dafür genügt Lesezugriff auf den Server (das Funkgerät im Leitstellenblatt). Nur mit Session, nicht mit API-Key.

404 wenn es die Einheit nicht gibt, 403 wenn die aufrufende Person nicht zu ihrer Besatzung gehört, 400 wenn status keine konfigurierte Meldung ist. Antwort 200 mit der aktualisierten Einheit.

GET /me/alarm

Was das Leitstellenblatt braucht, um einen Notruf hörbar zu machen. Bewusst klein: die Route wird von jedem offenen Tab alle 5 Sekunden abgefragt.

{ "onDuty": true, "alarms": ["Adam-01"] }

GET /admin/statuses alarm: true trägt. Reihenfolge wie GET /admin/units. Ein Freitext-Status, den die Statusliste nicht kennt, zählt nicht.

Browser den Alarmton.

Lesezugriff genügt. Ohne verknüpftes Discord-Konto ist onDuty immer false.

Personalblatt

Das Personalblatt verwaltet Stammdaten und Ausbildungsstand aller Mitarbeitenden in admin-konfigurierbaren Spalten. GET /personal darf lesen, wer das Leitstellenblatt lesen darf (Berechtigung view); jede andere Route unter /personal – einschließlich GET /personal/sync – braucht Admin-Rechte (Server-Administrator oder unter Einstellungen eingetragener zusätzlicher Admin). Das gilt auch für Dispatcher, die andernorts in dieser API Schreibrechte haben: hier haben sie keine. Ein API-Schlüssel hat wie überall vollen Zugriff.

Im aktivierten Rollenmodus ersetzt der Capability-Schlüssel die grobe Legacy-Stufe: personal.view liest das Blatt, personal.write ändert Zellen, und Rang-, Notiz- sowie Synchronisationsverwaltung verlangt settings.manage. Die folgenden Legacy-Beschreibungen gelten für Guilds ohne aktiviertes Rangsystem.

GET /personal

{
  "employees": [
    {
      "id": "clx…",
      "userId": "123456789012345678",
      "badgeNumber": "01",
      "name": "Jermaine Prince",
      "active": true,
      "values": {
        "clm…": { "v": true, "at": "2026-07-26T10:00:00.000Z", "by": "987654321012345678" }
      }
    }
  ],
  "columns": [
    {
      "id": "clm…",
      "label": "Flug Ausbildung",
      "kind": "checkbox",
      "options": [],
      "group": "Ausbildung",
      "color": "orange",
      "adminOnly": false,
      "order": 0
    }
  ]
}

userId ist null bei manuell angelegten Mitarbeitern ohne Discord-Konto. suspended ist der In-Game-RP-Suspendierungsstatus. Er wird nur für aktive Mitarbeiterzeilen ausgewertet; ein Discord-Mitglied ohne Employee-Zeile wird durch die Suspendierungsrolle nicht eingeschränkt. values ist nach Spalten-id indiziert; fehlt eine Spalte in einer Zeile, wurde für sie noch nie ein Wert gesetzt. kind ist eine von text, number, date, promotion, select, checkbox, rank; color eine von red, green, orange, blue, magenta oder null. Eine Spalte mit "adminOnly": true wird Nicht-Admins überhaupt nicht ausgeliefert – weder die Spaltendefinition noch die Werte der Mitarbeitenden dazu. Ein kind: "promotion" verhält sich wie date, wird vom Server aber zusätzlich selbst gesetzt: wechselt der Rang einer Person von einem Rang auf einen anderen, trägt der Server das heutige Datum (Zeitzone Europe/Berlin) ein. Die erste Rangvergabe und das Leeren des Rangs lösen das nicht aus, ein mitgeschickter eigener Wert hat Vorrang. Aktive, mit einem Discord-Konto verknüpfte Zeilen werden bei jedem Lesen automatisch mit Dienstmarke/Name aus dem Mitarbeiter-Cache abgeglichen; archivierte Zeilen behalten ihren letzten Stand.

PUT /personal/columns

Body { "columns": [ … ] } – die vollständige Spaltenliste. Ein Eintrag mit id behält sie, ein Eintrag ohne id bekommt eine neue; eine gespeicherte Spalte, die im Body fehlt, wird gelöscht – ihr Wert ist danach bei keinem Mitarbeiter mehr sichtbar. Die Position im Array bestimmt order; ein mitgeschicktes order-Feld wird ignoriert. options gilt nur für select (max. 50 Einträge à 48 Zeichen), label max. 64 Zeichen, group max. 32 Zeichen, insgesamt max. 100 Spalten. adminOnly ist optional und gilt nur bei exakt true als gesetzt.

400 mit "Keine Spaltenliste übergeben", wenn der Body kein columns-Array enthält. 400 mit "Mindestens eine Spalte ist ungültig: Bezeichnung fehlt oder ist länger als 64 Zeichen." hat drei mögliche Ursachen: ein unbekanntes kind, eine leere oder zu lange Bezeichnung – oder schlicht mehr als 100 Einträge im Body (die Normalisierung kappt bei genau 100 Spalten und verwirft den Rest stillschweigend, was denselben Längenvergleich auslöst wie eine ungültige Spalte). In allen drei Fällen schlägt die ganze Anfrage fehl, statt eine Spalte still zu löschen; die Meldung nennt dabei immer den Bezeichnungs-Grund, auch wenn die tatsächliche Ursache die 100er-Grenze war. Antwort { "columns": [ … ] }.

POST /personal/employees

Legt einen Mitarbeiter ohne Discord-Konto an. Body { "name": "…", "badgeNumber": 1 } (badgeNumber optional/null, als Zahl oder numerischer String, z. B. "01"). 400 mit "Name fehlt oder Dienstnummer ist ungültig", wenn name leer oder länger als 64 Zeichen ist, oder badgeNumber keine positive Ganzzahl ist. Antwort: der neue Mitarbeiter (Schema wie in GET /personal, also badgeNumber als zweistelliger, nullpadded String).

PATCH /personal/employees/:id

Body { "values": { "<spaltenId>": <wert> } } – setzt nur die mitgeschickten Zellen, alle anderen bleiben unverändert. at und by kommen ausschließlich vom Server (aktueller Zeitpunkt, aufrufender Discord-User bzw. "api-key" bei Zugriff per API-Schlüssel) und werden aus dem Body ignoriert. Der Wert wird je nach Spalten-kind geprüft: checkbox akzeptiert nur true/false (ein gesendetes null wird als false gespeichert), select nur einen der hinterlegten Optionswerte, date nur das Format YYYY-MM-DD, number nur eine gültige Zahl. Passt der gesendete Wert bei einer dieser vier Arten nicht zum kind, wird die betroffene Zelle stillschweigend übersprungen und behält ihren alten Wert; der Rest des Patches wird trotzdem angewendet. Ausnahme text: ein String über 512 Zeichen wird nicht übersprungen, sondern auf 512 Zeichen gekürzt und in dieser gekürzten Form gespeichert – die vorherige Zelle wird also überschrieben, nicht erhalten. 400 mit "Keine Werte übergeben", wenn values fehlt oder kein Objekt ist. 404 mit "Mitarbeiter nicht gefunden". Antwort: der aktualisierte Mitarbeiter.

Der Patch darf zusätzlich { "suspended": true | false } enthalten. Bei einer verknüpften Discord-Person wird die konfigurierte Suspendierungsrolle zuerst gesetzt bzw. entfernt und erst danach die Employee-Zeile aktualisiert. Schlägt die Discord-Synchronisierung fehl, bleibt der Datenbankstatus unverändert.

Rangquellen und Rangzuweisungen

Ein Rang kann über discordRoleId, einen stabilen inGameKey und eine Liste von capabilities konfiguriert werden. Während die rangbasierte Berechtigung aktiv ist, werden die Capabilities aller aktuell passenden, verknüpften Discord-Rollen und aller nicht mit Discord verknüpften Employee-Zuweisungen vereinigt; keine Rolle nimmt Rechte einer anderen weg. Veraltete gespeicherte Zuweisungen können deshalb eine verknüpfte Discord-Rolle nicht umgehen. settings.manage ist dabei die Instanz-Administrationsfähigkeit und gewährt die übrigen Capabilities.

Rangdefinitionen. Die Rangliste wird wie bisher vollständig ersetzt; neue Metadaten sind capabilities, discordRoleId und inGameKey.

{ "assignments": [ … ], "discord": { "status": "available" | "unavailable" | "not-linked" } }. Live erkannte Discord-Ränge tragen source: "discord". Bei unavailable ist die Antwort ausdrücklich kein maßgeblicher leerer Discord-Rangstand.

unabhängige Synchronisierung sendet der Aufrufer zusätzlich replaceSources: ["manual"] oder ["ingame"]; dadurch bleiben die jeweils anderen Zuweisungen und sämtliche live in Discord verwalteten Ränge erhalten. Mit Discord verknüpfte Ränge werden an diesem Endpunkt abgelehnt.

nicht suspendierte Administrator-Session der eigenen Instanz; API-Schlüssel und Partnerzugriffe sind ausgeschlossen. Der Body ist ein explizites Delta, z. B. { "addRankIds": ["…"], "removeRankIds": ["…"] }. Ausschließlich die genannten, mit Discord verknüpften Rollen werden über einzelne Discord-Operationen geändert; sonstige Rollen bleiben unberührt. Eine teilweise fehlgeschlagene Synchronisierung antwortet mit 502, den bereits angewandten Rang-IDs, der fehlgeschlagenen Rang-ID und – sofern abrufbar – dem erneut gelesenen aktuellen Stand. Dasselbe Delta kann sicher erneut gesendet werden.

{ "rankKeys": ["…"] } an. Die Schlüssel werden gegen inGameKey aufgelöst und ersetzen ausschließlich die InGame-Zuweisungen.

Die Instanz-Einstellungen ergänzen rolePermissionsEnabled, suspendedRoleId und suspendedCapabilities. Die Suspendierungs-Capabilities sind eine Allowlist und überschreiben bei einer Suspendierung die vollständige normale Rang- und Administratorauflösung. Verknüpfte Ränge werden aus dem live gelesenen Discord-Stand abgeleitet; Änderungen aus dem Panel benutzen dafür ausschließlich den expliziten Discord-Delta-Endpunkt.

Archivieren (/personal/employees/:id/archive, /restore)

Ein Mitarbeiter wird nie gelöscht, nur archiviert.

"Mitarbeiter nicht gefunden". Antwort: der aktualisierte Mitarbeiter.

zurück; die Zeile und ihre bisherigen Spaltenwerte (Ausbildungsstand) bleiben dabei erhalten. 404 mit "Mitarbeiter nicht gefunden". Antwort: der aktualisierte Mitarbeiter.

Discord-Abgleich (/personal/sync)

Der Abgleich mit Discord schlägt nur vor, er ändert nie selbständig etwas.

– Kandidaten mit userId, badgeNumber, name und kind ("add" oder "remove"). Ein Vorschlag entsteht für jedes Guild-Mitglied mit Dienstmarke (und, falls konfiguriert, Mitarbeiter-Rolle), das noch keine aktive Zeile hat (add), bzw. für jede aktive Zeile, deren Person die Bedingung nicht mehr erfüllt (remove). „Noch keine aktive Zeile“ zählt dabei die Dienstnummer mit, nicht nur die verknüpfte userId: trägt bereits eine aktive Zeile die DN des Mitglieds – etwa eine von Hand angelegte, die nie eine userId bekommt –, entsteht kein add. Archivierte Zeilen zählen nicht mit, damit eine zurückkehrende Person weiterhin vorgeschlagen wird.

"kind": "add" | "remove", "accept": true | false } ] }. accept: true bei add reaktiviert eine archivierte Zeile dieser Person (falls vorhanden), statt eine neue anzulegen, und erhält so die Ausbildungshistorie; hat die Person keine eigene Zeile, trägt aber eine aktive Zeile ohne userId ihre DN, wird diese übernommen und verknüpft, statt eine zweite daneben zu schreiben; accept: true bei remove archiviert die Zeile. accept: false blendet den Vorschlag aus, bis sich die zugrunde liegende Bedingung wieder ändert. 400 mit "Keine Entscheidungen übergeben", wenn decisions fehlt oder kein Array ist. Antwort: wie GET /personal/sync`, mit dem Stand nach der Anwendung.

Personalakte-Notizen (/personal/employees/:id/notes)

Freitext-Notizen zu einer Person, sichtbar ausschließlich für Admins – auch lesend. Jeder Admin darf jede Notiz ändern und löschen; authorUserId hält fest, wer sie ursprünglich verfasst hat, und wird dabei nicht überschrieben.

{
  "notes": [
    {
      "id": "cln…",
      "body": "Zweite Verspätung diesen Monat, Gespräch geführt.",
      "authorUserId": "987654321012345678",
      "createdAt": "2026-07-26T10:00:00.000Z",
      "updatedAt": "2026-07-26T10:00:00.000Z"
    }
  ]
}

400 mit "Notiz fehlt oder ist länger als 2000 Zeichen", wenn body fehlt, leer ist oder 2000 Zeichen überschreitet – zu lange Notizen werden nicht gekürzt, sondern abgelehnt. 404 mit "Mitarbeiter nicht gefunden" bzw. "Notiz nicht gefunden", auch dann, wenn die Id zu einem anderen Server oder zu einer anderen Person gehört. authorUserId ist "api-key" bei Zugriff per API-Schlüssel.

Kurse

Eine Instanz führt beliebig viele Kurse. Jeder Kurs bestimmt selbst, welche Felder eine Anmeldung hat und welche Knöpfe an einer Anmeldezeile stehen — bis Migration 0065 gab es genau einen Kurs mit den festen Spalten name, phone, affiliation und den festen Aktionen „Bestanden“, „Zurück“, „Löschen“. Die Migration hat jeder Gilde genau diesen Kurs als Daten angelegt, samt der beiden Felder und der drei Aktionen; die alten Aufrufe funktionieren deshalb unverändert weiter (siehe „Alte Feldnamen“ unten).

GET und POST /courses dürfen alle nutzen, die das Leitstellenblatt lesen dürfen (Berechtigung view); alle anderen Routen unter /courses — Bearbeiten, Aktionen ausführen, Löschen — brauchen Admin-Rechte. Die Routen unter /courses/definitions verwalten die Kurse selbst und brauchen im Rollenmodus zusätzlich settings.manage. Ein API-Schlüssel hat wie überall vollen Zugriff.

GET /courses

{
  "courses": [
    {
      "id": "clx…",
      "name": "Erste-Hilfe-Kurs",
      "description": null,
      "color": null,
      "nameLabel": "Name",
      "openLabel": "Anmeldungen",
      "closedLabel": "Bestanden",
      "selfSignup": true,
      "adminOnly": false,
      "archived": false,
      "order": 0,
      "fields": [
        {
          "id": "clf…",
          "label": "Telefonnummer",
          "kind": "text",
          "options": [],
          "required": false,
          "adminOnly": false,
          "order": 0
        }
      ],
      "actions": [
        {
          "id": "cla…",
          "label": "Bestanden",
          "kind": "complete",
          "fieldId": null,
          "value": null,
          "style": "primary",
          "confirm": false,
          "stage": "open",
          "order": 0
        }
      ]
    }
  ],
  "registrations": [
    {
      "id": "clx…",
      "courseId": "clx…",
      "name": "Jermaine Prince",
      "values": { "clf…": "0176 12345678" },
      "passedAt": null,
      "outcome": null,
      "createdAt": "2026-07-26T10:00:00.000Z"
    }
  ]
}

registrations ist eine Liste über alle Kurse hinweg; courseId sagt, zu welchem eine Anmeldung gehört. values ist { Feld-Id: Wert }, getippt nach dem kind des Feldes (text/select → String, number → Zahl, date → "YYYY-MM-DD", checkbox → Boolean). passedAt ist null, solange die Anmeldung nicht abgeschlossen wurde, sonst der Zeitpunkt (ISO-8601, UTC); outcome ist dann die Beschriftung der Aktion, die sie abgeschlossen hat.

Die Antwort hängt an der Leserin. Ein Kurs mit adminOnly fehlt für Nicht-Admins samt seinen Anmeldungen, ein Feld mit adminOnly fehlt samt jedem Wert dazu. Aus demselben Grund ist das Realtime-Topic courses ein Invalidierungs-Topic: der Server schickt nur „neu lesen“, keine Nutzlast.

Feldarten (kind): text, number, date, select, checkbox. Aktionsarten (kind): complete (schließt ab), reopen (nimmt das zurück), delete (löscht), setfield (schreibt value in fieldId). stage ist open, closed oder both — complete ist immer open, reopen immer closed. style ist neutral, primary oder danger.

POST /courses

Body { "courseId"?: "…", "name": "…", "values"?: { "<feldId>": … } }. name ist Pflicht (max. 64 Zeichen). Fehlt courseId, landet die Anmeldung auf dem ersten nicht archivierten Kurs — genau dem einen, den es vor Migration 0065 gab. 400 mit "Name fehlt oder ein Feld ist zu lang", wenn der Name fehlt oder zu lang ist; 400 mit "„<Feld>" ist ein Pflichtfeld", wenn ein Pflichtfeld leer bleibt; 403, wenn der Kurs keine Selbstanmeldung erlaubt und die Aufruferin kein Admin ist; 404, wenn es den Kurs nicht gibt; 409, wenn er archiviert ist. Ein Wert, der nicht zur Art seines Feldes passt, wird stillschweigend verworfen — dieselbe Regel wie im Personalblatt. Antwort: die neue Anmeldung.

Alte Feldnamen. phone und affiliation dürfen weiterhin neben name stehen. Sie werden auf das Feld gelegt, das im Kurs heute „Telefonnummer“ bzw. „Zugehörigkeit“ heißt; wurde es umbenannt oder gelöscht, verfällt der Wert. Neue Clients schicken values.

PATCH /courses/:id

Body { "values": { "name"?: "…", "<feldId>"?: … } } — setzt nur die mitgeschickten Felder. name steht dabei neben den Feld-Ids, weil es die eingebaute erste Spalte ist. phone/affiliation werden wie beim Anlegen aufgelöst. 400 mit "Keine gültigen Werte übergeben", wenn values fehlt, kein Objekt ist oder kein gültiges Feld enthält. 404 mit "Anmeldung nicht gefunden". Antwort: die aktualisierte Anmeldung.

Aktionen (/courses/:id/actions/:actionId)

POST führt den konfigurierten Knopf aus. Antwort { "ok": true, "deleted": false, "registration": { … } }, bzw. { "ok": true, "deleted": true } bei einer Aktion der Art delete. 404 mit "Anmeldung nicht gefunden" bzw. "Aktion nicht gefunden"; 409 mit "Diese Aktion gehört zur anderen Tabelle", wenn ein open-Knopf auf eine abgeschlossene Anmeldung angewendet wird (oder umgekehrt), und mit "Das Feld dieser Aktion gibt es nicht mehr", wenn ein setfield-Knopf ins Leere zeigt.

Abschluss (/courses/:id/pass, /unpass)

Die beiden Altwege. pass führt die erste complete-Aktion des Kurses aus, unpass die erste reopen-Aktion. 404 mit "Anmeldung nicht gefunden"; 409, wenn der Kurs keine solche Aktion (mehr) hat.

409 auch, wenn die Anmeldung bereits in der Tabelle steht, in die der Aufruf sie brächte — pass auf eine abgeschlossene Anmeldung, unpass auf eine offene. Das ist die eine Verhaltensänderung dieser Runde an den Altwegen: bis 0064 setzte ein zweites pass schlicht einen neuen Zeitstempel. Wer das gebraucht hat, nimmt erst unpass.

Antwort sonst: die aktualisierte Anmeldung.

DELETE /courses/:id

Löscht die Anmeldung endgültig. 404 mit "Anmeldung nicht gefunden". Antwort { "ok": true }.

POST /courses/definitions

Legt einen Kurs an. Body { "name": "…" }, dazu optional description, color, nameLabel, openLabel, closedLabel, selfSignup, adminOnly, archived. Der neue Kurs bekommt die drei Standardaktionen („Bestanden“, „Zurück“, „Löschen“) und keine Zusatzfelder. 400, wenn der Name fehlt oder zu lang ist oder die Instanz schon 50 Kurse hat. Antwort: der neue Kurs im Schema von GET /courses.

PATCH /courses/definitions/:courseId

Setzt nur die mitgeschickten Schlüssel (dieselben wie beim Anlegen). Eine leer gelassene Beschriftung fällt auf ihre Vorgabe zurück statt den Aufruf scheitern zu lassen. 400 mit "Keine gültigen Werte übergeben", 404 mit "Kurs nicht gefunden". Antwort: der geänderte Kurs.

PUT /courses/definitions/:courseId/layout

Body { "fields": […], "actions": […] } — beide beschreiben die vollständige Liste: Einträge mit id bleiben, id: null legt neu an, und was fehlt, wird gelöscht. Beides in einem Aufruf, weil eine setfield-Aktion auf ein Feld zeigt: ein Knopf darf ein Feld meinen, das derselbe Aufruf erst anlegt, und trägt dafür in fieldId den Platzhalter "field:<Position>".

Ein Eintrag, der die Prüfung nicht besteht — unbekannte Art, leere oder zu lange Beschriftung, setfield ohne erreichbares Feld — wird verworfen, nicht der ganze Aufruf. Höchstens 30 Felder und 12 Aktionen. 409, wenn eine mitgeschickte Id zwischenzeitlich gelöscht wurde. Antwort: der Kurs.

Werte gelöschter Felder bleiben in CourseRegistration.values stehen. Sie werden nirgends mehr angezeigt; ein versehentliches Löschen im Einstellungsdialog ist dadurch kein endgültiger Verlust.

PUT /courses/definitions/order

Body { "ids": ["…"] } — bringt die Kurse in diese Reihenfolge. Unbekannte Ids fallen weg, nicht genannte Kurse hängen hinten an. Antwort { "courses": […] }.

DELETE /courses/definitions/:courseId

Löscht den Kurs samt seinen Feldern und Aktionen — aber nur, solange keine Anmeldung mehr daran hängt. 409 sonst, mit der Zahl der Anmeldungen und dem Hinweis auf das Archivieren (archived: true über den PATCH). 404 mit "Kurs nicht gefunden". Antwort { "ok": true }.

Schwarzes Brett

Die Aushänge, die im Leitstellenblatt zwischen Kopfzeile und der Zeile „Leitung / Übersicht" von rechts nach links durchlaufen. Lesen darf jede und jeder mit Zugriff auf das Blatt (Berechtigung view); Veröffentlichen und Entfernen brauchen Admin-Rechte (Server-Administrator oder unter Einstellungen eingetragener zusätzlicher Admin). Ein API-Schlüssel hat wie überall vollen Zugriff.

GET /board-notices

{
  "notices": [
    {
      "id": "clx…",
      "body": "Schulung am Samstag um 20 Uhr",
      "expiresOn": "2026-07-30",
      "expired": false,
      "createdAt": "2026-07-26T10:00:00.000Z"
    }
  ]
}

Neueste zuerst. expiresOn ist der letzte Tag, an dem der Aushang läuft (YYYY-MM-DD), oder null für „läuft bis auf Widerruf". expired wird vom Server gegen den Berliner Kalendertag berechnet: Das Laufband zeigt die Aushänge mit expired: false, abgelaufene bleiben in der Antwort, damit ein Admin sie sehen und entfernen kann.

POST /board-notices

Body { "body": "…", "expiresOn": "2026-07-30" | null } – body ist Pflicht (max. 280 Zeichen, wird getrimmt), expiresOn ist optional. 400, wenn der Text fehlt, zu lang ist oder das Datum kein gültiger Kalendertag im Format YYYY-MM-DD ist. 409, wenn der Server bereits 20 Aushänge hält – dann muss zuerst einer entfernt werden. Antwort: der neue Aushang (Schema wie oben).

PATCH /board-notices/:id

Body wie bei POST – beide Felder werden gesetzt, auch wenn nur eines im Body steht: ein fehlendes oder null-Feld expiresOn heißt hier genauso „läuft bis auf Widerruf" wie beim Veröffentlichen und nicht „behalte das bisherige Datum". Dieselben 400-Regeln. 404 mit "Aushang nicht gefunden", wenn die id nicht zu diesem Brett gehört. createdAt bleibt unverändert, der Aushang behält also seinen Platz im Laufband. Antwort: der geänderte Aushang (Schema wie oben).

DELETE /board-notices/:id

Entfernt den Aushang endgültig. 404 mit "Aushang nicht gefunden". Antwort { "ok": true }.

Dokumente

Lesen braucht view; Schreiben (anlegen, ändern, sortieren, Papierkorb, Vorlagen) braucht Admin-Rechte bzw. mit Rängen die Fähigkeit documents.write. Ausnahmen: POST /documents/:id/read und POST /documents/:id/acknowledge darf jede Person mit Lesezugriff, GET /documents/:id/acknowledgements nur, wer schreiben darf. Jeder Schreibzugriff meldet das Echtzeit-Thema documentsStale.

Papierkorb

DELETE /documents/:id löscht nicht, sondern verschiebt das Dokument in die Kategorie mit systemKind: "TRASH" (Name „Papierkorb", beim ersten Mal angelegt) und setzt deletedAt und deletedByName. Antwort: das verschobene Dokument. DELETE /documents/:id?permanent=1 löscht endgültig und ist nur für Dokumente im Papierkorb erlaubt (sonst 409); Antwort { "ok": true }. POST /documents/:id/restore legt das Dokument in die Kategorie zurück, aus der es kam — gibt es die nicht mehr, landet es ohne Kategorie; 409, wenn das Dokument nicht im Papierkorb liegt.

Der Papierkorb lässt sich über PATCH /documents/categories/:id umbenennen, aber nicht löschen (409); er steht in categories immer zuletzt, auch nach PUT /documents/categories/order (dort darf seine Id fehlen). Wer nicht schreiben darf, sieht weder die Kategorie noch ihre Dokumente — nicht in GET /documents (auch nicht über q), nicht in GET /documents/:id, nicht im Verlauf und nicht in GET /documents/unread. PDF-Export und Bearbeiten sind für Dokumente im Papierkorb für niemanden möglich (404 bzw. 409).

Felder

GET /documents liefert zusätzlich je Kategorie systemKind (null oder "TRASH") und je Dokument revision, requiresAcknowledgement, deletedAt, deletedByName und acknowledged (die anfragende Person hat die aktuelle Version bestätigt). GET /documents/:id liefert dazu acknowledgedRevision (zuletzt bestätigte Version oder null) und lastSeenRevision: die neueste Version, die beim letzten POST /documents/:id/read schon existierte, oder null. Das Lesen selbst markiert nichts — erst das Dokument holen, dann als gelesen markieren.

Versionen und Konflikte

Jedes Anlegen schreibt Version 1; jedes Speichern, das Titel oder Text ändert, erhöht revision um eins und schreibt eine neue Version (Kategorie, Häkchen und Reihenfolge erzeugen keine). POST /documents nimmt zusätzlich requiresAcknowledgement (Ja/Nein) und note (Änderungsnotiz, max. 500 Zeichen). PATCH /documents/:id nimmt in values zusätzlich requiresAcknowledgement, und neben values die Felder note und baseRevision:

{ "values": { "body": "…" }, "note": "Abschnitt 3 ergänzt", "baseRevision": 4 }

Passt baseRevision nicht mehr zur aktuellen Version, antwortet der Server mit 409 und { "error": "…", "current": { …Dokument… } }, ohne zu speichern.

GET /documents/:id/revisions liefert [{ "revision", "title", "categoryId", "note", "authorName", "createdAt" }], neueste zuerst; GET /documents/:id/revisions/:rev eine Version mit body. POST /documents/:id/revisions/:rev/restore schreibt deren Titel und Text als neue Version mit der Notiz „Wiederhergestellt aus Version N" und antwortet mit dem Dokument (gleicher Inhalt wie jetzt: keine neue Version). POST /documents/:id/duplicate legt „<Titel> (Kopie)" in derselben Kategorie direkt hinter dem Original an und antwortet mit der Kopie.

Kenntnisnahme

POST /documents/:id/acknowledge bestätigt die aktuelle Version für die anfragende Person und antwortet { "revision": 4, "acknowledgedAt": "…" }; 409, wenn das Dokument keine Bestätigung verlangt, 400 für einen API-Schlüssel. Nach jeder neuen Version gilt eine ältere Bestätigung nicht mehr. GET /documents/:id/acknowledgements:

{
  "revision": 4,
  "acknowledged": [{ "userId": "123…", "userName": "Mia Berger", "acknowledgedAt": "…" }],
  "outstanding": [{ "userId": "456…", "name": "Tom Wagner" }]
}

outstanding sind die Mitarbeitenden aus dem Personal-Abgleich des Bots, die die aktuelle Version noch nicht bestätigt haben — null, wenn der Bot für diese Instanz noch niemanden kennt.

Vorlagen

GET /documents/templates liefert { "templates": [{ "id", "name", "description", "body", "position", "builtIn", "updatedAt" }] }: zuerst die vier eingebauten (builtIn: true, Id builtin:<key>, nur lesbar: Dienstanweisung, Fahrzeugeinweisung, SOP, Protokoll), dann die eigenen. POST nimmt { "name", "description"?, "body" } (Name max. 80, Beschreibung max. 200 Zeichen) und antwortet mit der Vorlage; PATCH dieselben Felder einzeln. DELETE und PUT /documents/templates/order ({ "ids": [...] }, genau die eigenen Vorlagen) antworten mit der ganzen Liste. Eingebaute Vorlagen ändern oder löschen: 409.

Lager

Ein Lager ist ein Warenbestand, dessen Stand einmal pro Berliner Kalendertag als Inventur erfasst wird – es gibt keine laufende Summe, sondern nur Tage: der jüngste erfasste Tag ist der aktuelle Bestand.

Jede Route unter /lager verlangt Admin-Rechte (Server-Administrator oder unter Einstellungen eingetragener zusätzlicher Admin) – auch das Lesen. Das weicht von der sonst üblichen Regel dieser API ab, wonach Lesen (GET) Zugriff auf das Leitstellenblatt (Berechtigung view) genügt und nur Schreibzugriffe eingeschränkt sind; /stats/ und die meisten Routen unter /personal/ (über das bloße GET /personal hinaus) verlangen ebenfalls Admin-Rechte fürs Lesen. Ein API-Schlüssel hat wie überall vollen Zugriff.

GET /lager

Alle Lager des Servers, sortiert nach Position:

{
  "lager": [
    { "id": "clx…", "name": "Hauptlager", "position": 0, "itemCount": 12, "lastCountDate": "2026-07-30" }
  ]
}

itemCount zählt nur nicht archivierte Artikel. lastCountDate ist das Datum (YYYY-MM-DD) des jüngsten erfassten Tages dieses Lagers, oder null, wenn noch nie gezählt wurde.

GET /lager/:id[?date=YYYY-MM-DD]

Die Inventur eines einzelnen Tages. Fehlt date, oder entspricht der Wert nicht dem Format YYYY-MM-DD, antwortet die Route für heute.

{
  "lager": { "id": "clx…", "name": "Hauptlager" },
  "itemSort": "name",
  "date": "2026-07-30",
  "today": "2026-08-01",
  "editable": false,
  "recorded": true,
  "prefilledFrom": null,
  "savedBy": { "name": "Jermaine Prince", "at": "2026-07-30T10:00:00.000Z" },
  "recordedDates": ["2026-07-30", "2026-07-15"],
  "rows": [
    { "itemId": "cli…", "name": "Verbandpäckchen", "unit": "Packungen", "note": null,
      "amount": 40, "delta": -2, "isNew": false }
  ]
}

abgefragte Tag, today immer der heutige Berliner Kalendertag laut Server-Uhr – auch beim Blättern in der Vergangenheit ist das nicht dasselbe Feld. Der Browser kann in einer anderen Zeitzone stehen, deshalb kommt „welcher Tag ist heute" hier immer vom Server, nie aus dem Query-Parameter abgeleitet.

Lesezugriffe.

"nameDesc" (Z–A) oder "custom" (die frei gezogene Reihenfolge, siehe PUT /lager/:id/items/order). rows folgt bereits dieser Sortierung, auch an vergangenen Tagen; das Feld sagt der Oberfläche nur, welche Sortierung aktiv ist.

dann false und rows ein leeres Array [], statt dass die Route einen Fehlerstatus liefert. Einzige Ausnahme ist der heutige Tag ohne eigene Erfassung – dort liefert die Route die aktuelle Artikelliste, vorbelegt mit den Mengen des jüngsten erfassten Tages (prefilledFrom nennt dessen Datum; ein Artikel ohne Vorwert startet bei 0).

Zeitpunkt der letzten Speicherung.

Lager je eine Inventur gespeichert wurde.

zum Kalendertag davor); null (mit isNew: true), wenn es den Artikel an jenem Tag noch nicht gab.

ist:

wenn heute bereits erfasst ist. Ein nach der Erfassung angelegter Artikel steht also sofort in rows (mit amount: 0, bis jemand ihn zählt), ein archivierter verschwindet daraus. Das ist genau die Liste, die PUT /lager/:id/day schreibt: was hier steht, lässt sich auch speichern.

wurde. Ein damals gezählter Artikel bleibt dort in rows stehen, auch wenn er inzwischen archiviert wurde – die Historie ändert sich nicht mehr.

404 mit "Lager nicht gefunden", wenn die Id zu keinem Lager dieses Servers gehört.

GET /lager/search?q=&lagerId=

Sucht Artikel über alle Lager des Servers hinweg, nicht nur eines:

{
  "results": [
    { "itemId": "cli…", "name": "Sterile Verbände", "unit": "Packungen",
      "lagerId": "clx…", "lagerName": "Hauptlager" }
  ]
}

und Umlauten – q=ol findet auch „Öl". ß wird dabei nicht wie ss behandelt.

kein Fehler, sondern liefert { "results": [] } – die Oberfläche fragt bereits beim Tippen, und beim ersten Buchstaben gibt es nichts Sinnvolles zu antworten.

Artikelname – nicht in Datenbank-(Heap-)Reihenfolge. Ohne das würde ein unbeteiligtes UPDATE (z. B. ein umbenannter Artikel woanders) die Reihenfolge der ungefilterten Zeilen verschieben und könnte so, welche 50 Treffer eine identische Suche liefert, von Anfrage zu Anfrage ändern.

ist. Dessen Treffer stehen dann vor allen anderen – und zwar bevor die Liste auf 50 Einträge gedeckelt wird, nicht erst danach: sonst könnte ein Treffer aus dem offenen Lager bei mehr als fünfzig Treffern schon abgeschnitten sein, bevor er seinen Vorrang bekäme. Fehlt der Parameter (oder ist er leer), bleibt die (bereits nach Lagername/Artikelname sortierte) Reihenfolge unangetastet, aber weiterhin gedeckelt.

eindeutig zu genau einem Lager gehört, die Suche aber über alle geht.

PUT /lager/:id/day

Body { "counts": [ { "itemId": "…", "amount": 40 } ] }. Speichert die heutige Inventur. Die Route nimmt kein Datum entgegen – geschrieben wird immer der heutige Berliner Kalendertag aus der Server-Uhr. Deshalb lassen sich vergangene Tage über diese API grundsätzlich nicht ändern.

counts darf unvollständig sein: nicht genannte Artikel behalten ihren bisherigen Wert (den bereits gespeicherten heutigen, falls vorhanden, sonst die Vorbelegung aus dem jüngsten erfassten Tag) – der Server legt die gesendete Liste über den bestehenden Stand, statt ihn zu ersetzen. Ein Client, der nur ein Feld geändert hat, muss also nicht die ganze Liste mitschicken.

amount ist eine ganze Zahl im Bereich 0–1000000 (beide Grenzen eingeschlossen); jede itemId darf höchstens einmal vorkommen. 400 mit "Ungültige Mengen", wenn counts fehlt, kein Array ist, ein Eintrag keine gültige itemId/amount-Kombination hat oder eine itemId doppelt vorkommt. 400 mit "Die Artikelliste ist veraltet", wenn eine gesendete itemId in diesem Lager unbekannt oder bereits archiviert ist – ein Client mit veralteter Artikelliste soll das ausdrücklich erfahren, statt still einen Geistereintrag zu erzeugen. 404 mit "Lager nicht gefunden". Antwort bei Erfolg: der Tages-Payload wie bei GET /lager/:id ohne date (also für heute).

Lager anlegen, umbenennen, löschen

400 mit "Name fehlt oder ist zu lang". Antwort: die vollständige Liste wie bei GET /lager.

| "custom" }; **mindestens eines** der beiden Felder ist Pflicht, beide dürfen zusammen gesendet werden. name unterliegt derselben Prüfung wie bei POST /lager. 400 mit "Keine gültigen Werte übergeben", wenn keines von beiden gültig gesetzt ist – das schließt einen leeren Body, einen zu langen name und einen unbekannten itemSort-Wert ein. 404 mit "Lager nicht gefunden". Antwort: die vollständige Liste wie bei GET /lager`.

Zählungen**; die Historie ist danach unwiderruflich fort. Rückt die Positionen der verbleibenden Lager lückenlos nach. 404 mit "Lager nicht gefunden". Antwort: die vollständige Liste wie bei GET /lager.

Artikel (/lager/:id/items)

(name max. 80 Zeichen, unit max. 16, note max. 200; unit/note sind optional – leer oder weggelassen heißt „kein Zusatz", zu lang macht die ganze Anfrage ungültig). 400 mit "Name fehlt oder ein Feld ist zu lang". 404 mit "Lager nicht gefunden".

"unit"?: "…", "note"?: "…" } }, setzt nur die mitgeschickten Felder, dieselben Längengrenzen. 400 mit "Keine gültigen Werte übergeben", wenn values fehlt, kein Objekt ist oder kein gültiges Feld enthält. 404 mit "Artikel nicht gefunden"` – auch dann, wenn bereits die Lager-Id selbst unbekannt ist.

löschen: er verschwindet aus der heutigen Liste und aus der Vorbelegung künftiger Tage – auch dann, wenn heute bereits erfasst ist –, bleibt aber in den vergangenen** erfassten Tagen stehen, die ihn enthalten. Ein zweiter Aufruf auf einen bereits archivierten Artikel meldet denselben Erfolg, kein Fehler. 404 mit "Artikel nicht gefunden" – auch dann, wenn bereits die Lager-Id selbst unbekannt ist.

Antwort bei Erfolg aller drei Artikel-Routen: der Tages-Payload wie bei GET /lager/:id ohne date (also für heute) – so zeigt die Oberfläche sofort die aktualisierte Zeile, ohne eigens nachzuladen.

PUT /lager/:id/items/order

Body { "ids": ["…", "…"] }. Schreibt die eigene Reihenfolge der Artikel dieses Lagers: position wird für jede Id auf ihren Index in ids gesetzt – das ist die Reihenfolge, die itemSort: "custom" anzeigt.

ids muss eine Permutation aller nicht archivierten Artikel dieses Lagers sein: jede Id genau einmal, keine fehlt, keine unbekannte oder bereits archivierte Id ist dabei. 400 mit "Ungültige Reihenfolge", wenn ids fehlt, kein Array ist, ein Eintrag keine nicht-leere Zeichenkette ist oder eine Id doppelt vorkommt. 409 mit "Die Liste ist veraltet", wenn ids zwar so geformt ist, aber keine Permutation der aktuellen Artikelliste trifft – etwa weil zwischenzeitlich ein Artikel angelegt oder archiviert wurde; ein Client mit veralteter Liste soll das ausdrücklich erfahren, statt Lücken oder Kollisionen in position zu hinterlassen. 404 mit "Lager nicht gefunden". Antwort bei Erfolg: der Tages-Payload wie bei GET /lager/:id ohne date (also für heute).

Plattform-Ressourcen

Die Plattform-Endpunkte verwenden die Sitzungs- und Actor-Home-Prüfungen der Plattform. Die Bürger-, Dokument-, Einheiten-, Einsatz- und Beweisrouten sind keine API-Key-Aliasse; ein Bearer-Token wird dort abgelehnt. Die folgenden Pfade sind relativ zur Basis-URL /api/v1.

Methode & PfadBeschreibungFormat
GET /citizen-registry/source-catalogImportquellen-Katalog lesenJSON
GET /citizen-registry/sourcesBürgerdatenquellen auflistenJSON
POST /citizen-registry/sourcesBürgerdatenquelle registrieren (sourceSystem, authorityKind, adapterVersion, mappingVersion, imagesPolicy); MedicNet akzeptiert nur die drei katalogisierten Mapping-VersionenJSON
GET /platform/outbox/preferencesBenachrichtigungseinstellungen auflistenJSON
POST /platform/outbox/preferencesBenachrichtigungseinstellung anlegenJSON
PATCH /platform/outbox/preferences/:preferenceIdBenachrichtigungseinstellung ändernJSON
DELETE /platform/outbox/preferences/:preferenceIdBenachrichtigungseinstellung löschenJSON
POST /citizen-registry/sources/:sourceRegistryId/archiveImportquelle archivierenJSON
GET /citizen-registry/batchesImportläufe auflistenJSON
GET /citizen-registry/batches/:batchIdImportlauf lesenJSON
POST /citizen-registry/batches/:batchId/commitImportlauf übernehmenJSON
POST /citizen-registry/batches/:batchId/dry-runImportlauf vorprüfenJSON
GET /citizen-registry/batches/:batchId/issuesImportprobleme lesenJSON
GET /citizen-registry/batches/:batchId/progressImportfortschritt lesenJSON
POST /citizen-registry/batches/:batchId/rollbackImportlauf zurücksetzenJSON
GET /citizen-registry/batches/:batchId/rowsImportzeilen lesenJSON
POST /citizen-registry/batches/:batchId/stageImportdaten vorverarbeitenJSON
POST /citizen-registry/links/:linkId/unlinkBürgerverknüpfung lösenJSON
POST /citizen-registry/links/:sourceEntityId/rejectBürgerverknüpfung ablehnenJSON
POST /citizen-registry/links/:sourceEntityId/reviewBürgerverknüpfung prüfenJSON
POST /citizen-registry/links/legacyLegacy-Bürgerverknüpfung anlegenJSON
GET /platform/citizens/discoveryFreigegebene Bürgerdatenquellen entdeckenJSON
GET /platform/citizens/:ownerGuildId/:domainIdBürgerdatensätze eines Bereichs lesenJSON
POST /platform/citizens/:ownerGuildId/:domainIdBürgerdatensatz anlegen; jedes geschriebene Feld muss im Felddefinitionskatalog des Bereichs stehen und einzeln freigegeben seinJSON
GET /platform/citizens/:ownerGuildId/:domainId/:authorityRecordIdEinen Bürgerdatensatz lesenJSON
PATCH /platform/citizens/:ownerGuildId/:domainId/:authorityRecordIdBürgerdatensatz versionsgesichert ändern; ein Konflikt antwortet mit 409 und currentVersionJSON
POST /platform/citizens/:ownerGuildId/:domainId/:authorityRecordId/archiveBürgerdatensatz archivierenJSON
POST /platform/citizens/:ownerGuildId/:domainId/:authorityRecordId/evidence-linksBeweisdatei mit Bürgerdatensatz verknüpfenJSON
GET /platform/citizens/:ownerGuildId/:domainId/:authorityRecordId/evidence-linksAktive und archivierte Beweisverknüpfungen berechtigt und seitenweise lesen (limit 1–100, cursor; items, returnedCount, hasMore, nextCursor); Metadaten, keine DateibytesJSON
PATCH /platform/citizens/:ownerGuildId/:domainId/:authorityRecordId/evidence-links/:linkIdBeweisverknüpfung ändernJSON
POST /platform/citizens/:ownerGuildId/:domainId/:authorityRecordId/evidence-links/:linkId/archiveBeweisverknüpfung archivierenJSON
POST /platform/citizens/:ownerGuildId/:domainId/:authorityRecordId/evidence-links/:linkId/restoreBeweisverknüpfung wiederherstellenJSON
GET /platform/citizens/:ownerGuildId/:domainId/field-definitionsFelddefinitionen lesenJSON
POST /platform/citizens/:ownerGuildId/:domainId/field-definitionsFelddefinition anlegen (key, label, valueType, classification, position); Schlüssel und Datentyp sind danach unveränderlichJSON
PATCH /platform/citizens/:ownerGuildId/:domainId/field-definitions/:definitionIdFelddefinition ändernJSON
POST /platform/citizens/:ownerGuildId/:domainId/field-definitions/:definitionId/archiveFelddefinition archivierenJSON
POST /platform/citizens/:ownerGuildId/:domainId/mergesIdentitätszusammenführungen prüfenJSON
POST /platform/citizens/:ownerGuildId/:domainId/merges/:mergeId/reverseZusammenführung zurücknehmenJSON
GET /platform/citizen-matching/:ownerGuildId/:domainId/sources/:sourceEntityIdImportidentität für den Bürgerabgleich lesenJSON
GET /platform/citizen-matching/:ownerGuildId/:domainId/sources/:sourceEntityId/candidatesAbgleichkandidaten ladenJSON
POST /platform/citizen-matching/:ownerGuildId/:domainId/sources/:sourceEntityId/decisionsBürgerabgleich bestätigen oder ablehnenJSON
POST /platform/citizen-matching/:ownerGuildId/:domainId/sources/:sourceEntityId/links/:linkId/unlinkBürgerabgleich korrigierenJSON
GET /platform/custody/inboxEingehende Verwahrungsübergaben auflistenJSON
POST /platform/custody/:sourceMedicalGuildId/offersVerwahrungsübergabe anbietenJSON
GET /platform/custody/:sourceMedicalGuildId/transfers/:transferIdVerwahrungsübergabe lesenJSON
POST /platform/custody/:sourceMedicalGuildId/transfers/:transferId/acceptVerwahrungsübergabe annehmenJSON
POST /platform/custody/:sourceMedicalGuildId/transfers/:transferId/cancelVerwahrungsübergabe abbrechenJSON
POST /platform/custody/:sourceMedicalGuildId/transfers/:transferId/eventsVerwahrungsverlauf ergänzenJSON
POST /platform/custody/:sourceMedicalGuildId/transfers/:transferId/rejectVerwahrungsübergabe ablehnenJSON
GET /platform/documents/:ownerGuildIdPlattformdokumente auflistenJSON
GET /platform/documents/:ownerGuildId/:documentIdPlattformdokument lesenJSON
POST /platform/documents/:ownerGuildId/:documentId/convertLegacy-Dokument konvertierenJSON
POST /platform/documents/:ownerGuildId/:documentId/profileDokument für Plattformverwaltung aktivierenJSON
PATCH /platform/documents/:ownerGuildId/:documentId/profileKlassifizierung, Organisationseinheit oder Freigabestufe des Dokuments versionsgesichert ändernJSON
GET /platform/documents/:ownerGuildId/:documentId/projection/:actionDokumentaktion projizierenJSON
POST /platform/documents/:ownerGuildId/:documentId/readDokument als gelesen markierenJSON
POST /platform/documents/:ownerGuildId/:documentId/sectionsDokumentabschnitt anlegenJSON
GET /platform/documents/:ownerGuildId/:documentId/sections/:sectionIdDokumentabschnitt lesenJSON
PATCH /platform/documents/:ownerGuildId/:documentId/sections/:sectionIdAbschnitt versionsgesichert ändern: sectionKey, title, body sowie die Zugriffsregeln classificationKey, owningOrgUnitId und requiredClearanceLevelId; reason ist PflichtJSON
POST /platform/documents/:ownerGuildId/:documentId/sections/:sectionId/archiveDokumentabschnitt archivierenJSON
POST /platform/documents/:ownerGuildId/:documentId/sections/:sectionId/restoreDokumentabschnitt wiederherstellenJSON
GET /platform/documents/:ownerGuildId/:documentId/sections/:sectionId/revisionsAbschnittsversionen lesenJSON
PUT /platform/documents/:ownerGuildId/:documentId/sections/orderDokumentabschnitte sortierenJSON
GET /platform/documents/:ownerGuildId/countAnzahl der Plattformdokumente lesenJSON
GET /platform/documents/:ownerGuildId/unreadUngelesene Plattformdokumente lesenJSON
GET /platform/evidence/attachments/:attachmentIdBeweisdatei-Metadaten lesenJSON
GET /platform/evidence/policyBeweisablage-Richtlinie lesenJSON
PUT /platform/evidence/policyBeweisablage-Richtlinie ändernJSON
POST /platform/evidence/attachmentsBeweisdatei-Upload beginnenJSON
POST /platform/evidence/attachments/:attachmentId/archiveBeweisdatei archivierenJSON
PUT /platform/evidence/attachments/:attachmentId/versions/:versionId/bytesBeweisdateiversion speichernBinär
GET /platform/evidence/attachments/:attachmentId/versions/:versionId/contentBeweisdateiinhalt lesenBinär
GET /platform/evidence/:ownerGuildId/attachments/:attachmentId/versions/:versionId/contentFreigegebenen Beweisdateiinhalt einer Partnerinstanz lesen oder exportierenBinär
POST /platform/evidence/attachments/:attachmentId/versions/:versionId/finalizeBeweisdateiversion abschließenJSON
POST /platform/evidence/attachments/:attachmentId/versions/:versionId/purgeBeweisdateiversion entfernenJSON
GET /platform/incidents/:ownerGuildId/:einsatzId/citizensBeteiligte Bürger eines Einsatzes lesenJSON
POST /platform/incidents/:ownerGuildId/:einsatzId/citizensBürgerbeteiligung an einem Einsatz erfassenJSON
GET /platform/incidents/:ownerGuildId/:einsatzId/citizens/:contributorGuildId/:involvementIdBürgerbeteiligung lesenJSON
POST /platform/incidents/:ownerGuildId/:einsatzId/citizens/:involvementId/closeBürgerbeteiligung beendenJSON
GET /platform/incidents/:ownerGuildId/:einsatzId/contributionsEinsatzbeiträge auflistenJSON
GET /platform/incidents/:ownerGuildId/:einsatzId/contributions/:contributorGuildId/:contributionIdEinsatzbeitrag lesenJSON
POST /platform/incidents/:ownerGuildId/:einsatzId/participationsInstanz zu Einsatz einladenJSON
POST /platform/incidents/:ownerGuildId/:einsatzId/participations/:participationId/endEinsatzteilnahme beendenJSON
POST /platform/incidents/:ownerGuildId/:einsatzId/participations/:participationId/reviewEinsatzteilnahme prüfenJSON
POST /platform/incidents/:ownerGuildId/:einsatzId/patientsPatientenbeteiligung verknüpfenJSON
GET /platform/incidents/:ownerGuildId/:einsatzId/patients/:medicalGuildId/:patientParticipationIdPatientenbeteiligung lesenJSON
GET /platform/incidents/:ownerGuildId/:einsatzId/patientsPatientenbeteiligungen eines Einsatzes projiziert auflistenJSON
POST /platform/incidents/:ownerGuildId/:einsatzId/patients/:patientParticipationId/unlinkPatientenbeteiligung lösenJSON
GET /platform/incidents/:ownerGuildId/:einsatzId/personnelEinsatzpersonal auflistenJSON
GET /platform/incidents/:ownerGuildId/:einsatzId/personnel/:contributorGuildId/:involvementIdPersonaleintrag lesenJSON
POST /platform/incidents/:ownerGuildId/:einsatzId/personnel/:involvementId/closePersonaleintrag beendenJSON
GET /platform/incidents/:ownerGuildId/:einsatzId/profileEinsatzprofil lesenJSON
PATCH /platform/incidents/:ownerGuildId/:einsatzId/profileEinsatzprofil versionsgesichert ändernJSON
POST /platform/incidents/:ownerGuildId/:einsatzId/profileEinsatzprofil anlegenJSON
POST /platform/incidents/:ownerGuildId/:einsatzId/contributionsEinsatzbeitrag anlegenJSON
POST /platform/incidents/:ownerGuildId/:einsatzId/contributions/:contributorGuildIdEinsatzbeitrag im Namen einer beitragenden Instanz anlegen (nur vertrauenswürdiger Plattformkontext)JSON
PATCH /platform/incidents/:ownerGuildId/:einsatzId/contributions/:contributorGuildId/:contributionIdEinsatzbeitrag ändernJSON
POST /platform/incidents/:ownerGuildId/:einsatzId/personnelEinsatzpersonal dokumentierenJSON
POST /platform/incidents/:ownerGuildId/:einsatzId/patientsPatientenbeteiligung verknüpfenJSON
POST /platform/network/identity-domain-memberships/:membershipId/leaveIdentitätsbereich verlassenJSON
POST /platform/network/identity-domain-memberships/:membershipId/reviewIdentitätsbereichsbeitritt prüfenJSON
GET /platform/network/identity-domain-memberships/sponsor-inboxSponsor-Anfragen lesenJSON
POST /platform/network/identity-domainsIdentitätsbereich anlegenJSON
POST /platform/network/identity-domains/:domainId/requestsBeitrittsanfrage erstellenJSON
GET /platform/network/identity-domains/eligibleBeitrittsfähige Identitätsbereiche lesenJSON
GET /platform/network/identity-domains/membershipsIdentitätsbereichsmitgliedschaften lesenJSON
GET /platform/network/identity-domains/:domainId/matching-fieldsMatching-Feldkonfiguration des Identitätsbereichs lesen (frisch geprüfter Instanzadministrator)JSON
PUT /platform/network/identity-domains/:domainId/matching-fieldsMatching-Felder mit expectedVersion und fields ersetzen; jedes Feld enthält fieldKey, valueType, weight, position, requiredJSON
POST /platform/citizens/:ownerGuildId/:domainId/match-assistantLesende Matching-Anfrage mit genau einem von authorityRecordId oder claims, optional limit (1–100); liefert nur policy-geprüfte Vorschläge und reviewRequired: trueJSON
POST /platform/citizens/:ownerGuildId/:domainId/:authorityRecordId/match-assistant/rejectionsVorschlag mit candidateOwnerGuildId, candidateAuthorityRecordId und reason ablehnen; beide Datensätze müssen die Aktion erlaubenJSON
POST /platform/evidence/:ownerGuildId/attachments/:attachmentId/write-requestsMit reason Schreibzugriff beantragen; Browser-Sitzung, explizite Akteursinstanz und Lesefreigabe des Anhangs erforderlichJSON
GET /platform/evidence/:ownerGuildId/attachments/:attachmentId/write-requestsSchreibanfragen als frisch geprüfter Besitzeradministrator lesenJSON
POST /platform/evidence/:ownerGuildId/attachments/:attachmentId/write-requests/:requestId/reviewSchreibanfrage als Besitzeradministrator mit expectedVersion, decision (APPROVE/REJECT) und reason entscheidenJSON
GET /platform/dispatchers/sourcesFür die Leitstelle freigegebene Dispatcherquellen auflisten (Browser-Sitzung, keine API-Schlüssel)JSON
GET /platform/units/board-sourcesFür die Leitstelle freigegebene Partnerquellen auflisten (Browser-Sitzung, keine API-Schlüssel)JSON
GET /platform/units/board-catalogBeide Quellenkataloge in einer Antwort: unitSources und dispatcherSources, jeweils mit derselben Prüfung wie die Einzelrouten. Eine Quelle, die die Einheitenfreigabe verweigert, kann weiterhin unter dispatcherSources stehen (Browser-Sitzung, keine API-Schlüssel)JSON
GET /platform/units/:ownerGuildIdPlattform-Einheiten auflistenJSON
POST /platform/units/:ownerGuildId/board-capabilitiesLesende Berechtigungsprüfung für bis zu 100 Einheiten ({ "unitIds": ["…"] }); gibt nur sichtbare IDs mit Aktionsfreigaben zurück (Browser-Sitzung, keine API-Schlüssel)JSON
GET /platform/units/:ownerGuildId/:unitIdPlattform-Einheit lesenJSON
POST /platform/units/:ownerGuildIdPlattform-Einheit anlegenJSON
PATCH /platform/units/:ownerGuildId/:unitIdPlattform-Einheit ändernJSON
POST /platform/units/:ownerGuildId/:unitId/archivePlattform-Einheit archivierenJSON
DELETE /platform/units/:ownerGuildId/:unitIdPlattform-Einheit versionsgesichert löschen ({ "expectedVersion": n }); der Verlauf bleibt erhaltenJSON
GET /platform/units/:ownerGuildId/:unitId/assignmentsEinheitszuweisungen lesenJSON
POST /platform/units/:ownerGuildId/:unitId/assignmentsEinheit einem Einsatz zuordnenJSON
POST /platform/units/:ownerGuildId/:unitId/assignments/:assignmentId/endEinheitszuweisung beendenJSON
POST /platform/units/:ownerGuildId/:unitId/assignments/:assignmentId/reassignEinheitszuweisung ändernJSON

Dokumentabschnitte und Freigabestufen

Ein Plattformdokument wird über POST /platform/documents/:ownerGuildId/:documentId/profile in die Plattformverwaltung übernommen und trägt danach drei Zugriffsfelder, die auf dem Dokument selbst und auf jedem Abschnitt gesetzt werden können:

FeldBedeutung
classificationKeyEinstufung; SENSITIVE und RESTRICTED sind geschützte Einstufungen
owningOrgUnitIdbesitzende Organisationseinheit, oder null
requiredClearanceLevelIdverlangte Freigabestufe des Eigentümers, oder null

Die Ressourcenmetadaten sind eine eigenständige Schranke neben den gewöhnlichen Freigaben: Eine geschützte Einstufung ohne benannte Freigabestufe fällt nach geschlossen — der Abschnitt ist dann für niemanden lesbar, auch nicht für seinen Eigentümer. Geprüft wird das beim Lesen, nicht beim Schreiben: Die API nimmt diese Kombination an und sperrt den Abschnitt damit stillschweigend aus. Wer classificationKey auf SENSITIVE oder RESTRICTED setzt, sollte deshalb im selben Zug requiredClearanceLevelId setzen; der Abschnittseditor der Web-Oberfläche verweigert dieses Paar von sich aus.

Eine benannte Freigabestufe erfüllt nur, wer sie aktiv und unabgelaufen in der besitzenden Instanz hält. Ein Partner braucht dafür eine ausdrückliche, direkte Zuordnung in der wirksamen Richtlinie des Eigentümers; Verbindungen werden nicht weiterverfolgt, ein Partner eines Partners erfüllt die Prüfung also nie, und eine gewöhnliche Erlaubnisregel hebt die Einstufung nicht auf.

PATCH …/sections/:sectionId schickt nur die tatsächlich geänderten Felder, verlangt expectedVersion und einen reason. owningOrgUnitId und requiredClearanceLevelId werden mit null ausdrücklich entfernt; ein weggelassenes Feld bleibt unverändert.

Partnerinstanzen lesen freigegebene Dokumente über dieselben Routen mit der Owner-Guild im Pfad. GET /platform/documents/:ownerGuildId liefert nur die projizierten, für den Betrachter lesbaren Dokumente. Gibt ein Partner gar nichts frei, ist die Antwort ein 403 — für einen Client, der eine Partnerliste aufbaut, heißt das „dieser Partner teilt nichts" und nicht „hier ist etwas schiefgegangen".

Statuscodes

CodeBedeutung
200OK
201Angelegt (Plattformressourcen antworten beim Anlegen mit 201)
400Ungültiger Parameter oder Body (z. B. unbekannte period)
401Fehlender oder ungültiger API-Schlüssel
403Keine Berechtigung (z. B. Einsatzblatt umbenennen, API-Schlüssel auf einer Plattformressource)
404Route oder Diagramm-Typ unbekannt, keine Daten — oder eine Plattformfläche, deren Rollout-Flag beim Eigentümer aus ist
409Versions- oder Zustandskonflikt (bereits eingestempelt, Rufzeichen existiert, veraltete Reihenfolge); Plattformressourcen liefern dazu currentVersion
413Nutzlast zu groß (Import-Chunk, Medic-Batch)
500Interner Fehler
502Fehler beim vorgelagerten KI-Anbieter
503Berechtigungsprüfung oder ein Plattformdienst vorübergehend nicht verfügbar

Browser-WebSocket

GET /api/v1/socket?guild=<guildId> verwendet ausschließlich eine Browser-Sitzung; API-Schlüssel sind nicht zugelassen. Der Origin-Header muss exakt dem Ursprung von BETTER_AUTH_URL (bzw. dessen konfiguriertem Fallback) entsprechen. Fehlende, null- und fremde Origins erhalten 403. Außerhalb von Produktion sind zusätzlich http://localhost:3000 und http://localhost:3001 erlaubt. Auch nicht im Browser laufende Testclients müssen einen erlaubten Origin mitsenden.

Offene Verbindungen prüfen im bestehenden 20-Sekunden-Zyklus sowohl die gespeicherte Sitzung als auch die Berechtigungen erneut. Eine gelöschte/abgelaufene Sitzung oder ein fehlgeschlagener Sitzungsabruf schließt die Verbindung; dies geschieht beim nächsten Prüfzyklus zuzüglich Verarbeitungszeit. Pro Verbindung können höchstens 64 geteilte Dispatcher-/Einheitenquellen gleichzeitig beobachtet werden.

GET /api/v1/documents/:id/collab?guild=<guildId> ist der WebSocket für das gemeinsame Bearbeiten eines Dokuments. Für ihn gelten dieselben Regeln wie oben (Browser-Sitzung, Origin, 20-Sekunden-Prüfzyklus); er steht nur denen offen, die PATCH /api/v1/documents/:id ausführen dürfen. Nachrichten sind JSON mit dem Feld t (join, op, sel, meta bzw. init, ack, op, peers, sel, meta, saved, state, error); Frames bis 1 MiB, höchstens 20 Verbindungen je Dokument. Gespeichert wird weiterhin per PATCH.