# TourAPI – API-Handbuch v1

Für Entwickler, die ein Reiseportal oder ein Veranstaltersystem an TourAPI anbinden.
Stand: 30.09.2026, API-Version `v1`; Änderungen stehen im Changelog. Maschinenlesbar: [`openapi.yaml`](openapi.yaml)
(OpenAPI 3.1). Für KI-Agenten (Claude Code, Codex, Antigravity): Kurzfassung mit Ablauf,
Formaten und Wiederhol-Regeln im Portal unter `/doku/agenten.md`, nach der Anmeldung mit dem
eigenen Zugang ausgefüllt (Seite „KI-Agenten“, Download als `AGENTS.md`).

**Jedes Beispiel in diesem Handbuch ist ein Test.** Die Anfragen stehen unter
[`beispiele/`](beispiele/) (im Format der REST-Clients von JetBrains und VS Code) und laufen bei
jedem Build gegen eine frische TourAPI-Instanz mit Testdaten; die gezeigten Antworten
werden dabei mit den echten verglichen.

| Kürzel | Bedeutung | Endpunkt |
|---|---|---|
| BA | Verfügbarkeit und Preis anfragen, suchen | `/v1/search`, `/v1/price`, `/v1/prices` |
| BA | offene Suche ohne festen Termin (Recht je Key) | `/v1/search/open` (Abschnitt 4a) |
| BA | Termin-Matrix eines Hotels (Recht je Key) | `/v1/search/open/dates` (Abschnitt 4b) |
| – | wirksame Grenzen des eigenen Keys | `/v1/limits` (Abschnitt 4c) |
| B | buchen | `/v1/book` |
| – | Buchungsinfo lesen | `/v1/booking` |
| S | stornieren | `/v1/cancel` |
| – | Gesundheit des Knotens | `/v1/health` (Abschnitt 1.8) |
| – | EDF-Lieferung (Cache-Export) | `/v1/export/edf/full`, `/changes`, `/ack` (Abschnitt 10) |
| – | Sandbox: Test-Keys, Testbuchungen, Szenarien | alle Endpunkte mit Test-Key (Abschnitt 11) |
| – | Hotelinhalte (Stamm, Lage, Texte, Bilder, Ausstattung) | `/v1/content/hotels`, `/changes`, `/catalog` (Abschnitt 12) |

Konsole/Agenten: nicht Teil der Käufer-API (`POST /changes` der Veranstalter-Konsole,
Anmeldung über die Sitzung, kein API-Key; Beschreibung und Grenzen auf Anfrage beim Betreiber).

---

## 1. Einstieg

### 1.1 Zugang

- Jede Anfrage trägt den API-Key im Kopf **`X-Api-Key`**. Es gibt keine Sitzung, kein Token.
- Den Key vergibt der Veranstalter (Zugangspaket). Ein Key gehört zu genau **einem
  Veranstalter** (Mandant) und optional zu einer **Kundengruppe**. Die Kundengruppe bestimmt
  Sonderpreise und, als Kontingent-Gruppe, das Kontingent, gegen das gebucht wird. Ein Key
  ohne Gruppe arbeitet auf dem Basisvertrag. Welche Art eine Kundengruppe ist, legt der
  Veranstalter fest:
  - **Kontingent-Gruppe** (Vertriebspartner mit Kontingent): sieht nur die Hotels, für die sie
    eine Zuteilung hat, und bucht gegen diese Zuteilung. Andere Hotels gibt es für den Key
    nicht (`404 ERR_HOTEL_NOT_FOUND`, nicht in Suche und `/v1/destinations`). Hat sie gerade
    keine Zuteilung, sieht sie kein Hotel.
  - **Preisgruppe**: sieht alle Hotels des Veranstalters und bucht aus dem allgemeinen
    Bestand wie ein Key ohne Gruppe – nur zu ihrem Preis.
- Lässt sich der Sonderpreis einer Kundengruppe (mit oder ohne Zuteilung) für ein Hotel nicht
  rechnen (fehlerhafte Preisaktion beim Veranstalter), gibt es das Hotel für diese Gruppe
  nicht (`404 ERR_HOTEL_NOT_FOUND`, in der Suche Grund `ERR_HOTEL_NOT_FOUND` in
  `diagnostics.reasons`, auch `/v1/book` lehnt mit `404` ab) – nie still den Basispreis.
- Mandant, Kundengruppe und Preise kommen **nur aus dem Key**, nie aus einem Parameter.
  Hotels anderer Veranstalter gibt es für den Key nicht (`404`).
- Fehlender, unbekannter oder widerrufener Key: `401 ERR_UNAUTHORIZED`. Gueltiger Key eines
  gesperrten Veranstalters: `403 ERR_TENANT_SUSPENDED`. Key einer deaktivierten
  Kundengruppe: `403 ERR_KEY_GROUP_INACTIVE` (nie still Basisvertrag).
- Keys gehören nicht in Browser-Code oder Logs. Wer einen Key verloren hat, lässt ihn beim
  Veranstalter widerrufen – oder sperrt ihn selbst im Abnehmer-Portal (falls der Veranstalter
  es freigeschaltet hat).
- **Keys im Abnehmer-Portal:** Dort sehen Sie die Keys Ihres Zugangs, holen vom Veranstalter
  freigegebene Live-Keys ab, rotieren Live-Keys und sperren eigene Keys. Den Rohwert zeigt das
  Portal genau einmal. Eine Sperre lehnt die API nach wenigen Sekunden ab (`401`).
- **Rotation:** Der neue Live-Key hat dieselben Rechte (Kundengruppe, Export, Inhalte, Suchprofil). Der
  bisherige gilt noch eine vom Veranstalter festgelegte Zeit (Standard 24 Stunden, 1 Stunde
  bis 7 Tage) und wird danach auf die Sekunde abgewiesen (`401 ERR_UNAUTHORIZED`). Stellen Sie
  in dieser Zeit alle Systeme auf den neuen Key um. Den neuen Key rotieren Sie erst wieder,
  wenn der bisherige abgelaufen ist – so gelten nie mehr als zwei Keys gleichzeitig.
- Zum Entwickeln gibt es **Test-Keys** (`tk_test_…`): gleiche Daten wie der Live-Key, Buchungen
  nur in der Sandbox (Abschnitt 11). Jede Antwort nennt den Modus im Kopf `X-TourAPI-Mode`.

### 1.2 Basis-URL, Transport, Version

- Basis-URL steht im Zugangspaket; in den Beispielen `{{baseUrl}}`.
- Alle Pfade beginnen mit **`/v1`**. Was sich innerhalb von `v1` ändern darf, regelt die
  Kompatibilitätszusage (Abschnitt 1.9). Unbekannte Antwortfelder bitte ignorieren.
- Anfragen: JSON in UTF-8, `Content-Type: application/json`, höchstens **1 MiB** Body.
  `GET` sind `/v1/booking`, `/v1/destinations`, `/v1/health` und die EDF-Lieferung
  (`/v1/export/edf/full`, `/changes`), alle übrigen sind `POST`; falsche Methode:
  `405 ERR_METHOD_NOT_ALLOWED`.
- Zeitstempel in Antworten (`bookedAt`, `updatedAt`) sind RFC 3339 in UTC, z. B.
  `2026-09-26T08:15:03Z`.
- Der Server bricht eine Verbindung nach 15 s Lesen bzw. 15 s Schreiben ab. Ausnahme: Pakete der
  EDF-Lieferung (`/v1/export/edf/full`, `/changes`) dürfen bis 10 min schreiben (Abschnitt 10).
- Ein unbekannter Pfad (z. B. Tippfehler in `/v1/…`) ist `404` **ohne** JSON-Körper
  (`text/plain`, kein `errorCode`); alle anderen Fehler haben die Form aus 1.6.

### 1.3 Datum, Aufenthalt, Stichtag

- Datumsfelder sind Kalendertage **`JJJJ-MM-TT`** ohne Uhrzeit und Zone.
- Ein Aufenthalt ist halb offen: `checkIn` ist die erste Nacht, `checkOut` der Abreisetag.
  `checkIn=2026-10-26`, `checkOut=2026-10-28` sind **zwei Nächte**.
- **Stichtag** ist das Serverdatum in der Zone Europe/Berlin. Frühbucher- und
  Zeitraumregeln rechnen immer damit. Das Anfragefeld `now` ist nur noch geduldet: leer
  lassen. Schickt man ein anderes Datum, rechnet die Auskunft trotzdem mit dem Serverdatum
  und sagt es in `warnings`; `/v1/book` lehnt ein abweichendes `priceCheck.now` ab
  (`ERR_NOW_MISMATCH`).
- Grenzen je Anfrage (greifen vor jeder Rechnung):

| Grenze | Wert | Code (422) |
|---|---|---|
| Nächte je Aufenthalt | 1–30 | `ERR_EMPTY_STAY`, `ERR_STAY_TOO_LONG` |
| Anreise frühestens | heute (Stichtag) | `ERR_STAY_IN_PAST` |
| Anreise spätestens | Stichtag + 732 Tage | `ERR_STAY_TOO_FAR` |
| Reisende je Anfrage | 1–20 | `ERR_NO_TRAVELLERS`, `ERR_TOO_MANY_TRAVELLERS` |
| Alter | 0–120 | `ERR_INVALID_AGE` |

### 1.4 Geld

- Alle Beträge sind **ganze Cent** (`…Cents`, Ganzzahl). 18000 = 180,00.
- **Rundung:** kaufmännisch auf 2 Stellen, **einmal je Reisendem** (`rounding` in jeder
  Preis-Antwort: `{"mode": "Commercial", "decimalPlaces": 2, "scope": "Person"}` – dieselbe
  Regel, die das EDF des Hotels deklariert). Die Posten eines Reisenden werden exakt
  (ungerundet) summiert, erst die Summe wird gerundet: das ist `perTravellerCents[i]`,
  `totalCents` ist deren Summe. Ein Posten wie „−15 % auf 89,90“ ist −13,485 und bleibt es bis
  zur Summe. Den exakten Betrag je Posten zeigt `breakdown[].amountExact` (Abschnitt 3.1).
- Die Währung ist die **Vertragswährung** des Hotels (`currency`, ISO 4217). TourAPI rechnet
  nicht um. `currency` in der Anfrage ist ein Wunsch: weicht er ab, kommt die Antwort in
  Vertragswährung mit Hinweis in `warnings`. Ist am Vertrag keine Währung hinterlegt, bleibt
  `currency` leer, ebenfalls mit Hinweis – es wird nicht „EUR“ geraten.
- Bei `/v1/book` mit `priceCheck` ist ein Währungswiderspruch dagegen ein Fehler
  (`ERR_CURRENCY_NOT_AVAILABLE`): Beträge ohne gemeinsame Einheit werden nicht verglichen.

### 1.5 Belegung

`occupancy.travellers` ist die Liste der Reisenden **eines Zimmers**, je Reisendem das
`age` am Anreisetag. `name` und `type` dürfen mitkommen, haben aber keine Wirkung auf den
Preis. Kinderpreise, Vollzahler und Mindestbelegung ergeben sich aus dem Vertrag:

- Ob jemand Kind oder Erwachsener ist, entscheidet das Kinderband des Zimmers (z. B. 2–11
  Jahre: ein 14-Jähriger zahlt den Erwachsenenpreis), nicht eine feste Grenze.
- Ein Zimmer kann eine Zahl von Vollzahlern verlangen (z. B. 2 im Doppelzimmer). Reisen
  weniger Erwachsene, besetzt ein Kind die freie Vollzahler-Stelle und **zahlt den vollen
  Grundpreis** (Verpflegung zum Kindersatz); Kinderermäßigungen gelten erst für Kinder
  darüber hinaus.
- Staffeln wie „1. Kind / 2. Kind“ zählen die Kinder in der Reihenfolge, die das Hotel
  festlegt (ältestes oder jüngstes zuerst). Dieselbe Reihenfolge bestimmt, welches Kind eine
  freie Vollzahler-Stelle besetzt.
- Wer jünger ist als das Kinderband, ist Säugling: beim Preis je Person ohne Grundpreis, nie
  auf einer Vollzahler-Stelle; Verpflegung oder ein eigener Säuglingspreis nur, wenn der
  Vertrag sie nennt. Bei „ab 3 Personen“-Angeboten zählt ein Säugling nur mit, wenn das Zimmer
  ihn zur Belegung zählt.
- Kein Reisender zahlt weniger als 0. Prozent-Rabatte rechnen auf das, was der Reisende nach
  seiner Kinder- oder Personenermäßigung schuldet (ein freies Kind bleibt frei, ein
  Frühbucher −20 % trifft bei −50 % Kinderpreis die Hälfte).

### 1.6 Antworten, Fehler, Hinweise

- Erfolg: `200` mit dem Ergebnis. Fehler: `4xx`/`5xx` mit

  ```
  {"errorCode": "ERR_…", "message": "…", "warnings": ["…"]}
  ```

  `errorCode` ist stabil und für Programme gedacht, `message` ist ein deutscher Text für
  Menschen und kann sich ändern. Vollständige Liste: Abschnitt 7.
- `warnings` (optional, auch in Erfolgsantworten): Was die API an der Anfrage bemerkt hat,
  ohne abzulehnen – ignorierte Felder, abweichende Währung, ignoriertes `now`.
  **Bitte loggen.** Viele Integrationsfehler stehen genau dort.
- **Rechenzeit:** Jede Antwort eines Endpunkts – auch Fehlerantworten und `/v1/health` – trägt
  den Kopf `Server-Timing: tourapi;dur=<ms>` (W3C Server Timing), z. B. `tourapi;dur=12.4`:
  Millisekunden vom Eingang der Anfrage am Endpunkt bis zum Beginn der Antwort, einschließlich
  Lesen des Anfrage-Körpers und Wartezeit an Takt und Such-Gate (Abschnitt 8), ohne die
  Übertragung der Antwort. Die Differenz zur selbst gemessenen Zeit ist Netz und Übertragung.
  Der Wert ist eine Auskunft, keine Zusage; wer ihn nicht braucht, ignoriert den Kopf.
- **Unbekannte Felder:** In Auskunftsanfragen (`/v1/price`, `/v1/prices`, `/v1/search`)
  wird ein unbekanntes Feld ignoriert und in `warnings` genannt. Innerhalb von `occupancy`
  wird es abgelehnt (`422 ERR_UNKNOWN_FIELD`), weil ein Tippfehler dort den Preis ändert.
  `/v1/book` und `/v1/cancel` lehnen **jedes** unbekannte Feld ab.

### 1.7 Die Testwelt der Beispiele

Die Beispiele laufen gegen die Testwelt des Betreibers (Golden Seed): Veranstalter
`TEST-TENANT-A`, Hotels `TEST-HOTEL-…`, Zimmer `DZ`/`EZ`, Verpflegung `RO`/`BB`/`HB`. Platzhalter:

| Platzhalter | Bedeutung |
|---|---|
| `{{baseUrl}}` | Basis-URL |
| `{{apiKey}}` | Key **ohne Kundengruppe** (Basisvertrag). |
| `{{rabattKey}}` | Key der Kundengruppe `TEST-PARTNER-DISCOUNT` (−20 % auf `TEST-HOTEL-DISCOUNT`, Preisgruppe) |
| `{{partnerKey}}` | Key der Kontingent-Gruppe `TEST-PARTNER-BASE` |
| `{{poolKey}}` | Key der Kontingent-Gruppe `TEST-PARTNER-POOL`, **ohne** Export-Recht |
| `{{gesperrterKey}}` | widerrufener Key |
| `{{exportEpoch}}`, `{{exportSeq}}` | `epoch` und `to_seq` aus dem Manifest des Beispiels `export-voll` |
| `{{ohnePreisRef}}`, `{{zweiteRef}}` | Buchungsreferenzen aus den Beispielen `buchen-ohne-preispruefung` bzw. `buchen-gleiche-kundenreferenz` |
| `{{D0}}`, `{{D2}}`, … | Anreisetag D0 der Testwelt plus n Tage. D0 = Tag des ersten Testdaten-Aufbaus + 30 Tage. D0 ist nur ein fester Kalendertag der Testdaten, **nicht** der Stichtag aus 1.3 (der ist immer das Serverdatum) |
| `{{heute}}`, `{{gestern}}` | Serverdatum (= Stichtag), Vortag |
| `{{buchungsRef}}` | Buchungsreferenz aus dem Beispiel `buchen` |
| `{{feedToken}}`, `{{inhaltCursor}}` | `feedToken` und `nextCursor` aus dem Beispiel `inhalt-verzeichnis` |
| `{{inhaltEtag}}` | `ETag` aus dem Beispiel `inhalt-hotel` |
| `{{*}}` | (nur in Antworten) beliebiger Wert, z. B. Zeitstempel |

Preise der Testwelt (DZ, 2 Erwachsene, Nur Übernachtung): erste Nacht 100,00, jede
weitere 80,00 je Zimmer; Frühstück +20,00, Halbpension +35,00 je Person und Nacht.
Ausnahme `TEST-HOTEL-KIND` (Ziel `ACE`): Preis je Person und Nacht (DZ 89,90 / 79,90),
Kinder von 2 bis 11 Jahren −15 %.

### 1.8 Gesundheit: `GET /v1/health`

Für Load-Balancer und Überwachung, **ohne Key**. `200 {"status": "ok"}` heißt: Der Knoten hat
den Bestand geladen, und sein letzter Abgleich mit der Datenbank ist frisch (Standard: jünger
als 60 s). Sonst `503` mit `status` `degraded` (Abgleich zu alt, `reason: "sync_stale"`) oder
`down` (Bestand nicht geladen, `reason: "view_not_loaded"`), dazu `detail` als Text. Die
Antwort enthält keine Veranstalter- oder Bestandsdaten. Mehr als 10 Aufrufe je Sekunde von
einer Absender-IP werden verzögert beantwortet (höchstens 1 s, Abschnitt 8).

Liest der Knoten über eine Lese-Replica, nennt die Antwort zusätzlich deren Verzug in
Sekunden (`replica_lag_s`). Über 5 s bleibt der Status `ok`, `warnings` enthält dann
`"replica_lag"`. `503 degraded` kommt, wenn Abgleich-Alter plus Verzug die Grenze
überschreiten (`reason: "replica_lag"`) oder der Verzug nicht messbar ist
(`reason: "replica_lag_unknown"`).

Jede Antwort (auch `503`) nennt außerdem die Laufzeit des Prozesses in Sekunden
(`uptime_s`) und den Build-Stand (`version`, leer bei einem Build ohne Stand). Sobald der
Knoten die Datenbank gemessen hat, steht `db` da: `"ok"` mit `db_latency_ms` (Dauer einer
Rundreise, gemessen im Abgleich-Takt, nicht beim Health-Aufruf) oder `"error"` (letzte
Messung gescheitert). `"error"` allein kippt den Status nicht — der Knoten antwortet aus
seinem geladenen Bestand; bei `ok` steht dann `"db_error"` in `warnings`, und bleibt der
Abgleich aus, folgt nach der Grenze `503 degraded` (`sync_stale`).

`"capacity_overlap"` in `warnings` (Status bleibt `ok`) heißt: Die Bestandsprüfung im
Abgleich-Takt hat Kapazitäts-Zeiträume gefunden, die sich je Zimmerart überlappen — ein
Betreiber-Befund (Bestand am Schreibweg vorbei geändert), kein Fehler der Anfrage.

```http
### gesundheit
GET {{baseUrl}}/v1/health
```

```json
{
  "status": "ok",
  "db": "ok",
  "db_latency_ms": "{{*}}",
  "uptime_s": "{{*}}",
  "version": "{{*}}"
}
```

### 1.9 Kompatibilitätszusage für `/v1`

Innerhalb von `/v1` ändert sich die API nur **additiv**:

| Darf innerhalb `/v1` kommen | Kommt nie innerhalb `/v1` (das wäre `/v2`) |
|---|---|
| neue optionale Anfragefelder | neue Pflichtfelder in der Anfrage |
| neue Antwortfelder | Felder entfernen oder umbenennen |
| neue Endpunkte | Typ oder Bedeutung eines Feldes ändern |
| neue `ERR_*`-Codes, jeweils mit dokumentiertem Umgang im Fehlerkatalog | Bedeutung eines bestehenden `ERR_*`-Codes ändern |

Was der Abnehmer dafür tun muss:

- **Unbekannte Antwortfelder ignorieren**, nicht ablehnen.
- **Unbekannte `ERR_*`-Codes** nach dem HTTP-Status behandeln (`4xx`: nicht blind
  wiederholen, `5xx`/`429`: mit Abstand wiederholen, Abschnitt 7).
- `message` und `warnings` sind Texte für Menschen und nicht Teil der Zusage.

Fest innerhalb `/v1`: Zeitstempel sind RFC 3339 in UTC (`Z`), Beträge ganze Cent (1.4),
Textlängen werden vor dem Schreiben geprüft (`422` mit Feldnamen, nie `500`, Anhang),
`/v1/health` braucht keinen Key (1.8).

### 1.10 Anmeldung im Abnehmer-Portal

Das Portal (Doku, eigene Zugänge und Keys) gehört nicht zur API; die API braucht nur den Key.
Für die Anmeldung im Portal gilt:

- Den Portal-Zugang legt der Veranstalter an. Sie bekommen einen Benutzer und ein
  **Einmal-Passwort** (30 Tage gültig).
- **Erste Anmeldung:** Benutzer und Einmal-Passwort, dann den **zweiten Faktor** einrichten
  (Authenticator-App nach RFC 6238: QR-Code scannen oder Schlüssel abtippen, sechsstelligen Code
  eingeben), danach ein eigenes Passwort vergeben. Ohne gültiges Einmal-Passwort lässt sich der
  zweite Faktor nicht einrichten.
- **Jede weitere Anmeldung:** Passwort und aktueller Code. Ein Code gilt nur einmal.
- **Falsche Codes:** nach 5 endet die Anmeldung. 10 Fehlversuche innerhalb von 7 Tagen sperren
  den Zugang, auch für den richtigen Code. Entsperren kann nur der Veranstalter.
- **Gesperrt oder Telefon verloren:** beim Veranstalter melden. „Sperre aufheben“ gibt den Zugang
  mit dem bisherigen zweiten Faktor wieder frei. „2. Faktor neu“ vergibt ein neues
  Einmal-Passwort; danach richten Sie zweiten Faktor und Passwort neu ein, das alte Passwort gilt
  nicht mehr.
- Portal und Veranstalter-Konsole sind getrennt: Portal-Zugangsdaten gelten an der Konsole nicht.

---

## 2. Ablauf einer Anbindung

```
/v1/destinations
  -> /v1/search
  -> /v1/price | /v1/prices
  -> /v1/book        idemKey, priceCheck
  -> /v1/booking
  -> /v1/cancel      idemKey
```

- `/v1/destinations`: gültige `destination`-Codes des Keys.
- `/v1/search`: buchbare Hotels mit Ab-Preis und Verfügbarkeit.
- `/v1/price`: Preisauskunft für Hotel, Zimmer, Verpflegung und Belegung (verbindlich erst
  `/v1/book`); `/v1/prices`: alle Verpflegungen (und Zimmer) auf einmal.
- `/v1/book`: bucht, mit `idemKey` und `priceCheck` (dem Preis von eben).
- `/v1/booking`: liest die Buchung über unsere oder die eigene Referenz.
- `/v1/cancel`: storniert über denselben `idemKey`.

Regeln, die man kennen muss:

1. **Suche und Preis sind Auskunft, verbindlich ist erst `/v1/book`.** Zwischen Preis und
   Buchung kann der Veranstalter Preise oder Kontingent ändern. Mit `priceCheck` lehnt
   `/v1/book` einen geänderten Preis ab (`409 ERR_PRICE_DRIFT`) statt still zum neuen zu
   buchen.
2. **Jede Buchung hat einen eigenen `idemKey`** (vom Aufrufer vergeben, z. B. die eigene
   Vorgangsnummer). Wiederholungen mit demselben Key buchen nie doppelt (Abschnitt 9).
3. **Fehler nicht blind wiederholen.** Welche Fehler eine Wiederholung lohnen, steht im
   Fehlerkatalog (Spalte „Aufrufer“).

---

## 3. Preis: `/v1/price` und `/v1/prices` (BA)

### 3.1 `POST /v1/price` – ein Preis

Bepreist genau ein Zimmer eines Hotels für einen Aufenthalt, eine Verpflegung und eine
Belegung.

| Feld | Pflicht | Bedeutung |
|---|---|---|
| `hotel` | ja | Hotel-Code |
| `room` | nein | Zimmer-Code. Fehlt er, nimmt die API das **günstigste verfügbare** Zimmer, das die Belegung zulässt und die Verpflegung anbietet; ist keines verfügbar, das günstigste davon (dann `available=false`, 3.3). Ein Zimmer, dessen Vertragsdaten der Rechenkern ablehnt (7.5, z. B. `ERR_INVALID_AMOUNT`, `ERR_NO_SECTION`), wird dabei ausgelassen und in `warnings` mit Zimmer und Code genannt; rechnet kein Zimmer, kommt der Fehler als 422. Mit `room` kommt der Fehler dieses Zimmers immer als 422. |
| `board` | ja | Verpflegungs-Code, genau wie im Vertrag (`RO`, nicht `ro`). Fehlt: `ERR_BOARD_MISSING`; bietet das Zimmer (ohne `room`: kein Zimmer) ihn nicht an: `422 ERR_BOARD_NOT_OFFERED` |
| `checkIn`, `checkOut` | ja | Aufenthalt (Abschnitt 1.3) |
| `occupancy.travellers[]` | ja | Reisende mit `age` |
| `currency` | nein | Wunschwährung, nur Hinweis (1.4) |
| `now` | nein | geduldet, wird ignoriert (1.3) |

Antwort:

| Feld | Bedeutung |
|---|---|
| `room` | das bepreiste Zimmer (ohne `room` in der Anfrage: das günstigste verfügbare); so an `/v1/book` übergeben |
| `currency` | Vertragswährung, leer = am Vertrag nicht hinterlegt |
| `totalCents` | Gesamtpreis des Zimmers für den Aufenthalt |
| `rounding` | Rundungsregel der Antwort (Abschnitt 1.4) |
| `perTravellerCents[]` | Preis je Reisendem, geordnet nach **Alter absteigend** (ältester zuerst, bei gleichem Alter Anfrage-Reihenfolge): die exakte Summe seiner Posten, einmal gerundet. Summe = `totalCents`. Bei Zimmerpreisen (Objektpreis) trägt der erste Reisende die Zimmerposten, die Verpflegung steht beim jeweiligen Reisenden. |
| `breakdown[]` | Einzelposten: `chargeType` (`BaseCharge`, `PhantomBaseCharge`, `GuestCharge`, `BoardCharge`, `Extra`), `code`, `traveller` (Index in `perTravellerCents`), `night` (Index ab 0, `-1` = je Aufenthalt), `amountExact` (exakter Betrag als Dezimalzahl, z. B. `"-13.485"`), `amountCents`, bei Extras `applianceCode`, bei Extras einer Extra-Familie (mehrere Extras mit demselben `applianceCode`) zusätzlich `variant` (die Variante; erst beide zusammen nennen das Extra), bei den Posten einer Freinacht zusätzlich `freeNight: true` (diese Nacht ist ganz oder anteilig erlassen, z. B. „7=6“: die siebte Nacht trägt je Reisendem einen Posten, der Grundpreis und – je nach Angebot – Verpflegung aufhebt). Reihenfolge: Nacht, darin Reisender, darin Rechenschritt (Grundpreis, Personenrabatt/-zuschlag, Verpflegung samt ihrem Personenrabatt, Extras); Posten je Aufenthalt zuletzt. |
| `separateExtras[]` | nur wenn vorhanden: einzeln ausgewiesene Extras. `inTotal=true`: Pflicht-Extra, im Gesamtpreis enthalten. `inTotal=false`: optionales Extra, **nicht** im Gesamtpreis. `code` nennt das Extra, bei einer Extra-Familie zusammen mit `variant`. `amountCents` ist die exakte Summe des Extras, einmal gerundet; bei `inTotal=true` kann sie von der Summe seiner `breakdown`-Zeilen um mehrere Cent abweichen – maßgeblich ist `breakdown`. |
| `availability` | `configured` (Kontingent hinterlegt), `available` (jede Nacht offen), `minFree` (kleinste freie Anzahl über die Nächte, exakt bis 99; `-1` = jede Nacht mehr als 99 frei oder Freiverkauf; `0` = mindestens eine Nacht nicht buchbar) |
| `warnings[]` | Hinweise (1.6); ohne `room` auch je ausgelassenem Zimmer mit Vertragsfehler: `zimmer 'EZ' ausgelassen: ERR_INVALID_AMOUNT (…)` |

