Fehlercodes
Der vollständige Katalog der Fehler, die die externe API v1 zurückgibt: welcher code bei welchem
HTTP-Status kommt, was ihn auslöst und was du dagegen tun kannst. Codes, die es nur auf der internen
SPA-Oberfläche gibt (Passwortwechsel, Fahrzeugschein-Scan, Kundenportal, Signaturprozesse), stehen
hier bewusst nicht — sie können auf /api/external/v1/** nicht auftreten. Eine benannte Ausnahme
steht am Ende der Seite: die Webhook-Verwaltung gehört zur
Betreiber-Oberfläche, aber zur selben Integration.
Fehlerformat
Client-behebbare Fehler (4xx) tragen Code und Meldung:
{ "code": "VALIDATION", "message": "status muss gesetzt sein." }
Serverseitige Fehler (5xx) tragen bewusst keine Meldung — nur den Code und eine pro Vorfall erzeugte Referenz:
{ "code": "INTERNAL_ERROR", "errorId": "3f2a…" }
Die Ursache (voller Stacktrace) steht ausschließlich im Server-Log unter derselben errorId; zum
Client leakt kein internes Detail. Gib die errorId bei einer Support-Meldung immer mit.
Der code ist stabil und der Schlüssel für dein Fehler-Handling — werte ihn aus, nicht den
Meldungstext. Die Meldungen sind für Menschen gedacht und dürfen sich ändern.
Übersicht
code | HTTP | Bedeutung | Retry sinnvoll? |
|---|---|---|---|
API_KEY_INVALID | 401 | Key fehlt, ist unbrauchbar oder nicht mehr gültig | nein — alarmieren |
ACCESS_DENIED | 403 | Scope fehlt, oder echter Rechtefehler der Grant-Person | nein |
NOT_FOUND | 404 | Ressource unbekannt / fremder Mandant / nicht sichtbar | nein |
VALIDATION | 400 | Ungültige Eingabe | nein — Request korrigieren |
ALREADY_MODIFIED | 400 | Optimistic-Lock-Konflikt | ja, nach erneutem Lesen |
CONFLICT | 409 | Zustand passt nicht zur Aktion (Status, fehlende Dokumente, Duplikat) | nur nach Zustandsänderung |
RATE_LIMITED | 429 | IP-Limit vor der Authentifizierung erreicht | ja, nach Retry-After |
API_RATE_LIMITED | 429 | Key-/Benutzer-/Mandanten-Limit nach der Authentifizierung erreicht | ja, nach Retry-After |
API_KEY_AUTH_UNAVAILABLE | 503 | Authentifizierung selbst gestört | ja, mit Backoff |
INTERNAL_ERROR | 500 | unerwarteter Serverfehler | ja, mit Backoff |
API_KEY_INVALID (401)
{ "code": "API_KEY_INVALID", "message": "API-Zugangsdaten sind ungültig" }
Zusätzlich kommt der Header WWW-Authenticate: Bearer realm="usp-api", error="invalid_token".
Eine Antwort für alle Ursachen — kein Existenz- oder Statusorakel:
| Ursache | Woran es liegt |
|---|---|
Kein, mehrfacher oder falsch formatierter Authorization-Header | Genau ein Header mit Präfix Bearer senden |
Key entspricht nicht dem Format usp_<env>_<publicId>_<secret> | Key vollständig und ohne Zeilenumbruch übernehmen |
Unbekannte publicId oder falsches Secret | Key aus der Verwaltung neu ausstellen lassen |
| Key abgelaufen, deaktiviert, widerrufen oder als kompromittiert markiert | Status in der Schlüsselverwaltung prüfen |
| Rotations-Übergangsfrist des Vorgängerschlüssels abgelaufen | Auf den neuen Key umstellen |
| Grant der Person ausgesetzt oder widerrufen | Neuen Grant anfordern |
| Mandanten-Policy deaktiviert | Operator einbeziehen |
| Benutzer inaktiv, technisch oder nicht mehr Mitglied des Mandanten | Benutzerkonto prüfen lassen |
Nicht automatisch wiederholen. Ein 401 ist nie transient. Prüfe mit GET /me, ob der Key
überhaupt noch lebt, und sieh in der Schlüsselverwaltung nach.
ACCESS_DENIED (403)
{ "code": "ACCESS_DENIED", "message": "Zugriff verweigert" }
| Auslöser | Abhilfe |
|---|---|
| Dem Key fehlt der Scope des Endpunkts (häufigster Fall) | Effektive Scopes mit GET /me prüfen; Key/Grant/Policy erweitern lassen |
PUT /cases/{caseId} verschiebt den Fall auf einen Standort, den die Grant-Person nicht bedienen darf | locationId unverändert lassen |
PUT /cases/{caseId} ändert die Bearbeitungsart ohne die nötigen Rechte | processingType nicht verändern |
PUT /cases/{caseId}/status verlangt Fallabwickler- oder Admin-Rechte (Abschließen, Wiedereröffnen aus CLOSED) | Aktion einer berechtigten Person überlassen |
403 gibt es nur dort, wo du die Ressource ohnehin sehen darfst. Alles andere ist 404 — siehe unten.
NOT_FOUND (404)
{ "code": "NOT_FOUND", "message": "Ressource nicht gefunden" }
Bei Fall-Endpunkten lautet die Meldung Fall nicht gefunden, bei Stammdaten entsprechend
Standort nicht gefunden, Benutzer nicht gefunden, Versicherung nicht gefunden.
Drei Ursachen sind bewusst ununterscheidbar:
- Die Ressource existiert nicht.
- Sie gehört zu einem fremden Mandanten.
- Sie liegt außerhalb der Sichtbarkeit der Person hinter dem Grant.
Damit kann eine Integration nicht durch Ausprobieren von IDs herausfinden, was es anderswo gibt.
Zwei Sonderfälle, die kein Fehler sind:
GET /cases/{caseId}/evaluation-valuesantwortet 404, wenn für den Fall noch keine Regulierungswerte erfasst sind → als „leer" behandeln.- Ein 404 ohne
code-Feld bedeutet, dass es für den Pfad gar keinen Endpunkt gibt (Tippfehler, falsche Version, deaktivierte externe API).
VALIDATION (400)
Die message benennt das Problem konkret.
| Endpunkt | Auslöser |
|---|---|
alle mit {caseId} | caseId ist keine gültige UUID |
GET /cases | page negativ; size außerhalb 1–200 |
PUT /cases/{caseId} | Body fehlt; id im Body ≠ Pfad-caseId; locationId fehlt oder leer; status weicht vom persistierten Status ab |
PUT /cases/{caseId}/status | status fehlt; status = HIDDEN; status = CANCELLED ohne reason |
PUT /cases/{caseId}/tags | tag fehlt oder ist NONE |
DELETE /cases/{caseId}/tags/{tag} | tag unbekannt oder NONE |
PUT /cases/{caseId}/file-sign | Body fehlt; fileSign fehlt oder ist leer |
PUT /cases/{caseId}/evaluation-values | Body fehlt; caseId im Body ≠ Pfad |
POST /cases/{caseId}/attachments | Datei leer; Dateiname mit Pfad-Traversal; tag unbekannt |
POST /cases/{caseId}/comments | Text fehlt/leer; Text enthält eine auflösbare @-Erwähnung |
GET /locations/{locationCode} | leerer Standortcode |
POST/PUT /insurances | insuranceId, displayName oder type fehlt |
ALREADY_MODIFIED (400)
{ "code": "ALREADY_MODIFIED", "message": "Case was already modified" }
Optimistic-Lock-Konflikt bei PUT /cases/{caseId}: das mitgeschickte lastModifiedDate fehlt oder
ist älter als der Serverstand. Achtung: HTTP 400, nicht 409.
Erwarte diesen Fehler nicht nur bei echter Parallelarbeit: auch deine eigenen Aufrufe bewegen den Zeitstempel — Anhang-Upload, Anhang-Löschen, Kommentar, Fall-Tag und Regulierungswerte schreiben jeweils einen Historieneintrag.
Richtiges Verhalten: GET /cases/{caseId}, Änderung auf dem frischen Objekt erneut anwenden, noch
einmal schreiben. Nie blind wiederholen — du würdest fremde Änderungen überschreiben.
CONFLICT (409)
| Endpunkt | Auslöser |
|---|---|
PUT /cases/{caseId} | Fall ist CLOSED, CANCELLED, HIDDEN oder WAITING_FOR_CUSTOMER (nicht editierbar) |
POST /cases/{caseId}/attachments | wie oben |
PUT /cases/{caseId}/status → RELEASED | keine signierte Vollmacht/RKÜ (entfällt bei Eigenbearbeitung) |
PUT /cases/{caseId}/status → HANDED_OVER_APPRAISER | kein signierter Gutachtenauftrag bzw. keine Vollmacht |
PUT /cases/{caseId}/status → CANCELLED | Fall ist beim Schreiben der Storno-Begründung nicht editierbar |
PUT /cases/{caseId}/status (allgemein) | Wechsel nach WAITING_FOR_CUSTOMER oder aus WAITING_FOR_CUSTOMER nach RELEASED/HANDED_OVER_APPRAISER |
POST /insurances | Eintrag mit dieser insuranceId und diesem type existiert bereits |
RATE_LIMITED und API_RATE_LIMITED (429)
Beide tragen den Header Retry-After (Sekunden bis zum Ende des Minutenfensters).
RATE_LIMITED— Drossel pro IP, greift vor der Authentifizierung und damit auch für fehlgeschlagene Versuche.API_RATE_LIMITED— hierarchische Drossel pro Key, Benutzer und Mandant nach erfolgreicher Authentifizierung.
Beide tragen den Header Retry-After und das Feld retryAfterSeconds im Body — du kannst also
in beiden Fällen dieselbe Wartelogik verwenden und musst die Codes nur zum Verstehen der Ursache
unterscheiden.
Der einzige 4xx, den du automatisch wiederholen darfst. Grenzwerte und Reihenfolge: Authentifizierung und Scopes.
API_KEY_AUTH_UNAVAILABLE (503)
{ "code": "API_KEY_AUTH_UNAVAILABLE", "message": "Die API-Authentifizierung ist vorübergehend nicht verfügbar" }
Die Authentifizierung selbst ist gestört (Datenbank oder Schlüsselkonfiguration des Servers). Bewusst nicht als 401 getarnt: dein Key ist wahrscheinlich in Ordnung. Backoff und erneut versuchen; hält es an, ist es ein Betriebsvorfall der Plattform.
INTERNAL_ERROR (500)
{ "code": "INTERNAL_ERROR", "errorId": "3f2a…" }
Catch-all für unerwartete Serverfehler (Dateisystem, Datenbank, Identity-Provider …). Der Body
enthält keine message. Die errorId ist eine pro Vorfall erzeugte UUID, unter der der Server
den vollen Stacktrace loggt — sie ist die Referenz für den Support. Wiederholen mit Backoff ist
sinnvoll; bleibt es dabei, melde errorId, Uhrzeit und Endpunkt.
Framework-Fehler ohne code
Springs eigene Request-Fehler behalten ihren Status und werden nicht in das
{code, message}-Format übersetzt:
| HTTP | Wann |
|---|---|
| 400 | Ungültiges JSON im Body; nicht-numerisches page/size; unparsbares changedSince |
| 404 | Kein Endpunkt für den Pfad (Tippfehler, falsche Version, externe API deaktiviert) |
| 405 | Falsche HTTP-Methode am Endpunkt |
| 413 | Upload überschreitet 20 MB |
| 415 | Falscher oder fehlender Content-Type (z. B. Upload ohne multipart/form-data) |
Behandle „4xx ohne code" in deinem Client als Programmierfehler auf deiner Seite, nicht als
fachlichen Zustand.
Empfohlenes Fehler-Handling
| Antwort | Verhalten |
|---|---|
| 401, 403 | sofort abbrechen und alarmieren — Konfigurationsproblem, kein Retry |
| 404 | Ressource als „für mich nicht existent" behandeln, nächsten Datensatz verarbeiten |
400 VALIDATION / ohne code | abbrechen und loggen — dein Request ist falsch |
400 ALREADY_MODIFIED | neu lesen, Änderung erneut anwenden, ein Mal wiederholen |
| 409 | fachlichen Zustand protokollieren, nicht wiederholen |
| 429 | Retry-After abwarten, dann mit Backoff weiter |
| 5xx | exponentieller Backoff, begrenzte Versuche, errorId mitloggen |
Webhook-Verwaltung (nicht Teil der v1)
Die ausgehenden Webhooks werden nicht über /api/external/v1 verwaltet, sondern über die
Betreiber-Oberfläche (/api/admin/webhooks, nur Administrator:innen; Partner-Admins sehen
ausschließlich Abos ihres eigenen Standort-Teilbaums). Als empfangendes System löst du diese Codes
nie selbst aus — sie stehen hier, weil dieselbe Integration verwaltet und beliefert wird und weil
die Rückfrage zu einem stehengebliebenen Ereignisstrom hier landet.
code | HTTP | Bedeutung | Retry sinnvoll? |
|---|---|---|---|
WEBHOOK_SUBSCRIPTION_NOT_FOUND | 404 | Abo unbekannt oder außerhalb des Verwaltungsbereichs | nein |
WEBHOOK_TARGET_URL_INVALID | 400 | Ziel-URL fehlt, ist kein absoluter URI oder nicht https:// | nein — Adresse korrigieren |
WEBHOOK_SECRET_REQUIRED | 400 | Für das Abo ist kein ausgehendes Geheimnis hinterlegt | nein — Geheimnis mitgeben |
WEBHOOK_SUBSCRIPTION_SUSPENDED | 409 | Schutzschalter hat ausgelöst, die Warteschlange ist eingefroren | erst nach dem Aktivieren |
WEBHOOK_REPLAY_REASON_REQUIRED | 400 | Replay über bereits zugestellte Ereignisse ohne reason | nein — Begründung mitgeben |
WEBHOOK_SUBSCRIPTION_NOT_FOUND (404)
{ "code": "WEBHOOK_SUBSCRIPTION_NOT_FOUND", "message": "Webhook-Abo nicht gefunden" }
Unbekannte publicId und fremdes Abo antworten gleich — dieselbe Regel wie beim NOT_FOUND der
v1: ein eigener 403 für „existiert, gehört dir aber nicht" wäre ein Enumerationsorakel für die Abos
fremder Mandanten.
WEBHOOK_TARGET_URL_INVALID (400)
{ "code": "WEBHOOK_TARGET_URL_INVALID", "message": "Die Ziel-URL muss mit https:// beginnen" }
Auslöser sind eine fehlende oder leere Ziel-URL, ein nicht absoluter URI, eine Adresse ohne
Hostnamen — und jedes andere Schema als https. http:// ist ausgeschlossen, weil das ausgehende
Geheimnis als Bearer-Token im Kopf jeder Zustellung steht und sonst im Klartext über die Leitung
ginge. Dieselbe Regel steht zusätzlich als Datenbank-Check.
WEBHOOK_SECRET_REQUIRED (400)
{ "code": "WEBHOOK_SECRET_REQUIRED", "message": "Für das Abo muss ein Geheimnis hinterlegt werden" }
Drei Auslöser: ein Abo anlegen ohne Geheimnis, das Geheimnis ersetzen ohne neuen Wert und die
Zustellprobe (ping) auf einem Abo, für das keines hinterlegt ist. Beim Ändern eines Abos
bedeutet ein leeres Feld dagegen ausdrücklich „unverändert" — sonst würde das Speichern einer
Kleinigkeit das Geheimnis löschen.
WEBHOOK_SUBSCRIPTION_SUSPENDED (409)
{ "code": "WEBHOOK_SUBSCRIPTION_SUSPENDED", "message": "Das Abo ist gesperrt. Bitte zuerst aktivieren …" }
Nach zu vielen Fehlversuchen in Folge oder einem vom Empfänger abgelehnten Geheimnis legt der
Schutzschalter das Abo still und friert die wartenden Zustellungen ein. Einzelwiederholung und
scharf geschalteter Replay werden dann abgewiesen: sie würden Zeilen einreihen, die die Pumpe
ohnehin nicht anfasst — ein folgenloser Eingriff, der wie eine Nachlieferung aussähe. Die
Replay-Vorschau (dryRun) bleibt erlaubt. Erst das Aktivieren taut die eingefrorenen Zeilen auf und
liefert sie in Sequenzreihenfolge nach.
WEBHOOK_REPLAY_REASON_REQUIRED (400)
{ "code": "WEBHOOK_REPLAY_REASON_REQUIRED", "message": "Für die Wiederholung bereits zugestellter Ereignisse ist eine Begründung erforderlich" }
Ein Replay, der den Zustand DONE einschließt, sendet Ereignisse erneut, die der Empfänger bereits
quittiert hat, und erzeugt dort bewusst Dubletten. Deshalb verlangt er ein reason, das ins
Betriebsprotokoll geht und die spätere Rückfrage beantwortet, warum ein Ereignisstrom doppelt
ankam. Der Standardbereich (DEAD/INVALID, also nur Liegengebliebenes) braucht keine Begründung.