---
title: Persistenz-API
description: Ein Project anlegen, Fracht importieren, eine Berechnung starten und den gespeicherten Ladeplan lesen.
---

Die Persistenz-API ist REST unter `/api/v1` auf dem Origin der App, zum Beispiel `https://app.example.com/api/v1`. Das OpenAPI-3.1-Dokument liegt unter `/api/v1/spec.json`, und `/api/v1` selbst zeigt die Scalar-Referenz, in der Sie jede Route ausprobieren können. Für Postman importieren Sie `/api/v1/postman.json`, und der [API-Schnellstart](/de/guides/api-quickstart) zeigt den ganzen Ablauf. Das ist nicht der Cookie-Baum `/rpc` und nicht die Admin-Referenz unter `/api-reference`.

Jede Route, jedes Feld und jeder Fehlercode unten wird aus einem Vertrag erzeugt, daher entspricht die Spezifikation unter `/api/v1/spec.json` immer dem Verhalten des Servers. Erzeugen Sie Ihren Client daraus, statt Strukturen von Hand abzuschreiben.

## Authentifizierung

Senden Sie bei jeder Anfrage einen API-Schlüssel der Organisation als Bearer-Token:

```http
Authorization: Bearer sc_live_…
```

Das Geheimnis wird in der App unter API-Schlüssel einmal angezeigt, und gespeichert wird nur ein Hash. Inhaber und Admins können ihn rotieren oder widerrufen. Ein widerrufener oder rotierter Schlüssel funktioniert sofort nicht mehr. Ein Schlüssel handelt nur für seine Organisation: Ids einer anderen Organisation antworten mit `404`, als gäbe es sie nicht.

### Scopes

Jeder Schlüssel hat einen von zwei Scopes, den Sie beim Erstellen wählen:

- **Lesen und schreiben** (`read_write`, der Standard) ruft jede Route auf.
- **Nur lesen** (`read`) ruft nur die `GET`-Routen auf: ein Project, seine Fracht, einen Ladeplan oder eine Berechnung lesen und die Bibliotheken auflisten. Jede andere Route antwortet mit `403`, `code: "FORBIDDEN"` und `data.reason: "insufficient_scope"`, bevor etwas geändert wird.

Schlüssel, die vor den Scopes erstellt wurden, haben Lese- und Schreibzugriff. Beim Rotieren behält ein Schlüssel seinen Scope.

### Ablauf

Ein Schlüssel läuft nie ab, außer Sie legen beim Erstellen einen Ablauf fest: in 30 Tagen, 90 Tagen oder einem Jahr (die API erlaubt höchstens zwei Jahre im Voraus). Ab diesem Zeitpunkt antwortet der Schlüssel wie ein widerrufener mit `401`. Rotieren Sie einen Schlüssel mit Ablauf, erhält der neue Schlüssel eine neue Laufzeit gleicher Länge; so macht eine Rotation auch einen abgelaufenen Schlüssel wieder nutzbar.

### Von wem der Schlüssel abhängt

Ein Schlüssel bleibt nur gültig, solange das Mitglied, das ihn erstellt hat, noch in der Organisation ist **und** noch Inhaber oder Admin ist, also eine Rolle hat, die API-Schlüssel verwalten darf. Verlässt dieses Mitglied die Organisation, wird es entfernt oder zu Mitglied oder Betrachter herabgestuft, antwortet jeder Schlüssel, den es erstellt hat, mit `401`. Der Schlüssel wird nicht auf Nur-Lesen herabgestuft, sondern gesperrt. Ein Inhaber oder Admin erstellt dann einen neuen Schlüssel.