```http
### preis-einzeln
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "board": "RO",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "currency": "EUR",
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]}
}
```

```json
{
  "room": "DZ",
  "currency": "EUR",
  "rounding": {"mode": "Commercial", "decimalPlaces": 2, "scope": "Person"},
  "totalCents": 18000,
  "perTravellerCents": [18000, 0],
  "breakdown": [
    {"chargeType": "BaseCharge", "code": "Base", "traveller": 0, "night": 0, "amountExact": "100.00", "amountCents": 10000},
    {"chargeType": "BaseCharge", "code": "ExtraDay", "traveller": 0, "night": 1, "amountExact": "80.00", "amountCents": 8000}
  ],
  "availability": {"configured": true, "available": true, "minFree": 5}
}
```

Ohne `room` sucht die API das günstigste passende Zimmer – für eine Person hier das EZ;
`room` in der Antwort nennt es.

```http
### preis-guenstigstes-zimmer
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "board": "RO",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}]}
}
```

```json
{
  "room": "EZ",
  "currency": "EUR",
  "rounding": {"mode": "Commercial", "decimalPlaces": 2, "scope": "Person"},
  "totalCents": 12500,
  "perTravellerCents": [12500],
  "breakdown": [
    {"chargeType": "BaseCharge", "code": "Base", "traveller": 0, "night": 0, "amountExact": "70.00", "amountCents": 7000},
    {"chargeType": "BaseCharge", "code": "ExtraDay", "traveller": 0, "night": 1, "amountExact": "55.00", "amountCents": 5500}
  ],
  "availability": {"configured": true, "available": true, "minFree": 5}
}
```

Ausgebuchte, gesperrte, nicht mit Kontingent hinterlegte oder – beim Key einer Kundengruppe –
nicht zugeteilte Zimmer überspringt die Wahl ohne `room`, auch wenn sie billiger sind: Im Hotel unten hat das EZ (70 EUR) kein Kontingent
und ist nicht buchbar, die API nimmt das teurere, buchbare DZ.

```http
### preis-guenstigstes-verfuegbares
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-STOP",
  "board": "RO",
  "checkIn": "{{D10}}",
  "checkOut": "{{D11}}",
  "occupancy": {"travellers": [{"age": 40}]}
}
```

```json
{
  "room": "DZ",
  "currency": "EUR",
  "rounding": {"mode": "Commercial", "decimalPlaces": 2, "scope": "Person"},
  "totalCents": 10000,
  "perTravellerCents": [10000],
  "breakdown": [
    {"chargeType": "BaseCharge", "code": "Base", "traveller": 0, "night": 0, "amountExact": "100.00", "amountCents": 10000}
  ],
  "availability": {"configured": true, "available": true, "minFree": 5}
}
```

**Preis je Person bei Zimmerpreisen.** Viele Verträge bepreisen das Zimmer, nicht die Person
(Objektpreis). Dann stehen alle Zimmerposten – Grundpreis, Extras je Zimmer oder Vorgang –
im Topf des ersten Reisenden in `perTravellerCents`, die anderen tragen dafür `0` (im
Beispiel `preis-einzeln`: `[18000, 0]`). Verpflegung ist immer ein Preis je Person und Nacht,
auch beim Objektpreis: sie steht beim jeweiligen Reisenden (mit Frühstück `[22000, 4000]`,
siehe `preis-alle-verpflegungen`); ein Alleinreisender zahlt den vollen Zimmerpreis, aber nur
eine Verpflegung. Ein fester Rabatt je Person (z. B. Frühbucher −20 € je Person) mindert beim
Objektpreis den Zimmerpreis und steht darum ebenfalls im ersten Topf. Das ist Absicht und bleibt so
(EDF 5.1.6: „objektbasierte Zusatzleistungen werden der 1. Person zugerechnet“; jeder Topf wird
einmal gerundet, die Summe ist immer genau `totalCents`). Der erste Topf gehört dem **ältesten**
Reisenden – `perTravellerCents` und `breakdown[].traveller` sind nach Alter absteigend
geordnet, nicht nach der Reihenfolge der Anfrage. Personenbezogene Posten (z. B.
Kinderpreise) stehen beim jeweiligen Reisenden. Für eine Anzeige „pro Person“ ist
`perTravellerCents` deshalb nicht gedacht: `totalCents` durch die Zahl der Reisenden teilen
und selbst runden.

Der Preis hängt am Key: Dieselbe Anfrage mit dem Key einer Kundengruppe liefert deren
Sonderpreis (hier −20 %). `TEST-PARTNER-DISCOUNT` ist eine Preisgruppe: Sie bucht aus dem
allgemeinen Bestand, `availability` ist dieselbe wie ohne Gruppe (1.1).

```http
### preis-kundengruppe
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{rabattKey}}

{
  "hotel": "TEST-HOTEL-DISCOUNT",
  "room": "DZ",
  "board": "RO",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]}
}
```

```json
{
  "room": "DZ",
  "currency": "EUR",
  "rounding": {"mode": "Commercial", "decimalPlaces": 2, "scope": "Person"},
  "totalCents": 14400,
  "perTravellerCents": [14400, 0],
  "breakdown": [
    {"chargeType": "BaseCharge", "code": "Base", "traveller": 0, "night": 0, "amountExact": "80.00", "amountCents": 8000},
    {"chargeType": "BaseCharge", "code": "ExtraDay", "traveller": 0, "night": 1, "amountExact": "64.00", "amountCents": 6400}
  ],
  "availability": {"configured": true, "available": true, "minFree": 5}
}
```

Preis je Person mit Kinderermäßigung: −15 % auf 89,90 sind −13,485 – ein Bruchteil eines
Cents. Der Posten bleibt exakt (`amountExact`); gerundet wird einmal die Summe des Kindes:
89,90 − 13,485 + 79,90 − 11,985 = 144,33 → `perTravellerCents[2]` = 14433. Die `amountCents`
eines Reisenden sind so verteilt, dass sie genau seinen Preis ergeben: jede Zeile ist die
gerundete laufende Summe des Reisenden bis zu dieser Zeile minus die bis zur Vorzeile
(8990, −1348, 7990, −1199 = 14433). Wer Posten einzeln rundet und addiert (−13,49, −11,99),
kommt auf 144,32 – das ist nicht der Preis.

```http
### preis-kind-bruchcent
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-KIND",
  "room": "DZ",
  "board": "RO",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}, {"age": 8}]}
}
```

```json
{
  "room": "DZ",
  "currency": "EUR",
  "rounding": {"mode": "Commercial", "decimalPlaces": 2, "scope": "Person"},
  "totalCents": 48393,
  "perTravellerCents": [16980, 16980, 14433],
  "breakdown": [
    {"chargeType": "BaseCharge", "code": "Base", "traveller": 0, "night": 0, "amountExact": "89.90", "amountCents": 8990},
    {"chargeType": "BaseCharge", "code": "Base", "traveller": 1, "night": 0, "amountExact": "89.90", "amountCents": 8990},
    {"chargeType": "BaseCharge", "code": "Base", "traveller": 2, "night": 0, "amountExact": "89.90", "amountCents": 8990},
    {"chargeType": "GuestCharge", "code": "Base", "traveller": 2, "night": 0, "amountExact": "-13.485", "amountCents": -1348},
    {"chargeType": "BaseCharge", "code": "ExtraDay", "traveller": 0, "night": 1, "amountExact": "79.90", "amountCents": 7990},
    {"chargeType": "BaseCharge", "code": "ExtraDay", "traveller": 1, "night": 1, "amountExact": "79.90", "amountCents": 7990},
    {"chargeType": "BaseCharge", "code": "ExtraDay", "traveller": 2, "night": 1, "amountExact": "79.90", "amountCents": 7990},
    {"chargeType": "GuestCharge", "code": "ExtraDay", "traveller": 2, "night": 1, "amountExact": "-11.985", "amountCents": -1199}
  ],
  "availability": {"configured": true, "available": true, "minFree": 5}
}
```

**Freinächte („7=6“).** Der Posten, der eine Nacht erlässt, ist ein `Extra` mit dem
`applianceCode` des Angebots und `freeNight: true`; er ist im Gesamtpreis enthalten und steht in
der erlassenen Nacht je Reisendem direkt hinter dessen Grundpreis. In der Testwelt
(`TEST-HOTEL-FREINACHT`, DZ, Nur Übernachtung, Preis je Person: 100,00 erste Nacht, 80,00 jede
weitere, `checkIn` = `{{D0}}`) kosten 2 Erwachsene für 7 Nächte 1000,00 statt 1160,00: Nacht 6
(die siebte) trägt je Reisendem `{"chargeType": "Extra", "code": "PN", "night": 6, "amountExact":
"-80.00", "applianceCode": "FSO1", "freeNight": true}`. Mit Halbpension erlässt derselbe Posten
auch die Verpflegung (`-115.00`). 14 Nächte ergeben zwei Freinächte (Nacht 12 und 13), 6 Nächte
keine.

### 3.2 `POST /v1/prices` – alle Verpflegungen auf einmal

Wie `/v1/price`, aber statt `board` optional `boards[]` (fehlt es: alle Verpflegungen des
Zimmers) und `room` optional (fehlt es: alle Zimmer). Zimmer, die die Belegung nicht
zulassen, fallen weg; lässt keines sie zu, kommt der Grund als Fehler
(`ERR_OCCUPANCY_NOT_ALLOWED`). Eine angefragte Verpflegung, die kein Zimmer anbietet:
`422 ERR_BOARD_NOT_OFFERED`. Das Feld `board` filtert hier **nicht**: Wer es mitschickt,
bekommt trotzdem alle Verpflegungen und einen Hinweis in `warnings` (Beispiel unten);
gefiltert wird nur mit `boards[]`.

Antwort: `currency`, `rounding`, `rooms[]` mit `room` und `boards[]` (je Verpflegung `board`,
`globalType` (Verpflegungsart wie im EDF-Export und in der offenen Suche: `AO` für `RO`, sonst die Zuordnung des Veranstalters, sonst der Code selbst, wenn er eine Verpflegungsart ist, sonst `XX` = nicht zuordenbar), `totalCents`, `perTravellerCents`, `separateExtras`,
`availability`), je Zimmer nach Preis aufsteigend; `warnings`. Kein `breakdown`.

Rechnet eine Verpflegung eines Zimmers für diese Belegung nicht, weil der Vertrag dort
fehlerhaft ist (`ERR_NEGATIVE_TRAVELLER_PRICE`, `ERR_NEGATIVE_PERCENT_BASE`, Abschnitt 7.5)
oder weil sie für diese Reisegruppe nicht verkauft wird (`ERR_BOARD_NOT_AVAILABLE`, 3.5),
fällt nur diese Kombination aus: Sie steht ohne Preis in `rooms[].errors[]` (`board`,
`globalType`, `errorCode`, `message`) und in `warnings`; der Rest der Matrix bleibt bepreist,
`boards[]` des Zimmers kann dann leer sein. Ebenso bei jedem anderen Vertragsfehler eines
Zimmers (Abschnitt 7.5, z. B. `ERR_NO_SECTION`: für eine Nacht keine Saison im Vertrag): Die
betroffenen Verpflegungen stehen mit dem Code in `rooms[].errors[]` und in `warnings`, alle
anderen Zimmer bleiben bepreist, mit denselben Preisen wie `/v1/price` mit `room` und `board`.
Rechnet keine einzige Kombination, kommt der Code als Fehler (422). Fehler der Anfrage selbst
lehnen sie weiterhin ganz ab.

**Zeitgrenze:** `/v1/prices` und `/v1/price` ohne `room` rechnen mehrere Zimmer bzw.
Verpflegungen; überschreitet das 2 s (im Normalbetrieb dauert es wenige Millisekunden), kommt
`503 ERR_PRICE_TIMEOUT` – nie eine halbe Matrix oder ein „günstigstes“ Zimmer aus einem Teil
der Zimmer. Abhilfe: `room` bzw. `boards` angeben. Die Frist gilt für `/v1/prices` immer, auch
mit `room` und einer einzigen Verpflegung; nur `/v1/price` **mit** `room` hat keine eigene
Frist und ist darum der sichere Ausweg. Die `503` trägt kein `Retry-After`: erst eingrenzen,
dann wiederholen. Schließt der Aufrufer die Verbindung,
bricht die Rechnung ab und es gibt keine Antwort.

```http
### preis-alle-verpflegungen
POST {{baseUrl}}/v1/prices
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]}
}
```

```json
{
  "currency": "EUR",
  "rounding": {"mode": "Commercial", "decimalPlaces": 2, "scope": "Person"},
  "rooms": [
    {
      "room": "DZ",
      "boards": [
        {"board": "RO", "globalType": "AO", "totalCents": 18000, "perTravellerCents": [18000, 0],
         "availability": {"configured": true, "available": true, "minFree": 5}},
        {"board": "BB", "globalType": "BB", "totalCents": 26000, "perTravellerCents": [22000, 4000],
         "availability": {"configured": true, "available": true, "minFree": 5}},
        {"board": "HB", "globalType": "HB", "totalCents": 32000, "perTravellerCents": [25000, 7000],
         "availability": {"configured": true, "available": true, "minFree": 5}}
      ]
    }
  ]
}
```

Mit `board` statt `boards[]` kommt dieselbe Matrix wie ohne, dazu der Hinweis
`board: 'HB' wird bei /v1/prices ignoriert — Verpflegungen ueber boards waehlen (fehlt boards:
alle des Zimmers)`:

```http
### preis-alle-verpflegungen-board-ignoriert
POST {{baseUrl}}/v1/prices
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "board": "HB",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]}
}
```

### 3.3 Verfügbarkeit in Preis-Antworten

`availability` ist immer gesetzt und bezieht sich auf das bepreiste Zimmer über alle Nächte.
Ein Preis mit `available=false` ist eine **Preisauskunft, kein Angebot**: Die Buchung
scheitert (Abschnitt 5.3). Gezählt wird genau das, wogegen `/v1/book` verkauft: freie
Kapazität je Nacht nach Buchungen und Tagesstatus, und beim Key einer Kontingent-Gruppe
zusätzlich deren Zuteilung (was die Gruppe schon gebucht hat, ist abgezogen; ohne Zuteilung
für Zimmer und Nacht ist nichts frei). Eine Preisgruppe zählt wie ein Key ohne Gruppe (1.1). `minFree` ist das Kleinste davon über die Nächte.

Je Nacht zählt TourAPI exakt bis 99. Welche Nacht was zu `minFree` beiträgt:

| Nacht | Beitrag zu `minFree` | Allotment-Datei (Abschnitt 10) |
|---|---|---|
| 1 bis 99 Einheiten frei | die Anzahl | `01`–`99` |
| mehr als 99 frei oder Freiverkauf | senkt `minFree` nicht | `**` |
| Stop-Sale | `0` | `SS` |
| auf Anfrage | `0` | `RR` |
| ausgebucht, geschlossen, ohne Kapazität, Kundengruppe ohne Zuteilung für Zimmer und Nacht | `0` | `00` |

`minFree` ist `-1` nur, wenn **jede** Nacht mehr als 99 frei hat oder im Freiverkauf ist; eine
einzige Nacht mit Stop-Sale, Anfrage oder `0` macht `minFree` zu `0` (`available=false`). Das
ist dieselbe Darstellung wie im EDF-Allotment der Cache-Lieferung, damit Cache und API
dieselbe Zahl zeigen.

- `configured=true`: TourAPI kennt für dieses Zimmer ein Kontingent. Das sagt noch nichts
  über jede einzelne Nacht: Eine Nacht, für die keine Kapazität eingetragen ist, macht das
  Zimmer `available=false` (`minFree` 0), es bleibt aber `configured=true`.
- `configured=false`: für dieses Zimmer ist gar kein Kontingent hinterlegt; buchen geht nicht.

Fehlt nur einzelnen Nächten die Kapazität, nennen Suche und `/v1/book` denselben Grund:
`ERR_NO_INVENTORY` (Suche 4.2, Buchung 5.3). Maßgeblich für „buchbar“ ist `available`, nicht
`configured`.

`availability` ist ein Stand, keine Zusage; verbindlich prüft erst `/v1/book`. Eine Buchung
oder ein Storno zeigt der Server-Knoten, der sie ausgeführt hat, in der nächsten Anfrage
(`minFree` sinkt bzw. steigt). Andere Knoten, Buchungen anderer Kanäle und Vertragsänderungen
ziehen nach wenigen Sekunden nach – ebenso der eigene Knoten in dem seltenen Fall, dass er
den neuen Stand nicht sofort nachladen konnte (die Buchung selbst gilt dann trotzdem).

### 3.4 Verkaufsregeln des Zimmers: Mindestaufenthalt, Anreisetage, Verkaufsfenster, Release

Ein Hotelvertrag kann festlegen, welche Aufenthalte das Hotel annimmt, zum Beispiel
„Hochsaison mindestens 7 Nächte, Anreise nur samstags“, „Halbpension nur bei Anreise
bis 31.10.“ oder „Release 14 Tage“ (Buchung spätestens 15 Tage vor Anreise). Solche Regeln ändern keinen Preis; sie entscheiden, **ob** ein Zimmer für den
angefragten Aufenthalt angeboten wird. Ein Aufenthalt, den eine Regel ausschließt, bekommt
keinen Preis:

| Code | Die Regel verlangt … | Aufrufer |
|---|---|---|
| `ERR_STAY_LENGTH_NOT_ALLOWED` | eine andere Aufenthaltsdauer (Mindest- oder Höchstnächte) | Dauer ändern |
| `ERR_ARRIVAL_DAY_NOT_ALLOWED` | einen anderen An- oder Abreise-Wochentag | Reisetage verschieben |
| `ERR_TRAVEL_DATES_NOT_ALLOWED` | Reisedaten in einem bestimmten Zeitraum (Verkaufsfenster) | anderer Zeitraum |
| `ERR_BOARD_NOT_ALLOWED` | eine andere Verpflegung für diesen Aufenthalt | andere Verpflegung |
| `ERR_LEAD_TIME_NOT_ALLOWED` | mehr Vorlauf: die Anreise liegt innerhalb der Release-Frist | spätere Anreise |

`message` nennt Zimmer, Regel und das Verlangte, etwa „Zimmer DZ, Verkaufsregel 1: wenn
Anreise 2026-07-01 bis 2026-08-31, verlangt mindestens 7 Nächte“. Verletzt ein Aufenthalt
mehrere Teile einer Regel, gilt diese Reihenfolge: Dauer, Wochentag, Zeitraum, Verpflegung,
Release.

**Release (Vorlauffrist).** Eine Regel mit Release n Tagen verlangt **mehr als** n
Kalendertage zwischen Stichtag (Serverdatum, 1.3) und Anreise: bei Release 3 und Stichtag
10.05. ist die Anreise am 13.05. noch gesperrt, ab dem 14.05. buchbar; bei Release 0 ist nur
die Anreise am Stichtag selbst gesperrt. Die Frist kann an einem Zeitraum hängen (etwa
„ab September 14 Tage“): dann zählt sie, sobald eine Nacht des Aufenthalts in diesem Zeitraum
liegt, gemessen bis zur Anreise. Bezieht sich die Regel auf die Abreise (`ApplyTo`
`Departure`), zählt die Frist bis zur Abreise: verlangt sind dann mehr als n Kalendertage
zwischen Stichtag und Abreise. `message` nennt Stichtag, Anreise und den Vorlauf. Ein
heute gesperrter Aufenthalt kann es morgen nicht mehr werden; eine Wiederholung lohnt nicht.

- `/v1/price`: ohne `room` fällt ein Zimmer, dessen Regel den Aufenthalt ausschließt, aus der
  Auswahl; nimmt kein Zimmer ihn an, kommt der Grund als 422. Er hat Vorrang vor
  `ERR_OCCUPANCY_NOT_ALLOWED` eines anderen Zimmers, weil er genauer sagt, was zu ändern ist.
- `/v1/prices`: Regeln können je Verpflegung gelten; es fällt nur die betroffene
  Verpflegung weg. Bleibt nichts übrig, kommt der Grund als 422.
- `/v1/search`: das Hotel fällt weg, der Code steht in `diagnostics.reasons`.
- `/v1/book`: mit `priceCheck` gilt die Regel für die angefragte Verpflegung; ohne
  `priceCheck` (Verpflegung unbekannt) wird verkauft, sobald eine Verpflegung des Zimmers den
  Aufenthalt annimmt.

`ERR_ROOM_RESTRICTION_INVALID` (422) heißt: eine Regel im Vertrag ist nicht auswertbar. Das
ist ein Vertragsfehler – melden.

### 3.5 Verpflegung nur für bestimmte Reisegruppen

Ein Verpflegungszuschlag kann an die Zusammensetzung der Reisegruppe gebunden sein, etwa
„Halbpension-Zuschlag gilt für Gruppen mit mindestens 2 Erwachsenen“. Passt die angefragte
Belegung nicht dazu, ist diese Verpflegung für diese Belegung **nicht buchbar**:
`422 ERR_BOARD_NOT_AVAILABLE`. TourAPI rechnet nie einen Preis für eine Verpflegung ohne die
Verpflegung. `message` nennt Verpflegung, Reisenden, Nacht und die verlangte Gruppe. Das ist
kein Vertragsfehler; eine andere Verpflegung oder Belegung kann rechnen (Aufrufer: andere
Verpflegung bzw. Belegung).

- `/v1/price`: ohne `room` fällt das Zimmer für diese Verpflegung aus der Auswahl (wie bei
  einer Verkaufsregel, 3.4); rechnet kein Zimmer, kommt der Grund als 422.
- `/v1/prices`: nur diese Verpflegung des Zimmers fällt aus, als Eintrag in
  `rooms[].errors[]` mit `errorCode` (3.2); der Rest bleibt bepreist.
- `/v1/search`: das Hotel fällt weg, der Code steht in `diagnostics.reasons`.
- `/v1/book` mit `priceCheck`: 422, nichts wird gebucht.

---

## 4. Suche: `/v1/search` (BA)

### 4.1 Anfrage und Antwort

Findet die buchbaren Hotels des Veranstalters für einen Aufenthalt, eine Verpflegung und
eine Belegung, je Hotel mit dem günstigsten **verfügbaren** Zimmer (wie `/v1/price` ohne
`room`): Ein ausgebuchtes billigeres Zimmer lässt das Hotel nicht aus der Liste fallen,
solange ein anderes Zimmer buchbar ist.

| Feld | Pflicht | Bedeutung |
|---|---|---|
| `destination` | nein | Zielgebiet- oder Flughafen-Code des Hotels (unten); leer = alle Hotels |
| `board`, `checkIn`, `checkOut`, `occupancy`, `currency`, `now` | wie `/v1/price` | |
| `includeUnavailable` | nein | `true`: nicht buchbare Hotels kommen gekennzeichnet mit (Standard `false`) |
| `pageSize`, `cursor` | nein | Seiten, Abschnitt 4.3 |

Antwort:

| Feld | Bedeutung |
|---|---|
| `results[]` | Treffer der Seite, nach `fromTotalCents` aufsteigend, bei Gleichstand nach Hotel-Code |
| `results[].hotel`, `name`, `room` | Hotel und das Zimmer, auf das sich der Preis bezieht |
| `results[].fromTotalCents`, `currency` | Ab-Preis (günstigstes verfügbares Zimmer; bei `bookable=false` das günstigste überhaupt) in Vertragswährung des Hotels |
| `results[].availability` | wie bei `/v1/price` |
| `results[].bookable` | `true` = alle Nächte verfügbar |
| `results[].reason`, `priceInformational` | nur bei `bookable=false` (nur mit `includeUnavailable`): Grund und Kennzeichen „nur Preisauskunft“ |
| `diagnostics` | je Seite: `considered` = geprüfte Hotels = `returned` + `skipped`; `reasons[]` = warum Hotels wegfielen, je Grund mit Anzahl; `timeBudgetExhausted` = Seite wegen Zeitbudget gekappt (4.3); `roomErrors[]` = Zimmer der geprüften Hotels, die wegen eines Vertragsfehlers (7.5) ausgelassen wurden, je Code mit Anzahl – auch wenn das Hotel mit einem anderen Zimmer trifft |
| `nextCursor` | gesetzt, solange weitere Hotels zu prüfen sind (4.3) |
| `warnings[]` | Hinweise; bei gemischten Vertragswährungen ein Sammelhinweis |

Die Suche nennt keine Hotel-Codes der weggefallenen Hotels, nur Gründe und Anzahlen. Leere
Trefferliste heißt also nicht „kein Bestand“: `diagnostics.reasons` sagt, ob z. B. die Saison
nicht passt (`ERR_NO_SECTION`), alles ausgebucht ist (`ERR_NOT_AVAILABLE`), für eine Nacht
kein Kontingent hinterlegt ist (`ERR_NO_INVENTORY`) oder kein Zimmer
des Hotels die angefragte Verpflegung anbietet (`ERR_BOARD_NOT_OFFERED`).

**Woher die Codes für `destination` kommen:** Jedes Hotel hat im Vertrag des Veranstalters
einen Zielgebiet-Code (z. B. `PMI`) und optional Flughafen-Codes; ein Hotel passt, wenn
`destination` **genau** einem davon gleicht (Groß-/Kleinschreibung zählt). Zielgebiet-Codes
bestehen nur aus `A-Z a-z 0-9 . _ -` (1 bis 64 Zeichen, kein Freitext); Flughafen-Codes sind
IATA-Codes aus drei Großbuchstaben. Die gültigen Codes
des Keys liefert `GET /v1/destinations` (4.5). Ein Code, zu dem es für diesen Key kein Hotel
gibt, ist ein Fehler: `422 ERR_UNKNOWN_DESTINATION` (Beispiel `suche-unbekanntes-ziel`), nie
eine still leere Liste. Geprüft wird die erste Seite (ohne `cursor`); verschwindet das Ziel
beim Blättern, endet die Suche mit einer leeren Schlussseite (4.3).

```http
### suche-ziel
POST {{baseUrl}}/v1/search
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "destination": "PMI",
  "board": "RO",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]}
}
```

```json
{
  "results": [
    {"hotel": "TEST-HOTEL-BASE", "name": "TEST Basis Palma", "room": "DZ", "currency": "EUR",
     "fromTotalCents": 18000, "availability": {"configured": true, "available": true, "minFree": 5},
     "bookable": true},
    {"hotel": "TEST-HOTEL-DISCOUNT", "name": "TEST Rabatt Palma", "room": "DZ", "currency": "EUR",
     "fromTotalCents": 18000, "availability": {"configured": true, "available": true, "minFree": 5},
     "bookable": true},
    {"hotel": "TEST-HOTEL-TWIN", "name": "TEST Zwilling A Palma", "room": "DZ", "currency": "EUR",
     "fromTotalCents": 18000, "availability": {"configured": true, "available": true, "minFree": 5},
     "bookable": true}
  ],
  "diagnostics": {"considered": 3, "returned": 3, "skipped": 0}
}
```

Derselbe Code klein geschrieben ist für die API ein anderes, unbekanntes Ziel:

```http
### suche-unbekanntes-ziel
POST {{baseUrl}}/v1/search
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "destination": "pmi",
  "board": "RO",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]}
}
```

```json
{"errorCode": "ERR_UNKNOWN_DESTINATION",
 "message": "destination: kein Hotel mit diesem Ziel-Code fuer diesen Key (gueltige Codes: GET /v1/destinations; Gross-/Kleinschreibung zaehlt)"}
```

### 4.2 Nicht buchbare Hotels

Standardmäßig liefert die Suche nur buchbare Treffer und zählt den Rest in `diagnostics`.
Mit `includeUnavailable=true` kommen nicht buchbare Hotels mit `bookable=false`, `reason`
und `priceInformational=true`. Die Gründe bedeuten dasselbe wie bei `/v1/book`:

- `ERR_NO_INVENTORY`: für mindestens eine Nacht ist gar kein Kontingent hinterlegt – für das
  Zimmer überhaupt keins (`configured=false`) oder nur für diese Nacht nicht. `/v1/book`
  lehnt denselben Aufenthalt mit `ERR_NO_INVENTORY` ab.
