# 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=` (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": ""`. 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": ""`; `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-----.xml`, EDF 5.1.6) und eine Allotment-Datei (`hotels/hotelonly/allotment/EDF-----.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: ``; 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=` bzw. `since=:` (wenn das Manifest `to_after` trägt) **und** `until=` (Wert der Vorseite, URL-kodiert, unverändert), beim `full` zusätzlich mit `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 --api [--max-bytes N] [--ohne-ack] edf-empfaenger stand --dir edf-empfaenger rechne --dir --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 (`/m//.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=`; 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": "

TEST-Hotel am Strand mit Pool und Garten.

\n

TEST-Lage: ruhige Bucht.

", "updatedAt": "{{*}}"}, {"type": "GENERAL", "lang": "de", "fallbackFrom": "tr", "html": "

TEST-Hotel am Strand mit Pool und Garten.

\n

TEST-Lage: ruhige Bucht.

", "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": "

TEST hotel on the beach with a pool and garden.

\n

TEST location: quiet bay.

", "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=` (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 |