Ein fehlender, fehlerhafter, widerrufener, abgelaufener oder unbekannter Schlüssel, oder einer, dessen Ersteller die Bedingung nicht mehr erfüllt, antwortet mit `401`, `code: "UNAUTHORIZED"` und dem Header `WWW-Authenticate: Bearer realm="api"`. Die Antwort ist in jedem Fall gleich und verrät nicht, welche Prüfung fehlschlug; der Grund wird serverseitig im API-Audit-Protokoll festgehalten (siehe [Audit-Protokoll](#audit-protokoll)).

API-Zugang gibt es nur mit Enterprise (`apiAccess`). Andere Pläne erhalten `403` mit `code: "FORBIDDEN"`. Die Pläne, die heute verkauft werden, setzen dieses Flag nicht, bis der Enterprise-Katalog live ist.

Die API ist für Server-zu-Server-Aufrufe gedacht. Behalten Sie den Schlüssel auf Ihrem Server und rufen Sie die API nicht aus einem Browser auf. Cross-Origin-Anfragen aus dem Browser anderer Websites sind nicht erlaubt, und `/api/v1` sendet und akzeptiert nie ein Cookie.

## Was ein Schlüssel kann

| Methode | Pfad                                   | Wirkung                                             | Erfolg |
| ------- | -------------------------------------- | --------------------------------------------------- | ------ |
| `POST`  | `/projects`                            | Ein Project anlegen                                 | `201`  |
| `GET`   | `/projects/{projectId}`                | Ein Project mit seinen Load-Plan-Köpfen lesen       | `200`  |
| `POST`  | `/projects/{projectId}/load-plans`     | Einen Ladeplan anlegen                              | `201`  |
| `POST`  | `/projects/{projectId}/cargo`          | Frachtzeilen importieren                            | `201`  |
| `GET`   | `/projects/{projectId}/cargo`          | Die Frachtzeilen des Projects auflisten             | `200`  |
| `PATCH` | `/load-plans/{loadPlanId}/equipment`   | Der Laderaum des Ladeplans ändern                   | `200`  |
| `POST`  | `/load-plans/{loadPlanId}/calculate`   | Eine Berechnung einreihen                           | `202`  |
| `GET`   | `/calculates/{id}`                     | Einen Berechnungslauf lesen                         | `200`  |
| `POST`  | `/calculates/{id}/cancel`              | Einen Berechnungslauf abbrechen                     | `200`  |
| `GET`   | `/load-plans/{id}`                     | Den gespeicherten Ladeplan lesen, mit Placements    | `200`  |
| `POST`  | `/load-plans/{loadPlanId}/share-links` | Einen rein lesenden Freigabelink erzeugen           | `201`  |
| `GET`   | `/libraries/cargo`                     | Die Cargo Library auflisten                         | `200`  |
| `GET`   | `/libraries/equipment`                 | Laderaum-Typen auflisten, inkl. Sea- und Road-Typen | `200`  |

- Frachtzeilen liegen schon in Millimetern und Gramm vor. Eine Zeile kann einen `groupKey` tragen: Zeilen mit demselben Schlüssel sind eine Gruppe, die als ein Block in eine Ladeeinheit geladen oder als Ganzes mit dem Grund `group-does-not-fit` nicht verladen wird. Ein Import von bis zu 5.000 Zeilen ist eine Transaktion: Er wird ganz oder gar nicht angehängt.
- Eine Zeile kann eine `stop` (Entladestelle) tragen, eine ganze Zahl von 1 bis 99 oder `null`, beim Schreiben optional. Entladestelle 1 wird zuerst entladen, und eine Zeile ohne Entladestelle wird nach allen Entladestellen entladen. Ein Projekt kann `stops` tragen, ein Array aus `{stop, name}` mit eindeutigen, aufsteigenden Ordnungszahlen, beim Schreiben optional. Die Namen sind nur Bezeichnungen. Projektdateien tragen dieselben optionalen Felder, und ältere Dateien lassen sich weiterhin importieren.
- Eine Zeile kann `minSupportPermille` tragen, eine ganze Zahl von 501 bis 1000 oder `null`, beim Schreiben optional. Es ist der Mindestanteil der Grundfläche des Stücks in Promille, der auf direkt darunterliegender Ladung aufliegen muss, wenn es nicht auf dem Boden steht. `null` und weggelassen bedeuten beides 1000, volle Auflage. Bibliothekseinträge und Projektdateien tragen dasselbe optionale Feld, und ältere Dateien lassen sich weiterhin importieren.
- Eine Zeile kann `frictionSurface` tragen, worauf ihre Stücke für die Ladungssicherungsprüfung stehen: eines von `wood_on_wood`, `planed_wood_on_wood`, `wood_on_steel`, `plastic_on_wood`, `plastic_on_metal`, `steel_on_wood`, `steel_on_steel` und `rubber_mat`, oder `null`, beim Schreiben optional. `null` und weggelassen bedeuten beides die Standard-Reibung des Ladeplans. Bibliothekseinträge und Projektdateien tragen dasselbe optionale Feld, und ältere Dateien lassen sich weiterhin importieren.
- Ein Ladeplan in einer Projektdatei kann `securing` tragen, die Transportabschnitte, für die er die Ladungssicherung prüft: `{"legs": [...]}` mit einem oder mehreren von `road`, `rail`, `sea-a`, `sea-b` und `sea-c` und einem optionalen `frictionPermille` von 1 bis 1500. `null` und weggelassen bedeuten beides keine Prüfung, daher lassen sich ältere Dateien weiterhin importieren. Wurde das gespeicherte Ergebnis einer Datei gegen andere Abschnitte geprüft, wird es beim Import gegen die Abschnitte des Plans neu geprüft.
- Laderaum-Ids stammen aus `GET /libraries/equipment`, inklusive der eingebauten Sea- und Road-Typen.

## Projects und Fracht lesen

`GET /projects/{projectId}` liefert die Metadaten des Projects, `cargoLineCount` und je Ladeplan einen Kopf, der älteste zuerst: `id`, `name`, `revision`, `stale`, `solvedAt` (`null` vor der ersten Berechnung), `createdAt` und `updatedAt`. Fracht, Laderaum und Placements sind nicht enthalten; Laderaum und gespeichertes Ergebnis eines Ladeplans liest `GET /load-plans/{id}`.

`GET /projects/{projectId}/cargo` liefert die Frachtzeilen des Projects seitenweise in Grid-Reihenfolge, also in der Reihenfolge, in der sie importiert oder in der App angeordnet wurden. Jeder Eintrag hat jedes Feld, das eine Importzeile nimmt, mit ausgefüllten Standardwerten (`rotation: "free"`, `stackable: true`, `fragile: false`) und fehlenden Werten als `null`, dazu `id`, `revision`, `sourceLibraryItemId` (gesetzt, wenn die Zeile aus der Cargo Library stammt), `createdAt` und `updatedAt`. Um Fracht in ein anderes Project zu kopieren, lassen Sie diese zusätzlichen Felder weg und senden den Rest an `POST /projects/{projectId}/cargo`.

## Einheiten und Formate

Längen sind ganze Millimeter (`lengthMm`, `innerWidthMm`, …), Gewichte ganze Gramm (`weightG`, `maxPayloadG`, …). Zeitpunkte sind RFC-3339-Strings in UTC, Kalenderdaten `YYYY-MM-DD`. Request-Bodies sind JSON mit `Content-Type: application/json` und höchstens 4 MB groß; ein größerer Body antwortet mit `413`. Unbekannte Schlüssel im Request-Body ergeben `400`. Antworten können neue Felder bekommen; ignorieren Sie Felder, die Sie nicht kennen.

## Laderaum

`PATCH /load-plans/{loadPlanId}/equipment` führt eine Änderung mit dem Laderaum des Ladeplans zusammen:

- `equipmentTypeIds` ist die vollständige Menge der gewählten Laderaum-Typen aus der Bibliothek. Ein weggelassener Typ wird abgewählt.
- `maxUnitsByEquipmentTypeId` setzt optional die Höchstzahl an Ladeeinheiten pro gewähltem Typ (`null` heißt ohne Grenze). Ein gewählter Typ, den Sie weglassen, behält seine Grenze.
- Laderaum ohne Bibliothekstyp (importiert, oder dessen Typ gelöscht wurde) bleibt, außer seine Id steht in `removeEquipmentIds`. Eine Id, die nicht mehr am Plan hängt, wird ignoriert.
- Gewählte Typen und verbleibendes Laderaum dürfen zusammen 20 Zeilen nicht überschreiten.

Senden Sie `expectedRevision` mit der `revision` Ihres letzten Lesens. Hat sich der Ladeplan inzwischen geändert, wird nichts gespeichert, und der Aufruf antwortet mit `412` und `code: "PRECONDITION_FAILED"`; `data.conflicts` nennt die Ressource mit `expectedRevision` und `currentRevision`. Lesen Sie erneut und wiederholen Sie den Aufruf. Die Antwort enthält die neue `revision` für Ihren nächsten Schreibaufruf.

## Berechnung

Eine Berechnung nutzt denselben Optimierer wie Berechnen in der App und läuft im Hintergrund:

1. `GET /load-plans/{id}` aufrufen und `revision` merken.
2. `POST /load-plans/{loadPlanId}/calculate` mit `{"expectedRevision": <revision>}` senden. Gesperrte Placements bleiben immer erhalten. Hat jemand Stücke von Hand verschoben, ohne sie zu sperren, senden Sie `"replaceUnlockedManual": true`, damit der Lauf sie ersetzen darf; ohne das Flag antwortet der Aufruf mit `409`, statt die manuelle Arbeit zu überschreiben. Ein gesperrtes Placement über dem Boden muss auf anderen gesperrten Placements stehen: Sonst antwortet der Aufruf mit `400` und einer Meldung, die das betroffene Stück nennt, weil der Solver nie ein freies Stück unter ein gesperrtes schiebt. Sperren dürfen auch keine Gruppe aufbrechen: Eine nur teilweise gesperrte Gruppe, gesperrte Stücke einer Gruppe in zwei Ladeeinheiten oder eine Sperre im Block einer anderen Gruppe ergeben ebenfalls `400` mit der Gruppe oder den betroffenen Stücken. Die App sperrt und entsperrt eine Gruppe immer als Ganzes. Die Antwort ist `202` mit der Lauf-`id` und `status: "queued"`.
3. `GET /calculates/{id}` abfragen oder [Webhooks](#webhooks) abonnieren und auf das Ereignis warten. `status` wechselt über `queued` und `running` (mit `phase`, `processedItems` und `totalItems`) zu `completed`, `cancelled`, `failed` oder `timed_out`. Ein fehlgeschlagener Lauf liefert einen `failureCode`, zum Beispiel `revision_conflict`, wenn sich der Ladeplan nach Ihrem Lesen geändert hat.
4. Ist der Lauf `completed`, lesen Sie `GET /load-plans/{id}` erneut für die Placements. Der Lauf selbst meldet nur die neue `revision` und `solvedAt`.

Im gespeicherten Ergebnis listet `warnings` Regelverstöße, nicht geladene Ladung und den Schwerpunkt-Hinweis. Achslast-Hinweise für Straßen-Laderaum mit Achsprofil (`axle-overload`, `axle-lift`, `steer-axle-underload`, `drive-axle-underload`, `gross-overload`) stehen gesondert in `axleWarnings`; das Feld fehlt, wenn es keine gibt. Eine Entladestelle, deren Ladung unter Ladung einer späteren Entladestelle oder ohne Entladestelle steht oder vor jeder Öffnung, durch die sie hinaus könnte, wird als `stop-blocked` im optionalen Array `stopWarnings` gemeldet, neben `warnings` und `axleWarnings`. Die Liste `warnings` bleibt unverändert. Es ist nur eine Warnung, und ein Die Berechnung wird deswegen nie abgelehnt.

`GET /load-plans/{id}` liefert das `securing` des Plans: die Transportabschnitte, für die er die Ladungssicherung prüft, oder `null` für keine Prüfung. Die Abschnitte werden in der App gesetzt; die API liest sie nur. Hinweise zur Ladungssicherung (`securing-unblocked` und `securing-tipping`) stehen im optionalen Array `securingWarnings` des gespeicherten Ergebnisses; das Feld fehlt, wenn es keine gibt. Jeder Hinweis nennt eine Ladeeinheit, eine `direction` (`forward`, `rearward` oder `sideways`) und die zu sichernden Packstücke. Sie sind ein Hinweis und keine rechtliche Bewertung und stehen nie in `warnings`. Die [Anleitung zur Ladungssicherung](/de/guides/load-securing) erklärt sie.

`POST /calculates/{id}/cancel` stoppt eine wartende oder laufende Berechnung. Ein abgebrochener Lauf schreibt keine Placements.

Pro Ladeplan läuft eine Berechnung zur Zeit: Ein zweites Einreihen, während eine wartet oder läuft, antwortet mit `409`. Eine Organisation darf 10 Berechnungen gleichzeitig wartend oder laufend haben; die elfte antwortet mit `429`.

Eine Berechnung unterstützt höchstens 10.000 Ladungspositionen, insgesamt 20.000 Stücke und 500 Ladeeinheiten. Ladung über einer dieser Grenzen wird mit `400` abgelehnt, bevor ein Lauf eingereiht wird; die Meldung nennt die Grenze und Ihren Wert. Die Ladeeinheit-Prüfung nutzt eine untere Schranke (Gewicht, Volumen und Bodenfläche über dem größten angebotenen Laderaum) und lehnt daher nur Ladung ab, die sicher mehr als 500 Ladeeinheiten braucht.

## Webhooks

Sie müssen nicht abfragen. Eine Organisation im Enterprise-Plan kann unter **Einstellungen → Webhooks** HTTPS-Endpunkte registrieren und erhält einen signierten POST, wenn eine Berechnung endet: `calculate.completed`, `calculate.failed` oder `calculate.cancelled`. Der Body nennt Berechnung, Ladeplan und Project und enthält keine Plandaten. Nach `calculate.completed` rufen Sie für die Placements also weiterhin `GET /load-plans/{id}` auf. Die Zustellung erfolgt mindestens einmal und ohne feste Reihenfolge. Die [Webhooks-Anleitung](/de/guides/webhooks) behandelt die Signaturprüfung, Wiederholungen und die Netzwerkregeln, und die Spezifikation listet die Ereignisse unter `webhooks`.

## Freigabelinks

`POST /load-plans/{loadPlanId}/share-links` erzeugt einen Freigabelink, der nur das Ansehen erlaubt. Verlader-Links, deren Besucher Ladeschritte abhaken können, erstellen Sie in der App. Der Body ist optional; senden Sie `{"expiresAt": "2026-12-31T23:59:59Z"}` für einen Link, der abläuft, oder lassen Sie ihn weg für einen, der nie abläuft. Hat der Ladeplan noch kein gespeichertes Ergebnis, antwortet der Aufruf mit `400` und `data.reason` `load_plan_not_calculated`. Starten Sie zuerst eine Berechnung. Mit `showLoadingSteps: false` blenden Sie die schrittweise Beladung für Besucher aus; der Standard ist `true`. Das ist eine Darstellungsoption: Schrittseiten, Schrittbilder und die Spalte mit dem Ladeschritt entfallen, aber die Platzierungsliste im Bericht und das CSV behalten die Ladereihenfolge, und die Plandaten des Links enthalten weiter das vollständige Ergebnis.

`token` und `url` stehen nur in der Antwort, die den Link erzeugt hat: Gespeichert wird nur ein Hash des Tokens. Bewahren Sie beides auf, oder erzeugen Sie einen neuen Link, wenn es verloren ist.

## Idempotenz

`POST /projects`, `POST /projects/{projectId}/load-plans`, `POST /projects/{projectId}/cargo`, `POST /load-plans/{loadPlanId}/calculate` und `POST /load-plans/{loadPlanId}/share-links` verlangen den Header `Idempotency-Key`: 1 bis 255 sichtbare ASCII-Zeichen ohne Leerzeichen (eine UUID passt). Ohne ihn antworten sie mit `400`.

- Eine Wiederholung mit demselben Schlüssel und demselben Body innerhalb von 24 Stunden liefert die erste Antwort mit demselben Status, statt ein zweites Project, einen zweiten Import, eine zweite Berechnung oder einen zweiten Freigabelink zu schreiben.
- Derselbe Schlüssel mit einem anderen Body, oder während die erste Anfrage noch läuft, antwortet mit `409` und `code: "CONFLICT"`.
- Eine Anfrage, die fehlschlug, bevor etwas geschrieben wurde (zum Beispiel `400` oder `404`), verbraucht den Schlüssel nicht; wiederholen Sie sie mit demselben Schlüssel.
- Schlüssel gelten pro API-Schlüssel. Nach dem Rotieren beginnt der neue API-Schlüssel ohne gespeicherte Idempotency-Keys.
- Ein wiederholter Freigabelink hat `token` und `url` auf `null`, weil das Token nie gespeichert wird.

`PATCH` für Laderaum und `POST /calculates/{id}/cancel` nehmen keinen `Idempotency-Key`: Das erste schützt `expectedRevision`, und zweimal abbrechen wirkt wie einmal abbrechen.

## Seitenweises Lesen

`GET /libraries/cargo`, `GET /libraries/equipment` und `GET /projects/{projectId}/cargo` liefern eine Seite: `{ "items": [...], "nextCursor": "…" }`. Übergeben Sie `nextCursor` als `cursor` für die nächste Seite; auf der letzten Seite ist er `null`. Ein `cursor`, der kein Eintrag der Liste ist, antwortet mit `404`. Bei den Bibliotheken setzt `limit` die Seitengröße, 1 bis 100 (Standard 20), und `query` filtert nach einem Teilstring, ohne auf Groß- und Kleinschreibung zu achten. Bei Frachtzeilen ist `limit` 1 bis 500 (Standard 100), und `totalCount` zählt alle Zeilen des Projects.

Die Laderaum-Liste filtert außerdem nach `mode` (`sea` oder `road`) und nach `ids`, das auch für eine einzelne Id die Klammer-Schreibweise verlangt:

```bash
curl -sS "$ORIGIN/api/v1/libraries/equipment?ids[]=eqt_a&ids[]=eqt_b" \
  -H "Authorization: Bearer $CARGO_MARSHAL_API_KEY"
```

## Limits

| Limit                      | Geltung                          | Budget                  |
| -------------------------- | -------------------------------- | ----------------------- |
| Anfragen                   | pro Client-IP, vor der Anmeldung | 300 pro Minute          |
| Anfragen                   | pro API-Schlüssel                | 120 pro Minute          |
| Berechnungen einreihen     | pro API-Schlüssel                | 30 pro 10 Minuten       |
| Gleichzeitige Berechnungen | pro Organisation                 | 10 wartend oder laufend |

Eine Anfrage über einem Limit antwortet mit `429`, `code: "TOO_MANY_REQUESTS"` und einem `Retry-After`-Header in Sekunden (auch in `data.retryAfter`, wenn das Limit ihn kennt). Warten Sie so lange, bevor Sie es erneut versuchen. Eine wiederholte Berechnung (derselbe `Idempotency-Key`) verbraucht das Berechnungsbudget nicht.

Die Limits pro Schlüssel fallen geschlossen aus: Ist der Limiter in Produktion nicht erreichbar, antwortet der Aufruf mit `503`, statt durchgelassen zu werden. Wiederholen Sie `503` mit Backoff.

## Fehler

Jeder Fehler antwortet mit demselben JSON-Body:

```json
{
  "defined": true,
  "code": "BAD_REQUEST",
  "status": 400,
  "message": "Input validation failed",
  "data": {
    "reason": "invalid_input",
    "issues": [
      {
        "path": ["body", "lines", 0, "lengthMm"],
        "message": "Too small: expected number to be >=1",
        "code": "too_small"
      }
    ]
  }
}
```

Entscheiden Sie anhand von `code` und, wo gesetzt, `data.reason`; `message` ist englischer Text für Menschen und kann sich ändern. `defined: true` heißt, der Fehler ist einer, den die Spezifikation für diese Route dokumentiert, mit `data` in der dokumentierten Form; `defined: false` ist ein unerwarteter Fehler, meist ein `500`.

| Status | `code`                | Wann                                                                                                                                                                                                                                          |
| ------ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `BAD_REQUEST`         | Ungültige Eingabe (`data.reason` `invalid_input`, `data.issues` listet höchstens 50 fehlgeschlagene Prüfungen), Ladung, die der Solver nicht annimmt, oder ein Freigabelink auf einem nicht berechneten Ladeplan (`load_plan_not_calculated`) |
| `401`  | `UNAUTHORIZED`        | Fehlender, ungültiger, widerrufener oder abgelaufener API-Schlüssel, oder sein Ersteller ist nicht mehr Inhaber oder Admin                                                                                                                    |
| `403`  | `FORBIDDEN`           | Der Plan der Organisation enthält keinen API-Zugang, oder ein Schlüssel mit Nur-Lesen hat eine schreibende Route aufgerufen (`insufficient_scope`)                                                                                            |
| `404`  | `NOT_FOUND`           | Die Id gibt es in der Organisation des Schlüssels nicht, oder der Pfad ist keine Route                                                                                                                                                        |
| `405`  | —                     | Den Pfad gibt es für andere Methoden; der Header `Allow` nennt sie                                                                                                                                                                            |
| `409`  | `CONFLICT`            | Wiederverwendeter Idempotency-Key, eine bereits laufende Berechnung oder nicht gesperrte manuelle Placements                                                                                                                                  |
| `409`  | `PLAN_LIMIT`          | Ein Tariflimit für Projekte, Berechnungen oder Freigabelinks                                                                                                                                                                                  |
| `412`  | `PRECONDITION_FAILED` | `expectedRevision` ist veraltet                                                                                                                                                                                                               |
| `413`  | —                     | Der Body ist größer als 4 MB                                                                                                                                                                                                                  |
| `429`  | `TOO_MANY_REQUESTS`   | Ein Limit ist erreicht; siehe `Retry-After`                                                                                                                                                                                                   |
| `503`  | `SERVICE_UNAVAILABLE` | Der Limiter oder ein anderer Dienst ist nicht erreichbar; mit Backoff wiederholen                                                                                                                                                             |

Die Spezifikation nennt pro Route genau, welche dieser Codes sie liefern kann.

Ein Tariflimit antwortet mit `409`, `code: "PLAN_LIMIT"` und `data`, das das Limit nennt:

```json
{
  "reason": "plan-limit",
  "resource": "solves",
  "limit": 10,
  "planName": "Free"
}
```

`resource` ist `projects`, `solves` oder `share-links`.

Jede Antwort trägt einen Header `x-request-id`. Nennen Sie ihn, wenn Sie den Support kontaktieren; Sie können auch eine eigene `x-request-id` senden, die dann übernommen wird.

## Audit-Protokoll

Der Server legt eine API-Audit-Zeile (Schlüssel, Operation, Status, Ressourcen-Id) an für:

- jeden schreibenden Aufruf, auch eine Wiederholung mit demselben `Idempotency-Key`, ob er gelingt oder scheitert;
- jeden lesenden Aufruf, der in der Operation scheitert, zum Beispiel ein `404` für die Id einer anderen Organisation;
- jede Ablehnung eines bekannten Schlüssels: Rate-Limit (`429`), Nur-Lesen-Scope (`403`), Plan ohne API-Zugang (`403`) sowie ein abgelaufener Schlüssel oder einer, dessen Ersteller die Bedingung nicht mehr erfüllt (`401`).

Erfolgreiche Lesezugriffe (`GET`-Routen mit `200`) werden nicht festgehalten: Sie ändern nichts, und Integrationen fragen sie regelmäßig ab. Ebenfalls nicht festgehalten werden Anfragen, die am Eingabeschema scheitern (`400` mit `invalid_input`), unbekannte oder widerrufene Schlüssel und Anfragen, die das Limit pro IP ablehnt.

## Was ein Schlüssel nicht kann

Ein Schlüssel kann keine Placements und keinen Solve-Snapshot senden: Der Body der Berechnung nimmt nur die erwartete Revision und das Flag für manuelle Placements, und Placements werden immer auf dem Server berechnet. Admin, Abrechnung, Chat, Anmeldung und Dateispeicher bleiben außerhalb dieser API.

## Versionierung und Abkündigung

`/api/v1` ist stabil. Innerhalb von `v1` gibt es nur additive Änderungen: neue Routen, neue optionale Eingabefelder, neue Antwortfelder, neue Werte für `code` oder `data.reason` bei einem bestehenden Status. Bauen Sie Clients so, dass sie unbekannte Antwortfelder ignorieren.

Eine inkompatible Änderung (eine Route oder ein Feld entfernen oder umbenennen, eine optionale Eingabe zur Pflicht machen, einen Typ oder einen Erfolgsstatus ändern) erscheint unter einer neuen Version, `/api/v2`, und `v1` funktioniert daneben weiter. Jede Änderung der Spezifikation wird vor der Auslieferung auf inkompatible Änderungen geprüft.

Bevor eine Route oder ein Feld entfernt wird, ist sie in der Spezifikation mit `deprecated: true` markiert. Jede Antwort einer abgekündigten Route, ob erfolgreich oder nicht, trägt:

- `Deprecation`: `true` oder das Datum der Abkündigung in der Form `@<Unix-Sekunden>` nach [RFC 9745](https://www.rfc-editor.org/rfc/rfc9745), zum Beispiel `Deprecation: @1790812800`.
- `Sunset`: das Datum der Entfernung als HTTP-Datum ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)), zum Beispiel `Sunset: Thu, 01 Apr 2027 00:00:00 GMT`, sobald es feststeht.
- `Link: <…>; rel="deprecation"`: die Seite, die die Umstellung erklärt, sobald sie veröffentlicht ist.

Das Sunset-Datum liegt mindestens 6 Monate nach der Ankündigung. Protokollieren Sie diese Header in Ihrem Client, damit eine Abkündigung nicht unbemerkt bleibt. Heute ist keine `v1`-Route abgekündigt.

## Beispiel

```bash
curl -sS -X POST "$ORIGIN/api/v1/projects" \
  -H "Authorization: Bearer $CARGO_MARSHAL_API_KEY" \
  -H "Idempotency-Key: project-4401" \
  -H "Content-Type: application/json" \
  -d '{"name":"Hamburg 40HC"}'
```

Ersetzen Sie `$ORIGIN` durch die App-URL. Der Aufruf antwortet mit `201` und dem neuen Project.