- `ERR_NOT_AVAILABLE`: jede Nacht hat Kontingent, aber nicht jede ist offen (ausgebucht,
  Stop-Sale, geschlossen, auf Anfrage, Zuteilung der Kundengruppe erschöpft oder nicht
  vorhanden). Welcher Fall genau, nennt `/v1/book` (`ERR_SOLD_OUT`, `ERR_STOP_SALE`,
  `ERR_INVENTORY_CLOSED`, `ERR_GROUP_LIMIT`, Abschnitt 5.3).

Im Beispiel hat `TEST-HOTEL-SOLD` für die Nacht keine Kapazität, `TEST-HOTEL-STOP` steht
auf Stop-Sale.

```http
### suche-nicht-buchbare
POST {{baseUrl}}/v1/search
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "destination": "IBZ",
  "includeUnavailable": true,
  "board": "RO",
  "checkIn": "{{D11}}",
  "checkOut": "{{D12}}",
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]}
}
```

```json
{
  "results": [
    {"hotel": "TEST-HOTEL-SOLD", "name": "TEST Ausgebucht Ibiza", "room": "DZ", "currency": "EUR",
     "fromTotalCents": 10000, "availability": {"configured": true, "available": false, "minFree": 0},
     "bookable": false, "reason": "ERR_NO_INVENTORY", "priceInformational": true},
    {"hotel": "TEST-HOTEL-STOP", "name": "TEST Stop-Sale Ibiza", "room": "DZ", "currency": "EUR",
     "fromTotalCents": 10000, "availability": {"configured": true, "available": false, "minFree": 0},
     "bookable": false, "reason": "ERR_NOT_AVAILABLE", "priceInformational": true}
  ],
  "diagnostics": {"considered": 2, "returned": 2, "skipped": 0}
}
```

### 4.3 Seiten und Cursor

- Eine Anfrage prüft **eine Seite** von höchstens `pageSize` Hotels (Standard 50, höchstens
  100; außerhalb 1–100: `422 ERR_BAD_PAGE_SIZE`) in Hotel-Code-Reihenfolge.
- Gibt es weitere Hotels, steht `nextCursor` in der Antwort. Die nächste Seite: **dieselbe
  Anfrage** plus `"cursor": "<nextCursor>"`. Auf der letzten Seite fehlt `nextCursor`.
- Die Preis-Sortierung gilt **je Seite**. Wer eine Gesamtliste nach Preis braucht, blättert
  durch und sortiert selbst. `diagnostics` gilt je Seite.
- Der Cursor ist undurchsichtig und an Anfrage und Key gebunden. Andere Anfrage (Ziel,
  Zeitraum, Belegung, Verpflegung, `includeUnavailable`) oder anderer Key: `422
  ERR_CURSOR_MISMATCH`. Unlesbarer oder abgelaufener Cursor (z. B. nach einem Neustart des
  Servers): `422 ERR_BAD_CURSOR` – dann ohne `cursor` neu beginnen. `pageSize` und
  `currency` dürfen sich zwischen Seiten ändern. Die Länge des Cursors ist fest und sagt
  nichts über das Hotel.
- **Gekappte Seite:** Unter Last beginnt eine Seite nach 60 ms kein weiteres Hotel. Sie ist
  dann kürzer als `pageSize`, `diagnostics.timeBudgetExhausted=true`, und `nextCursor` führt
  weiter. Das ist kein Fehler: einfach weiterblättern.
- Schickt man weder `pageSize` noch `cursor` und gibt es mehr Hotels, steht zusätzlich ein
  Hinweis in `warnings` – die Liste ist dann nur die erste Seite.

```http
### suche-seitenweise
POST {{baseUrl}}/v1/search
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "pageSize": 3,
  "board": "RO",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]}
}
```

```json
{
  "results": [
    {"hotel": "TEST-HOTEL-ALTAKTION", "name": "TEST Altaktion Teneriffa", "room": "DZ", "currency": "EUR",
     "fromTotalCents": 18000, "availability": {"configured": true, "available": true, "minFree": 5},
     "bookable": true},
    {"hotel": "TEST-HOTEL-BASE", "name": "TEST Basis Palma", "room": "DZ", "currency": "EUR",
     "fromTotalCents": 18000, "availability": {"configured": true, "available": true, "minFree": 5},
     "bookable": true}
  ],
  "diagnostics": {"considered": 3, "returned": 2, "skipped": 1,
                  "reasons": [{"reason": "ERR_NO_INVENTORY", "count": 1}]},
  "nextCursor": "{{*}}"
}
```

### 4.4 Last und Zeitgrenzen der Suche

Die Suche ist die teuerste Anfrage. Je Veranstalter laufen bis zu 8 Suchen gleichzeitig; ihre
Rechenzeit teilen sie sich (je Veranstalter ein Viertel der Kerne des Servers), damit ein
lauter Veranstalter die anderen nicht ausbremst. Ist das Kontingent voll, kommt sofort
`429 ERR_SEARCH_BUSY` mit `Retry-After: 1`. Eine Suche, die länger als 10 s rechnet, bricht
mit `503 ERR_SEARCH_TIMEOUT` ab – nie mit einer still gekürzten Liste (eine unter Last
gekappte Seite hat dagegen immer `nextCursor`, 4.3). Abhilfe: `destination` setzen oder
kleinere `pageSize`. Schließt der Aufrufer die Verbindung, bricht die Suche ihre Rechnung
ab und antwortet nicht mehr.

### 4.5 `GET /v1/destinations` – gültige Ziel-Codes

Liefert alle Codes, auf die `destination` in der Suche dieses Keys filtern kann: Zielgebiet-
und Flughafen-Codes der Hotels des Veranstalters, aufsteigend sortiert, je Code einmal. Nur
die eigenen – ein Key sieht nie Ziele eines anderen Veranstalters, der Key einer
Kontingent-Gruppe nur die Ziele ihrer Hotels mit Zuteilung (1.1; ohne Zuteilung ist die Liste
leer). Ohne Parameter; die Liste ändert sich, wenn der Veranstalter Hotels anlegt oder
entfernt – beim Key einer Kontingent-Gruppe auch, wenn der Veranstalter ihre Zuteilungen
ändert, und bei jedem Key einer Kundengruppe, wenn der Veranstalter deren Art umstellt.

| Feld | Bedeutung |
|---|---|
| `destinations[].code` | Code, genau so in `destination` zu übergeben |
| `destinations[].name` | Klartext zur Anzeige; fehlt, wenn TourAPI den Code nicht kennt |
| `warnings[]` | Hinweise, z. B. zu mitgeschickten Parametern (werden ignoriert) |

```http
### ziele
GET {{baseUrl}}/v1/destinations
X-Api-Key: {{apiKey}}
```

```json
{
  "destinations": [
    {"code": "ACE", "name": "Lanzarote"},
    {"code": "AGP", "name": "Malaga / Costa del Sol"},
    {"code": "ALC", "name": "Alicante / Costa Blanca"},
    {"code": "BCN", "name": "Barcelona"},
    {"code": "FAO", "name": "Faro / Algarve"},
    {"code": "FUE", "name": "Fuerteventura"},
    {"code": "IBZ", "name": "Ibiza"},
    {"code": "LPA", "name": "Gran Canaria"},
    {"code": "MAH", "name": "Menorca"},
    {"code": "PMI", "name": "Palma de Mallorca"},
    {"code": "RHO", "name": "Rhodos"},
    {"code": "TFS", "name": "Teneriffa Sued"}
  ]
}
```

---

## 4a. Offene Suche: `POST /v1/search/open` (BA)

### 4a.1 Wozu

„Wo ist es zwischen dem 1. und 30. Oktober für 5 bis 7 Nächte am billigsten?“ – ohne
Hotel-Code und ohne festen Termin. Die offene Suche liefert je Hotel das **beste Angebot**
über alle Anreisetage des Fensters, alle Dauern der Spanne, alle Zimmer und Verpflegungen,
**global sortiert** über alle Seiten. Zwei Zusagen:

- **Der Preis ist exakt:** `best.totalCents` und `best.perTravellerCents` sind bit-gleich zu
  `/v1/price` mit demselben Hotel, Zimmer, Verpflegung, An- und Abreise und derselben
  Belegung. Keine Ab-Preise aus einem Vorrat, keine Stichprobe.
- **Die Reihenfolge ist bewiesen:** kein Hotel, das nicht gezeigt wurde, hat ein besseres
  bestes Angebot als der letzte Treffer. Unter Last wird nur die Seite kürzer (4a.4), nie
  der Preis ungenauer oder die Reihenfolge geraten.

Ablauf einer Webseite: offene Suche (Liste) → „Termine & Preise“ eines Hotels mit der
Termin-Matrix (4b, dasselbe Fenster) → gewähltes Angebot mit `/v1/price` im Detail
(`hotel`, `room`, `board`, `checkIn`, `checkOut` aus `best` bzw. der Zelle, dieselbe Belegung:
derselbe Preis samt Aufbau; andere Verpflegungen dieses Termins je Verpflegung mit
`/v1/price` ohne `room`) → `/v1/book` mit `priceCheck.expectedCents` = angezeigter Preis.
Ein Angebots-Token gibt es nicht: die Buchung prüft den Preis ohnehin.

**Recht:** Die offene Suche ist je Key freigeschaltet (bei neuen Keys aus). Der Veranstalter-
Admin legt im Key das **Suchprofil** fest (Fenster, Dauern, Ziele, Seitengröße, Zeitbudget,
Takt); es gilt höchstens, was der Betreiber für den Veranstalter freigibt. Ohne Recht: `403
ERR_OPEN_SEARCH_NOT_ALLOWED`. Die Grenzen des Profils prüft die API am Feld und nennt sie in
der Fehlermeldung (4a.5); maschinenlesbar liefert sie `GET /v1/limits` (4c).

### 4a.2 Anfrage

| Feld | Pflicht | Bedeutung |
|---|---|---|
| `destinations` | nein | Ziel-Codes wie `GET /v1/destinations` (4.5), Vereinigung; höchstens so viele, wie das Profil erlaubt |
| `hotels` | nein | Hotel-Codes des Keys; nicht zusammen mit `destinations`. Ohne beides: ganzer Bestand des Keys – nur wenn das Profil alle Ziele erlaubt |
| `arrivalFrom`, `arrivalTo` | ja | Anreisefenster, beide Tage eingeschlossen; `arrivalFrom` ≥ Stichtag, `arrivalTo` ≤ Stichtag + 732 |
| `nightsMin`, `nightsMax` | ja | Dauer von–bis (1–30 und im Profil); jede Dauer dazwischen zählt |
| `occupancy` | ja | ein Zimmer, wie `/v1/price` |
| `boards` | nein | Verpflegungs-Codes genau wie im Vertrag; ohne = alle |
| `boardTypes` | nein | Verpflegungsart wie im EDF-Export (`AO`, `BB`, `HB`, `HB+`, `FB`, `FB+`, `SC`, `AI`, `AI+`, `XX`); mit `boards` zusammen: beide müssen passen |
| `minTotalCents`, `maxTotalCents` | nein | Filter auf den Gesamtpreis, Grenzen eingeschlossen |
| `currency` | bedingt | Filter auf die Vertragswährung, keine Umrechnung. Pflicht, wenn die Hotels der Suche in mehreren Währungen rechnen (`422 ERR_CURRENCY_REQUIRED`) |
| `category` | nein | offizielle Kategorie (Landeskategorie aus dem Hotelstamm): `scheme` `stars` oder `keys` (Pflicht), `min`/`max` Stufe 1 bis 5 in Halbschritten als Zahl (z. B. `3.5`, gleichwertig `3.50`), Grenzen eingeschlossen (ohne: 1 bzw. 5). „4 Superior“ zählt als 4 |
| `regions` | nein | Regionen aus dem Hotelstamm (Adresse), eine davon; genau wie gepflegt verglichen (Groß-/Kleinschreibung zählt); höchstens 50 |
| `geo` | nein | Umkreis: `lat`, `lon` (Dezimalgrad als Zahl, höchstens 6 Nachkommastellen) und `radiusKm` (0,001 bis 500, höchstens 3 Nachkommastellen). Abstand auf der Kugel (Haversine, Erdradius 6371 km), auf ganze Meter gerundet; der Rand zählt dazu. Über die Datumsgrenze und an den Polen richtig |
| `sort` | nein | `price` (Standard: Gesamtpreis), `pricePerNight` (Preis je Nacht, exakt als Bruch verglichen, ohne Rundung), `hotel` (Hotel-Code) |
| `pageSize` | nein | Treffer je Seite, 1 bis Profil; ohne Angabe 20 (oder weniger, wenn das Profil weniger erlaubt) |
| `cursor` | nein | `nextCursor` der Vorseite, unverändert (4a.4) |

Anders als die übrigen Auskunfts-Endpunkte lehnt die offene Suche **jedes** unbekannte Feld
ab (`422 ERR_UNKNOWN_FIELD`) – ein Tippfehler in einem Filter würde sonst die Trefferliste
still verändern. Ein `now` gibt es nicht; es gilt der Stichtag des Servers (1.3).

**Filter nach Hotelstamm** (`category`, `regions`, `geo`; zusammen gilt UND) wirken vor dem
Rechnen: ein Hotel außerhalb gehört nicht zum Suchraum – es zählt weder in `hotelsInScope`
noch gegen die Hotels je Anfrage des Suchprofils (`ERR_SEARCH_TOO_BROAD`), so geht auch ein
Umkreis über den ganzen Bestand. Fehlt einem Hotel der Wert, den ein Filter braucht (keine
offizielle Kategorie, keine Region, keine Koordinaten), und schließt es kein vorhandener Wert
aus, zählt es im Suchraum mit dem Grund `ERR_NO_CATEGORY`, `ERR_NO_REGION` bzw. `ERR_NO_GEO`
(erster fehlender Wert in dieser Reihenfolge) – Lücken im Hotelstamm bleiben sichtbar. Die
Werte pflegt der Veranstalter im Hotelstamm (Konsole: Hotel → Inhalt → Stamm & Lage); eine
Änderung wirkt nach dem nächsten Abgleich (Sekunden) und wechselt `stand` (4a.4).

### 4a.3 Antwort

| Feld | Bedeutung |
|---|---|
| `results[].hotel` | Hotel-Code; Reihenfolge nach dem Kriterium von `sort`, bei Gleichstand nach Hotel-Code |
| `results[].best` | bestes Angebot: `checkIn`, `checkOut`, `nights`, `room`, `board`, `boardType` (Verpflegungsart wie im EDF-Export), `currency`, `totalCents`, `perTravellerCents`, `availability` (wie `/v1/price`, immer `available: true`) |
| `results[].alternatives` | **Vorprüfung ohne Preis:** `dates` = Termine (Anreise × Dauer) mit mindestens einem Angebot laut Verfügbarkeit, Verkaufsregeln, Saison und Belegung, `boards`/`boardTypes`/`rooms` entsprechend. Eine Obergrenze: die Preisrechnung kann einzelne davon noch ausschließen. Nie als Preis- oder Trefferaussage lesen |
| `coverage.complete` | `true`: Seite voll oder Liste zu Ende. `false`: das Zeitbudget hat die Seite gekürzt (4a.4) |
| `coverage.timeBudgetExhausted` | Seite wegen des Zeitbudgets gekürzt (= `complete: false`) |
| `coverage.standChanged` | die Daten haben sich seit der Vorseite geändert (4a.4) |
| `coverage.hotelsInScope` | Hotels im Suchraum (Ziele bzw. `hotels`, Bestand des Keys, Filter nach Hotelstamm; Hotels ohne den gefilterten Wert zählen mit, 4a.2) |
| `coverage.hotelsFeasible` | davon mit mindestens einem Termin laut Vorprüfung – Obergrenze der Trefferzahl („bis zu N Hotels“), keine Trefferzahl |
| `coverage.hotelsPriced` | auf dieser Seite exakt gerechnete Hotels (hängt auch davon ab, wie viele Hotels der Server parallel rechnet; in den Beispielen daher offen) |
| `coverage.undecided` | Hotels, deren Platz beim Ende des Zeitbudgets noch offen war (0 bei `complete`) |
| `coverage.reasons[]` | Hotels ohne einen einzigen Termin, je Grund – für den ganzen Suchraum, auf jeder Seite gleich. Bietet kein Zimmer eine passende Verpflegung an: `ERR_BOARD_NOT_OFFERED`. Wie bei `/v1/search` dann die Verfügbarkeit: hat kein Zimmer auch nur einen freien Termin, ist sie der Grund (`ERR_NO_INVENTORY`, `ERR_NOT_AVAILABLE`). Sonst gilt, was `/v1/price` für den ersten freien Termin (Zimmer, Verpflegung, Anreise, Dauer in dieser Reihenfolge) antwortet, z. B. `ERR_OCCUPANCY_NOT_ALLOWED`, `ERR_NO_SECTION`, `ERR_STAY_LENGTH_NOT_ALLOWED`, oder `ERR_OUTSIDE_PRICE_FILTER`, wenn sein Preis außerhalb des Filters liegt; dazu `ERR_CURRENCY_NOT_AVAILABLE` (Hotel rechnet in anderer oder unbekannter Währung), `ERR_HOTEL_NOT_FOUND` (kein Angebot der Kundengruppe) und `ERR_NO_CATEGORY`, `ERR_NO_REGION`, `ERR_NO_GEO` (dem Hotel fehlt der Wert eines Filters nach Hotelstamm, 4a.2) |
| `coverage.priceReasons[]` | Hotels, die erst beim exakten Rechnen auf dieser Seite ausschieden, je Grund (z. B. `ERR_OUTSIDE_PRICE_FILTER`) |
| `coverage.roomErrors[]` | Zimmer mit Vertragsfehler (7.5), je Code – nie still ausgelassen |
| `stand` | Kennung des Datenstands dieser Seite (undurchsichtig) |
| `nextCursor` | gesetzt, solange weitere Treffer folgen können |
| `warnings[]` | Hinweise, z. B. zum Standwechsel |

**Bestes Angebot eines Hotels:** das Minimum des Kriteriums (Gesamtpreis bzw. Preis je
Nacht) über alle Termine × Zimmer × Verpflegungen des Filters, nur buchbare Angebote. Bei
Gleichstand gewinnt die frühere Anreise, dann die kürzere Dauer, dann Zimmer und Verpflegung
in der Reihenfolge des Vertrags. Bei `sort=hotel` ist das Kriterium der Gesamtpreis.

```http
### suche-offen
POST {{baseUrl}}/v1/search/open
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "destinations": ["PMI"],
  "arrivalFrom": "{{D0}}",
  "arrivalTo": "{{D6}}",
  "nightsMin": 2,
  "nightsMax": 3,
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]},
  "pageSize": 2
}
```

```json
{
  "results": [
    {"hotel": "TEST-HOTEL-BASE",
     "best": {"checkIn": "{{D0}}", "checkOut": "{{D2}}", "nights": 2, "room": "DZ", "board": "RO",
              "boardType": "AO", "currency": "EUR", "totalCents": 18000, "perTravellerCents": [18000, 0],
              "availability": {"configured": true, "available": true, "minFree": 5}},
     "alternatives": {"dates": 14, "boards": ["BB", "HB", "RO"], "boardTypes": ["AO", "BB", "HB"], "rooms": 1}},
    {"hotel": "TEST-HOTEL-DISCOUNT",
     "best": {"checkIn": "{{D0}}", "checkOut": "{{D2}}", "nights": 2, "room": "DZ", "board": "RO",
              "boardType": "AO", "currency": "EUR", "totalCents": 18000, "perTravellerCents": [18000, 0],
              "availability": {"configured": true, "available": true, "minFree": 5}},
     "alternatives": {"dates": 14, "boards": ["BB", "HB", "RO"], "boardTypes": ["AO", "BB", "HB"], "rooms": 1}}
  ],
  "coverage": {"complete": true, "timeBudgetExhausted": false, "standChanged": false,
               "hotelsInScope": 3, "hotelsFeasible": 3, "hotelsPriced": "{{*}}", "undecided": 0,
               "reasons": [], "priceReasons": [], "roomErrors": []},
  "stand": "{{*}}",
  "nextCursor": "{{*}}"
}
```

Die nächste Seite ist dieselbe Anfrage mit `cursor`:

```http
### suche-offen-weiter
POST {{baseUrl}}/v1/search/open
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "destinations": ["PMI"],
  "arrivalFrom": "{{D0}}",
  "arrivalTo": "{{D6}}",
  "nightsMin": 2,
  "nightsMax": 3,
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]},
  "pageSize": 2,
  "cursor": "{{offenCursor}}"
}
```

```json
{
  "results": [
    {"hotel": "TEST-HOTEL-TWIN",
     "best": {"checkIn": "{{D0}}", "checkOut": "{{D2}}", "nights": 2, "room": "DZ", "board": "RO",
              "boardType": "AO", "currency": "EUR", "totalCents": 18000, "perTravellerCents": [18000, 0],
              "availability": {"configured": true, "available": true, "minFree": 5}},
     "alternatives": {"dates": 14, "boards": ["BB", "HB", "RO"], "boardTypes": ["AO", "BB", "HB"], "rooms": 1}}
  ],
  "coverage": {"complete": true, "timeBudgetExhausted": false, "standChanged": false,
               "hotelsInScope": 3, "hotelsFeasible": 3, "hotelsPriced": "{{*}}", "undecided": 0,
               "reasons": [], "priceReasons": [], "roomErrors": []},
  "stand": "{{*}}"
}
```

Über den ganzen Bestand, nach Preis je Nacht, nur Frühstück oder Halbpension. Hotels ohne
Kontingent im Fenster stehen als Grund in `coverage.reasons`:

```http
### suche-offen-je-nacht
POST {{baseUrl}}/v1/search/open
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "arrivalFrom": "{{D0}}",
  "arrivalTo": "{{D13}}",
  "nightsMin": 3,
  "nightsMax": 5,
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]},
  "boardTypes": ["BB", "HB"],
  "sort": "pricePerNight",
  "pageSize": 3
}
```

```json
{
  "results": [
    {"hotel": "TEST-HOTEL-ALTAKTION",
     "best": {"checkIn": "{{D0}}", "checkOut": "{{D5}}", "nights": 5, "room": "DZ", "board": "BB",
              "boardType": "BB", "currency": "EUR", "totalCents": 62000, "perTravellerCents": [52000, 10000],
              "availability": {"configured": true, "available": true, "minFree": 5}},
     "alternatives": {"dates": 42, "boards": ["BB", "HB"], "boardTypes": ["BB", "HB"], "rooms": 1}},
    {"hotel": "TEST-HOTEL-BASE",
     "best": {"checkIn": "{{D0}}", "checkOut": "{{D5}}", "nights": 5, "room": "DZ", "board": "BB",
              "boardType": "BB", "currency": "EUR", "totalCents": 62000, "perTravellerCents": [52000, 10000],
              "availability": {"configured": true, "available": true, "minFree": 4}},
     "alternatives": {"dates": 42, "boards": ["BB", "HB"], "boardTypes": ["BB", "HB"], "rooms": 1}},
    {"hotel": "TEST-HOTEL-DISCOUNT",
     "best": {"checkIn": "{{D0}}", "checkOut": "{{D5}}", "nights": 5, "room": "DZ", "board": "BB",
              "boardType": "BB", "currency": "EUR", "totalCents": 62000, "perTravellerCents": [52000, 10000],
              "availability": {"configured": true, "available": true, "minFree": 5}},
     "alternatives": {"dates": 42, "boards": ["BB", "HB"], "boardTypes": ["BB", "HB"], "rooms": 1}}
  ],
  "coverage": {"complete": true, "timeBudgetExhausted": false, "standChanged": false,
               "hotelsInScope": 19, "hotelsFeasible": 13, "hotelsPriced": "{{*}}", "undecided": 0,
               "reasons": [{"reason": "ERR_NO_INVENTORY", "count": 6}], "priceReasons": [], "roomErrors": []},
  "stand": "{{*}}",
  "nextCursor": "{{*}}"
}
```

Mit Filtern nach Hotelstamm über Palma und Menorca: mindestens 3,5 Sterne, Region Mallorca, 25 km
um Palma. Das 3-Sterne-Hotel in Alcúdia fällt heraus, die zwei Hotels auf Menorca ohne gepflegten
Stamm stehen als Grund da:

```http
### suche-offen-stamm
POST {{baseUrl}}/v1/search/open
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "destinations": ["PMI", "MAH"],
  "arrivalFrom": "{{D0}}",
  "arrivalTo": "{{D6}}",
  "nightsMin": 2,
  "nightsMax": 3,
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]},
  "category": {"scheme": "stars", "min": 3.5},
  "regions": ["Mallorca"],
  "geo": {"lat": 39.57, "lon": 2.65, "radiusKm": 25}
}
```

```json
{
  "results": [
    {"hotel": "TEST-HOTEL-BASE",
     "best": {"checkIn": "{{D0}}", "checkOut": "{{D2}}", "nights": 2, "room": "DZ", "board": "RO",
              "boardType": "AO", "currency": "EUR", "totalCents": 18000, "perTravellerCents": [18000, 0],
              "availability": {"configured": true, "available": true, "minFree": 5}},
     "alternatives": {"dates": 14, "boards": ["BB", "HB", "RO"], "boardTypes": ["AO", "BB", "HB"], "rooms": 1}},
    {"hotel": "TEST-HOTEL-DISCOUNT",
     "best": {"checkIn": "{{D0}}", "checkOut": "{{D2}}", "nights": 2, "room": "DZ", "board": "RO",
              "boardType": "AO", "currency": "EUR", "totalCents": 18000, "perTravellerCents": [18000, 0],
              "availability": {"configured": true, "available": true, "minFree": 5}},
     "alternatives": {"dates": 14, "boards": ["BB", "HB", "RO"], "boardTypes": ["AO", "BB", "HB"], "rooms": 1}}
  ],
  "coverage": {"complete": true, "timeBudgetExhausted": false, "standChanged": false,
               "hotelsInScope": 4, "hotelsFeasible": 2, "hotelsPriced": "{{*}}", "undecided": 0,
               "reasons": [{"reason": "ERR_NO_CATEGORY", "count": 2}], "priceReasons": [], "roomErrors": []},
  "stand": "{{*}}"
}
```

### 4a.4 Seiten, Cursor, Stand, Zeitbudget

- **Seiten:** `nextCursor` führt zur nächsten Seite: **dieselbe Anfrage** plus
  `"cursor": "<nextCursor>"`; `pageSize` darf sich ändern. Die Folgeseite setzt hinter dem
  letzten Treffer fort (Kriterium, dann Hotel-Code) – alle Seiten zusammen sind **eine**
  sortierte Liste. Auf der letzten Seite fehlt `nextCursor`; eine Seite kann dann auch leer
  sein. Bei `sort=price` und `pricePerNight` rechnet eine Folgeseite die Hotels der Vorseiten
  nicht noch einmal: auch tief im Blättern kostet eine Seite etwa so viel wie die erste.
- **Cursor:** undurchsichtig, an Anfrage und Key gebunden, **15 Minuten** gültig. Andere
  Anfrage (irgendein Feld außer `pageSize`) oder anderer Key: `422 ERR_CURSOR_MISMATCH`.
  Unlesbar, abgelaufen oder nach einem Server-Neustart ohne festen Cursor-Schlüssel: `422
  ERR_BAD_CURSOR` – dann ohne `cursor` neu beginnen. Ein Cursor von `/v1/search` gilt hier
  nicht.
- **Stand:** `stand` wechselt, wenn der Veranstalter Verträge, Aktionen oder Zuteilungen
  veröffentlicht oder Region, Koordinaten oder offizielle Kategorie eines Hotels im
  Hotelstamm ändert, und um Mitternacht (Stichtag), nicht aber bei Buchungen oder anderen
  Inhalten (Texte, Bilder). Hat sich der Stand
  seit der Vorseite geändert, rechnet die Folgeseite im **neuen** Stand ab derselben Stelle
  weiter, mit `coverage.standChanged: true` und einem Hinweis in `warnings`. Dann (wie auch
  bei Buchungen anderer zwischen zwei Seiten) kann ein Hotel doppelt erscheinen oder fehlen:
  nach `hotel` deduplizieren. Die Preise jeder Seite gelten für ihren Stand; beim Buchen
  sichert `priceCheck` den Preis.
- **Zeitbudget:** Jede Seite hat ein Zeitbudget (Suchprofil, z. B. 150 ms), gezählt ab
  Eingang der Anfrage. Ist es aufgebraucht und hat die Seite schon einen Treffer, rechnet sie
  keine weiteren Hotels mehr; Treffer, deren Platz schon feststeht, kommen noch mit. Dann:
  `coverage.complete: false`, `timeBudgetExhausted: true`, `undecided` > 0 und `nextCursor`
  für den Rest. Die gezeigten Treffer sind trotzdem exakt und in bewiesener Reihenfolge –
  einfach weiterblättern. Eine Seite endet nie ohne Treffer, solange es noch einen gibt.
  Gekürzte Seiten sind kein Fehler und kommen vor allem bei sehr großen Suchen vor (langes
  Anreisefenster mit großer Spanne der Dauer im größten Ziel), wenn mehrere Suchen desselben
  Veranstalters gleichzeitig rechnen. Wer volle Seiten braucht, fragt kleinere Fenster oder
  Spannen ab oder sucht nacheinander statt gleichzeitig.
- **Last:** Die offene Suche teilt sich mit `/v1/search` die Such-Plätze und die Rechenzeit
  des Veranstalters (4.4), belegt aber höchstens die Hälfte der Such-Plätze (bei 8 also 4, alle
  Keys des Veranstalters und beide Endpunkte zusammen) – der Rest bleibt für `/v1/search`.
  Dazu gelten Takt und gleichzeitige Suchen des Suchprofils je Key (`429 ERR_RATE_LIMITED` bzw.
  `ERR_SEARCH_BUSY`, mit `Retry-After`); eine mit `429 ERR_SEARCH_BUSY` abgewiesene Suche
  verbraucht keinen Takt. Rechnet eine Suche länger als 10 s, endet sie mit
  `503 ERR_SEARCH_TIMEOUT`, nie mit einer Teilseite.
- **Nicht beweisbar:** Kann die Suche für eine Anfrage Preis, Reihenfolge oder Verfügbarkeit
  nicht beweisen (ein Fehler im Server, kein Anfragefehler), antwortet sie `500 ERR_INTERNAL`
  statt einer möglicherweise falschen Seite.

### 4a.5 Fehler der offenen Suche

Die Prüfung läuft in dieser Reihenfolge; der erste Befund wird gemeldet: Key → Recht →
Body und unbekannte Felder → Feldformat → Grenzen der API (1.3) → Grenzen des Suchprofils →
Ziele, Hotels, Verpflegung, Währung → Cursor → Takt und Such-Plätze → Rechnung. Eine
abgelehnte Anfrage belegt keinen Such-Platz und zählt nicht als Suche. Die Codes stehen im
Fehlerkatalog (7.2, 7.3).

```http
### suche-offen-ohne-recht
POST {{baseUrl}}/v1/search/open
Content-Type: application/json
X-Api-Key: {{poolKey}}

{
  "arrivalFrom": "{{D0}}",
  "arrivalTo": "{{D6}}",
  "nightsMin": 2,
  "nightsMax": 3,
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]}
}
```

```json
{"errorCode": "ERR_OPEN_SEARCH_NOT_ALLOWED",
 "message": "dieser API-Key hat kein Recht fuer die offene Suche — der Veranstalter-Admin schaltet es frei (Suchprofil des Keys)"}
```

```http
### suche-offen-fenster-zu-breit
POST {{baseUrl}}/v1/search/open
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "arrivalFrom": "{{D0}}",
  "arrivalTo": "{{D30}}",
  "nightsMin": 2,
  "nightsMax": 3,
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]}
}
```

```json
{"errorCode": "ERR_WINDOW_TOO_WIDE", "message": "arrivalFrom..arrivalTo: 31 Tage, erlaubt hoechstens 14 (Suchprofil des Keys)"}
```

Der Key der Kundengruppe darf nur in einem Ziel suchen:

```http
### suche-offen-ziel-nicht-erlaubt
POST {{baseUrl}}/v1/search/open
Content-Type: application/json
X-Api-Key: {{partnerKey}}

{
  "destinations": ["PMI"],
  "arrivalFrom": "{{D0}}",
  "arrivalTo": "{{D6}}",
  "nightsMin": 2,
  "nightsMax": 3,
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]}
}
```

```json
{"errorCode": "ERR_DESTINATION_NOT_ALLOWED", "message": "destinations[0]: Ziel ausserhalb der erlaubten Ziele dieses Keys (Suchprofil des Keys)"}
```

Ein Filter mit falschem Wert nennt das Feld:

```http
### suche-offen-filter-kaputt
POST {{baseUrl}}/v1/search/open
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "arrivalFrom": "{{D0}}",
  "arrivalTo": "{{D6}}",
  "nightsMin": 2,
  "nightsMax": 3,
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]},
  "geo": {"lat": 39.57, "lon": 2.65, "radiusKm": 600}
}
```

```json
{"errorCode": "ERR_BAD_FILTER", "message": "geo.radiusKm: 600 ausserhalb 0.001..500"}
```

---

## 4b. Termin-Matrix: `POST /v1/search/open/dates` (BA)

### 4b.1 Wozu

„Termine & Preise“ eines Hotels: für **ein** Hotel und dasselbe Fenster wie die offene Suche je
Termin (Anreise × Dauer) das günstigste buchbare Angebot oder der Grund, warum es keins gibt.
Recht und Grenzen kommen aus demselben Suchprofil wie bei der offenen Suche (4a.1); Takt und
gleichzeitige Suchen des Profils gelten für beide Endpunkte zusammen.

- **Der Preis ist exakt:** jede Zelle mit Angebot ist bit-gleich zu `/v1/price` mit demselben
  Hotel, Zimmer, Verpflegung, An- und Abreise und derselben Belegung.
- **Passt zur offenen Suche:** Bei gleichem Filter und gleichem `stand` ist `best` aus 4a die
  erste Zelle mit dem kleinsten Kriterium (Gesamtpreis bzw. Preis je Nacht) – dieselbe
  Gleichstands-Regel (frühere Anreise, kürzere Dauer, Zimmer und Verpflegung in der
  Reihenfolge des Vertrags). Mit `perBoard` ist `best` die Zelle seiner Verpflegung am ersten
  Termin mit dem kleinsten Kriterium.

### 4b.2 Anfrage

| Feld | Pflicht | Bedeutung |
|---|---|---|
| `hotel` | ja | Hotel-Code des Keys (z. B. `results[].hotel` aus 4a) |
| `arrivalFrom`, `arrivalTo` | ja | Anreisefenster wie 4a.2; höchstens `matrixMaxWindowDays` Tage (Suchprofil) |
| `nightsMin`, `nightsMax` | ja | Dauer von–bis wie 4a.2 (erlaubte Dauern des Profils) |
| `occupancy` | ja | ein Zimmer, wie `/v1/price` |
| `boards`, `boardTypes` | nein | wie 4a.2; jeder Code in `boards` muss im Hotel angeboten werden (`422 ERR_BOARD_NOT_OFFERED`) |
| `rooms` | nein | Zimmer-Codes des Hotels; ohne = alle (unbekannt: `404 ERR_ROOM_NOT_FOUND`) |
| `minTotalCents`, `maxTotalCents` | nein | Filter auf den Gesamtpreis wie 4a.2 |
| `currency` | nein | Vertragswährung des Hotels; rechnet das Hotel anders oder ohne gültige Währung: `422 ERR_CURRENCY_NOT_AVAILABLE` |
| `perBoard` | nein | `true`: je Termin eine Zelle **je Verpflegung** (Standard `false`: eine Zelle je Termin) |
| `category`, `regions`, `geo` | nein | Filter nach Hotelstamm wie 4a.2, dieselbe Wirkung: liegt das Hotel außerhalb, gehört es nicht zum Suchraum und die Matrix hat **keine Zelle** (`cells` leer); fehlt ihm der Wert, ist jede Zelle `none` mit `ERR_NO_CATEGORY`, `ERR_NO_REGION` bzw. `ERR_NO_GEO` |

Zellen = Anreisetage × Dauern (× Verpflegungen bei `perBoard`), höchstens `matrixMaxCells`
(Suchprofil, sonst `422 ERR_SEARCH_TOO_BROAD`). Einen Cursor gibt es nicht. Jedes unbekannte
Feld: `422 ERR_UNKNOWN_FIELD`. Die Prüfreihenfolge ist die der offenen Suche (4a.5), ohne Cursor.

### 4b.3 Antwort

| Feld | Bedeutung |
|---|---|
| `hotel`, `currency` | Hotel und Vertragswährung |
| `stand` | Datenstand wie in 4a (gleicher `stand` = gleiche Daten) |
| `boards` | nur mit `perBoard`: die Verpflegungen je Termin in Vertragsreihenfolge (erstes Auftreten über die Zimmer, RO zuerst, wie `/v1/prices`), in der Reihenfolge der Zellen |
| `cells[]` | **dicht** in der Reihenfolge `checkIn`, `nights` (bei `perBoard` dann Verpflegung); jede Zelle mit `checkIn`, `checkOut`, `nights`, `status`. Leer nur, wenn das Hotel außerhalb der Filter nach Hotelstamm liegt |
| `cells[].status = "offer"` | Angebot: `room`, `board`, `boardType`, `totalCents`, `perTravellerCents`, `availability` (wie `/v1/price`, immer `available: true`) – das günstigste Zimmer/die günstigste Verpflegung des Termins, bei Gleichstand in Vertragsreihenfolge |
| `cells[].status = "none"` | kein Angebot, Grund in `reason`: die Codes von `/v1/price`, die Reihenfolge der offenen Suche (4a) – erst die Verfügbarkeit (`ERR_NO_INVENTORY`, `ERR_NOT_AVAILABLE`), dann Verkaufsregeln, Belegung, Saison, Preisfilter (`ERR_OUTSIDE_PRICE_FILTER`) oder ein Vertragsfehler; mit Filtern nach Hotelstamm `ERR_NO_CATEGORY`, `ERR_NO_REGION`, `ERR_NO_GEO` (dem Hotel fehlt der Wert, jede Zelle) |
| `cells[].status = "unchecked"` | nicht geprüft, weil das Zeitbudget ablief – **nie** als „kein Angebot“ lesen |
| `coverage` | `complete` (keine Zelle `unchecked`), `timeBudgetExhausted`, `cells` = `offers` + `none` + `unchecked`, `roomErrors[]` (Zimmer mit Vertragsfehler je Code, 7.5) |
| `warnings[]` | Hinweise, z. B. zu ungeprüften Zellen |

Die Zellen laufen in Blöcken in der Reihenfolge der Matrix; ist das Zeitbudget des Profils
aufgebraucht, bleiben die restlichen Zellen `unchecked` (der erste Block wird immer geprüft).
Dann das Fenster oder die Dauern eingrenzen und neu fragen. Rechnet die Matrix länger als 10 s:
`503 ERR_SEARCH_TIMEOUT`; nicht beweisbar: `500 ERR_INTERNAL` (wie 4a.4).

```http
### suche-termine
POST {{baseUrl}}/v1/search/open/dates
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "arrivalFrom": "{{D0}}",
  "arrivalTo": "{{D1}}",
  "nightsMin": 2,
  "nightsMax": 3,
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]},
  "maxTotalCents": 20000
}
```

```json
{
  "hotel": "TEST-HOTEL-BASE",
  "currency": "EUR",
  "stand": "{{*}}",
  "cells": [
    {"checkIn": "{{D0}}", "checkOut": "{{D2}}", "nights": 2, "status": "offer", "room": "DZ", "board": "RO",
     "boardType": "AO", "totalCents": 18000, "perTravellerCents": [18000, 0],
     "availability": {"configured": true, "available": true, "minFree": 5}},
    {"checkIn": "{{D0}}", "checkOut": "{{D3}}", "nights": 3, "status": "none", "reason": "ERR_OUTSIDE_PRICE_FILTER"},
    {"checkIn": "{{D1}}", "checkOut": "{{D3}}", "nights": 2, "status": "offer", "room": "DZ", "board": "RO",
     "boardType": "AO", "totalCents": 18000, "perTravellerCents": [18000, 0],
     "availability": {"configured": true, "available": true, "minFree": 5}},
    {"checkIn": "{{D1}}", "checkOut": "{{D4}}", "nights": 3, "status": "none", "reason": "ERR_OUTSIDE_PRICE_FILTER"}
  ],
  "coverage": {"complete": true, "timeBudgetExhausted": false, "cells": 4, "offers": 2, "none": 2, "unchecked": 0,
               "roomErrors": []}
}
```

Je Verpflegung, nur Frühstück und Halbpension:

```http
### suche-termine-je-verpflegung
POST {{baseUrl}}/v1/search/open/dates
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "arrivalFrom": "{{D0}}",
  "arrivalTo": "{{D0}}",
  "nightsMin": 2,
  "nightsMax": 3,
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]},
  "boards": ["BB", "HB"],
  "perBoard": true
}
```

```json
{
  "hotel": "TEST-HOTEL-BASE",
  "currency": "EUR",
  "stand": "{{*}}",
  "boards": ["BB", "HB"],
  "cells": [
    {"checkIn": "{{D0}}", "checkOut": "{{D2}}", "nights": 2, "status": "offer", "room": "DZ", "board": "BB",
     "boardType": "BB", "totalCents": 26000, "perTravellerCents": [22000, 4000],
     "availability": {"configured": true, "available": true, "minFree": 5}},
    {"checkIn": "{{D0}}", "checkOut": "{{D2}}", "nights": 2, "status": "offer", "room": "DZ", "board": "HB",
     "boardType": "HB", "totalCents": 32000, "perTravellerCents": [25000, 7000],
     "availability": {"configured": true, "available": true, "minFree": 5}},
    {"checkIn": "{{D0}}", "checkOut": "{{D3}}", "nights": 3, "status": "offer", "room": "DZ", "board": "BB",
     "boardType": "BB", "totalCents": 38000, "perTravellerCents": [32000, 6000],
     "availability": {"configured": true, "available": true, "minFree": 5}},
    {"checkIn": "{{D0}}", "checkOut": "{{D3}}", "nights": 3, "status": "offer", "room": "DZ", "board": "HB",
     "boardType": "HB", "totalCents": 47000, "perTravellerCents": [36500, 10500],
     "availability": {"configured": true, "available": true, "minFree": 5}}
  ],
  "coverage": {"complete": true, "timeBudgetExhausted": false, "cells": 4, "offers": 4, "none": 0, "unchecked": 0,
               "roomErrors": []}
}
```

31 Anreisetage × 14 Dauern × 3 Verpflegungen sind mehr Zellen, als das Profil erlaubt:

```http
### suche-termine-zu-viele-zellen
POST {{baseUrl}}/v1/search/open/dates
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "arrivalFrom": "{{D0}}",
  "arrivalTo": "{{D30}}",
  "nightsMin": 1,
  "nightsMax": 14,
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]},
  "perBoard": true
}
```

```json
{"errorCode": "ERR_SEARCH_TOO_BROAD",
 "message": "Termin-Matrix: 1302 Zellen (Anreisen x Dauern x 3 Verpflegungen), erlaubt hoechstens 500 (Suchprofil des Keys)"}
```

---

## 4c. Grenzen des Keys: `GET /v1/limits`

Die wirksamen Grenzen des **eigenen** Keys, damit eine Webseite Datumswahl, Dauer-Auswahl und
Ziele einstellt, statt Grenzen über `422` zu ertasten. Keine Parameter (jeder: `422
ERR_UNKNOWN_FIELD`), zählt nicht als Suche.

| Feld | Bedeutung |
|---|---|
| `rate` | Takt aller Anfragen des Keys (4.4): `perSecond`, `burst`, `scope` (`key` = je Key und Knoten; `testCircle` = gemeinsamer Topf der Test-Keys, 11) |
| `export.allowed` | Recht an der EDF-Lieferung (10) |
| `content.allowed` | Zugang zur Content-API (`/v1/content/*`): der Betreiber hat Inhalte für den Veranstalter freigeschaltet **und** der Key trägt das Inhalts-Recht – dieselbe Regel wie dort; ohne Zugang `403 ERR_CONTENT_NOT_ALLOWED` |
| `openSearch.allowed` | Recht für die offene Suche und die Termin-Matrix; ohne Recht steht nur dieses Feld da |
| `openSearch.maxWindowDays`, `nightsMin`, `nightsMax`, `maxNightsSpan` | Anreisefenster, erlaubte Dauern und Dauer-Spanne je Anfrage (4a) |
| `openSearch.maxDestinations`, `maxHotels`, `maxCandidates`, `maxPageSize` | Ziele und Hotels je Anfrage, Hotels im Suchraum, Treffer je Seite (4a) |
| `openSearch.timeBudgetMs`, `rate`, `burst`, `concurrency` | Zeitbudget je Seite bzw. Matrix, Takt und gleichzeitige Suchen (beide Endpunkte zusammen) |
| `openSearch.matrixMaxWindowDays`, `matrixMaxCells` | Fenster und Zellen der Termin-Matrix (4b) |
| `openSearch.allDestinations`, `destinations` | alle Ziele erlaubt, sonst die erlaubten Ziel-Codes – nur solche mit Hotels im Bestand des Keys (wie `GET /v1/destinations`) |

Die Werte sind genau die, die `/v1/search/open` und `/v1/search/open/dates` anwenden: das
Kleinste aus dem, was der Betreiber für den Veranstalter freigibt, und dem Profil des Keys.
Welche Ebene eine Grenze setzt, steht nicht in der Antwort. Änderungen im Profil wirken nach
wenigen Sekunden.

```http
### grenzen
GET {{baseUrl}}/v1/limits
X-Api-Key: {{apiKey}}
```

```json
{
  "rate": {"perSecond": 200, "burst": 400, "scope": "key"},
  "export": {"allowed": true},
  "content": {"allowed": true},
  "openSearch": {"allowed": true, "maxWindowDays": 14, "nightsMin": 1, "nightsMax": 14, "maxNightsSpan": 3,
                 "maxDestinations": 3, "maxHotels": 20, "maxCandidates": 500, "maxPageSize": 20,
                 "timeBudgetMs": 150, "rate": 5, "burst": 20, "concurrency": 1,
                 "matrixMaxWindowDays": 31, "matrixMaxCells": 500, "allDestinations": true}
}
```

Ein Key ohne Recht für die offene Suche:

```http
### grenzen-ohne-recht
GET {{baseUrl}}/v1/limits
X-Api-Key: {{poolKey}}
```

```json
{
  "rate": {"perSecond": 200, "burst": 400, "scope": "key"},
  "export": {"allowed": false},
  "content": {"allowed": false},
  "openSearch": {"allowed": false}
}
```

---

## 5. Buchung (B) und Buchungsinfo

### 5.1 `POST /v1/book`

| Feld | Pflicht | Bedeutung |
|---|---|---|
| `hotel`, `room` | ja | Hotel und Zimmer-Code (Buchung ist verpflegungsneutral; die Verpflegung steht im `priceCheck`). Fehlt `room`: `400 ERR_INVALID_BUCKET`, mit und ohne `priceCheck` |
| `checkIn`, `checkOut` | ja | Aufenthalt (Abschnitt 1.3) |
| `quantity` | ja | Anzahl Zimmer, 1–1.000.000 (`400 ERR_QUANTITY_INVALID`) |
| `idemKey` | ja | eigener, eindeutiger Schlüssel des Vorgangs (Abschnitt 9), höchstens 128 Zeichen; fehlt er: `400 ERR_INVALID_IDEM_KEY` |
| `reference` | nein | eigene Buchungsreferenz, höchstens 128 Zeichen; damit ist die Buchung später lesbar |
| `leadPaxName` | nein | Name des Hauptreisenden, höchstens 255 Zeichen |
| `metadata` | nein | freies JSON-Objekt, wird gespeichert und bei `/v1/booking` zurückgegeben, nicht ausgewertet. `metadata.correlationId` (bis 64 Zeichen) wird als Korrelations-ID übernommen. |
| `priceCheck` | nein, **empfohlen** | Preisprüfung vor dem Verkauf, siehe unten |

Zu lange Texte (`idemKey`, `reference`, `leadPaxName`, `metadata.correlationId`) lehnt die API
vor jedem Verkauf mit `422 ERR_VALIDATION` ab; `message` nennt Feld, Länge und Grenze. Es wird
nie gekürzt.

`priceCheck`: `board`, `occupancy.travellers[]`, `expectedCents` (der Preis, den der Kunde
gesehen hat), `tolerancePercent` (erlaubte Abweichung in %, ≥ 0), optional `currency` und
`now`. TourAPI rechnet den Preis wie `/v1/price` neu; weicht er um mehr als die Toleranz ab:
`409 ERR_PRICE_DRIFT` mit dem aktuellen Preis in `message`, **nichts gebucht**. Mit
`priceCheck` speichert TourAPI den geprüften Preis an der Buchung (`totalCents`, `currency`,
`board` in `/v1/booking`). Ohne `priceCheck` ist die Buchung ein reiner Kontingentverkauf ohne
Preis (`totalCents: null`). Auch dann verkauft `/v1/book` nur Hotels, die `/v1/price` für den
Key kennt: Ohne gültigen Vertrag gibt es kein Hotel (`404 ERR_HOTEL_NOT_FOUND`), auch wenn
Kontingent hinterlegt ist.

Antwort: `booked`, `alreadyBooked` (`true` = Wiederholung, nichts neu verkauft), `reference`
(unsere Buchungsreferenz `TA-…`), `correlationId`. `correlationId` ist die mitgeschickte
`metadata.correlationId`; fehlt sie, vergibt TourAPI eine UUID und speichert sie am Beleg.
Eine Wiederholung (`alreadyBooked=true`) nennt immer die gespeicherte `correlationId` des
ersten Aufrufs – auch wenn sie keine oder eine andere mitschickt.

```http
### buchen
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "quantity": 1,
  "idemKey": "BEISPIEL-0001",
  "reference": "KUNDE-4711",
  "leadPaxName": "Erika Muster",
  "metadata": {"correlationId": "kette-0815", "vermittler": "Filiale 12"},
  "priceCheck": {
    "board": "RO",
    "currency": "EUR",
    "occupancy": {"travellers": [{"age": 40}, {"age": 38}]},
    "expectedCents": 18000,
    "tolerancePercent": 0
  }
}
```

```json
{"booked": true, "alreadyBooked": false, "reference": "{{buchungsRef}}", "correlationId": "kette-0815"}
```

### 5.2 Wiederholen ist sicher

Dieselbe Anfrage mit demselben `idemKey` noch einmal – etwa nach einem Netzfehler – verkauft
nicht erneut, sondern bestätigt die bestehende Buchung (`alreadyBooked: true`, dieselbe
Referenz).

```http
### buchen-wiederholt
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "quantity": 1,
  "idemKey": "BEISPIEL-0001",
  "reference": "KUNDE-4711",
  "leadPaxName": "Erika Muster",
  "metadata": {"correlationId": "kette-0815", "vermittler": "Filiale 12"},
  "priceCheck": {
    "board": "RO",
    "currency": "EUR",
    "occupancy": {"travellers": [{"age": 40}, {"age": 38}]},
    "expectedCents": 18000,
    "tolerancePercent": 0
  }
}
```

```json
{"booked": true, "alreadyBooked": true, "reference": "{{buchungsRef}}", "correlationId": "kette-0815"}
```

Derselbe `idemKey` mit anderen Buchungsdaten (Hotel, Zimmer, Aufenthalt, Menge) ist ein
Fehler im Aufrufer:

```http
### buchen-anderer-vorgang
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "quantity": 2,
  "idemKey": "BEISPIEL-0001"
}
```

```json
{"errorCode": "ERR_IDEMPOTENCY_MISMATCH", "message": "idemKey bereits mit anderen Buchungsdaten (hotel, room, checkIn, checkOut, quantity) vergeben"}
```

### 5.3 Warum eine Buchung scheitert

Scheitert der Verkauf an einer Nacht, nennt `message` die erste betroffene Nacht. Nichts
wird gebucht (alle Nächte oder keine).

| Code | Status | Bedeutung |
|---|---|---|
| `ERR_SOLD_OUT` | 422 | Nacht ausgebucht |
| `ERR_STOP_SALE` | 422 | Veranstalter hat den Verkauf gestoppt |
| `ERR_INVENTORY_CLOSED` | 422 | Nacht geschlossen oder nur auf Anfrage |
| `ERR_NO_INVENTORY` | 422 | für eine Nacht ist keine Kapazität hinterlegt |
| `ERR_GROUP_LIMIT` | 422 | die Zuteilung der Kundengruppe ist erschöpft (oder für Zimmer und Nacht nicht vorhanden) |
| `ERR_HOTEL_NOT_FOUND` | 404 | Hotel gibt es für diesen Key nicht (auch: kein gültiger Vertrag, mit und ohne `priceCheck`; Kundengruppe ohne Angebot, 1.1) |
| `ERR_PRICE_DRIFT` | 409 | Preis weicht vom `priceCheck` ab |
| `ERR_STAY_LENGTH_NOT_ALLOWED`, `ERR_ARRIVAL_DAY_NOT_ALLOWED`, `ERR_TRAVEL_DATES_NOT_ALLOWED`, `ERR_BOARD_NOT_ALLOWED`, `ERR_LEAD_TIME_NOT_ALLOWED` | 422 | eine Verkaufsregel des Zimmers schließt den Aufenthalt aus (3.4); nichts wird gebucht |
| `ERR_BOARD_NOT_AVAILABLE` | 422 | die Verpflegung des `priceCheck` wird für diese Reisegruppe nicht verkauft (3.5); nichts wird gebucht |

```http
### buchen-preis-geaendert
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "quantity": 1,
  "idemKey": "BEISPIEL-0002",
  "priceCheck": {
    "board": "RO",
    "occupancy": {"travellers": [{"age": 40}, {"age": 38}]},
    "expectedCents": 17000,
    "tolerancePercent": 2
  }
}
```

```json
{"errorCode": "ERR_PRICE_DRIFT", "message": "Preis hat sich geaendert: aktuell 18000 Cent, erwartet 17000 Cent"}
```

```http
### buchen-ausgebucht
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-SOLD",
  "room": "DZ",
  "checkIn": "{{D13}}",
  "checkOut": "{{D14}}",
  "quantity": 1,
  "idemKey": "BEISPIEL-0003"
}
```

```json
{"errorCode": "ERR_SOLD_OUT", "message": "Nacht {{D13}} ausgebucht"}
```

```http
### buchen-stop-sale
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-STOP",
  "room": "DZ",
  "checkIn": "{{D11}}",
  "checkOut": "{{D12}}",
  "quantity": 1,
  "idemKey": "BEISPIEL-0004"
}
```

```json
{"errorCode": "ERR_STOP_SALE", "message": "Nacht {{D11}} stop-sale"}
```

Ein Key mit Kundengruppe mit Zuteilung bucht gegen die Zuteilung seiner Gruppe, auch in
Nächten im Freiverkauf. Hier hat `TEST-PARTNER-BASE` für die Nacht 5 zugeteilt:

```http
### buchen-zuteilung-erschoepft
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{partnerKey}}

{
  "hotel": "TEST-HOTEL-FREESALE",
  "room": "DZ",
  "checkIn": "{{D20}}",
  "checkOut": "{{D21}}",
  "quantity": 6,
  "idemKey": "BEISPIEL-0005"
}
```

```json
{"errorCode": "ERR_GROUP_LIMIT", "message": "Gruppen-Kontingent fuer {{D20}} erschoepft"}
```

Eine Preisgruppe bucht aus dem allgemeinen Bestand wie ein Key ohne Gruppe:

```http
### buchen-preisgruppe
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{rabattKey}}

{
  "hotel": "TEST-HOTEL-DISCOUNT",
  "room": "DZ",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "quantity": 1,
  "idemKey": "BEISPIEL-0006"
}
```

```json
{"booked": true, "alreadyBooked": false, "reference": "{{*}}", "correlationId": "{{*}}"}
```

### 5.4 `GET /v1/booking?ref=…` – Buchungsinfo

`ref` ist unsere Referenz (`TA-…`) **oder** die eigene `reference` aus der Buchung; unsere
Referenz gewinnt. Passt die eigene `reference` zu mehreren sichtbaren Buchungen (auch
stornierten): `409 ERR_REFERENCE_AMBIGUOUS`, `message` nennt die passenden `TA-…`-Referenzen
(neueste zuerst, höchstens 10) – dann mit unserer Referenz aus der Buchungsantwort lesen. Antwort:
`reference`, `customerReference`, `hotel`, `room`, `group` (nur bei Kundengruppen-Buchungen; Gruppen-Code
aus `A-Z a-z 0-9 . _ -`, 1 bis 64 Zeichen),
`checkIn`, `checkOut`, `quantity`, `status` (`confirmed` | `released`), `bookedAt`,
`updatedAt` (letzte Änderung, bei Storno die Stornozeit; beide RFC 3339 in UTC, z. B.
`2026-09-26T08:15:03Z`), `metadata`, `totalCents`, `currency`, `board`. Personendaten der
Reisenden gibt die API nicht zurück.

| Gebucht … | dann in der Buchungsinfo |
|---|---|
| mit `priceCheck` | `totalCents` = geprüfter Preis, `currency` = Vertragswährung, `board` = Verpflegung aus dem `priceCheck` |
| ohne `priceCheck` | `totalCents: null`, `currency: ""`, `board: ""` (leere Texte, kein Preis erfasst) |
| ohne `reference` | `customerReference` fehlt |
| ohne `metadata` | `metadata` fehlt |
| mit Key ohne Kundengruppe | `group` fehlt |

Sichtbarkeit: Ein Key mit Kundengruppe sieht nur Buchungen seiner Gruppe. Ein Key auf dem
Basisvertrag sieht alle Buchungen des Veranstalters, auch die der Kundengruppen – stornieren
kann er aber nur seine eigenen (Abschnitt 6). Unbekannt oder nicht sichtbar:
`404 ERR_BOOKING_NOT_FOUND`.

```http
### buchung-lesen
GET {{baseUrl}}/v1/booking?ref={{buchungsRef}}
X-Api-Key: {{apiKey}}
```

```json
{
  "reference": "{{buchungsRef}}",
  "customerReference": "KUNDE-4711",
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "quantity": 1,
  "status": "confirmed",
  "bookedAt": "{{*}}",
  "updatedAt": "{{*}}",
  "metadata": {"correlationId": "kette-0815", "vermittler": "Filiale 12"},
  "totalCents": 18000,
  "currency": "EUR",
  "board": "RO"
}
```

Über die eigene Referenz:

```http
### buchung-lesen-kundenreferenz
GET {{baseUrl}}/v1/booking?ref=KUNDE-4711
X-Api-Key: {{apiKey}}
```

```json
{
  "reference": "{{buchungsRef}}",
  "customerReference": "KUNDE-4711",
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "quantity": 1,
  "status": "confirmed",
  "bookedAt": "{{*}}",
  "updatedAt": "{{*}}",
  "metadata": {"correlationId": "kette-0815", "vermittler": "Filiale 12"},
  "totalCents": 18000,
  "currency": "EUR",
  "board": "RO"
}
```

Eine Buchung ohne `priceCheck`, ohne `reference` und ohne `metadata` – die Antwort trägt eine
vom Server vergebene `correlationId`, die Buchungsinfo keinen Preis:

```http
### buchen-ohne-preispruefung
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D20}}",
  "checkOut": "{{D21}}",
  "quantity": 1,
  "idemKey": "BEISPIEL-0010"
}
```

```json
{"booked": true, "alreadyBooked": false, "reference": "{{ohnePreisRef}}", "correlationId": "{{*}}"}
```

```http
### buchung-lesen-ohne-preispruefung
GET {{baseUrl}}/v1/booking?ref={{ohnePreisRef}}
X-Api-Key: {{apiKey}}
```

```json
{
  "reference": "{{ohnePreisRef}}",
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D20}}",
  "checkOut": "{{D21}}",
  "quantity": 1,
  "status": "confirmed",
  "bookedAt": "{{*}}",
  "updatedAt": "{{*}}",
  "totalCents": null,
  "currency": "",
  "board": ""
}
```

Dieselbe eigene Referenz an einer zweiten Buchung macht sie mehrdeutig:

```http
### buchen-gleiche-kundenreferenz
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D22}}",
  "checkOut": "{{D23}}",
  "quantity": 1,
  "idemKey": "BEISPIEL-0011",
  "reference": "KUNDE-4711"
}
```

```json
{"booked": true, "alreadyBooked": false, "reference": "{{zweiteRef}}", "correlationId": "{{*}}"}
```

```http
### buchung-lesen-mehrdeutig
GET {{baseUrl}}/v1/booking?ref=KUNDE-4711
X-Api-Key: {{apiKey}}
```

```json
{"errorCode": "ERR_REFERENCE_AMBIGUOUS", "message": "ref 'KUNDE-4711' passt zu mehreren Buchungen ({{zweiteRef}}, {{buchungsRef}}) — mit der TourAPI-Referenz (TA-…) lesen"}
```

---

## 6. Storno (S): `POST /v1/cancel`

Einziges Feld: `idemKey` der Buchung (fehlt er: `400 ERR_INVALID_IDEM_KEY`; jedes weitere
Feld: `422 ERR_UNKNOWN_FIELD`). Das Kontingent aller Nächte wird zurückgegeben, der
Status wird `released`. Storniert werden kann nur, was mit **demselben Key-Kreis** gebucht
wurde: Ein Key mit Kundengruppe storniert die Buchungen seiner Gruppe, ein Key auf dem
Basisvertrag die Buchungen des Basisvertrags. Alles andere ist `404 ERR_BOOKING_NOT_FOUND`.
Stornogebühren berechnet die API nicht.
Jeder Storno wird beim Veranstalter mit Zeitpunkt und der Kennung (key_id) des
stornierenden Keys protokolliert und in seiner Buchungsansicht angezeigt; eine
Wiederholung (`alreadyReleased`) erzeugt keinen zweiten Eintrag.

Antwort: `released` (`true`), `alreadyReleased` (`true` = war schon storniert).

```http
### stornieren
POST {{baseUrl}}/v1/cancel
Content-Type: application/json
X-Api-Key: {{apiKey}}

{"idemKey": "BEISPIEL-0001"}
```

```json
{"released": true, "alreadyReleased": false}
```

Wiederholung ist sicher:

```http
### stornieren-wiederholt
POST {{baseUrl}}/v1/cancel
Content-Type: application/json
X-Api-Key: {{apiKey}}

{"idemKey": "BEISPIEL-0001"}
```

```json
{"released": true, "alreadyReleased": true}
```

Die Buchung bleibt lesbar, mit `status: released`:

```http
### buchung-lesen-storniert
GET {{baseUrl}}/v1/booking?ref={{buchungsRef}}
X-Api-Key: {{apiKey}}
```

```json
{
  "reference": "{{buchungsRef}}",
  "customerReference": "KUNDE-4711",
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "quantity": 1,
  "status": "released",
  "bookedAt": "{{*}}",
  "updatedAt": "{{*}}",
  "metadata": {"correlationId": "kette-0815", "vermittler": "Filiale 12"},
  "totalCents": 18000,
  "currency": "EUR",
  "board": "RO"
}
```

Ein stornierter `idemKey` ist verbraucht. Für eine neue Buchung einen neuen Key nehmen:

```http
### buchen-nach-storno
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "quantity": 1,
  "idemKey": "BEISPIEL-0001"
}
```

```json
{"errorCode": "ERR_IDEM_KEY_RELEASED", "message": "idemKey gehoert zu einer stornierten Buchung — fuer einen neuen Verkauf einen neuen idemKey verwenden"}
```

```http
### stornieren-unbekannt
POST {{baseUrl}}/v1/cancel
Content-Type: application/json
X-Api-Key: {{apiKey}}

{"idemKey": "GIBT-ES-NICHT"}
```

```json
{"errorCode": "ERR_BOOKING_NOT_FOUND", "message": "keine Buchung mit diesem idemKey"}
```

---

## 7. Fehlerkatalog

Spalte „Aufrufer“: **Anfrage korrigieren** = nicht wiederholen, der Fehler liegt in der
Anfrage. **Wiederholen** = dieselbe Anfrage später noch einmal (bei `Retry-After` frühestens
nach so vielen Sekunden). **Melden** = beim Veranstalter/Betreiber melden, Wiederholen hilft
nicht. Ein Code kann an mehreren Endpunkten vorkommen; Status und Bedeutung bleiben gleich.

### 7.1 Zugang und Transport

| Code | Status | Bedeutung | Aufrufer |
|---|---|---|---|
| `ERR_UNAUTHORIZED` | 401 | Key fehlt, unbekannt oder widerrufen | Key prüfen, nicht wiederholen (Wiederholen wird verzögert, Abschnitt 8) |
| `ERR_TENANT_SUSPENDED` | 403 | Key gueltig, Veranstalter gesperrt | Veranstalter fragen, nicht wiederholen |
| `ERR_KEY_GROUP_INACTIVE` | 403 | Kundengruppe des Keys deaktiviert | Veranstalter fragen |
| `ERR_MODE_MISMATCH` | 403 | `X-TourAPI-Require-Mode` verlangt einen anderen Modus als den des Keys (z. B. Live-Key in einer Test-Umgebung); nichts ausgeführt (Abschnitt 11.1) | Key tauschen, nicht wiederholen |
| `ERR_SCENARIO_NOT_ALLOWED` | 422 | `X-TourAPI-Sandbox-Scenario` mit Live-Key, unbekanntem Szenario oder an einem Endpunkt, an dem es nicht wirkt (Abschnitt 11.3) | Anfrage korrigieren |
| `ERR_METHOD_NOT_ALLOWED` | 405 | falsche HTTP-Methode | Anfrage korrigieren |
| `ERR_BAD_REQUEST` | 400 | Body kein JSON, zu groß (> 1 MiB), `ref` fehlt, `priceCheck.tolerancePercent` negativ, `priceCheck.currency` länger als 3 Zeichen (kürzer oder unbekannt: `422 ERR_CURRENCY_NOT_AVAILABLE`); Content-API: `since` fehlt, Parameter mehrfach, `lang` mit mehr als 5 oder doppelten Sprachen | Anfrage korrigieren |
| `ERR_UNKNOWN_FIELD` | 422 | unbekanntes Feld in `occupancy` (Auskunft) bzw. irgendwo (`/v1/search/open`, `/v1/search/open/dates`, `/v1/book`, `/v1/cancel`, Body von `/v1/export/edf/ack`); unbekannter Query-Parameter an `/v1/export/edf/*` und `/v1/limits` | Anfrage korrigieren |
| `ERR_OPEN_SEARCH_NOT_ALLOWED` | 403 | der Key hat kein Recht für die offene Suche und die Termin-Matrix (4a.1; bei neuen Keys aus; `GET /v1/limits` zeigt es) | Veranstalter fragen |
| `ERR_DESTINATION_NOT_ALLOWED` | 403 | offene Suche: das Ziel (`destinations[i]`) bzw. das Hotel (`hotels[i]`, Termin-Matrix: `hotel`) liegt außerhalb der erlaubten Ziele des Suchprofils | Ziel aus dem Suchprofil nehmen (`GET /v1/limits`) |

```http
### fehler-ohne-key
POST {{baseUrl}}/v1/price
Content-Type: application/json

{}
```

```json
{"errorCode": "ERR_UNAUTHORIZED", "message": "fehlender/ungueltiger API-Key"}
```

```http
### fehler-key-widerrufen
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{gesperrterKey}}

{}
```

```json
{"errorCode": "ERR_UNAUTHORIZED", "message": "fehlender/ungueltiger API-Key"}
```

```http
### fehler-methode
GET {{baseUrl}}/v1/price
X-Api-Key: {{apiKey}}
```

```json
{"errorCode": "ERR_METHOD_NOT_ALLOWED", "message": "nur POST"}
```

```http
### fehler-tippfehler-belegung
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "board": "RO",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}, {"alter": 8}]}
}
```

```json
{"errorCode": "ERR_UNKNOWN_FIELD", "message": "unbekanntes Feld 'occupancy.travellers[1].alter' — abgelehnt (die Belegung bestimmt den Preis, hier wird nicht geraten)"}
```

### 7.2 Anfrage (Felder und Grenzen)

| Code | Status | Bedeutung | Aufrufer |
|---|---|---|---|
| `ERR_BAD_DATE` | 422 | Datum fehlt oder ist nicht `JJJJ-MM-TT` (`message` nennt das Feld) | Anfrage korrigieren |
| `ERR_EMPTY_STAY` | 422 | `checkOut` ≤ `checkIn` | Anfrage korrigieren |
| `ERR_STAY_TOO_LONG` | 422 | mehr als 30 Nächte | Anfrage korrigieren |
| `ERR_STAY_IN_PAST` | 422 | Anreise vor dem Stichtag | Anfrage korrigieren |
| `ERR_STAY_TOO_FAR` | 422 | Anreise mehr als 732 Tage nach dem Stichtag | Anfrage korrigieren |
| `ERR_NO_TRAVELLERS` | 422 | keine Reisenden | Anfrage korrigieren |
| `ERR_TOO_MANY_TRAVELLERS` | 422 | mehr als 20 Reisende | Anfrage korrigieren |
| `ERR_INVALID_AGE` | 422 | Alter negativ oder über 120 | Anfrage korrigieren |
| `ERR_BOARD_MISSING` | 422 | `board` fehlt (`/v1/price`, `/v1/search`, `priceCheck`) | Anfrage korrigieren |
| `ERR_NOW_MISMATCH` | 422 | `priceCheck.now` ist nicht der Stichtag | Feld weglassen |
| `ERR_VALIDATION` | 422 | Text zu lang: `idemKey`, `reference` (128), `leadPaxName` (255), `metadata.correlationId` (64); `message` nennt Feld und Grenze | Anfrage korrigieren |
| `ERR_QUANTITY_INVALID` | 400 | `quantity` außerhalb 1–1.000.000 | Anfrage korrigieren |
| `ERR_INVALID_IDEM_KEY` | 400 | `idemKey` fehlt (`/v1/book`, `/v1/cancel`) | Anfrage korrigieren |
| `ERR_INVALID_BUCKET` | 400 | `room` fehlt (`/v1/book`, mit und ohne `priceCheck`) | Anfrage korrigieren |
| `ERR_INVALID_STAY` | 400 | Aufenthalt ungültig (Schutz im Verkauf; die API prüft vorher mit `ERR_BAD_DATE`/`ERR_EMPTY_STAY`) | Anfrage korrigieren |
| `ERR_BAD_PAGE_SIZE` | 422 | `pageSize` außerhalb 1–100 (`/v1/search`; offene Suche: 1 bis Suchprofil) bzw. 1–1000 (`/v1/content/*`) | Anfrage korrigieren |
| `ERR_BAD_CURSOR` | 422 | Cursor unlesbar, verändert oder abgelaufen (z. B. nach Server-Neustart; offene Suche: 15 Minuten nach Ausgabe); Content-API: `cursor`/`since` unlesbar, verändert oder von einem anderen Key | ohne `cursor` neu beginnen bzw. Verzeichnis neu holen |
| `ERR_CURSOR_MISMATCH` | 422 | Cursor gehört zu anderer Anfrage oder anderem Key (`/v1/search`, `/v1/search/open`) | Anfrage korrigieren |
| `ERR_UNKNOWN_DESTINATION` | 422 | `/v1/search` bzw. `/v1/search/open` ohne `cursor`: zu `destination` bzw. `destinations[i]` gibt es für diesen Key kein Hotel (Groß-/Kleinschreibung zählt) | Code aus `GET /v1/destinations` nehmen (4.5) |
| `ERR_BAD_WINDOW` | 422 | offene Suche und Termin-Matrix: `arrivalFrom`/`arrivalTo` fehlt oder `arrivalTo` liegt vor `arrivalFrom` | Anfrage korrigieren |
| `ERR_BAD_NIGHTS` | 422 | offene Suche und Termin-Matrix: `nightsMin`/`nightsMax` fehlt, < 1 oder `nightsMin` > `nightsMax` | Anfrage korrigieren |
| `ERR_BAD_TARGET` | 422 | offene Suche: `destinations` und `hotels` zugleich, leere Liste, leerer Code, oder keins von beiden, obwohl das Suchprofil nur einzelne Ziele erlaubt; Termin-Matrix: `hotel` fehlt | Anfrage korrigieren |
| `ERR_BAD_SORT` | 422 | offene Suche: `sort` unbekannt (`price`, `pricePerNight`, `hotel`) | Anfrage korrigieren |
| `ERR_BAD_FILTER` | 422 | offene Suche und Termin-Matrix: leere `boards`/`boardTypes`/`rooms`-Liste oder leerer Eintrag, unbekannte Verpflegungsart, Preisfilter negativ oder `minTotalCents` > `maxTotalCents`, `currency` kein Code aus drei Großbuchstaben; Filter nach Hotelstamm außerhalb der Form (`category`, `regions`, `geo`, 4a.2) – die Meldung nennt das Feld | Anfrage korrigieren |
| `ERR_WINDOW_TOO_WIDE` | 422 | offene Suche bzw. Termin-Matrix: Anreisefenster breiter als das Suchprofil erlaubt (`maxWindowDays` bzw. `matrixMaxWindowDays`, `message` nennt die Grenze) | Fenster teilen oder verkleinern |
| `ERR_NIGHTS_NOT_ALLOWED` | 422 | offene Suche: Dauer außerhalb der erlaubten Dauern oder Spanne `nightsMax - nightsMin + 1` zu breit; Termin-Matrix: Dauer außerhalb der erlaubten Dauern (`message` nennt die Grenze) | Dauer anpassen |
| `ERR_SEARCH_TOO_BROAD` | 422 | offene Suche: zu viele `destinations` oder `hotels` in der Anfrage oder zu viele Hotels im Suchraum; Termin-Matrix: mehr Zellen als `matrixMaxCells` (`message` nennt die Grenze) | eingrenzen |
| `ERR_CURRENCY_REQUIRED` | 422 | offene Suche: die Hotels des Suchraums rechnen in mehreren Vertragswährungen, `currency` fehlt (`message` nennt sie) | `currency` setzen |

```http
### fehler-aufenthalt-zu-lang
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "board": "RO",
  "checkIn": "{{D0}}",
  "checkOut": "{{D31}}",
  "occupancy": {"travellers": [{"age": 40}]}
}
```

```json
{"errorCode": "ERR_STAY_TOO_LONG", "message": "checkOut: Aufenthalt laenger als 30 Naechte"}
```

```http
### fehler-anreise-vergangen
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "board": "RO",
  "checkIn": "{{gestern}}",
  "checkOut": "{{heute}}",
  "occupancy": {"travellers": [{"age": 40}]}
}
```

```json
{"errorCode": "ERR_STAY_IN_PAST", "message": "checkIn: {{gestern}} liegt vor dem Stichtag {{heute}} (Serverdatum)"}
```

Zu lange Texte werden vor dem Verkauf abgelehnt, nie gekürzt:

```http
### fehler-text-zu-lang
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "quantity": 1,
  "idemKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
```

```json
{"errorCode": "ERR_VALIDATION", "message": "idemKey: zu lang (129 Zeichen, hoechstens 128)"}
```

Ein verschriebenes Feld außerhalb der Belegung wird nicht abgelehnt, aber benannt – hier
fehlt dadurch das Pflichtfeld, und die Antwort sagt beides:

```http
### fehler-tippfehler-feld
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "board": "RO",
  "checke": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}]}
}
```

```json
{"errorCode": "ERR_BAD_DATE", "message": "checkIn: fehlt (Pflichtfeld, JJJJ-MM-TT)",
 "warnings": ["unbekanntes Feld 'checke' — ignoriert (Tippfehler?)"]}
```

Hinweise in einer erfolgreichen Antwort:

```http
### hinweise-now-und-waehrung
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "EZ",
  "board": "RO",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "now": "{{gestern}}",
  "currency": "USD",
  "occupancy": {"travellers": [{"age": 40}]}
}
```

```json
{
  "room": "EZ",
  "currency": "EUR",
  "rounding": {"mode": "Commercial", "decimalPlaces": 2, "scope": "Person"},
  "totalCents": 12500,
  "perTravellerCents": [12500],
  "breakdown": [
    {"chargeType": "BaseCharge", "code": "Base", "traveller": 0, "night": 0, "amountExact": "70.00", "amountCents": 7000},
    {"chargeType": "BaseCharge", "code": "ExtraDay", "traveller": 0, "night": 1, "amountExact": "55.00", "amountCents": 5500}
  ],
  "availability": {"configured": true, "available": true, "minFree": 5},
  "warnings": [
    "now: '{{gestern}}' ignoriert — Stichtag ist das Serverdatum {{heute}}",
    "currency: angefragt 'USD', der Vertrag rechnet in 'EUR' (keine Umrechnung)"
  ]
}
```

### 7.3 Bestand, Preis, Verkauf

| Code | Status | Bedeutung | Aufrufer |
|---|---|---|---|
| `ERR_HOTEL_NOT_FOUND` | 404 | Hotel gibt es für diesen Key nicht (auch: fremder Veranstalter, nicht veröffentlicht, Sonderpreis der Kundengruppe nicht rechenbar – siehe 1.1) | Anfrage korrigieren; bei einem sonst bekannten Hotel den Veranstalter informieren |
| `ERR_ROOM_NOT_FOUND` | 404 | Zimmer gibt es in diesem Hotel nicht (Termin-Matrix: `rooms[i]`) | Anfrage korrigieren |
| `ERR_BOOKING_NOT_FOUND` | 404 | Buchung unbekannt oder für diesen Key nicht sichtbar | Referenz/Key prüfen |
| `ERR_REFERENCE_AMBIGUOUS` | 409 | eigene `reference` passt zu mehreren Buchungen (`/v1/booking`); `message` nennt die `TA-…`-Referenzen (Test-Key: `SB-…`) | mit der TourAPI-Referenz lesen; eigene Referenzen eindeutig halten |
| `ERR_TENANT_NOT_FOUND` | 404 | Veranstalter nicht (mehr) aktiv, nur Verkauf/Storno | Melden |
| `ERR_BOARD_NOT_OFFERED` | 422 | Verpflegung wird nicht angeboten (Code genau wie im Vertrag; `/v1/price`, `/v1/prices`, `priceCheck`; offene Suche: `boards[i]` von keinem Hotel des Suchraums; Termin-Matrix: `boards[i]` nicht im Hotel oder keine Verpflegung passt zu `boards`/`boardTypes`); in der Suche Grund in `diagnostics.reasons` bzw. `coverage.reasons` | Anfrage korrigieren |
| `ERR_OCCUPANCY_NOT_ALLOWED` | 422 | Belegung passt in kein Zimmer (bzw. nicht ins angefragte) | andere Belegung/anderes Zimmer |
| `ERR_STAY_LENGTH_NOT_ALLOWED` | 422 | eine Verkaufsregel des Zimmers verlangt eine andere Aufenthaltsdauer (3.4); in der Suche Grund in `diagnostics.reasons` | Dauer ändern |
| `ERR_ARRIVAL_DAY_NOT_ALLOWED` | 422 | eine Verkaufsregel verlangt einen anderen An-/Abreise-Wochentag (3.4) | Reisetage verschieben |
| `ERR_TRAVEL_DATES_NOT_ALLOWED` | 422 | der Aufenthalt liegt außerhalb des Verkaufsfensters einer Regel (3.4) | anderer Zeitraum |
| `ERR_BOARD_NOT_ALLOWED` | 422 | die Verpflegung wird für diesen Aufenthalt nicht verkauft (3.4) | andere Verpflegung |
| `ERR_LEAD_TIME_NOT_ALLOWED` | 422 | die Anreise liegt innerhalb der Release-Frist einer Verkaufsregel, gezählt ab dem Stichtag (3.4) | spätere Anreise |
| `ERR_BOARD_NOT_AVAILABLE` | 422 | die Verpflegung wird für diese Reisegruppe nicht verkauft, ihr Zuschlag verlangt eine andere Zusammensetzung (3.5); in `/v1/prices` Eintrag in `rooms[].errors[]`, in der Suche Grund in `diagnostics.reasons` | andere Verpflegung/Belegung |
| `ERR_ROOM_RESTRICTION_INVALID` | 422 | eine Verkaufsregel im Vertrag ist nicht auswertbar | Melden |
| `ERR_NO_SECTION` | 422 | für eine Nacht gibt es keinen Preis (Saison nicht im Vertrag); rechnet ein anderes Zimmer, in `/v1/prices` Eintrag in `rooms[].errors[]`, bei `/v1/price` ohne `room` in `warnings`, in der Suche in `diagnostics.roomErrors` | anderer Zeitraum |
| `ERR_NO_PRICE` | 422 | kein buchbares Zimmer für die Anfrage, ohne genaueren Grund | anderer Zeitraum/andere Belegung |
| `ERR_OCCUPANCY_NIGHT_UNCOVERED` | 422 | Belegungsregeln des Vertrags decken eine Nacht nicht ab | Melden |
| `ERR_OCCUPANCY_INCONSISTENT_MCA` | 422 | Mindestbelegung wechselt innerhalb des Aufenthalts (nicht unterstützt) | kürzerer Zeitraum oder Melden |
| `ERR_OCCUPANCY_INCONSISTENT_CHILDREN` | 422 | ein Reisender ist innerhalb des Aufenthalts einmal Kind, einmal Erwachsener (das Kinderband des Zimmers wechselt mit der Saison; nicht unterstützt) | kürzerer Zeitraum oder Melden |
| `ERR_OCCUPANCY_INCONSISTENT_INFANTS` | 422 | ein Säugling zählt innerhalb des Aufenthalts einmal zur Belegung, einmal nicht (die Belegungsregel des Zimmers wechselt mit der Saison), und ein Zu- oder Abschlag hängt an der Personenzahl; nicht eindeutig | kürzerer Zeitraum oder Melden |
| `ERR_CHILDREN_ORDER_MISSING` | 422 | der Vertrag legt nicht fest, ob das älteste oder das jüngste Kind zuerst zählt, und für diese Kinder macht das einen Preisunterschied (Vertragsfehler) | Melden |
| `ERR_INVALID_AMOUNT` | 422 | ein Betrag oder Prozentsatz im Vertrag ist nicht lesbar | Melden |
| `ERR_AMOUNT_OVERFLOW` | 422 | der Preis überschreitet den darstellbaren Cent-Bereich (Vertragsfehler) | Melden |
| `ERR_CURRENCY_NOT_AVAILABLE` | 422 | `priceCheck.currency` passt nicht zur Vertragswährung oder diese ist unbekannt; in der offenen Suche Grund in `coverage.reasons` (Hotel rechnet in anderer als der angefragten oder in unbekannter Währung); Termin-Matrix: dasselbe als 422 | Anfrage korrigieren bzw. Melden |
| `ERR_PRICE_DRIFT` | 409 | aktueller Preis weicht vom `priceCheck` ab | neuen Preis anzeigen, mit neuem `expectedCents` buchen |
| `ERR_SOLD_OUT` | 422 | Nacht ausgebucht | nicht wiederholen |
| `ERR_STOP_SALE` | 422 | Verkaufsstopp | nicht wiederholen |
| `ERR_INVENTORY_CLOSED` | 422 | Nacht geschlossen oder nur auf Anfrage | nicht wiederholen |
| `ERR_NO_INVENTORY` | 422 | für mindestens eine Nacht ist keine Kapazität hinterlegt (Buchung und Suchgrund gleich) | nicht wiederholen |
| `ERR_NOT_AVAILABLE` | – | nur als Grund in der Suche: jede Nacht hat Kapazität, aber nicht jede ist offen (Buchung: `ERR_SOLD_OUT`, `ERR_STOP_SALE`, `ERR_INVENTORY_CLOSED`) | – |
| `ERR_OUTSIDE_PRICE_FILTER` | – | nur als Grund in der offenen Suche (jedes Angebot des Hotels) bzw. der Termin-Matrix (jedes Angebot der Zelle) liegt außerhalb von `minTotalCents`/`maxTotalCents` | – |
| `ERR_NO_CATEGORY` | – | nur als Grund (offene Suche; Termin-Matrix: jede Zelle des Hotels): Filter `category`, das Hotel hat keine offizielle Kategorie im Hotelstamm | Veranstalter: Kategorie pflegen |
| `ERR_NO_REGION` | – | nur als Grund (offene Suche; Termin-Matrix: jede Zelle des Hotels): Filter `regions`, das Hotel hat keine Region im Hotelstamm | Veranstalter: Region pflegen |
| `ERR_NO_GEO` | – | nur als Grund (offene Suche; Termin-Matrix: jede Zelle des Hotels): Filter `geo`, das Hotel hat keine Koordinaten im Hotelstamm | Veranstalter: Koordinaten pflegen |
| `ERR_GROUP_LIMIT` | 422 | Zuteilung der Kundengruppe erschöpft oder für Zimmer und Nacht nicht vorhanden | nicht wiederholen |
| `ERR_IDEMPOTENCY_MISMATCH` | 409 | `idemKey` schon mit anderen Buchungsdaten benutzt | Fehler im Aufrufer: eindeutige Keys vergeben |
| `ERR_IDEM_KEY_RELEASED` | 409 | `idemKey` gehört zu einer stornierten Buchung | neuen `idemKey` nehmen |
| `ERR_SANDBOX_LIMIT` | 422 | Test-Key: mehr als 5.000 offene Testbuchungen dieses Zugangs (Abschnitt 11.2) | Testbuchungen stornieren |

```http
### fehler-verpflegung-nicht-angeboten
POST {{baseUrl}}/v1/prices
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "boards": ["AI"],
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}]}
}
```

```json
{"errorCode": "ERR_BOARD_NOT_OFFERED", "message": "boards: Verpflegung 'AI' wird nicht angeboten"}
```

Dasselbe gilt für `/v1/price` – eine Verpflegung, die das Zimmer nicht anbietet, wird nie zum
Preis ohne Verpflegung gerechnet:

```http
### fehler-verpflegung-preis
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "board": "AI",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}]}
}
```

```json
{"errorCode": "ERR_BOARD_NOT_OFFERED", "message": "board: Verpflegung 'AI' wird nicht angeboten"}
```

### 7.4 Last und Betrieb

| Code | Status | Bedeutung | Aufrufer |
|---|---|---|---|
| `ERR_RATE_LIMITED` | 429 | zu viele Anfragen dieses Keys (Abschnitt 8); offene Suche und Termin-Matrix: Takt des Suchprofils überschritten (beide zusammen) | Wiederholen nach `Retry-After` |
| `ERR_SEARCH_BUSY` | 429 | zu viele gleichzeitige Suchen des Veranstalters (offene Suche: auch des Keys laut Suchprofil) | Wiederholen nach `Retry-After` (1 s) |
| `ERR_SEARCH_TIMEOUT` | 503 | Suche hat die Zeitgrenze (10 s) überschritten | eingrenzen (`destination`/`destinations`, kleineres Fenster), dann wiederholen |
| `ERR_PRICE_TIMEOUT` | 503 | `/v1/price` ohne `room` oder `/v1/prices` hat die Zeitgrenze (2 s) überschritten | auf `/v1/price` mit `room` ausweichen (für `/v1/prices` gilt die Frist immer, auch mit `room` und `boards`), dann wiederholen |
| `ERR_BOOKING_DISABLED` | 503 | Verkauf/Storno auf diesem Knoten nicht eingeschaltet (Test-Key: Sandbox nicht eingeschaltet – ein Test-Key bucht nie live) | Wiederholen, dauerhaft: Melden |
| `ERR_BOOKING_BUSY` | 503 | Buchung/Storno kam wegen gleichzeitiger Vorgänge am selben Hotel nicht durch, nichts gebucht bzw. storniert | Wiederholen nach `Retry-After` (1 s) mit **demselben** `idemKey` |
| `ERR_INTERNAL` | 500 | interner Fehler, z. B. Datenbank nicht erreichbar (eine verletzte Invariante im Rechenkern kommt an den Auskunftsendpunkten als 422); offene Suche: Ergebnis nicht beweisbar (4a.4) | Wiederholen mit Pause; bei `/v1/book` mit **demselben** `idemKey` |
| `ERR_INVENTORY_DRIFT` | 500 | Kontingent-Invariante verletzt, nichts verkauft | Melden |
| `ERR_INVENTORY_STATUS_UNKNOWN` | 500 | unbekannter Tagesstatus im Kontingent, nichts verkauft | Melden |
| `ERR_RELEASE_DRIFT` | 500 | Storno wegen Kontingent-Invariante blockiert, nichts storniert | Melden |

### 7.5 Vertragsdaten (Fehler beim Veranstalter)

Diese Codes kommen aus dem Rechenkern, wenn der Vertrag des Hotels eine Regel enthält, die
TourAPI nicht (oder nicht so) rechnet. Sie sollten nach der Veröffentlichung nicht vorkommen,
weil der Vertrag vorher geprüft wird. Status immer **422**. Aufrufer: **Melden** (mit Hotel,
Zeitraum und `message`); in der Suche erscheinen sie als Grund in `diagnostics.reasons`
(Hotel fällt weg) bzw. in `diagnostics.roomErrors` (einzelnes Zimmer ausgelassen). Bei
`/v1/price` ohne `room` wird ein betroffenes Zimmer ausgelassen und in `warnings` genannt,
solange ein anderes Zimmer rechnet; `/v1/prices` nennt die betroffenen Verpflegungen in
`rooms[].errors[]` und bepreist den Rest (Abschnitt 3.2). `ERR_NEGATIVE_TRAVELLER_PRICE` und
`ERR_NEGATIVE_PERCENT_BASE` hängen an Belegung und Verpflegung: Das Schreib-Gate warnt davor,
lässt den Vertrag aber zu; sie können darum auch nach der Veröffentlichung kommen. `/v1/prices`
lässt dann nur die betroffene Verpflegung aus (`rooms[].errors[]`, Abschnitt 3.2).

| Code | Bedeutung |
|---|---|
| `ERR_NO_BASECHARGE` | Grundpreis fehlt |
| `ERR_SECTION_BAD_DATE`, `ERR_BOARD_BAD_DATE`, `ERR_OCCUPANCY_BAD_DATE` | ungültiges Datum im Vertrag |
| `ERR_OCCUPANCY_INCOMPLETE` | Belegungsregel unvollständig |
| `ERR_AMBIGUOUS_BOARDCHARGE` | zwei Verpflegungszuschläge können dieselbe Person in derselben Nacht treffen; welcher gilt, ist nicht eindeutig (das Schreib-Gate lässt das nicht zu, nur Altbestand) |
| `ERR_AMBIGUOUS_BASECHARGE`, `ERR_AMBIGUOUS_SECTION` | zwei Grundpreise gleichen Typs in einer Saison bzw. zwei Saisons für denselben Tag; welcher Preis gilt, ist nicht eindeutig (das Schreib-Gate lässt das nicht zu, nur Altbestand) |
| `ERR_AMBIGUOUS_FREENIGHT` | zwei Freinacht-Angebote können für denselben Aufenthalt greifen; welche Nächte das zweite erlässt, ist nicht bestimmt (das Schreib-Gate lässt das nicht zu, nur Altbestand) |
| `ERR_UNSUPPORTED_FREENIGHT`, `ERR_UNSUPPORTED_REDUCTION_MODE` | Freinacht-Angebot in einer Form, die TourAPI nicht rechnet (z. B. fester Betrag statt Prozentsatz, Personen-Einschränkung, Nachtwahl „größer/kleiner als“) |
| `ERR_NEGATIVE_TRAVELLER_PRICE` | nach allen Zu- und Abschlägen würde ein Reisender weniger als 0 zahlen (z. B. fester Rabatt je Person auf ein Kind, das nichts kostet, oder gestapelte Rabatte über 100 %). Ein Reisender zahlt nie weniger als 0; `message` nennt Reisenden und Zu-/Abschlag. Prozent-Rabatte rechnen auf den Betrag, den der Reisende nach Kinder-/Personenermäßigung schuldet, und lösen den Fehler allein nicht aus |
| `ERR_NEGATIVE_PERCENT_BASE` | ein Kinder-/Personenabschlag (fester Betrag) ist größer als der Tagespreis bzw. die Verpflegung, auf die er wirkt; ein Prozent-Rabatt darauf würde zum Zuschlag, eine Freinacht (z. B. „7=6“) würde den Aufenthalt verteuern. `message` nennt Zu-/Abschlag bzw. Freinacht, Reisenden und Nacht |
| `ERR_UNSUPPORTED_OCCUPANCY_PRICEBLOCK` | Grundpreis je Belegung (aus einer fremden Lieferung, z. B. „genau 1 Person“ / „ab 2 Personen“) in einer Form, die TourAPI nicht rechnet — oder ein Säugling reist in einem solchen Zimmer (die liefernde Stelle verkauft es mit Säugling nicht) |
| `ERR_UNSUPPORTED_GUESTCHARGE_OBJECT` | Personenermäßigung (z. B. Kinderrabatt) auf einen Zimmerpreis (Objektpreis): es gibt keinen Preis je Person, auf den sie wirken könnte (das Schreib-Gate lässt das nicht zu, nur Altbestand) |
| `ERR_UNSUPPORTED_COMBIGROUP` | ein Angebot ist in Kombinationsgruppe 0 exklusiv; Gruppe 0 steht im EDF auch für alle Angebote ohne Gruppe, ein EDF-Empfänger rechnete anders (das Schreib-Gate lässt das nicht zu, nur Altbestand) |
| `ERR_COMPATIBLE_WITH_INVALID` | die Liste „kombinierbar nur mit Gruppen“ eines Angebots ist nicht eindeutig: Gruppe doppelt, außerhalb 0..2147483647 oder zusammen mit „nur eins je Gruppe“ (das Schreib-Gate lässt das nicht zu, nur Altbestand) |
| `ERR_CALCMODE_MISSING`, `ERR_CALCMODE_UNSUPPORTED` | Rechenart des Zimmers fehlt bzw. nicht unterstützt |
| `ERR_BASE_BOARD_INVALID`, `ERR_BASE_BOARD_CHARGED` | Basis-Board des Zimmers (Verpflegung im Grundpreis) ungültig bzw. mit eigenem Zuschlag (das Schreib-Gate lässt das nicht zu, nur Altbestand) |
| `ERR_MINCHARGEDPERSONS_MISSING`, `ERR_INVALID_MIN_CHARGED_PERSONS` | Mindestzahl zahlender Personen fehlt bzw. ungültig |
| `ERR_INVALID_ENUM`, `ERR_INVALID_WEEKDAY_MASK` | ungültiger Aufzählungswert bzw. Wochentagsmaske |
| `ERR_MISSING_APPLIANCE_TYPE`, `ERR_MISSING_AMOUNT_OR_PERCENT`, `ERR_MISSING_BOARD_CODE`, `ERR_AMOUNT_PERCENT_CONFLICT`, `ERR_DUPLICATE_APPLIANCE_CODE`, `ERR_EXTRA_TYPE_INVALID` | Zu- oder Abschlag unvollständig oder widersprüchlich |
| `ERR_EXTRACALC_UNSUPPORTED`, `ERR_UNSUPPORTED_APPLIANCE_TYPE`, `ERR_UNSUPPORTED_APPLYTOBOARD`, `ERR_UNSUPPORTED_EXTRA_REF`, `ERR_UNSUPPORTED_GUESTCHARGE_LINK`, `ERR_UNSUPPORTED_GUESTCHARGE_TYPE`, `ERR_UNSUPPORTED_INVERT`, `ERR_UNSUPPORTED_MANDATORY`, `ERR_UNSUPPORTED_MCA_AGEWINDOW`, `ERR_UNSUPPORTED_MCA_RANGE`, `ERR_UNSUPPORTED_RESTRICTION`, `ERR_UNSUPPORTED_VARMCP_PERSTAY` | Vertragsregel, die TourAPI nicht rechnet |

### 7.6 EDF-Lieferung (`/v1/export/edf/*`, Abschnitt 10)

| Code | Status | Bedeutung | Aufrufer |
|---|---|---|---|
| `ERR_EXPORT_BAD_CURSOR` | 400 | `epoch`/`since` fehlt oder unlesbar, `until` kein gültiger Kettenschlüssel, `max_bytes` keine Zahl ≥ 65536, Parameter doppelt, Seite mitten in einem Stand ohne `until`, Folgeseite des `full` ohne `epoch`/`since`/`until` | Anfrage korrigieren bzw. Kette neu beginnen |
| `ERR_EXPORT_NOT_ALLOWED` | 403 | Key ohne Export-Recht | Veranstalter fragen, nicht wiederholen |
| `ERR_EXPORT_EPOCH` | 409 | `epoch` passt nicht (Feed neu aufgebaut) oder der Stand (`since`, `until`, `seq` der Quittung) liegt **über** dem aktuellen Lieferstand (größer als der letzte gelieferte Stand) | `full` abrufen |
| `ERR_EXPORT_CURSOR_EXPIRED` | 410 | Stand älter als die Aufbewahrung | `full` abrufen |
| `ERR_EXPORT_NOT_READY` | 503 | Lieferung für diesen Key noch nicht gebaut, vorübergehend nicht aktuell (Lieferung hinkt mehr als 2 Minuten hinterher) bzw. auf dem Knoten nicht eingerichtet | Wiederholen nach `Retry-After`; dauerhaft: Melden |

Außerdem an `/v1/export/edf/*`: `401`/`403` aus 7.1 (`ERR_TENANT_SUSPENDED`,
`ERR_KEY_GROUP_INACTIVE`), `422 ERR_UNKNOWN_FIELD` (unbekannter Parameter bzw. Feld der Quittung)
und `429 ERR_RATE_LIMITED` für den Takt (10.3; `Retry-After` bis 3600 s beim `full` – nicht
blind abwarten, sondern den nächsten Takt planen).

### 7.7 Hotelinhalte (`/v1/content/*`, Abschnitt 12)

| Code | Status | Bedeutung | Aufrufer |
|---|---|---|---|
| `ERR_CONTENT_NOT_ALLOWED` | 403 | Inhalte für den Veranstalter nicht freigeschaltet (auch: Testumgebung auf dieser Installation nicht beliefert) oder Key ohne Inhalts-Recht | Veranstalter fragen, nicht wiederholen |
| `ERR_LANGUAGE_NOT_OFFERED` | 422 | `lang` nennt eine Sprache, die keine Inhaltssprache des Veranstalters ist (bzw. am Katalog keine Beschriftungssprache); die angebotenen stehen in `warnings` | Anfrage korrigieren |
| `ERR_CONTENT_CURSOR_EXPIRED` | 410 | `since` liegt vor dem Aufbewahrungshorizont des Feeds (30 Tage) | Verzeichnis neu holen, mit dessen `feedToken` weiter |
| `ERR_CONTENT_NOT_READY` | 503 | Inhalte bzw. Bild-Adressen auf diesem Knoten nicht eingerichtet | Wiederholen nach `Retry-After`; dauerhaft: Melden |

Außerdem an `/v1/content/*`: `401`/`403` aus 7.1, `404 ERR_HOTEL_NOT_FOUND` (Hotel nicht im
Verzeichnis des Keys), `400 ERR_BAD_REQUEST`, `422 ERR_BAD_PAGE_SIZE`, `ERR_BAD_CURSOR`
und `429 ERR_RATE_LIMITED` (Rate je Key, Abschnitt 8). Unbekannte
Query-Parameter werden ignoriert und in `warnings` genannt.

---

## 8. Fairness: Rate-Limit und Such-Gate

| Riegel | Grenze (Voreinstellung) | Antwort |
|---|---|---|
| Anfragen je API-Key | 200 je Sekunde, kurzzeitig bis 400 (Token-Bucket) | `429 ERR_RATE_LIMITED` + `Retry-After` |
| gleichzeitige Suchen je Veranstalter | 8 (Rechenzeit: ein Viertel der Kerne, mind. 1) | `429 ERR_SEARCH_BUSY` + `Retry-After: 1` |
| Rechenzeit einer Suche | 10 s | `503 ERR_SEARCH_TIMEOUT` |
| Rechenzeit von `/v1/price` ohne `room` und `/v1/prices` | 2 s | `503 ERR_PRICE_TIMEOUT` |

- Die Grenzen gelten **je Server-Knoten**. Sie sind ein Schutz gegen Schleifen und
  Lastspitzen, keine abrechenbare Quote. Der Betreiber kann sie ändern.
- `Retry-After` ist in ganzen Sekunden (mindestens 1). Vorher nicht wiederholen; danach mit
  derselben Anfrage (bei `/v1/book` mit demselben `idemKey`).
- Wer vor Ablauf von `Retry-After` erneut schickt und wieder abgewiesen wird, bekommt die
  `429` erst verzögert (bis zum Ende der angesagten Wartezeit, höchstens 1 s). Die Wartezeit
  gilt je API-Key: arbeiten mehrere Prozesse mit demselben Key parallel, trifft die
  Verzögerung jeden, der nach einer `429` weiterschickt, egal mit wie vielen Verbindungen.
  Die Verbindung bleibt offen.
- Abgewiesene Keys (`401`, `403 ERR_TENANT_SUSPENDED`, `403 ERR_KEY_GROUP_INACTIVE`) zählen
  je Absender-IP: nach 20 Abweisungen in kurzer Folge kommen nur noch 5 je Sekunde sofort,
  weitere erst verzögert (höchstens 1 s), egal mit wie vielen Verbindungen. Status und
  Antwort bleiben gleich. Anfragen mit gültigem Key sind davon nie betroffen.
- Unbekannte Pfade (`404`) und falsche Methoden (`405 ERR_METHOD_NOT_ALLOWED`) zählen in
  dieselbe Grenze je Absender-IP wie abgewiesene Keys.
- `/v1/health` hat eine eigene Grenze je Absender-IP: 50 Aufrufe sofort, danach 10 je
  Sekunde sofort, weitere erst verzögert (höchstens 1 s). Die Antwort ist immer der
  aktuelle Zustand, nie eine Abweisung.
- Abgewiesene Anfragen zählen nicht als Nutzung.
- Gekappte Suchseiten sind kein Fehler, siehe 4.3.

So sieht die Drosselung aus (die Beispiele laufen gegen eine Instanz mit 1 Anfrage je 10 s):

```http
### drossel-erste-anfrage
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "EZ",
  "board": "RO",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}]}
}
```

```http
### drossel-zweite-anfrage
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{apiKey}}

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "EZ",
  "board": "RO",
  "checkIn": "{{D0}}",
  "checkOut": "{{D2}}",
  "occupancy": {"travellers": [{"age": 40}]}
}
```

```json
{"errorCode": "ERR_RATE_LIMITED", "message": "Anfrage-Rate dieses API-Keys ueberschritten"}
```

Kopfzeile dazu: `Retry-After: 10`.

---

## 9. Idempotenz und Nebenläufigkeit

**Buchung:**

- `idemKey` ist Pflicht und gehört dem Aufrufer. Er gilt je Veranstalter **und** Key-Kreis
  (Kundengruppe bzw. Basisvertrag): Zwei Kundengruppen können denselben Key benutzen, ohne
  sich zu stören.
- Gleicher `idemKey`, gleiche Buchungsdaten (Hotel, Zimmer, Aufenthalt, Menge) = dieselbe
  Buchung: `200`, `alreadyBooked: true`, gleiche Referenz. `reference`, `metadata`,
  `leadPaxName` und `priceCheck` einer Wiederholung werden nicht verglichen und nicht
  übernommen – es gilt die erste Buchung.
- Gleicher `idemKey`, andere Buchungsdaten: `409 ERR_IDEMPOTENCY_MISMATCH`.
- Stornierter `idemKey`: `409 ERR_IDEM_KEY_RELEASED`.
- Die Idempotenzprüfung kommt **vor** Stichtag, Grenzen und `priceCheck`: Besteht die
  Buchung, bestätigt die Wiederholung sie auch nach Mitternacht (Anreise inzwischen
  Vergangenheit, `priceCheck.now` nicht mehr der Stichtag) oder nach einer Preisänderung.
  Nur ein neuer `idemKey` durchläuft diese Prüfungen. Form-Fehler der Anfrage (unbekannte
  Felder, fehlende Pflichtfelder, zu lange Texte) werden weiterhin vorher abgelehnt.
- Läuft die erste Buchung eines `idemKey` noch, wartet eine Wiederholung auf deren Ausgang
  und bestätigt sie dann (`200`, `alreadyBooked: true`); scheitert die erste, läuft die
  Wiederholung als neue Buchung. Dauert das Warten zu lange: `503 ERR_BOOKING_BUSY` mit
  `Retry-After` – mit demselben `idemKey` wiederholen.
- Zwei gleichzeitige Buchungen desselben Zimmers können das Kontingent nie überbuchen: Jede
  Nacht wird atomar gezählt, und eine Buchung nimmt alle Nächte oder keine.
- Kommen Buchung oder Storno wegen gleichzeitiger Vorgänge am selben Hotel nicht durch:
  `503 ERR_BOOKING_BUSY` mit `Retry-After` (Abschnitt 7.4). Es ist nichts gebucht bzw.
  storniert; nach der Wartezeit mit demselben `idemKey` wiederholen.
- Wird ein Hotel gelöscht, während eine Buchung dafür läuft, gilt die Reihenfolge: war die
  Buchung zuerst, bleibt sie bestehen; sonst `404 ERR_HOTEL_NOT_FOUND`, nichts gebucht.
- Nach Zeitüberschreitung oder `5xx` ist unklar, ob gebucht wurde: **mit demselben
  `idemKey` wiederholen**, nie mit einem neuen.

**Storno:**

- Adressiert über den `idemKey` der Buchung. Doppelstorno ist ein Erfolg
  (`alreadyReleased: true`). Gleichzeitige Stornos derselben Buchung geben das Kontingent
  genau einmal zurück.

---

## 10. EDF-Lieferung (Cache-Export)

Zweck: Ein Abnehmer hält die Preise und Verfügbarkeiten aller Hotels seines Keys als
EDF-Dateien im eigenen Cache und fragt vor der Buchung live nach (`/v1/price`, dann
`/v1/book`). Verbindlich ist immer `/v1/book`; der Cache ist ein Angebot, kein Bestand.
Den vollständigen Liefervertrag (kanonische Form des Manifests, Grenzen) gibt es auf Anfrage
beim Betreiber.

| Methode/Pfad | Antwort |
|---|---|
| `GET /v1/export/edf/full[?max_bytes=N]` | `200` Zip mit dem Vollstand (bei großen Beständen die erste Seite) |
| `GET /v1/export/edf/full?epoch=E&since=S&until=K[&max_bytes=N]` | `200` Zip mit der nächsten Seite des Vollstands |
| `GET /v1/export/edf/changes?epoch=E&since=S[&until=K][&max_bytes=N]` | `200` Zip mit allen Änderungen nach Stand `S`; `204`, wenn nichts neu ist |
| `POST /v1/export/edf/ack` `{"epoch": E, "seq": T}` | `204`; meldet „verarbeitet“ (nur für die Überwachung beim Veranstalter) |

- **Zuschnitt nur über den Key:** Der Key bestimmt, was geliefert wird – dieselben Hotels
  und Preise wie `/v1/search` und `/v1/price` für diesen Key (Basisvertrag, Kundengruppe mit
  ihrem Preis, Kontingent-Gruppe nur Hotels mit Zuteilung). Es gibt keinen Parameter, der
  Veranstalter oder Gruppe wählt; ein unbekannter Parameter ist `422 ERR_UNKNOWN_FIELD`.
- **Export-Recht:** je Key freizuschalten (Veranstalter-Admin, in der Konsole an der
  Key-Zeile „Export erteilen“); ohne Recht `403 ERR_EXPORT_NOT_ALLOWED`.
- **Nur Veröffentlichtes:** Entwürfe ändern die Lieferung nie. Änderungen (Veröffentlichung,
  Stop-Sale, Buchung, Aktion, Zuteilung) stehen spätestens nach etwa einer Minute im Feed.

### 10.1 Paket

Ein Zip mit `manifest.json` als erstem Eintrag, dann je Hotel eine Preisdatei
(`hotels/hotelonly/EDF----<tenant>-<hotel>.xml`, EDF 5.1.6) und eine Allotment-Datei
(`hotels/hotelonly/allotment/EDF----<tenant>-<hotel>.xml`, HotelAllotmentRoot 1.012).
Identität aus dem Manifest bzw. `BasicData`, nie aus dem Dateinamen (Codes dürfen `-`
enthalten). Jede Datei hat ihre sha256-Prüfsumme und Länge im Manifest; ein Paket wird ganz
oder gar nicht angewendet.

Ein Eintrag in `objects` und einer in `removed`:

```json
{"path": "hotels/hotelonly/EDF----TEST-TENANT-A-TEST-HOTEL-BASE.xml", "kind": "hotel", "hotel": "TEST-HOTEL-BASE", "seq": 3, "sha256": "9f2c…", "bytes": 2210, "source_rev": 4}
```

```json
{"kind": "hotel", "hotel": "TEST-HOTEL-ALTAKTION", "seq": 5, "reason": "withdrawn:variant_error"}
```

- **Tombstones:** Zurückgezogene Hotels stehen unter `removed` mit Grund
  (`withdrawn:deleted`, `withdrawn:variant_error`, `withdrawn:not_exportable`,
  `withdrawn:no_currency`, `withdrawn:not_in_universe`). Der Abnehmer löscht sie aus seinem
  Cache. Ein `full` hat immer `removed: []` – er ersetzt den Cache ganz.
- **Pattern der Allotment-Datei:** zwei Zeichen je Nacht (`PatternLength="2"`, wie die
  MTS-Lieferungen): `00`–`99` = freie Einheiten für diesen Key, `**` = mehr als 99 frei oder
  Freiverkauf, `SS` = Stop-Sale, `RR` = auf Anfrage; eine Nacht ohne Kapazität ist `00`.
  Fehlt eine Allotment-Datei, gilt das Hotel als nicht verfügbar. Aufbau je Zimmer:
  `<Allotments RoomCode="DZ"><Allotment Start="JJJJ-MM-TT" End="JJJJ-MM-TT" Pattern="…"/>`; die
  ersten zwei Zeichen von `Pattern` gehören zur Nacht `Start`, jedes weitere Paar zur Folgenacht.
- **Pattern und `minFree`:** `SS`, `RR` und `00` ergeben ausdrücklich `minFree` `0`
  (nicht buchbar); nur `**` senkt `minFree` nicht, und `-1` heißt: jede Nacht `**`. Das
  Minimum über die Nächte eines Aufenthalts ist `availability.minFree` von `/v1/price` für
  denselben Key (Tabelle in Abschnitt 3.3) – beide kommen aus derselben Quelle je Nacht.

### 10.2 Vollstand, Änderungen, Quittung

Beim Start und nach `409`/`410` holt der Abnehmer den Vollstand. Aus dem Manifest merkt er
`epoch` und `to_seq`. Passt der Vollstand nicht in ein Paket (mehr als 10.000 Dateien, mehr als
1 GiB entpackt oder mehr als `max_bytes`), kommt er in Seiten, siehe unten „Seiten“:

```http
### export-voll
GET {{baseUrl}}/v1/export/edf/full
X-Api-Key: {{apiKey}}
```

```json
{
  "format": "tourapi-edf-feed/1",
  "tenant": "TEST-TENANT-A",
  "scope": "",
  "epoch": "{{exportEpoch}}",
  "type": "full",
  "from_seq": 0,
  "to_seq": "{{*}}",
  "more": false,
  "generated_at": "{{*}}",
  "rules": {"edf": "5.1.6", "allotment": "1.012", "spec": ""},
  "objects": "{{*}}",
  "removed": []
}
```

Kopfzeilen jeder `200`/`204`-Antwort: `X-Export-Epoch`, `X-Export-Seq` (letzter
vollständiger Stand), `X-Export-From` (Stand, ab dem geliefert wurde), `X-Export-More`;
bei einem `changes`-Paket und bei einer `full`-Seite mit `more: true` zusätzlich
`X-Export-Until` (Kettenschlüssel, siehe unten). Endet eine Seite mitten in einem Stand (`to_after`
im Manifest), nennt `X-Export-Seq` den Stand davor – auf den ersten Seiten eines `full` also
`0`. Maßgeblich für `since`, Anwenden und Quittung sind `to_seq`/`to_after` aus dem Manifest. Ein Key einer
Kundengruppe bekommt den Stand seiner Gruppe (`scope`), mit deren Preisen:

```http
### export-partner
GET {{baseUrl}}/v1/export/edf/full
X-Api-Key: {{rabattKey}}
```

Danach fragt er im Takt nach Änderungen. `since` ist das `to_seq` des letzten angewendeten
Pakets; steht es schon auf dem aktuellen Stand, kommt `204` ohne Inhalt:

```http
### export-nichts-neu
GET {{baseUrl}}/v1/export/edf/changes?epoch={{exportEpoch}}&since={{exportSeq}}
X-Api-Key: {{apiKey}}
```

**Seiten (`max_bytes`):** `full` und `changes` sind Seitenketten. Eine Seite hat höchstens
10.000 Dateien und 1 GiB entpackt; mit `max_bytes=N` schneidet der Server zusätzlich Seiten von
höchstens `N` Bytes (mindestens eine Datei je Seite); `N` ist mindestens 65536 (64 KiB), kleiner
ist `400 ERR_EXPORT_BAD_CURSOR`. Die erste Seite (`full` ohne Parameter bzw. `changes` ohne
`until`) legt das Ziel der Kette fest (den aktuellen Stand) und gibt es als
**Kettenschlüssel** in `X-Export-Until` aus (beim `full` nur, wenn `more: true`): eine
undurchsichtige, versiegelte Zeichenkette (`u1.…` bei `changes`, `f1.…` bei `full`), gebunden
an Key und `epoch`. Folgeseiten fragt der Abnehmer mit `since=<to_seq>` bzw.
`since=<to_seq>:<to_after>` (wenn das Manifest `to_after` trägt) **und**
`until=<X-Export-Until>` (Wert der Vorseite, URL-kodiert, unverändert), beim `full` zusätzlich mit
`epoch=<epoch>` (Wert der ersten Seite), bis `more` `false` ist. Jede Seite gibt den Schlüssel frisch
aus; er gilt 15 Minuten nach der letzten Seite, beim `full` nur für genau den Stand der
nächsten Seite. Ein selbst gewähltes `until` (Zahl), ein fremder oder abgelaufener Schlüssel ist
`400 ERR_EXPORT_BAD_CURSOR` – dann die Kette neu beginnen. Eine `full`-Seite trägt nie
`removed`; die erste beginnt bei `from_seq: 0`, jede weitere beim `to` der Vorseite. Erst am
Kettenende ist der Stand konsistent: angewendet (getauscht) wird dort, auch wenn der
Veranstalter zwischendurch weiter ändert – die Kette endet genau auf ihrem Ziel.

**Abbruch und Wiederholen:** Scheitert ein Paket, nachdem `200` schon gesendet ist (etwa weil
eine Datei beim Veranstalter inzwischen fehlt), bricht der Server die Verbindung ab. Der
Abnehmer sieht dann einen Transportfehler (`unexpected EOF`, Verbindung zurückgesetzt), nie
ein sauber beendetes, verkürztes Paket. Trotzdem **muss** er jedes Paket vor dem Anwenden
gegen sein Manifest prüfen: jede Datei aus `objects` vorhanden, Länge (`bytes`) und `sha256`
stimmen, keine Datei zu viel. Ein Streaming-Leser (z. B. Javas `ZipInputStream`) erkennt ein
Zip, das an einer Eintragsgrenze endet, nicht von selbst als unvollständig. Eine Folgeseite,
die kurz scheitert (Transportfehler, abgebrochenes oder unlesbares Paket, `5xx`), fragt er mit
denselben `epoch`/`since`/`until` noch einmal an (der Schlüssel gilt 15 Minuten, `503` nach
`Retry-After`), statt die Kette neu zu beginnen – ein neuer Anfang kostet den Takt (`full`:
eine Stunde). Nur nach `400` beginnt er die Kette neu, nach `409`/`410` holt er `full`. Bricht
eine Kette ab, bleibt der alte Stand.

Nach dem Anwenden quittiert der Abnehmer den Stand. Die Quittung ist freiwillig und ändert
nichts an der Lieferung; der Veranstalter sieht daran, welchen Stand der Abnehmer verarbeitet
hat:

```http
### export-quittung
POST {{baseUrl}}/v1/export/edf/ack
Content-Type: application/json
X-Api-Key: {{apiKey}}

{"epoch": "{{exportEpoch}}", "seq": {{exportSeq}}}
```

### 10.3 Fehler und Takt

| Status | Code | Wann | Abnehmer tut |
|---|---|---|---|
| 400 | `ERR_EXPORT_BAD_CURSOR` | `epoch`/`since` fehlt oder unlesbar, `until` kein gültiger Kettenschlüssel (fremd, abgelaufen, Zahl, beim `full` für einen anderen Stand), `max_bytes` keine Zahl ≥ 65536, Seite mitten in einem Stand ohne `until`, Stand über dem Kettenziel, Folgeseite des `full` ohne `epoch`/`since`/`until` | Anfrage korrigieren bzw. Kette neu beginnen |
| 403 | `ERR_EXPORT_NOT_ALLOWED` | Key ohne Export-Recht | Veranstalter fragen |
| 403 | `ERR_KEY_GROUP_INACTIVE` | Kundengruppe des Keys deaktiviert (nie still der Basisvertrag) | Veranstalter fragen |
| 409 | `ERR_EXPORT_EPOCH` | `epoch` passt nicht (Feed neu aufgebaut) oder `since`/`until` bzw. `seq` der Quittung **über** dem aktuellen Lieferstand (größer als der letzte gelieferte Stand; ein älteres `since` ist erlaubt und liefert alles danach) | `full` |
| 410 | `ERR_EXPORT_CURSOR_EXPIRED` | Stand älter als die Aufbewahrung (14 Tage) | `full` |
| 429 | `ERR_RATE_LIMITED` | Takt überschritten, siehe unten | nach `Retry-After` |
| 503 | `ERR_EXPORT_NOT_READY` | Lieferung für diesen Key noch nie gebaut (neuer Key, neue Gruppe), oder die Lieferung ist vorübergehend nicht aktuell (sie hinkt mehr als 2 Minuten hinter den Daten her) | nach `Retry-After`, alter Stand bleibt gültig |

```http
### export-stand-unlesbar
GET {{baseUrl}}/v1/export/edf/changes?epoch={{exportEpoch}}&since=gestern
X-Api-Key: {{apiKey}}
```

```http
### export-fremder-stand
GET {{baseUrl}}/v1/export/edf/changes?epoch=01J00000000000000000000000&since=1
X-Api-Key: {{partnerKey}}
```

```json
{"errorCode": "ERR_EXPORT_EPOCH", "message": "epoch veraltet (Feed neu aufgebaut) oder Stand neuer als der aktuelle Lieferstand — full abrufen"}
```

```http
### export-ohne-recht
GET {{baseUrl}}/v1/export/edf/full
X-Api-Key: {{poolKey}}
```

```json
{"errorCode": "ERR_EXPORT_NOT_ALLOWED", "message": "dieser API-Key hat kein Export-Recht (EDF-Lieferung) — der Veranstalter-Admin schaltet es frei"}
```

**Takt je Key:** ein neuer `full` höchstens einmal je Stunde, eine neue `changes`-Kette
höchstens einmal je Minute. Folgeseiten einer Kette (`full` oder `changes`, mit dem
Kettenschlüssel) und Quittungen haben einen eigenen, großzügigen Takt (5 je Sekunde, 50 auf
einmal). Welcher Abruf eines Kettenanfangs den Takt verbraucht:

| Antwort | verbraucht den Takt |
|---|---|
| `400 ERR_EXPORT_BAD_CURSOR`, `422 ERR_UNKNOWN_FIELD` (ungültige Anfrage, vor jeder Arbeit geprüft) | nein |
| `503 ERR_EXPORT_NOT_READY` (Lieferung noch nie gebaut oder vorübergehend nicht aktuell) | nein – wer `Retry-After` befolgt, bekommt kein 429 |
| `200` (auch ein abgebrochener Download), `204` („nichts neu“), `409 ERR_EXPORT_EPOCH` | ja |

Die Takt-Prüfung kommt **vor** der Prüfung von `epoch` und Stand. Ein Abnehmer mit veralteter
`epoch` sieht innerhalb des Takts zuerst `429`, erst nach dessen `Retry-After` das `409`.
Beispielablauf `changes` (gemessen, Takt 1 min):

| Zeit | Anfrage | Antwort | Abnehmer tut |
|---|---|---|---|
| 0 s | `changes?epoch=E&since=S&foo=1` (Tippfehler) | `422 ERR_UNKNOWN_FIELD` | korrigieren; Takt nicht verbraucht |
| 0 s | `changes?epoch=E&since=S` | `204` | Takt verbraucht; nächste Kette frühestens in 60 s |
| 0 s | `changes?epoch=E&since=S`, der Feed wurde inzwischen neu aufgebaut (`E` veraltet) | `429 ERR_RATE_LIMITED`, `Retry-After: 60` | `Retry-After` abwarten |
| 60 s | derselbe Abruf | `409 ERR_EXPORT_EPOCH` | `full` holen (eigener Takt, 1 je Stunde) |

Darüber hinaus gilt das Rate-Limit des Keys (Abschnitt 8). Ein zweites `full` in derselben Stunde:

```http
### export-zu-oft
GET {{baseUrl}}/v1/export/edf/full
X-Api-Key: {{apiKey}}
```

```json
{"errorCode": "ERR_RATE_LIMITED", "message": "full hoechstens einmal je 1 h je API-Key (danach changes)"}
```

### 10.4 Anwenden und aus dem Cache rechnen (Referenz-Empfänger)

TourAPI hat einen Referenz-Empfänger, der genau diese Regeln umsetzt und bei jedem Build
gegen die echte API geprüft wird: das Werkzeug `edf-empfaenger` (auf Anfrage beim Betreiber)
(`pull`, `apply`, `stand`, `rechne`, `reset`; der Key kommt nur aus der Umgebung
`EDF_EMPFAENGER_KEY`). Was er aus der Lieferung rechnet, ist für jede Anfrage der Prüfmenge
centgenau `/v1/price` desselben Keys – Gesamtpreis, Preis je Reisendem, gewähltes Zimmer,
`available` und `minFree`, bei Ablehnung derselbe Code; das gilt auch für ungültige und
mehrfach ungültige Anfragen (welche Grenze zuerst greift). Referenz-Empfänger und
`make e2e-export` liegen im Quellbaum von TourAPI und gehören nicht zur Lieferung: Sie als
Kunde bekommen Key und Zugangspaket und setzen die Regeln dieses Abschnitts in Ihrem System
um – der Referenz-Empfänger ist der Beleg, dass sie aufgehen.

```
EDF_EMPFAENGER_KEY=… edf-empfaenger pull   --dir <bestand> --api <baseUrl> [--max-bytes N] [--ohne-ack]
edf-empfaenger stand  --dir <bestand>
edf-empfaenger rechne --dir <bestand> --anfragen anfragen.json [--heute JJJJ-MM-TT]
```

`anfragen.json` ist eine Liste; je Anfrage `hotel`, `room` (optional, fehlt = günstigstes
verfügbares Zimmer), `board`, `checkIn`, `checkOut` und die Alter der Reisenden als `ages`
(nicht `occupancy.travellers` wie bei `/v1/price`), z. B.
`[{"hotel": "TEST-HOTEL-BASE", "room": "DZ", "board": "RO", "checkIn": "2026-10-27",
"checkOut": "2026-10-29", "ages": [40, 38]}]`. Die Antwort nennt je Anfrage `anfrage` (Index),
`room`, `currency`, `totalCents`, `perTravellerCents`, `availability` (`available`, `minFree`)
bzw. `errorCode`.

- **Ganz oder gar nicht:** Paket prüfen (sha256 und Länge je Datei), den neuen Stand
  vollständig neben dem alten aufbauen, dann atomar umschalten; erst danach gelten `epoch`
  und `to_seq` als gespeichert. Eine Kette mit Seiten (`full` wie `changes`) wird erst am
  Kettenende (`more: false`) umgeschaltet; bricht sie ab, bleibt der alte Stand. Eine
  `full`-Kette beginnt immer mit `from_seq: 0`; eine `full`-Folgeseite schließt nur an die
  offene Kette an, nie an einen gespeicherten Stand – auch wenn ihr `from_seq` gleich dem
  gespeicherten `to_seq` ist (ein `full` trägt keine `removed`, gelöschte Hotels blieben
  sonst stehen).
- **Nie rückwärts:** Maßgeblich ist das Ziel der Kette, also `to_seq` der letzten Seite
  (`more: false`), nicht das der ersten. Die erste Seite einer `full`-Kette trägt oft ein
  kleineres `to_seq` als der gespeicherte Stand (etwa nach `410`: die ältesten Dateien kommen
  zuerst) und ist trotzdem kein Rückschritt. Eine `full`-Kette derselben `epoch`, deren Ziel
  kleiner ist als der gespeicherte Stand, oder ein `full` einer älteren `epoch` (die `epoch`
  ist eine ULID, zeitlich sortiert) wird abgelehnt – ein verspätet zugestelltes altes Paket
  setzt den Cache nicht zurück. `changes` müssen lückenlos an den Stand anschließen
  (`from_seq` = gespeichertes `to_seq`). Ein `full` eines anderen Mandanten oder Scopes
  (vertauschter Key) wird ebenfalls abgelehnt, als Scope-Fehler vor der Rückwärtsprüfung.
- **Abgelehntes `full`:** Der alte Stand bleibt, der Cache rechnet weiter darauf, holt aber
  nichts Neues mehr. Das ist gewollt laut – nie still zurückspringen. Liegt kein vertauschter
  Key vor und steht die Lieferung wirklich auf einer älteren `epoch` (etwa nach einem Restore
  auf einem Rechner, dessen Uhr nachgeht: die neue `epoch`-ULID ist dann kleiner) oder einem
  kleineren `to_seq`, setzen Sie den Stand bewusst zurück (Referenz-Empfänger:
  `edf-empfaenger reset`) und holen ein neues `full`. Im Zweifel vorher beim Betreiber
  nachfragen.
- **Lesen während des Abrufs:** Ein Abruf darf dauern (große Kette, `429`-Warten). Preise aus
  dem Cache laufen währenddessen weiter auf dem alten Stand und nach dem Umschalten auf dem
  neuen; den alten Stand erst löschen, wenn niemand mehr in ihm liest. Der Referenz-Empfänger
  erlaubt einen Schreiber (`pull`, `apply`, `reset`) und beliebig viele Leser (`stand`,
  `rechne`) je Bestand. `429` wartet er je Antwort höchstens `--max-warten`, je Abruf
  zusammen höchstens `--max-warten-gesamt`, dann bricht er ab (Stand unverändert).
- **Wiederholen statt neu beginnen:** Eine Folgeseite mit kurzem Fehler holt der
  Referenz-Empfänger mit denselben Parametern bis zu dreimal (Regel in 10.2); den
  Kettenanfang wiederholt er nicht.
- **Preis aus dem Cache:** EDF 5.1.6 mit den deklarierten Regeln, dazu die Regeln der API:
  dieselben Anfrage-Grenzen (Anhang, gleiche Fehlercodes; Stichtag = heute in
  Europe/Berlin); mit Zimmer das günstigste Angebot dieses Zimmercodes, ohne Zimmer das
  günstigste **verfügbare** Zimmer, ist keines verfügbar das günstigste bepreisbare (wie
  Abschnitt 3.1); ein Zimmer, das die Verpflegung nicht anbietet, wird nicht bepreist.
- **Deklarierte Regeln:** Jede Hotel-Datei nennt in `SellingData` die Kinderreihenfolge
  (`ChildrenAgeOrder`, `Descending` = ältestes Kind zuerst: wer ist „1. Kind“, welches Kind
  besetzt eine freie Vollzahler-Stelle), `FullPayerDoesNotAffectBoardCharges/PersonType=C` (ein
  Kind auf der Vollzahler-Stelle zahlt den vollen Grundpreis, die Verpflegung zum Kindersatz)
  und `Rounding Mode="Commercial" DecimalPlace="2" Scope="Person"` (eine Rundung je Reisendem,
  Abschnitt 1.4). Wo das Schema Spielraum lässt, rechnet TourAPI so – der Cache muss es
  ebenso tun, sonst weicht er ab: `MinCount`/`MaxCount` zählen Kinder je Personenart ab dem
  ersten Kind, Erwachsene ab dem ersten nach den Vollzahlern; ein Säugling zählt in einer
  Personenbedingung („ab 3 Personen“) nur, wenn das Zimmer ihn zur Belegung zählt
  (`Infants/@ApplyToOccupancy` `Min`, `Max` oder `Yes`); ein fehlendes `ExtraMaxApply` heißt 1; mehrere
  Datumsfenster mit `Operator="AND"` gelten im Schnitt; Prozent-Zu- und -Abschläge neben einer
  Freinacht rechnen nur auf die nicht erlassenen Nächte; ein fester Betrag je Person (`PS`,
  `PN`) wirkt auch beim Objektpreis je Person; Zimmerposten (Objektpreis, unbesetzte
  Vollzahler-Stelle) gehören zum ältesten Reisenden und werden mit ihm gerundet.
- **Verfügbarkeit aus dem Pattern:** Minimum über die Nächte `[checkIn, checkOut)` des
  Zimmers wie in 10.1; eine Nacht, die die Datei nicht nennt, ist zu (nie „offen, weil
  nichts geliefert“). Das Feld `configured` von `/v1/price` hat im Cache keine Entsprechung:
  ein Zimmer ohne Kontingent steht dort als `00`.
- **Vor der Buchung live:** Der Cache kann einen Stand hinter der API liegen; eine im Cache
  offene, inzwischen gesperrte oder ausgebuchte Nacht lehnt `/v1/book` ab
  (`ERR_STOP_SALE`, `ERR_SOLD_OUT`).

---

## 11. Sandbox: Test-Keys und Testbuchungen

Ein **Test-Key** (`tk_test_…`) ist der Zwilling eines Live-Keys: derselbe Veranstalter, dieselbe
Kundengruppe, dieselben Rechte (Export-Recht, später Suchprofil) — zur Laufzeit vom Live-Key
übernommen. Er liest **dieselben Daten** wie der Live-Key (Hotels, Preise, Ziele,
Verfügbarkeit, EDF-Lieferung), aber **Buchungen landen in der Sandbox**: nie beim
Veranstalter, kein Kontingent, kein Export, keine Buchungsliste der Konsole. So lassen sich
Buchung, Buchungsinfo, Storno, Idempotenz und Wiederhol-Logik mit echten Angeboten testen.

- Den Test-Key stellt der Veranstalter in der Konsole zum Live-Key aus (Knopf „Test-Key“ an der
  Key-Zeile) und gibt ihn mit einem eigenen Zugangspaket weiter – oder Sie stellen ihn selbst
  im Abnehmer-Portal aus (7, 30 oder 90 Tage), wenn der Veranstalter das für Ihren Zugang
  erlaubt. Gültig 30 Tage (höchstens 90), höchstens 5 aktive Test-Keys je Zugang.
- Ist der Live-Key gesperrt oder der Test-Key abgelaufen: `401 ERR_UNAUTHORIZED`. Kundengruppe
  deaktiviert: `403 ERR_KEY_GROUP_INACTIVE` wie live. Nach einer Rotation des Live-Keys
  arbeitet der Test-Key mit dem neuen Live-Key weiter; er endet nie später als sein Live-Key.
- Maßgeblich für den Modus ist die Zuordnung beim Veranstalter, nicht der Präfix des Keys.

### 11.1 Kennzeichnung und Schutz vor Verwechslung

Jede Antwort auf einen angenommenen Key trägt den Kopf **`X-TourAPI-Mode: test`** bzw.
**`live`** (Kopfnamen wie in HTTP üblich ohne Groß-/Kleinschreibung vergleichen: der Server
schreibt `X-Tourapi-Mode`). Buchung, Buchungsinfo und Storno eines Test-Keys tragen zusätzlich das Feld
**`"sandbox": true`**, die Referenz beginnt mit **`SB-`** (live `TA-`). Lesen mit dem Test-Key
liefert dieselbe Antwort wie mit dem Live-Key:

```http
### sandbox-preis
POST {{baseUrl}}/v1/price
Content-Type: application/json
X-Api-Key: {{testKey}}
X-TourAPI-Require-Mode: test

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "board": "RO",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "occupancy": {"travellers": [{"age": 40}, {"age": 38}]}
}
```

Test-Umgebungen (CI, Agenten, Entwicklerrechner) senden den Kopf **`X-TourAPI-Require-Mode:
test`**. Kommt dann ein Live-Key, lehnt die API **jede** Anfrage ab und führt nichts aus – ein
versehentlich eingesetzter Live-Key kann so nicht buchen (die Prüfung liegt beim Server, nicht
im Client). `X-TourAPI-Require-Mode: live` verlangt umgekehrt einen Live-Key; ein anderer Wert
ist `400 ERR_BAD_REQUEST`.

```http
### sandbox-live-key-in-testumgebung
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{apiKey}}
X-TourAPI-Require-Mode: test

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "quantity": 1,
  "idemKey": "BEISPIEL-0003"
}
```

```json
{"errorCode": "ERR_MODE_MISMATCH", "message": "X-TourAPI-Require-Mode: test verlangt, der API-Key ist ein live-Key — nichts ausgefuehrt"}
```

### 11.2 Testbuchung, Buchungsinfo, Storno

`/v1/book` mit Test-Key durchläuft **dieselben Prüfungen in derselben Reihenfolge** wie live
(Felder, Wiederholung, Hotel, Stichtag und Grenzen, Verkaufsregeln, `priceCheck`,
Verfügbarkeit) und liefert dieselben Fehlercodes. Die Verfügbarkeit wird geprüft, aber nicht
verbraucht:

```http
### sandbox-buchen
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{testKey}}
X-TourAPI-Require-Mode: test

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "quantity": 1,
  "idemKey": "BEISPIEL-0001",
  "reference": "TEST-4711",
  "leadPaxName": "Test Person",
  "priceCheck": {
    "board": "RO",
    "currency": "EUR",
    "occupancy": {"travellers": [{"age": 40}, {"age": 38}]},
    "expectedCents": 18000,
    "tolerancePercent": 0
  }
}
```

```json
{"booked": true, "alreadyBooked": false, "reference": "{{sandboxRef}}", "correlationId": "{{*}}", "sandbox": true}
```

```http
### sandbox-buchung-lesen
GET {{baseUrl}}/v1/booking?ref={{sandboxRef}}
X-Api-Key: {{testKey}}
```

```json
{"reference": "{{sandboxRef}}", "customerReference": "TEST-4711", "hotel": "TEST-HOTEL-BASE", "room": "DZ",
 "checkIn": "{{D2}}", "checkOut": "{{D4}}", "quantity": 1, "status": "confirmed",
 "bookedAt": "{{*}}", "updatedAt": "{{*}}", "totalCents": 18000, "currency": "EUR", "board": "RO", "sandbox": true}
```

Test- und Live-Welt sind getrennt: Ein Test-Key sieht und storniert nur Testbuchungen, ein
Live-Key nur echte. Derselbe `idemKey` bucht in beiden Welten unabhängig voneinander.

```http
### sandbox-live-key-sieht-testbuchung-nicht
GET {{baseUrl}}/v1/booking?ref={{sandboxRef}}
X-Api-Key: {{apiKey}}
```

```json
{"errorCode": "ERR_BOOKING_NOT_FOUND", "message": "keine Buchung zu '{{sandboxRef}}'"}
```

```http
### sandbox-stornieren
POST {{baseUrl}}/v1/cancel
Content-Type: application/json
X-Api-Key: {{testKey}}

{"idemKey": "BEISPIEL-0001"}
```

```json
{"released": true, "alreadyReleased": false, "sandbox": true}
```

- Testbuchungen werden 30 Tage nach Anlage gelöscht. Höchstens 5.000 offene (nicht
  stornierte) Testbuchungen je Zugang, darüber `422 ERR_SANDBOX_LIMIT`.
- Die Aufrufe eines Test-Keys (Methode, Pfad, Status, `errorCode`, Dauer, Anfrage und Antwort)
  protokolliert TourAPI 7 Tage lang, damit der Veranstalter bei der Fehlersuche helfen kann.
  Deshalb: in Testbuchungen **Testnamen** verwenden.
- EDF-Lieferung mit Test-Key: Abrufe (`full`, `changes`) und Quittungen (`ack`) stehen nur in
  diesem Protokoll der Test-Aufrufe, **nie im Lieferprotokoll** des Veranstalters – sie zählen
  nicht als Abholung oder Verarbeitung im Health der Lieferung.

### 11.3 Szenarien: Fehlerfälle erzwingen

Mit dem Kopf **`X-TourAPI-Sandbox-Scenario`** erzwingt ein Test-Key einen Fehlerfall – zum
Testen der Wiederhol-Logik (Abschnitt 8 und 9). Mit einem Live-Key, einem unbekannten Namen
oder an einem Endpunkt, an dem das Szenario nicht wirkt: `422 ERR_SCENARIO_NOT_ALLOWED`.

| Szenario | Endpunkte | Wirkung |
|---|---|---|
| `booking_busy` | `/v1/book`, `/v1/cancel` | erster Versuch je `idemKey` und Endpunkt (Buchung und Storno zählen getrennt): `503 ERR_BOOKING_BUSY` mit `Retry-After: 1`, nichts gebucht bzw. storniert; die Wiederholung mit **demselben** `idemKey` läuft durch |
| `price_drift` | `/v1/book` mit `priceCheck` | erster Versuch je `idemKey`: `409 ERR_PRICE_DRIFT`; der Preis selbst ändert sich nicht (`message` nennt aktuellen und erwarteten Preis, beide gleich); neuen Preis holen, erneut buchen |
| `sold_out` | `/v1/book` | immer `422 ERR_SOLD_OUT` (nach allen anderen Prüfungen) |
| `rate_limited` | alle | erste Anfrage je Test-Key und Endpunkt: `429 ERR_RATE_LIMITED` mit `Retry-After: 1` |
| `search_busy` | `/v1/search` | erste Anfrage je Test-Key: `429 ERR_SEARCH_BUSY` mit `Retry-After: 1` |
| `price_timeout` | `/v1/price`, `/v1/prices` | erste Anfrage je Test-Key und Endpunkt: `503 ERR_PRICE_TIMEOUT` |

„Erste Anfrage“ gilt 10 Minuten; jede Wiederholung in dieser Zeit läuft normal durch. Das
Gedächtnis liegt je Knoten der API.

```http
### sandbox-szenario-busy
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{testKey}}
X-TourAPI-Sandbox-Scenario: booking_busy

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "quantity": 1,
  "idemKey": "BEISPIEL-0002"
}
```

```json
{"errorCode": "ERR_BOOKING_BUSY", "message": "Buchung/Storno kam wegen gleichzeitiger Vorgaenge am selben Hotel nicht durch; nichts geaendert — mit demselben idemKey wiederholen (Sandbox-Szenario booking_busy)"}
```

```http
### sandbox-szenario-busy-wiederholt
POST {{baseUrl}}/v1/book
Content-Type: application/json
X-Api-Key: {{testKey}}
X-TourAPI-Sandbox-Scenario: booking_busy

{
  "hotel": "TEST-HOTEL-BASE",
  "room": "DZ",
  "checkIn": "{{D2}}",
  "checkOut": "{{D4}}",
  "quantity": 1,
  "idemKey": "BEISPIEL-0002"
}
```

```json
{"booked": true, "alreadyBooked": false, "reference": "{{*}}", "correlationId": "{{*}}", "sandbox": true}
```

```http
### sandbox-szenario-live-key
GET {{baseUrl}}/v1/destinations
X-Api-Key: {{apiKey}}
X-TourAPI-Sandbox-Scenario: rate_limited
```

```json
{"errorCode": "ERR_SCENARIO_NOT_ALLOWED", "message": "X-TourAPI-Sandbox-Scenario: nur mit einem Test-Key (Sandbox)"}
```

### 11.4 Grenzen der Test-Keys

Alle Test-Keys eines Zugangs teilen sich **einen** Topf: 20 Anfragen/s, Burst 40 (daneben
bleibt die Grenze des Live-Keys unberührt). Test-Keys eines Veranstalters belegen höchstens 2
gleichzeitige Suchen. Der Takt der EDF-Lieferung (`full` 1/h, neue `changes`-Kette 1/min)
gilt je Zugang, nicht je Test-Key.

### 11.5 Was die Sandbox nicht beweist

- **Kontingent und Rennen um das letzte Zimmer:** Testbuchungen verbrauchen nichts. Eine
  Testbuchung kann gelingen, wo live ein anderer schneller war.
- **Abläufe beim Veranstalter** nach der Buchung (Bestätigung, Änderung, Rechnung).
- **Verhalten unter Live-Last** (eigener, kleinerer Topf, siehe 11.4).

### 11.6 KI-Agenten: MCP-Server im Portal

Das Portal bietet unter `/mcp` einen MCP-Server (Model Context Protocol, Transport
„Streamable HTTP“, zustandslos, nur Werkzeuge). Ein KI-Agent wie Claude Code, Codex oder
Antigravity ruft damit die API über Werkzeuge auf, statt HTTP-Code zu schreiben. Jedes Werkzeug
ist **genau ein** `/v1`-Aufruf mit dem Key der MCP-Anfrage (`X-Api-Key` oder
`Authorization: Bearer`) und `X-TourAPI-Require-Mode: test` – Verhalten, Grenzen, Isolation und
Fehlercodes sind die der API. **Nur Test-Keys:** ein Live-Key bekommt `ERR_MODE_MISMATCH` (403),
nichts wird ausgeführt. Das Portal speichert keinen Key.

| Werkzeug | Aufruf | Argumente |
|---|---|---|
| `list_destinations` | `GET /v1/destinations` | keine |
| `search_hotels` | `POST /v1/search` | Anfrage-Körper; Seiten per `cursor` |
| `price_offer` | `POST /v1/price` | Anfrage-Körper |
| `price_all_rooms` | `POST /v1/prices` | Anfrage-Körper |
| `sandbox_book` | `POST /v1/book` | Anfrage-Körper, `idemKey` Pflicht |
| `get_booking` | `GET /v1/booking` | `ref` |
| `sandbox_cancel` | `POST /v1/cancel` | Anfrage-Körper |
| `get_limits` | `GET /v1/limits` | keine |
| `open_search` | `POST /v1/search/open` | Anfrage-Körper |
| `open_search_dates` | `POST /v1/search/open/dates` | Anfrage-Körper |
| `hotel_details` | `GET /v1/content/hotels/{code}` | `code`, `lang` |

Werkzeuge mit Anfrage-Körper reichen ihre Argumente unverändert als JSON-Körper an die API
weiter (Schema = OpenAPI des Endpunkts). Die übrigen nehmen nur die genannten Argumente; ein
anderes ergibt `ERR_UNKNOWN_FIELD`, ein ungültiger Wert `ERR_BAD_REQUEST` – ohne API-Aufruf.

**`hotel_details` nur mit Inhalts-Recht.** `tools/list` fragt mit dem Key der Anfrage
`GET /v1/limits` und nennt `hotel_details` nur bei `content.allowed: true` (dieselbe Regel wie die
Content-API, 4c); ohne Key fehlt es. Wird es trotzdem aufgerufen, antwortet die API
`ERR_CONTENT_NOT_ALLOWED` (403). Weist die API den Key dabei ab (z. B. `ERR_MODE_MISMATCH` für
einen Live-Key) oder antwortet sie nicht, ist `tools/list` ein JSON-RPC-Fehler (`-32000`) mit
Fehlercode bzw. Grund in `message` – keine Werkzeugliste.

**`hotel_details` und `lang`.** Bietet der Veranstalter eine angefragte Inhaltssprache nicht an,
gibt das Werkzeug nicht `ERR_LANGUAGE_NOT_OFFERED` weiter: es liest die Inhaltssprachen
(`GET /v1/content/catalog`, `languages`) und fragt die angefragten Sprachen, soweit angeboten,
sonst eine Rückfallsprache (`en`, dann `de`, dann die erste angebotene). `structuredContent`
nennt das unter `language` (`requested`, `delivered`, `offered`, `fallback: true`). Die
Content-API selbst bleibt streng; bietet der Veranstalter keine Inhaltssprache an, bleibt ihr Fehler.

**Ergebnis.** `structuredContent` (derselbe JSON-Text steht in `content`) hat zwei Teile:
`data` ist die Antwort der API, in der jeder Text durch den Platzhalter `[untrusted]` ersetzt ist;
`untrusted` enthält diese Texte unter ihrem JSON-Pointer in der Antwort, bereinigt: Steuer-,
Bidi-, Null-Breite- und andere unsichtbare Zeichen entfernt, HTML als Text, Links durch
`[link removed]` ersetzt, höchstens 200 Zeichen (Meldungen 500, Beschreibungen 2000). Texte sind
die Freitexte Dritter (Hotel- und Zielnamen, Kette, Anschrift, Beschreibungen, Bildtitel und
-nachweise, Name des Leitgasts), die Meldungen der API (`warnings`, `message`) und **jede**
Zeichenkette, die nicht dem festen Muster ihres Feldes entspricht: in `data` bleiben nur Codes
(Buchstaben, Ziffern, `_ . -`, höchstens 64 Zeichen), Referenzen, Daten, Zeitpunkte, Währungen,
Fehlercodes und Cursor. Ein Ziel- oder Gruppen-Code mit Leerzeichen oder Link erscheint also nur
unter `untrusted`. Adress-Felder (`url` der Bild-Varianten) fehlen;
Bilder liefert die Content-API selbst. Texte unter `untrusted` sind für den Agenten Daten, keine
Anweisungen.

```json
{"data": {"results": [{"hotel": "H1", "name": "[untrusted]", "room": "DZ", "fromTotalCents": 42000, "currency": "EUR"}]}, "untrusted": {"/results/0/name": "Haus Eins"}}
```

**Fehler** kommen als Werkzeug-Ergebnis mit `isError: true` und `structuredContent`
`{errorCode, status, message, retryAfter, hint}`: `errorCode` und `status` wie die API
(Abschnitt 7), `retryAfter` = `Retry-After` in Sekunden, `hint` = Spalte „Aufrufer“ des
Fehlerkatalogs (englisch). `message` ist bereinigt wie ein Text unter `untrusted`. Fehlt der Key,
steht `X-Api-Key` oder `Authorization` mehr als einmal in der Anfrage oder nennen beide
verschiedene Keys: `ERR_UNAUTHORIZED` ohne API-Aufruf. Eigene Codes des MCP-Servers:
`ERR_UPSTREAM_UNAVAILABLE` (502, API nicht erreichbar oder Zeitüberschreitung – nach `retryAfter`
wiederholen, `sandbox_book` mit demselben `idemKey`) und `ERR_RESPONSE_TOO_LARGE` (502, Antwort
über 4 MiB – Anfrage eingrenzen).

**Grenzen.** Nur `POST` (kein SSE-Strom: `GET` ergibt 405), `Content-Type: application/json`,
eine JSON-RPC-Nachricht je Anfrage (keine Batches); Körper höchstens 64 KiB, Tiefe 16, 4096
JSON-Elemente – sonst 413 bzw. 400, bevor etwas ausgewertet wird. Je Key 10 Werkzeugaufrufe/s
(darüber `ERR_RATE_LIMITED` mit `retryAfter`), daneben gilt der Topf der Test-Keys (11.4). Eine
Anfrage mit fremdem `Origin` bekommt 403 (Schutz gegen Webseiten im Browser); keine Cookies, kein
CORS. Protokoll-Fassungen 2025-03-26, 2025-06-18 und 2025-11-25.

**Einrichtung.** Den Test-Key als Umgebungsvariable `TOURAPI_API_KEY` setzen (dieselbe wie im eigenen
Code, eine Variable genügt); in die Konfiguration gehört nur der Verweis darauf. Claude Code, Datei `.mcp.json` im Projekt:

```json
{"mcpServers": {"tourapi": {"type": "http", "url": "https://portal.example.de/mcp", "headers": {"X-Api-Key": "${TOURAPI_API_KEY}"}}}}
```

Codex (der Key geht als Bearer-Token aus der Umgebungsvariable):

```sh
codex mcp add tourapi --url https://portal.example.de/mcp --bearer-token-env-var TOURAPI_API_KEY
```

Antigravity setzt in `headers` keine Umgebungsvariablen ein; die Brücke `mcp-remote` liest den Key
aus der Umgebung (Eintrag in `mcp_config.json`, Fassung der Brücke fest, damit kein ungeprüfter
Stand den Key bekommt):

```json
{"mcpServers": {"tourapi": {"command": "npx", "args": ["-y", "mcp-remote@0.14.3", "https://portal.example.de/mcp", "--header", "X-Api-Key:${TOURAPI_API_KEY}"]}}}
```

Die Seite „KI-Agenten“ im Portal zeigt dieselben Einträge mit der Adresse Ihres Portals.

---

## 12. Hotelinhalte: `/v1/content/*`

Die Content-API liefert die Hotelinhalte des Veranstalters für Webseiten und Kataloge der
Abnehmer: Stamm (Objektart, Kette, Adresse), Lage (Koordinaten), Kategorien, Fakten, Texte,
Bilder und Ausstattung. Sie ist ein eigener Weg neben Preis und Buchung: Inhalte ändern
keinen Preis und keine Verfügbarkeit.

| Route | Zweck |
|---|---|
| `GET /v1/content/hotels` | Verzeichnis der Hotels des Keys, aufsteigend nach `code`, seitenweise |
| `GET /v1/content/hotels/{code}` | Inhalt eines Hotels (`ETag`, `If-None-Match` → `304`) |
| `GET /v1/content/changes?since=…` | Änderungen seit einem Stand, in der Reihenfolge, in der sie gespeichert wurden |
| `GET /v1/content/catalog` | Kataloge mit Beschriftungen (Objektarten, Kategorieskalen, Text- und Bildtypen, Ausstattung) |

### 12.1 Zugang und Umfang

- Die Content-API antwortet nur, wenn **beides** gilt: der Betreiber hat Inhalte für den
  Veranstalter freigeschaltet, und der Key trägt das **Inhalts-Recht** (vergibt der
  Veranstalter-Admin unter API-Zugang, Voreinstellung aus). Sonst `403 ERR_CONTENT_NOT_ALLOWED`.
  Ein Test-Key hat das Recht seines Live-Keys und liest dieselben Inhalte.
- Einen Veranstalter, den der Betreiber als Testumgebung führt, beliefert nur die dafür
  eingerichtete Installation. Überall sonst gilt er als nicht freigeschaltet: dieselbe Antwort
  `403 ERR_CONTENT_NOT_ALLOWED`.
- Welche Hotels ein Key sieht, bestimmt der Key wie bei `/v1/destinations` (1.1): ohne
  Kundengruppe und mit Preisgruppe alle Hotels des Veranstalters, mit Kontingent-Gruppe nur
  Hotels mit Zuteilung – unabhängig von der Verfügbarkeit. Jedes andere Hotel ist
  `404 ERR_HOTEL_NOT_FOUND`.
- Ausgeliefert werden nur sichtbare Texte in den **Inhaltssprachen** des Veranstalters und
  sichtbare, fertig verarbeitete Bilder. **Kontaktdaten des Hotels** (Telefon, Mail, Web)
  gibt es in der Content-API nicht, auch nicht als leeres Feld.
- Bilder liegen unter öffentlichen, nicht erratbaren Adressen
  (`<base>/m/<key>/<size>.jpg`, ohne Key abrufbar, 1 Tag cachebar). Neben jedem Bild
  steht `credit`; bei `attributionRequired: true` muss die Nennung neben dem Bild erscheinen.
- Keys gehören nicht in Browser-Code (1.1): die Webseite liest die Content-API auf dem Server,
  nur die Bild-Adressen gehen an den Browser.

### 12.2 Verzeichnis

```http
### inhalt-verzeichnis
GET {{baseUrl}}/v1/content/hotels?pageSize=2
X-Api-Key: {{apiKey}}
```

```json
{
  "hotels": [
    {"code": "TEST-HOTEL-ALTAKTION", "name": "TEST Altaktion Teneriffa", "destination": "TFS", "contentVersion": 0, "websiteReady": false, "missing": ["general_text", "geo", "category", "images", "amenities"]},
    {
      "code": "TEST-HOTEL-BASE",
      "name": "TEST Basis Palma",
      "destination": "PMI",
      "contentVersion": "{{*}}",
      "updatedAt": "{{*}}",
      "category": {"kind": "official", "scheme": "stars", "value": 4, "superior": true},
      "geo": {"lat": 39.565, "lon": 2.627, "precision": "address"},
      "websiteReady": false,
      "missing": ["images", "amenities"]
    }
  ],
  "nextCursor": "{{*}}",
  "feedToken": "{{*}}",
  "scopeHash": "{{*}}"
}
```

- `pageSize` 1–1000 (Standard 500). Die nächste Seite holt man mit `cursor=<nextCursor>`;
  ohne `nextCursor` ist das Verzeichnis vollständig.
- `contentVersion` zählt jede Änderung der Auslieferung eines Hotels; `0` = noch kein Inhalt
  (dann fehlt `updatedAt`). `category` ist die offizielle Landeskategorie, `geo` die Lage –
  beide nur, wenn gepflegt. `websiteReady` und `missing` wie im Inhalt eines Hotels (12.3).
- `feedToken` ist der Stand, ab dem `/v1/content/changes` weitermacht. Alle Seiten eines
  Verzeichnis-Durchlaufs tragen denselben Stand (den der ersten Seite).
- `scopeHash` ändert sich, sobald sich die Hotelmenge des Keys ändert (neues oder gelöschtes
  Hotel, Zuteilung kommt oder geht). Das steht nicht im Feed: dann das Verzeichnis neu holen.
- `cursor`, `feedToken` und `next` sind undurchsichtig und an den Key gebunden: mit einem
  anderen Key, verändert oder nach einem Schlüsselwechsel des Servers `422 ERR_BAD_CURSOR`.

```http
### inhalt-verzeichnis-weiter
GET {{baseUrl}}/v1/content/hotels?pageSize=2&cursor={{inhaltCursor}}
X-Api-Key: {{apiKey}}
```

```json
{
  "hotels": [
    {"code": "TEST-HOTEL-CLOSED", "name": "TEST Geschlossen Mahon", "destination": "MAH", "contentVersion": 0, "websiteReady": false, "missing": ["general_text", "geo", "category", "images", "amenities"]},
    {
      "code": "TEST-HOTEL-DISCOUNT",
      "name": "TEST Rabatt Palma",
      "destination": "PMI",
      "contentVersion": "{{*}}",
      "updatedAt": "{{*}}",
      "category": {"kind": "official", "scheme": "stars", "value": 4},
      "geo": {"lat": 39.5696, "lon": 2.6502, "precision": "locality"},
      "websiteReady": false,
      "missing": ["general_text", "images", "amenities"]
    }
  ],
  "nextCursor": "{{*}}",
  "feedToken": "{{*}}",
  "scopeHash": "{{*}}"
}
```

### 12.3 Inhalt eines Hotels

```http
### inhalt-hotel
GET {{baseUrl}}/v1/content/hotels/TEST-HOTEL-BASE?lang=de,tr
X-Api-Key: {{apiKey}}
```

```json
{
  "code": "TEST-HOTEL-BASE",
  "name": "TEST Basis Palma",
  "destination": "PMI",
  "contentVersion": "{{*}}",
  "updatedAt": "{{*}}",
  "accommodationType": "HOTEL",
  "categories": [{"kind": "official", "scheme": "stars", "value": 4, "superior": true}],
  "address": {"street": "Passeig Marítim 12", "postalCode": "07014", "city": "Palma", "region": "Mallorca", "country": "ES"},
  "geo": {"lat": 39.565, "lon": 2.627, "precision": "address"},
  "facts": {"rooms": 120, "checkInFrom": "14:00", "checkOutUntil": "11:00"},
  "texts": [
    {"type": "GENERAL", "lang": "de", "html": "<p>TEST-Hotel am Strand mit <b>Pool</b> und Garten.</p>\n<p>TEST-Lage: ruhige Bucht.</p>", "updatedAt": "{{*}}"},
    {"type": "GENERAL", "lang": "de", "fallbackFrom": "tr", "html": "<p>TEST-Hotel am Strand mit <b>Pool</b> und Garten.</p>\n<p>TEST-Lage: ruhige Bucht.</p>", "updatedAt": "{{*}}"}
  ],
  "media": [],
  "amenities": [
    {"code": "WIFI_ROOM", "available": true, "charge": "included"},
    {"code": "SPA", "available": false},
    {"code": "DIST_AIRPORT", "available": true, "distanceM": 12000, "ref": "PMI"}
  ],
  "websiteReady": false,
  "missing": ["images", "amenities"]
}
```

- **Sprachen:** ohne `lang` kommen alle Texte in allen Inhaltssprachen des Veranstalters.
  Mit `lang` (1–5 Sprachen, ISO 639-1 klein, kommagetrennt) kommt je Texttyp genau ein
  Eintrag je angefragter Sprache. Fehlt der Text in dieser Sprache, springt die
  Standardsprache des Veranstalters ein, dann Englisch; der Eintrag trägt dann
  `fallbackFrom` mit der angefragten Sprache (hier: kein Text auf Türkisch, es kommt Deutsch).
  Gibt es auch dort keinen, fehlt der Eintrag. Titel (`titles`) und Alternativtexte (`alts`)
  der Bilder folgen derselben Regel. Eine Sprache, die der Veranstalter nicht anbietet:
  `422 ERR_LANGUAGE_NOT_OFFERED` (die aktiven Sprachen stehen in `warnings`).
- `html` enthält nur `p`, `br`, `b`, `strong`, `i`, `em`, `ul`, `ol`, `li` ohne Attribute.
  `machineTranslated: true` kennzeichnet eine maschinelle Übersetzung.
- `categories`: `kind` `official` (Landeskategorie) oder `operator` (Einstufung des
  Veranstalters), `scheme` `stars` oder `keys`, `value` 1–5 in halben Schritten; „4 Superior“
  ist `value: 4, superior: true`, nie 4,5.
- `geo.precision`: `address`, `street`, `locality` oder `unknown`.
- `media` in Anzeigereihenfolge, `order: 1` ist das Hauptbild. Je Bild `variants` mit den
  erzeugten Breiten (`w320` bis `w2048`, nie breiter als das Original), jeweils mit Breite,
  Höhe, Bytes und `url`; `focus` (x, y in Prozent) ist der wichtigste Bildpunkt für eigene
  Zuschnitte. `id` ist stabil, solange das Bild im Hotel bleibt.
- `amenities`: Ausstattung mit Code aus dem Katalog (12.5). `available: false` heißt
  ausdrücklich nicht vorhanden; ein Merkmal, das fehlt, ist unbekannt. Je nach Merkmal mit
  `count`, `distanceM`, `areaM2`, `ref` (Flughafen-Code zur Entfernung) und bei
  kostenpflichtig möglichen Merkmalen `charge` (`included`, `extra`, `unknown`).
- `name`, `destination` und `giataCode` kommen aus dem Vertrag des Hotels. Ändert sich einer
  davon, steht das Hotel im Feed (12.4, `reason: contract`), und Detail wie Verzeichnis
  liefern ab dann den neuen Wert.
- `websiteReady` und `missing`: Reifegrad „webseitenbereit“ – ob der Veranstalter genug
  Inhalt für eine Webseite gepflegt hat. Eine Kennzahl, sie ändert weder Verkauf noch
  Auslieferung. `missing` nennt die fehlenden Kriterien in fester Reihenfolge, leer =
  webseitenbereit: `general_text` (allgemeiner Text in der Standardsprache des Veranstalters),
  `geo` (Lage), `category` (Kategorie oder Objektart), `images` (mindestens 5 ausgelieferte
  Bilder, das Hauptbild darunter), `amenities` (mindestens 10 gepflegte Merkmale, auch
  ausdrücklich „nicht vorhanden“). Die Bewertung ändert sich nur mit dem Inhalt (neue
  `contentVersion`); die Konsole des Veranstalters zeigt dieselbe. Unbekannte Werte in
  `missing` ignorieren (es können Kriterien dazukommen). Im Verzeichnis stehen beide Felder
  ebenfalls.

Jede Antwort trägt einen `ETag`. Er ändert sich mit dem Inhalt, der Sprachwahl und der
Darstellung. Mit `If-None-Match` kommt `304` ohne Körper, solange sich nichts geändert hat:

```http
### inhalt-hotel-unveraendert
GET {{baseUrl}}/v1/content/hotels/TEST-HOTEL-BASE?lang=de,tr
X-Api-Key: {{apiKey}}
If-None-Match: {{inhaltEtag}}
```

Ohne `lang` alle Inhaltssprachen (hier `en` als maschinelle Übersetzung):

```http
### inhalt-hotel-alle-sprachen
GET {{baseUrl}}/v1/content/hotels/TEST-HOTEL-BASE
X-Api-Key: {{apiKey}}
```

```json
{
  "code": "TEST-HOTEL-BASE",
  "name": "TEST Basis Palma",
  "destination": "PMI",
  "contentVersion": "{{*}}",
  "updatedAt": "{{*}}",
  "accommodationType": "HOTEL",
  "categories": "{{*}}",
  "address": "{{*}}",
  "geo": "{{*}}",
  "facts": "{{*}}",
  "texts": [
    {"type": "GENERAL", "lang": "de", "html": "{{*}}", "updatedAt": "{{*}}"},
    {"type": "GENERAL", "lang": "en", "html": "<p>TEST hotel on the beach with a <b>pool</b> and garden.</p>\n<p>TEST location: quiet bay.</p>", "machineTranslated": true, "updatedAt": "{{*}}"}
  ],
  "media": [],
  "amenities": "{{*}}",
  "websiteReady": false,
  "missing": ["images", "amenities"]
}
```

### 12.4 Änderungen

```http
### inhalt-aenderungen
GET {{baseUrl}}/v1/content/changes?since={{feedToken}}
X-Api-Key: {{apiKey}}
```

```json
{"changes": [], "next": "{{*}}", "more": false, "scopeHash": "{{*}}"}
```

- `since` ist der `feedToken` des Verzeichnisses oder `next` der vorigen Antwort (Pflicht,
  sonst `400 ERR_BAD_REQUEST`). `pageSize` 1–1000 (Standard 500).
- Je Eintrag `code`, `change` (`upsert` = Inhalt geändert, neu holen; `removed` = Hotel
  gelöscht), bei `upsert` die aktuelle `contentVersion`, und optional `reason`
  (`languages` = die Inhaltssprachen des Veranstalters haben sich geändert, `media_ready` =
  ein Bild ist fertig verarbeitet, `contract` = `name`, `destination` oder `giataCode` hat sich
  mit dem Vertrag geändert, `deleted` bei `removed`). Unbekannte Werte ignorieren.
- In der Reihenfolge, in der die Änderungen gespeichert wurden, lückenlos; ein Hotel steht je
  Seite höchstens einmal (mit seinem letzten Stand). `more: true` = sofort mit `next`
  weiterlesen.
- Nur Hotels des Keys. Eine Änderung der Hotelmenge steht nicht im Feed, sondern im
  `scopeHash`.
- Der Feed reicht 30 Tage zurück. Ein älterer Stand: `410 ERR_CONTENT_CURSOR_EXPIRED` – dann
  das Verzeichnis neu holen und mit dessen `feedToken` weitermachen.

### 12.5 Katalog

```http
### inhalt-katalog
GET {{baseUrl}}/v1/content/catalog?lang=de
X-Api-Key: {{apiKey}}
```

```json
{
  "version": "2026.1",
  "languages": ["de", "en", "tr"],
  "defaultLanguage": "de",
  "labelLanguages": ["de"],
  "accommodationTypes": "{{*}}",
  "categorySchemes": [{"code": "sterne", "sort": 1, "labels": {"de": "Sterne"}}, {"code": "schluessel", "sort": 2, "labels": {"de": "Schlüssel"}}],
  "textTypes": "{{*}}",
  "mediaTypes": "{{*}}",
  "amenityGroups": "{{*}}",
  "amenities": "{{*}}"
}
```

- `languages` sind die Inhaltssprachen des Veranstalters, `defaultLanguage` seine
  Standardsprache. `lang` wählt die Beschriftungssprachen (`de`, `en`, `tr`; ohne `lang` alle).
- Je Ausstattungsmerkmal `group`, `valueType` (`flag`, `anzahl`, `meter`, `flaeche_m2`,
  `meter_mit_bezug`), `unit` und `chargeable`. Ein Code wird nie umgedeutet; `locked: true`
  heißt entfallen (bleibt lesbar). `version` ändert sich mit jedem neuen Katalog; der Katalog
  trägt einen `ETag`.

### 12.6 Abgleich für Abnehmer

1. **Erstbefüllung:** Verzeichnis seitenweise holen, `feedToken` und `scopeHash` merken, je
   Hotel den Inhalt holen und den `ETag` speichern.
2. **Laufend** (etwa alle paar Minuten): `changes?since=<token>` (beim ersten Abruf
   `feedToken`, danach das zuletzt gemerkte `next`), geänderte
   Hotels neu holen, `removed` löschen, `next` merken. Ändert sich `scopeHash`: sofort
   Schritt 3.
3. **Täglich und bei `410`:** Verzeichnis neu holen und mit dem eigenen Bestand abgleichen
   (fehlende Hotels löschen, neue holen, `contentVersion` vergleichen).
4. Inhalte immer mit `If-None-Match` holen.

Zugangs- und Umfangsfehler:

```http
### inhalt-ohne-recht
GET {{baseUrl}}/v1/content/hotels
X-Api-Key: {{poolKey}}
```

```json
{"errorCode": "ERR_CONTENT_NOT_ALLOWED", "message": "dieser API-Key hat kein Inhalts-Recht — der Veranstalter-Admin schaltet es unter API-Zugang frei"}
```

```http
### inhalt-nicht-im-verzeichnis
GET {{baseUrl}}/v1/content/hotels/TEST-HOTEL-DISCOUNT
X-Api-Key: {{partnerKey}}
```

```json
{"errorCode": "ERR_HOTEL_NOT_FOUND", "message": "Hotel 'TEST-HOTEL-DISCOUNT' nicht im Verzeichnis dieses API-Keys"}
```

```http
### inhalt-sprache-nicht-angeboten
GET {{baseUrl}}/v1/content/hotels/TEST-HOTEL-BASE?lang=fr
X-Api-Key: {{apiKey}}
```

```json
{"errorCode": "ERR_LANGUAGE_NOT_OFFERED", "message": "lang: 'fr' ist keine Inhaltssprache dieses Veranstalters (ISO 639-1, klein)", "warnings": ["aktive Inhaltssprachen: de, en, tr (Standard de)"]}
```

```http
### inhalt-stand-fremder-key
GET {{baseUrl}}/v1/content/changes?since={{feedToken}}
X-Api-Key: {{partnerKey}}
```

```json
{"errorCode": "ERR_BAD_CURSOR", "message": "since: unlesbar, von einem anderen API-Key oder nicht von diesem Server — den Wert der Vorseite unveraendert zurueckgeben, sonst neu beginnen"}
```

---

## 13. Geplant und Grenzen

**Geplant:** Innerhalb von `/v1` kommen nur additive Änderungen hinzu (Abschnitt 1.9); jede
Änderung steht mit Datum im Changelog. Derzeit ist keine Änderung angekündigt, die eine
bestehende Anbindung anpassen müsste.

**Grenzen:** Die Zahlenwerte (Größen, Takte, Zeitgrenzen) stehen im Anhang „Grenzen auf einen
Blick“, was die Sandbox nicht abbildet in 11.5.

---

## Anhang: Grenzen auf einen Blick

| Was | Wert |
|---|---|
| Body je Anfrage | 1 MiB |
| Nächte je Aufenthalt | 1–30 |
| Anreise | Stichtag bis Stichtag + 732 Tage |
| Reisende je Anfrage, Alter | 1–20, 0–120 |
| Zimmer je Buchung (`quantity`) | 1–1.000.000 |
| `idemKey`, `reference` | 128 Zeichen (länger: `422 ERR_VALIDATION`) |
| `leadPaxName` | 255 Zeichen (länger: `422 ERR_VALIDATION`) |
| `metadata.correlationId` | 64 Zeichen (länger: `422 ERR_VALIDATION`) |
| Rate je Key | 200/s, Burst 400 |
| Test-Keys je Zugang | höchstens 5, gültig bis 90 Tage; zusammen 20/s, Burst 40; 2 gleichzeitige Suchen je Veranstalter |
| Testbuchungen | 5.000 offene je Zugang, gelöscht 30 Tage nach Anlage; Protokoll der Test-Aufrufe 7 Tage |
| Such-Zeitgrenze | 10 s |
| Suchseite | Standard 50, höchstens 100 Hotels |
| Offene Suche | je Key laut Suchprofil (Fenster, Dauern, Ziele, Seite, Zeitbudget, Takt; Termin-Matrix: Fenster und Zellen), abrufbar mit `GET /v1/limits`; Seite ohne Angabe 20; Cursor 15 min |
| Verbindung | 15 s lesen, 15 s schreiben |
| EDF-Lieferung je Key | `full` 1/h, neue `changes`-Kette 1/min, Folgeseiten/Quittungen 5/s (Burst 50); Paket-Antworten bis 10 min schreiben |
| Content-API | Verzeichnis- und Feed-Seite 1–1000 (Standard 500), `lang` 1–5 Sprachen, Feed 30 Tage |
