<!-- Uebersetzung von HANDBUCH.md (en), Stand 2026-10-09. Massgeblich ist die deutsche Fassung; je Ueberschrift eine Abschnitts-Marke (docs/api/apidoku.go). -->
# TourAPI – API Handbook v1
<!-- de:7006b6403a99 -->

For developers connecting a travel portal or a tour operator system to TourAPI.
As of: 2026-09-30, API version `v1`; changes are listed in the changelog. Machine-readable: [`openapi.yaml`](openapi.yaml)
(OpenAPI 3.1). For AI agents (Claude Code, Codex, Antigravity): a summary of the flow,
formats and retry rules in the portal at `/doku/agenten.md`, filled in with your own access
after signing in (page “AI agents”, download as `AGENTS.md`).

**Every example in this handbook is a test.** The requests are in
[`beispiele/`](beispiele/) (in the format of the JetBrains and VS Code REST clients) and run on
every build against a fresh TourAPI instance with test data; the responses shown
are compared with the actual ones.

| Code | Meaning | Endpoint |
|---|---|---|
| BA | Query availability and price, search | `/v1/search`, `/v1/price`, `/v1/prices` |
| BA | open search without fixed dates (permission per key) | `/v1/search/open` (section 4a) |
| BA | date matrix of a hotel (permission per key) | `/v1/search/open/dates` (section 4b) |
| – | effective limits of your own key | `/v1/limits` (section 4c) |
| B | Book | `/v1/book` |
| – | Read booking info | `/v1/booking` |
| S | Cancel | `/v1/cancel` |
| – | Node health | `/v1/health` (section 1.8) |
| – | EDF delivery (cache export) | `/v1/export/edf/full`, `/changes`, `/ack` (section 10) |
| – | Sandbox: test keys, test bookings, scenarios | all endpoints with a test key (section 11) |
| – | Hotel content (master data, location, texts, images, amenities) | `/v1/content/hotels`, `/changes`, `/catalog` (section 12) |

Console/agents: not part of the Buyer API (`POST /changes` of the tour operator console,
login via session, no API key; description and limits available from the operator on request).

---

## 1. Getting started
<!-- de:151881b9cd96 -->

### 1.1 Access
<!-- de:883f83c4f74c -->

- Every request carries the API key in the **`X-Api-Key`** header. There is no session and no token.
- The key is issued by the tour operator (access package). A key belongs to exactly **one
  tour operator** (tenant) and optionally to one **customer group**. The customer group determines
  special prices and, as an allotment group, the allotment booked against. A key
  without a group works on the base contract. The tour operator decides which kind a customer
  group is:
  - **Allotment group** (sales partner with an allotment): sees only the hotels for which it
    has an allocation, and books against that allocation. Other hotels do not exist for the key
    (`404 ERR_HOTEL_NOT_FOUND`, not in search or `/v1/destinations`). If it currently has
    no allocation, it sees no hotel.
  - **Price group**: sees all hotels of the tour operator and books from the general
    inventory like a key without a group – only at its own price.
- If the special price of a customer group (with or without allocation) cannot be calculated
  for a hotel (faulty price promotion at the tour operator), the hotel does not exist for this group
  (`404 ERR_HOTEL_NOT_FOUND`, in search reason `ERR_HOTEL_NOT_FOUND` in
  `diagnostics.reasons`, `/v1/book` also rejects with `404`) – never silently the base price.
- Tenant, customer group and prices come **only from the key**, never from a parameter.
  Hotels of other tour operators do not exist for the key (`404`).
- Missing, unknown or revoked key: `401 ERR_UNAUTHORIZED`. Valid key of a
  suspended tour operator: `403 ERR_TENANT_SUSPENDED`. Key of a deactivated
  customer group: `403 ERR_KEY_GROUP_INACTIVE` (never silently the base contract).
- Keys do not belong in browser code or logs. If you lose a key, have the
  tour operator revoke it – or revoke it yourself in the partner portal (if the tour operator
  has enabled it).
- **Keys in the partner portal:** there you see the keys of your access, pick up live keys
  released by the tour operator, rotate live keys and revoke your own keys. The portal shows the
  raw value exactly once. The API rejects a revoked key within a few seconds (`401`).
- **Rotation:** the new live key has the same rights (customer group, export, content, search profile). The
  previous one stays valid for a period set by the tour operator (default 24 hours, 1 hour to
  7 days) and is rejected to the second afterwards (`401 ERR_UNAUTHORIZED`). Switch all systems
  to the new key within that period. You can rotate the new key again only once the previous one
  has expired – so no more than two keys are valid at the same time.
- For development there are **test keys** (`tk_test_…`): same data as the live key, bookings
  only in the sandbox (section 11). Every response states the mode in the `X-TourAPI-Mode` header.

### 1.2 Base URL, transport, version
<!-- de:dcd2ff977925 -->

- The base URL is in the access package; in the examples `{{baseUrl}}`.
- All paths start with **`/v1`**. What may change within `v1` is governed by the
  compatibility promise (section 1.9). Please ignore unknown response fields.
- Requests: JSON in UTF-8, `Content-Type: application/json`, at most **1 MiB** body.
  `GET` endpoints are `/v1/booking`, `/v1/destinations`, `/v1/health` and the EDF delivery
  (`/v1/export/edf/full`, `/changes`); all others are `POST`. Wrong method:
  `405 ERR_METHOD_NOT_ALLOWED`.
- Timestamps in responses (`bookedAt`, `updatedAt`) are RFC 3339 in UTC, e.g.
  `2026-09-26T08:15:03Z`.
- The server closes a connection after 15 s of reading or 15 s of writing. Exception: packages of the
  EDF delivery (`/v1/export/edf/full`, `/changes`) may write for up to 10 min (section 10).
- An unknown path (e.g. a typo in `/v1/…`) returns `404` **without** a JSON body
  (`text/plain`, no `errorCode`); all other errors have the form from 1.6.

### 1.3 Date, stay, reference date
<!-- de:29109e02a38c -->

- Date fields are calendar days **`JJJJ-MM-TT`** without time or zone.
- A stay is half-open: `checkIn` is the first night, `checkOut` the departure day.
  `checkIn=2026-10-26`, `checkOut=2026-10-28` are **two nights**.
- The **reference date** is the server date in the Europe/Berlin zone. Early booking and
  date-range rules always use it. The request field `now` is only tolerated: leave it
  empty. If you send a different date, the query still uses the server date
  and says so in `warnings`; `/v1/book` rejects a differing `priceCheck.now`
  (`ERR_NOW_MISMATCH`).
- Limits per request (applied before any calculation):

| Limit | Value | Code (422) |
|---|---|---|
| Nights per stay | 1–30 | `ERR_EMPTY_STAY`, `ERR_STAY_TOO_LONG` |
| Earliest arrival | today (reference date) | `ERR_STAY_IN_PAST` |
| Latest arrival | reference date + 732 days | `ERR_STAY_TOO_FAR` |
| Travellers per request | 1–20 | `ERR_NO_TRAVELLERS`, `ERR_TOO_MANY_TRAVELLERS` |
| Age | 0–120 | `ERR_INVALID_AGE` |

### 1.4 Money
<!-- de:3186200e07be -->

- All amounts are **whole cents** (`…Cents`, integer). 18000 = 180.00.
- **Rounding:** commercial rounding to 2 decimal places, **once per traveller** (`rounding` in every
  price response: `{"mode": "Commercial", "decimalPlaces": 2, "scope": "Person"}` – the same
  rule the hotel's EDF declares). A traveller's line items are summed exactly
  (unrounded); only the sum is rounded: that is `perTravellerCents[i]`,
  `totalCents` is their sum. A line item such as "−15 % on 89.90" is −13.485 and stays so until
  the sum. The exact amount per line item is shown in `breakdown[].amountExact` (section 3.1).
- The currency is the hotel's **contract currency** (`currency`, ISO 4217). TourAPI does not
  convert. `currency` in the request is a preference: if it differs, the response comes in the
  contract currency with a note in `warnings`. If no currency is set on the contract,
  `currency` stays empty, also with a note – "EUR" is not guessed.
- With `/v1/book` and `priceCheck`, by contrast, a currency mismatch is an error
  (`ERR_CURRENCY_NOT_AVAILABLE`): amounts without a common unit are not compared.

### 1.5 Occupancy
<!-- de:c5a60936f983 -->

`occupancy.travellers` is the list of travellers of **one room**, each with their
`age` on the arrival day. `name` and `type` may be sent but have no effect on the
price. Child prices, full payers and minimum occupancy follow from the contract:

- Whether someone is a child or an adult is decided by the room's child age band (e.g. 2–11
  years: a 14-year-old pays the adult price), not by a fixed limit.
- A room can require a number of full payers (e.g. 2 in a double room). If fewer
  adults travel, a child takes the free full-payer slot and **pays the full
  base price** (board at the child rate); child reductions apply only to children
  beyond that.
- Tiers such as "1st child / 2nd child" count the children in the order the hotel
  defines (oldest or youngest first). The same order determines which child takes a
  free full-payer slot.
- Anyone younger than the child age band is an infant: no base price for per-person pricing, never
  on a full-payer slot; board or a separate infant price only if the
  contract specifies them. For "from 3 persons" offers an infant counts only if the room
  counts infants towards occupancy.
- No traveller pays less than 0. Percentage discounts apply to what the traveller owes after
  their child or person reduction (a free child stays free; an
  early booking −20 % on a −50 % child price hits half).

### 1.6 Responses, errors, warnings
<!-- de:0d055d711a92 -->

- Success: `200` with the result. Errors: `4xx`/`5xx` with

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

  `errorCode` is stable and intended for programs; `message` is a German text for
  humans and may change. Full list: section 7.
- `warnings` (optional, also in success responses): what the API noticed about the request
  without rejecting it – ignored fields, differing currency, ignored `now`.
  **Please log them.** Many integration errors show up exactly there.
- **Processing time:** Every response of an endpoint – including error responses and
  `/v1/health` – carries the header `Server-Timing: tourapi;dur=<ms>` (W3C Server Timing), e.g.
  `tourapi;dur=12.4`: milliseconds from the request reaching the endpoint until the response
  starts, including reading the request body and waiting at the rate limit and search gate
  (section 8), excluding the transfer of the response. The difference to the time you measure
  yourself is network and transfer. The value is information, not a commitment; if you do not
  need it, ignore the header.
- **Unknown fields:** In query requests (`/v1/price`, `/v1/prices`, `/v1/search`)
  an unknown field is ignored and named in `warnings`. Within `occupancy`
  it is rejected (`422 ERR_UNKNOWN_FIELD`), because a typo there changes the price.
  `/v1/book` and `/v1/cancel` reject **any** unknown field.

### 1.7 The test world of the examples
<!-- de:babe2802fad0 -->

The examples run against the operator's test world (golden seed): tour operator
`TEST-TENANT-A`, hotels `TEST-HOTEL-…`, rooms `DZ`/`EZ`, board `RO`/`BB`/`HB`. Placeholders:

| Placeholder | Meaning |
|---|---|
| `{{baseUrl}}` | Base URL |
| `{{apiKey}}` | Key **without a customer group** (base contract). |
| `{{rabattKey}}` | Key of the customer group `TEST-PARTNER-DISCOUNT` (−20 % on `TEST-HOTEL-DISCOUNT`, price group) |
| `{{partnerKey}}` | Key of the allotment group `TEST-PARTNER-BASE` |
| `{{poolKey}}` | Key of the allotment group `TEST-PARTNER-POOL`, **without** export permission |
| `{{gesperrterKey}}` | revoked key |
| `{{exportEpoch}}`, `{{exportSeq}}` | `epoch` and `to_seq` from the manifest of the `export-voll` example |
| `{{ohnePreisRef}}`, `{{zweiteRef}}` | Booking references from the examples `buchen-ohne-preispruefung` and `buchen-gleiche-kundenreferenz` respectively |
| `{{D0}}`, `{{D2}}`, … | Arrival day D0 of the test world plus n days. D0 = day of the first test data build + 30 days. D0 is only a fixed calendar day of the test data, **not** the reference date from 1.3 (that is always the server date) |
| `{{heute}}`, `{{gestern}}` | Server date (= reference date), previous day |
| `{{buchungsRef}}` | Booking reference from the `buchen` example |
| `{{feedToken}}`, `{{inhaltCursor}}` | `feedToken` and `nextCursor` from the `inhalt-verzeichnis` example |
| `{{inhaltEtag}}` | `ETag` from the `inhalt-hotel` example |
| `{{*}}` | (responses only) any value, e.g. a timestamp |

Test world prices (DZ, 2 adults, room only): first night 100.00, each
further night 80.00 per room; breakfast +20.00, half board +35.00 per person and night.
Exception `TEST-HOTEL-KIND` (destination `ACE`): price per person and night (DZ 89.90 / 79.90),
children from 2 to 11 years −15 %.

### 1.8 Health: `GET /v1/health`
<!-- de:45efe8eeb5b4 -->

For load balancers and monitoring, **without a key**. `200 {"status": "ok"}` means: the node has
loaded the inventory, and its last sync with the database is fresh (default: younger
than 60 s). Otherwise `503` with `status` `degraded` (sync too old, `reason: "sync_stale"`) or
`down` (inventory not loaded, `reason: "view_not_loaded"`), plus `detail` as text. The
response contains no tour operator or inventory data. More than 10 calls per second from
one sender IP are answered with a delay (at most 1 s, section 8).

If the node reads via a read replica, the response also states its lag in
seconds (`replica_lag_s`). Above 5 s the status stays `ok`, and `warnings` then contains
`"replica_lag"`. `503 degraded` is returned when sync age plus lag exceed the limit
(`reason: "replica_lag"`) or the lag cannot be measured
(`reason: "replica_lag_unknown"`).

Every response (including `503`) also states the process uptime in seconds
(`uptime_s`) and the build state (`version`, empty for a build without a state). Once the
node has measured the database, `db` is present: `"ok"` with `db_latency_ms` (duration of one
round trip, measured in the sync cycle, not on the health call) or `"error"` (last
measurement failed). `"error"` alone does not flip the status — the node answers from
its loaded inventory; with `ok`, `"db_error"` then appears in `warnings`, and if the
sync stops, `503 degraded` (`sync_stale`) follows after the limit.

`"capacity_overlap"` in `warnings` (status stays `ok`) means: the inventory check in the
sync cycle found capacity periods that overlap per room type — an
operator finding (inventory changed bypassing the write path), not an error in the request.

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

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

### 1.9 Compatibility promise for `/v1`
<!-- de:e05b432844b4 -->

Within `/v1` the API changes only **additively**:

| May be added within `/v1` | Never within `/v1` (that would be `/v2`) |
|---|---|
| new optional request fields | new required request fields |
| new response fields | removing or renaming fields |
| new endpoints | changing the type or meaning of a field |
| new `ERR_*` codes, each with documented handling in the error catalogue | changing the meaning of an existing `ERR_*` code |

What the buyer has to do in return:

- **Ignore unknown response fields**, do not reject them.
- Handle **unknown `ERR_*` codes** by HTTP status (`4xx`: do not retry
  blindly, `5xx`/`429`: retry with backoff, section 7).
- `message` and `warnings` are texts for humans and not part of the promise.

Fixed within `/v1`: timestamps are RFC 3339 in UTC (`Z`), amounts are whole cents (1.4),
text lengths are checked before writing (`422` with field name, never `500`, appendix),
`/v1/health` needs no key (1.8).

### 1.10 Signing in to the partner portal
<!-- de:e1506791fd15 -->

The portal (documentation, your own accounts and keys) is not part of the API; the API needs only
the key. Signing in to the portal works like this:

- The tour operator creates the portal account. You receive a user name and a **one-time
  password** (valid for 30 days).
- **First sign-in:** user name and one-time password, then set up the **second factor**
  (authenticator app per RFC 6238: scan the QR code or type the key, enter the six-digit code),
  then choose your own password. Without a valid one-time password the second factor cannot be
  set up.
- **Every later sign-in:** password and current code. A code is valid only once.
- **Wrong codes:** after 5 the sign-in ends. 10 failed attempts within 7 days lock the account,
  even for the correct code. Only the tour operator can unlock it.
- **Locked or phone lost:** contact the tour operator. “Lift lockout” releases the account with
  the existing second factor. “Reset 2nd factor” issues a new one-time password; you then set up
  the second factor and your password again, the old password no longer works.
- Portal and tour operator console are separate: portal credentials do not work in the console.

---

## 2. Integration flow
<!-- de:8bccf11eafcc -->

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

- `/v1/destinations`: valid `destination` codes of the key.
- `/v1/search`: bookable hotels with from-price and availability.
- `/v1/price`: price information for hotel, room, board and occupancy (binding only with
  `/v1/book`); `/v1/prices`: all boards (and rooms) at once.
- `/v1/book`: books, with `idemKey` and `priceCheck` (the price just quoted).
- `/v1/booking`: reads the booking via our reference or your own.
- `/v1/cancel`: cancels via the same `idemKey`.

Rules you need to know:

1. **Search and price are informational; only `/v1/book` is binding.** Between price and
   booking the tour operator can change prices or allotment. With `priceCheck`,
   `/v1/book` rejects a changed price (`409 ERR_PRICE_DRIFT`) instead of silently booking at the new
   one.
2. **Every booking has its own `idemKey`** (assigned by the caller, e.g. your own
   transaction number). Retries with the same key never book twice (section 9).
3. **Do not retry errors blindly.** Which errors are worth a retry is stated in the
   error catalogue (column "Caller").

---

## 3. Price: `/v1/price` and `/v1/prices` (BA)
<!-- de:4e22b7e304e6 -->

### 3.1 `POST /v1/price` – one price
<!-- de:6ff33502f929 -->

Prices exactly one room of a hotel for one stay, one board and one
occupancy.

| Field | Required | Meaning |
|---|---|---|
| `hotel` | yes | Hotel code |
| `room` | no | Room code. If omitted, the API takes the **cheapest available** room that allows the occupancy and offers the board; if none is available, the cheapest of those (then `available=false`, 3.3). A room whose contract data the calculation core rejects (7.5, e.g. `ERR_INVALID_AMOUNT`, `ERR_NO_SECTION`) is skipped and named in `warnings` with room and code; if no room calculates, the error comes as 422. With `room`, that room's error always comes as 422. |
| `board` | yes | Board code, exactly as in the contract (`RO`, not `ro`). Missing: `ERR_BOARD_MISSING`; if the room (without `room`: no room) does not offer it: `422 ERR_BOARD_NOT_OFFERED` |
| `checkIn`, `checkOut` | yes | Stay (section 1.3) |
| `occupancy.travellers[]` | yes | Travellers with `age` |
| `currency` | no | Preferred currency, note only (1.4) |
| `now` | no | tolerated, ignored (1.3) |

Response:

| Field | Meaning |
|---|---|
| `room` | the priced room (without `room` in the request: the cheapest available); pass it like this to `/v1/book` |
| `currency` | Contract currency, empty = not set on the contract |
| `totalCents` | Total price of the room for the stay |
| `rounding` | Rounding rule of the response (section 1.4) |
| `perTravellerCents[]` | Price per traveller, ordered by **age descending** (oldest first, same age in request order): the exact sum of their line items, rounded once. Sum = `totalCents`. With room prices (per-unit price) the first traveller carries the room line items; board is shown with the respective traveller. |
| `breakdown[]` | Line items: `chargeType` (`BaseCharge`, `PhantomBaseCharge`, `GuestCharge`, `BoardCharge`, `Extra`), `code`, `traveller` (index in `perTravellerCents`), `night` (index from 0, `-1` = per stay), `amountExact` (exact amount as a decimal, e.g. `"-13.485"`), `amountCents`, for extras `applianceCode`, for extras of an extra family (several extras with the same `applianceCode`) additionally `variant` (the variant; only both together identify the extra), for the line items of a free night additionally `freeNight: true` (this night is waived in full or in part, e.g. "7=6": the seventh night carries one line item per traveller that cancels the base price and – depending on the offer – board). Order: night, within it traveller, within that calculation step (base price, person discount/surcharge, board including its person discount, extras); per-stay line items last. |
| `separateExtras[]` | only if present: separately listed extras. `inTotal=true`: mandatory extra, included in the total price. `inTotal=false`: optional extra, **not** in the total price. `code` identifies the extra, for an extra family together with `variant`. `amountCents` is the exact sum of the extra, rounded once; with `inTotal=true` it can differ from the sum of its `breakdown` rows by several cents – `breakdown` is authoritative. |
| `availability` | `configured` (allotment set up), `available` (every night open), `minFree` (smallest free count across the nights, exact up to 99; `-1` = more than 99 free every night or free sale; `0` = at least one night not bookable) |
| `warnings[]` | Warnings (1.6); without `room` also one per skipped room with a contract error: `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}
}
```

Without `room` the API looks for the cheapest matching room – for one person, here the EZ;
`room` in the response names it.

```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}
}
```

Rooms that are sold out, blocked, have no allotment set up or – with a customer group's key –
are not allocated are skipped by the selection without `room`, even if they are cheaper: in the hotel below the EZ (70 EUR) has no allotment
and is not bookable, so the API takes the more expensive, bookable 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}
}
```

**Price per person with room prices.** Many contracts price the room, not the person
(per-unit price). Then all room line items – base price, extras per room or per transaction –
are in the first traveller's pot in `perTravellerCents`, and the others carry `0` (in the
`preis-einzeln` example: `[18000, 0]`). Board is always a price per person and night,
even with a per-unit price: it is shown with the respective traveller (with breakfast `[22000, 4000]`,
see `preis-alle-verpflegungen`); a single traveller pays the full room price but only
one board. A fixed discount per person (e.g. early booking −20 € per person) reduces the
room price with a per-unit price and is therefore also in the first pot. This is intentional and will stay so
(EDF 5.1.6: "object-based additional services are attributed to the 1st person"; each pot is
rounded once, the sum is always exactly `totalCents`). The first pot belongs to the **oldest**
traveller – `perTravellerCents` and `breakdown[].traveller` are ordered by age descending,
not by request order. Person-related line items (e.g.
child prices) are shown with the respective traveller. `perTravellerCents` is therefore not
meant for a "per person" display: divide `totalCents` by the number of travellers
and round it yourself.

The price depends on the key: the same request with a customer group's key returns its
special price (here −20 %). `TEST-PARTNER-DISCOUNT` is a price group: it books from the
general inventory, and `availability` is the same as without a group (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}
}
```

Per-person price with child reduction: −15 % on 89.90 is −13.485 – a fraction of a
cent. The line item stays exact (`amountExact`); the child's sum is rounded once:
89.90 − 13.485 + 79.90 − 11.985 = 144.33 → `perTravellerCents[2]` = 14433. A traveller's `amountCents`
are distributed so that they add up exactly to their price: each row is the
traveller's rounded running sum up to this row minus that up to the previous row
(8990, −1348, 7990, −1199 = 14433). Rounding line items individually and adding them (−13.49, −11.99)
gives 144.32 – that is not the price.

```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}
}
```

**Free nights ("7=6").** The line item that waives a night is an `Extra` with the
offer's `applianceCode` and `freeNight: true`; it is included in the total price and appears in
the waived night per traveller directly after that traveller's base price. In the test world
(`TEST-HOTEL-FREINACHT`, DZ, room only, price per person: 100.00 first night, 80.00 each
further night, `checkIn` = `{{D0}}`) 2 adults pay 1000.00 instead of 1160.00 for 7 nights: night 6
(the seventh) carries per traveller `{"chargeType": "Extra", "code": "PN", "night": 6, "amountExact":
"-80.00", "applianceCode": "FSO1", "freeNight": true}`. With half board the same line item
also waives the board (`-115.00`). 14 nights give two free nights (nights 12 and 13), 6 nights
none.

### 3.2 `POST /v1/prices` – all boards at once
<!-- de:6d0181b133e5 -->

Like `/v1/price`, but instead of `board` optionally `boards[]` (if omitted: all boards of the
room) and `room` optional (if omitted: all rooms). Rooms that do not allow the occupancy
are dropped; if none allows it, the reason comes as an error
(`ERR_OCCUPANCY_NOT_ALLOWED`). A requested board that no room offers:
`422 ERR_BOARD_NOT_OFFERED`. The `board` field does **not** filter here: if you send it,
you still get all boards and a note in `warnings` (example below);
filtering works only with `boards[]`.

Response: `currency`, `rounding`, `rooms[]` with `room` and `boards[]` (per board `board`,
`globalType` (board type as in the EDF export and the open search: `AO` for `RO`, otherwise the tour operator's mapping, otherwise the code itself if it is a board type, otherwise `XX` = not assignable), `totalCents`, `perTravellerCents`, `separateExtras`,
`availability`), per room in ascending price order; `warnings`. No `breakdown`.

If a room's board does not calculate for this occupancy because the contract is
faulty there (`ERR_NEGATIVE_TRAVELLER_PRICE`, `ERR_NEGATIVE_PERCENT_BASE`, section 7.5)
or because it is not sold for this travel party (`ERR_BOARD_NOT_AVAILABLE`, 3.5),
only this combination drops out: it appears without a price in `rooms[].errors[]` (`board`,
`globalType`, `errorCode`, `message`) and in `warnings`; the rest of the matrix stays priced,
and the room's `boards[]` may then be empty. The same applies to any other contract error of a
room (section 7.5, e.g. `ERR_NO_SECTION`: no season in the contract for a night): the
affected boards appear with the code in `rooms[].errors[]` and in `warnings`, all
other rooms stay priced, with the same prices as `/v1/price` with `room` and `board`.
If not a single combination calculates, the code comes as an error (422). Errors in the
request itself still reject it as a whole.

**Time limit:** `/v1/prices` and `/v1/price` without `room` calculate several rooms or
boards; if this exceeds 2 s (in normal operation it takes a few milliseconds), the response is
`503 ERR_PRICE_TIMEOUT` – never half a matrix or a "cheapest" room from a subset
of the rooms. Remedy: specify `room` or `boards`. The deadline always applies to `/v1/prices`, even
with `room` and a single board; only `/v1/price` **with** `room` has no deadline of its own
and is therefore the safe way out. The `503` carries no `Retry-After`: narrow down first,
then retry. If the caller closes the connection,
the calculation is aborted and there is no response.

```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}}
      ]
    }
  ]
}
```

With `board` instead of `boards[]` you get the same matrix as without it, plus the warning
`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 Availability in price responses
<!-- de:b89228c85f35 -->

`availability` is always set and refers to the priced room across all nights.
A price with `available=false` is **price information, not an offer**: the booking
fails (section 5.3). What is counted is exactly what `/v1/book` sells against: free
capacity per night after bookings and daily status, and with an allotment group's key
additionally its allocation (what the group has already booked is deducted; without an allocation
for room and night nothing is free). A price group counts like a key without a group (1.1). `minFree` is the smallest of these across the nights.

Per night TourAPI counts exactly up to 99. What each night contributes to `minFree`:

| Night | Contribution to `minFree` | Allotment file (section 10) |
|---|---|---|
| 1 to 99 units free | the count | `01`–`99` |
| more than 99 free or free sale | does not lower `minFree` | `**` |
| Stop sale | `0` | `SS` |
| On request | `0` | `RR` |
| sold out, closed, no capacity, customer group without allocation for room and night | `0` | `00` |

`minFree` is `-1` only if **every** night has more than 99 free or is in free sale; a
single night with stop sale, on request or `0` makes `minFree` `0` (`available=false`). This
is the same representation as in the EDF allotment of the cache delivery, so that cache and API
show the same number.

- `configured=true`: TourAPI knows an allotment for this room. That says nothing yet
  about each individual night: a night for which no capacity is entered makes the
  room `available=false` (`minFree` 0), but it stays `configured=true`.
- `configured=false`: no allotment at all is set up for this room; booking is not possible.

If only individual nights lack capacity, search and `/v1/book` give the same reason:
`ERR_NO_INVENTORY` (search 4.2, booking 5.3). What counts for "bookable" is `available`, not
`configured`.

`availability` is a snapshot, not a commitment; only `/v1/book` checks bindingly. A booking
or a cancellation is reflected by the server node that executed it in the next request
(`minFree` drops or rises). Other nodes, bookings from other channels and contract changes
catch up after a few seconds – as does the own node in the rare case that it could not
reload the new state immediately (the booking itself is still valid).

### 3.4 Sales rules of the room: minimum stay, arrival days, sales window, release
<!-- de:0807e2cfe292 -->

A hotel contract can define which stays the hotel accepts, for example
"high season at least 7 nights, arrival on Saturdays only", "half board only for arrival
until 31.10." or "release 14 days" (book at the latest 15 days before arrival). Such rules do not change any price; they decide **whether** a room is offered for the
requested stay. A stay excluded by a rule gets
no price:

| Code | The rule requires … | Caller |
|---|---|---|
| `ERR_STAY_LENGTH_NOT_ALLOWED` | a different length of stay (minimum or maximum nights) | change the length |
| `ERR_ARRIVAL_DAY_NOT_ALLOWED` | a different arrival or departure weekday | shift the travel dates |
| `ERR_TRAVEL_DATES_NOT_ALLOWED` | travel dates within a specific period (sales window) | different period |
| `ERR_BOARD_NOT_ALLOWED` | a different board for this stay | different board |
| `ERR_LEAD_TIME_NOT_ALLOWED` | more lead time: the arrival lies within the release period | later arrival |

`message` names room, rule and requirement, such as "Zimmer DZ, Verkaufsregel 1: wenn
Anreise 2026-07-01 bis 2026-08-31, verlangt mindestens 7 Nächte". If a stay violates
several parts of a rule, this order applies: length, weekday, period, board, release.

**Release (lead time).** A rule with a release of n days requires **more than** n
calendar days between the reference date (server date, 1.3) and arrival: with release 3 and
reference date 10.05., arrival on 13.05. is still closed, from 14.05. it can be booked; with
release 0 only arrival on the reference date itself is closed. The release can be tied to a
period (e.g. "from September 14 days"): it then applies as soon as one night of the stay lies
in that period, measured up to arrival. If the rule refers to departure (`ApplyTo`
`Departure`), the release is measured up to departure: more than n calendar days are then
required between reference date and departure. `message` names the reference date, arrival and
the lead time. A stay closed today cannot open tomorrow; retrying does not help.

- `/v1/price`: without `room`, a room whose rule excludes the stay drops out of the
  selection; if no room accepts it, the reason comes as 422. It takes precedence over
  another room's `ERR_OCCUPANCY_NOT_ALLOWED` because it says more precisely what to change.
- `/v1/prices`: rules can apply per board; only the affected
  board is dropped. If nothing remains, the reason comes as 422.
- `/v1/search`: the hotel is dropped, the code appears in `diagnostics.reasons`.
- `/v1/book`: with `priceCheck` the rule applies to the requested board; without
  `priceCheck` (board unknown) the booking is sold as soon as one board of the room accepts the
  stay.

`ERR_ROOM_RESTRICTION_INVALID` (422) means: a rule in the contract cannot be evaluated. This
is a contract error – report it.

### 3.5 Board only for certain travel parties
<!-- de:a30ca6afd1dc -->

A board surcharge can be tied to the composition of the travel party, e.g.
"half board surcharge applies to parties with at least 2 adults". If the requested
occupancy does not match, this board is **not bookable** for this occupancy:
`422 ERR_BOARD_NOT_AVAILABLE`. TourAPI never calculates a price for a board without the
board. `message` names board, traveller, night and the required party. This is
not a contract error; a different board or occupancy may calculate (caller: different
board or occupancy).

- `/v1/price`: without `room`, the room drops out of the selection for this board (as with
  a sales rule, 3.4); if no room calculates, the reason comes as 422.
- `/v1/prices`: only this board of the room drops out, as an entry in
  `rooms[].errors[]` with `errorCode` (3.2); the rest stays priced.
- `/v1/search`: the hotel is dropped, the code appears in `diagnostics.reasons`.
- `/v1/book` with `priceCheck`: 422, nothing is booked.

---

## 4. Search: `/v1/search` (BA)
<!-- de:710284c16578 -->

### 4.1 Request and response
<!-- de:2e19c0fd56a1 -->

Finds the tour operator's bookable hotels for one stay, one board and
one occupancy, each hotel with the cheapest **available** room (like `/v1/price` without
`room`): a cheaper sold-out room does not drop the hotel from the list
as long as another room is bookable.

| Field | Required | Meaning |
|---|---|---|
| `destination` | no | Destination or airport code of the hotel (below); empty = all hotels |
| `board`, `checkIn`, `checkOut`, `occupancy`, `currency`, `now` | as `/v1/price` | |
| `includeUnavailable` | no | `true`: non-bookable hotels are included, flagged (default `false`) |
| `pageSize`, `cursor` | no | Pages, section 4.3 |

Response:

| Field | Meaning |
|---|---|
| `results[]` | Hits of the page, ascending by `fromTotalCents`, ties by hotel code |
| `results[].hotel`, `name`, `room` | Hotel and the room the price refers to |
| `results[].fromTotalCents`, `currency` | From-price (cheapest available room; with `bookable=false` the cheapest overall) in the hotel's contract currency |
| `results[].availability` | as with `/v1/price` |
| `results[].bookable` | `true` = all nights available |
| `results[].reason`, `priceInformational` | only with `bookable=false` (only with `includeUnavailable`): reason and "price information only" flag |
| `diagnostics` | per page: `considered` = hotels checked = `returned` + `skipped`; `reasons[]` = why hotels were dropped, per reason with count; `timeBudgetExhausted` = page cut short due to the time budget (4.3); `roomErrors[]` = rooms of the checked hotels that were skipped due to a contract error (7.5), per code with count – even if the hotel matches with another room |
| `nextCursor` | set as long as further hotels remain to be checked (4.3) |
| `warnings[]` | Warnings; with mixed contract currencies a collective warning |

The search does not name the hotel codes of dropped hotels, only reasons and counts. An empty
hit list therefore does not mean "no inventory": `diagnostics.reasons` tells whether e.g. the season
does not match (`ERR_NO_SECTION`), everything is sold out (`ERR_NOT_AVAILABLE`), no allotment
is set up for a night (`ERR_NO_INVENTORY`) or no room
of the hotel offers the requested board (`ERR_BOARD_NOT_OFFERED`).

**Where the codes for `destination` come from:** Every hotel has a destination code (e.g. `PMI`)
in the tour operator's contract and optionally airport codes; a hotel matches if
`destination` is **exactly** equal to one of them (case-sensitive). Destination codes
consist only of `A-Z a-z 0-9 . _ -` (1 to 64 characters, no free text); airport codes are
IATA codes of three capital letters. The valid codes
for the key are returned by `GET /v1/destinations` (4.5). A code for which there is no hotel for this key
is an error: `422 ERR_UNKNOWN_DESTINATION` (example `suche-unbekanntes-ziel`), never
a silently empty list. The check is done on the first page (without `cursor`); if the destination disappears
while paging, the search ends with an empty final page (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}
}
```

The same code in lower case is a different, unknown destination for the API:

```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 Non-bookable hotels
<!-- de:c672dd1d20b9 -->

By default the search returns only bookable hits and counts the rest in `diagnostics`.
With `includeUnavailable=true`, non-bookable hotels are returned with `bookable=false`, `reason`
and `priceInformational=true`. The reasons mean the same as with `/v1/book`:

- `ERR_NO_INVENTORY`: for at least one night no allotment at all is set up – none at all for the
  room (`configured=false`) or just not for this night. `/v1/book`
  rejects the same stay with `ERR_NO_INVENTORY`.
- `ERR_NOT_AVAILABLE`: every night has an allotment, but not every night is open (sold out,
  stop sale, closed, on request, customer group allocation exhausted or not
  present). Which case exactly is named by `/v1/book` (`ERR_SOLD_OUT`, `ERR_STOP_SALE`,
  `ERR_INVENTORY_CLOSED`, `ERR_GROUP_LIMIT`, section 5.3).

In the example `TEST-HOTEL-SOLD` has no capacity for the night, `TEST-HOTEL-STOP` is
on 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 Pages and cursor
<!-- de:3844e71067b2 -->

- A request checks **one page** of at most `pageSize` hotels (default 50, at most
  100; outside 1–100: `422 ERR_BAD_PAGE_SIZE`) in hotel code order.
- If there are more hotels, `nextCursor` is in the response. The next page: **the same
  request** plus `"cursor": "<nextCursor>"`. On the last page `nextCursor` is absent.
- Price sorting applies **per page**. If you need a complete list by price, page
  through and sort yourself. `diagnostics` applies per page.
- The cursor is opaque and bound to request and key. Different request (destination,
  period, occupancy, board, `includeUnavailable`) or different key: `422
  ERR_CURSOR_MISMATCH`. Unreadable or expired cursor (e.g. after a server
  restart): `422 ERR_BAD_CURSOR` – then start over without `cursor`. `pageSize` and
  `currency` may change between pages. The length of the cursor is fixed and says
  nothing about the hotel.
- **Truncated page:** Under load a page does not start another hotel after 60 ms. It is
  then shorter than `pageSize`, `diagnostics.timeBudgetExhausted=true`, and `nextCursor` leads
  on. This is not an error: just keep paging.
- If you send neither `pageSize` nor `cursor` and there are more hotels, there is also a
  note in `warnings` – the list is then only the first page.

```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 Search load and time limits
<!-- de:0c0f3af46ae9 -->

Search is the most expensive request. Per tour operator up to 8 searches run concurrently; they
share their computing time (one quarter of the server's cores per tour operator), so that a
noisy tour operator does not slow down the others. If this quota is full, the response is immediately
`429 ERR_SEARCH_BUSY` with `Retry-After: 1`. A search that computes for longer than 10 s aborts
with `503 ERR_SEARCH_TIMEOUT` – never with a silently shortened list (a page truncated
under load, by contrast, always has `nextCursor`, 4.3). Remedy: set `destination` or use a
smaller `pageSize`. If the caller closes the connection, the search aborts its calculation
and no longer responds.

### 4.5 `GET /v1/destinations` – valid destination codes
<!-- de:1a42c6ea9f71 -->

Returns all codes that `destination` in this key's search can filter on: destination
and airport codes of the tour operator's hotels, sorted ascending, each code once. Only
its own – a key never sees destinations of another tour operator, and an
allotment group's key sees only the destinations of its hotels with an allocation (1.1; without an allocation the list is
empty). No parameters; the list changes when the tour operator adds or
removes hotels – for an allotment group's key also when the tour operator changes its allocations,
and for any customer group's key when the tour operator switches the group's kind.

| Field | Meaning |
|---|---|
| `destinations[].code` | Code, to be passed exactly like this in `destination` |
| `destinations[].name` | Plain text for display; absent if TourAPI does not know the code |
| `warnings[]` | Warnings, e.g. about parameters sent along (they are ignored) |

```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. Open search: `POST /v1/search/open` (BA)
<!-- de:21abb0f088a4 -->

### 4a.1 What for
<!-- de:92d0c68259b8 -->

“Where is it cheapest between 1 and 30 October for 5 to 7 nights?” – without a hotel code
and without fixed dates. The open search returns the **best offer** per hotel across all
arrival days of the window, all lengths of the range, all rooms and boards, **sorted
globally** across all pages. Two promises:

- **The price is exact:** `best.totalCents` and `best.perTravellerCents` are bit-identical to
  `/v1/price` with the same hotel, room, board, arrival and departure and the same
  occupancy. No from-prices from a cache, no sampling.
- **The order is proven:** no hotel that was not shown has a better best offer than the last
  result. Under load only the page gets shorter (4a.4), never the price less exact or the
  order guessed.

Flow of a website: open search (list) → “dates & prices” of a hotel with the date matrix
(4b, same window) → chosen offer in detail with `/v1/price` (`hotel`, `room`, `board`,
`checkIn`, `checkOut` from `best` or the cell, the same occupancy: the same price including
its breakdown; other boards of these dates one board at a time with `/v1/price` without
`room`) → `/v1/book` with `priceCheck.expectedCents` = the price shown. There is no offer
token: the booking checks the price anyway.

**Permission:** The open search is enabled per key (off for new keys). The tour operator
admin sets the **search profile** in the key (window, lengths, destinations, page size, time
budget, rate); at most what the operator grants the tour operator applies. Without
permission: `403
ERR_OPEN_SEARCH_NOT_ALLOWED`. The API checks the profile's limits per field and names them in
the error message (4a.5); `GET /v1/limits` returns them machine-readably (4c).

### 4a.2 Request
<!-- de:2d7797b631a5 -->

| Field | Required | Meaning |
|---|---|---|
| `destinations` | no | Destination codes as in `GET /v1/destinations` (4.5), union; at most as many as the profile allows |
| `hotels` | no | Hotel codes of the key; not together with `destinations`. Without either: the key's whole inventory – only if the profile allows all destinations |
| `arrivalFrom`, `arrivalTo` | yes | Arrival window, both days inclusive; `arrivalFrom` ≥ reference date, `arrivalTo` ≤ reference date + 732 |
| `nightsMin`, `nightsMax` | yes | Length from–to (1–30 and within the profile); every length in between counts |
| `occupancy` | yes | one room, as for `/v1/price` |
| `boards` | no | Board codes exactly as in the contract; without = all |
| `boardTypes` | no | Board type as in the EDF export (`AO`, `BB`, `HB`, `HB+`, `FB`, `FB+`, `SC`, `AI`, `AI+`, `XX`); together with `boards`: both must match |
| `minTotalCents`, `maxTotalCents` | no | Filter on the total price, limits inclusive |
| `currency` | conditional | Filter on the contract currency, no conversion. Required if the hotels of the search price in several currencies (`422 ERR_CURRENCY_REQUIRED`) |
| `category` | no | official category (national classification from the hotel master data): `scheme` `stars` or `keys` (required), `min`/`max` level 1 to 5 in half steps as a number (e.g. `3.5`, equivalently `3.50`), limits included (without: 1 or 5). "4 Superior" counts as 4 |
| `regions` | no | regions from the hotel master data (address), one of them; compared exactly as maintained (case matters); at most 50 |
| `geo` | no | radius: `lat`, `lon` (decimal degrees as a number, at most 6 decimal places) and `radiusKm` (0.001 to 500, at most 3 decimal places). Distance on the sphere (haversine, earth radius 6371 km), rounded to whole metres; the edge counts as inside. Correct across the date line and at the poles |
| `sort` | no | `price` (default: total price), `pricePerNight` (price per night, compared exactly as a fraction, without rounding), `hotel` (hotel code) |
| `pageSize` | no | Results per page, 1 up to the profile; if omitted 20 (or fewer if the profile allows fewer) |
| `cursor` | no | `nextCursor` of the previous page, unchanged (4a.4) |

Unlike the other query endpoints, the open search rejects **every** unknown field
(`422 ERR_UNKNOWN_FIELD`) – otherwise a typo in a filter would silently change the result
list. There is no `now`; the server's reference date applies (1.3).

**Filters on hotel master data** (`category`, `regions`, `geo`; combined with AND) apply before
the calculation: a hotel outside does not belong to the search scope – it counts neither in
`hotelsInScope` nor against the hotels per request of the search profile
(`ERR_SEARCH_TOO_BROAD`), so a radius across the whole inventory works too. If a hotel lacks
the value a filter needs (no official category, no region, no coordinates) and no existing
value excludes it, it counts in the search scope with the reason `ERR_NO_CATEGORY`,
`ERR_NO_REGION` or `ERR_NO_GEO` (first missing value in this order) – gaps in the hotel master
data stay visible. The tour operator maintains the values in the hotel master data (console:
hotel → Content → Master data & location); a change takes effect after the next sync
(seconds) and changes `stand` (4a.4).

### 4a.3 Response
<!-- de:de059448ccbb -->

| Field | Meaning |
|---|---|
| `results[].hotel` | Hotel code; order by the criterion of `sort`, ties by hotel code |
| `results[].best` | best offer: `checkIn`, `checkOut`, `nights`, `room`, `board`, `boardType` (board type as in the EDF export), `currency`, `totalCents`, `perTravellerCents`, `availability` (as for `/v1/price`, always `available: true`) |
| `results[].alternatives` | **Pre-check without price:** `dates` = dates (arrival × length) with at least one offer according to availability, sales rules, season and occupancy, `boards`/`boardTypes`/`rooms` accordingly. An upper bound: the price calculation may still exclude some of them. Never read it as a statement about prices or results |
| `coverage.complete` | `true`: page full or list finished. `false`: the time budget shortened the page (4a.4) |
| `coverage.timeBudgetExhausted` | Page shortened because of the time budget (= `complete: false`) |
| `coverage.standChanged` | the data has changed since the previous page (4a.4) |
| `coverage.hotelsInScope` | Hotels in the search scope (destinations or `hotels`, inventory of the key, filters on hotel master data; hotels without the filtered value count too, 4a.2) |
| `coverage.hotelsFeasible` | of those, with at least one date according to the pre-check – upper bound of the number of results (“up to N hotels”), not a result count |
| `coverage.hotelsPriced` | hotels calculated exactly on this page (also depends on how many hotels the server calculates in parallel; therefore left open in the examples) |
| `coverage.undecided` | Hotels whose position was still open when the time budget ran out (0 when `complete`) |
| `coverage.reasons[]` | Hotels without a single date, per reason – for the whole search scope, the same on every page. If no room offers a matching board: `ERR_BOARD_NOT_OFFERED`. As with `/v1/search`, availability comes next: if no room has even one free date, it is the reason (`ERR_NO_INVENTORY`, `ERR_NOT_AVAILABLE`). Otherwise what `/v1/price` answers for the first free date (room, board, arrival, length in this order) applies, e.g. `ERR_OCCUPANCY_NOT_ALLOWED`, `ERR_NO_SECTION`, `ERR_STAY_LENGTH_NOT_ALLOWED`, or `ERR_OUTSIDE_PRICE_FILTER` if its price lies outside the filter; in addition `ERR_CURRENCY_NOT_AVAILABLE` (hotel prices in a different or unknown currency), `ERR_HOTEL_NOT_FOUND` (no offer for the customer group) and `ERR_NO_CATEGORY`, `ERR_NO_REGION`, `ERR_NO_GEO` (the hotel lacks the value of a filter on hotel master data, 4a.2) |
| `coverage.priceReasons[]` | Hotels that dropped out only during the exact calculation on this page, per reason (e.g. `ERR_OUTSIDE_PRICE_FILTER`) |
| `coverage.roomErrors[]` | Rooms with a contract error (7.5), per code – never silently skipped |
| `stand` | Identifier of the data state of this page (opaque) |
| `nextCursor` | set as long as further results may follow |
| `warnings[]` | Notes, e.g. about a change of the data state |

**Best offer of a hotel:** the minimum of the criterion (total price or price per night)
over all dates × rooms × boards of the filter, bookable offers only. On a tie the earlier
arrival wins, then the shorter length, then room and board in the order of the contract.
With `sort=hotel` the criterion is the total price.

```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": "{{*}}"
}
```

The next page is the same request with `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": "{{*}}"
}
```

Across the whole inventory, by price per night, only breakfast or half board. Hotels without
allocation in the window appear as a reason 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": "{{*}}"
}
```

With filters on hotel master data across Palma and Menorca: at least 3.5 stars, region Mallorca,
25 km around Palma. The 3-star hotel in Alcúdia drops out, the two hotels on Menorca without
maintained master data appear as a reason:

```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 Pages, cursor, data state, time budget
<!-- de:e3fd213995ec -->

- **Pages:** `nextCursor` leads to the next page: **the same request** plus
  `"cursor": "<nextCursor>"`; `pageSize` may change. The next page continues after the
  last result (criterion, then hotel code) – all pages together are **one**
  sorted list. On the last page `nextCursor` is missing; that page may also be empty. With
  `sort=price` and `pricePerNight` a next page does not recompute the hotels of earlier pages:
  even deep into the list a page costs about as much as the first one.
- **Cursor:** opaque, bound to request and key, valid for **15 minutes**. Different
  request (any field except `pageSize`) or different key: `422 ERR_CURSOR_MISMATCH`.
  Unreadable, expired or after a server restart without a fixed cursor key: `422
  ERR_BAD_CURSOR` – then start over without `cursor`. A cursor from `/v1/search` is not
  valid here.
- **Data state:** `stand` changes when the tour operator publishes contracts, promotions or
  allocations or changes the region, coordinates or official category of a hotel in the
  hotel master data, and at midnight (reference date), but not on bookings or other content
  (texts, images). If the data state has
  changed since the previous page, the next page continues from the same position in the
  **new** state, with `coverage.standChanged: true` and a note in `warnings`. Then (as also
  with bookings by others between two pages) a hotel may appear twice or be missing:
  deduplicate by `hotel`. The prices of each page apply to its data state; when booking,
  `priceCheck` secures the price.
- **Time budget:** Each page has a time budget (search profile, e.g. 150 ms), counted from
  the arrival of the request. Once it is used up and the page already has a result, it
  calculates no further hotels; results whose position is already fixed are still included.
  Then: `coverage.complete: false`, `timeBudgetExhausted: true`, `undecided` > 0 and
  `nextCursor` for the rest. The results shown are still exact and in proven order – just
  keep paging. A page never ends without a result as long as there is one.
  Shortened pages are not an error. They occur mainly with very large searches (a long
  arrival window with a wide range of stay lengths in the largest destination) while several
  searches of the same tour operator are running at the same time. If you need full pages,
  query smaller windows or ranges, or search one after another instead of in parallel.
- **Load:** The open search shares the search slots and computing time of the tour operator
  with `/v1/search` (4.4), but occupies at most half of the search slots (4 of 8; all keys of
  the tour operator and both endpoints together) – the rest stays free for `/v1/search`. In
  addition the rate and concurrent searches of the search profile apply per key
  (`429 ERR_RATE_LIMITED` or `ERR_SEARCH_BUSY`, with `Retry-After`); a search rejected with
  `429 ERR_SEARCH_BUSY` does not consume rate. If a search calculates for longer than 10 s,
  it ends with `503 ERR_SEARCH_TIMEOUT`, never with a partial page.
- **Not provable:** If the search cannot prove price, order or availability for a request
  (an error in the server, not a request error), it answers `500 ERR_INTERNAL` instead of a
  possibly wrong page.

### 4a.5 Errors of the open search
<!-- de:b477cf77b3ae -->

The checks run in this order; the first finding is reported: key → permission → body and
unknown fields → field format → limits of the API (1.3) → limits of the search profile →
destinations, hotels, board, currency → cursor → rate and search slots → calculation. A
rejected request occupies no search slot and does not count as a search. The codes are in
the error catalogue (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)"}
```

The customer group's key may only search in one destination:

```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)"}
```

A filter with a wrong value names the field:

```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. Date matrix: `POST /v1/search/open/dates` (BA)
<!-- de:1ecb6c006f14 -->

### 4b.1 What for
<!-- de:99c6f48dd1bc -->

“Dates & prices” of a hotel: for **one** hotel and the same window as the open search, per
date (arrival × length) the cheapest bookable offer or the reason why there is none.
Permission and limits come from the same search profile as for the open search (4a.1); the
profile's rate and concurrent searches apply to both endpoints together.

- **The price is exact:** every cell with an offer is bit-identical to `/v1/price` with the
  same hotel, room, board, arrival and departure and the same occupancy.
- **Matches the open search:** with the same filter and the same `stand`, `best` from 4a is the
  first cell with the smallest criterion (total price or price per night) – the same
  tie-break rule (earlier arrival, shorter length, room and board in contract order). With
  `perBoard`, `best` is the cell of its board at the first date with the smallest criterion.

### 4b.2 Request
<!-- de:69bed1913110 -->

| Field | Required | Meaning |
|---|---|---|
| `hotel` | yes | Hotel code of the key (e.g. `results[].hotel` from 4a) |
| `arrivalFrom`, `arrivalTo` | yes | Arrival window as in 4a.2; at most `matrixMaxWindowDays` days (search profile) |
| `nightsMin`, `nightsMax` | yes | Length from–to as in 4a.2 (allowed lengths of the profile) |
| `occupancy` | yes | one room, as for `/v1/price` |
| `boards`, `boardTypes` | no | as in 4a.2; every code in `boards` must be offered by the hotel (`422 ERR_BOARD_NOT_OFFERED`) |
| `rooms` | no | Room codes of the hotel; without = all (unknown: `404 ERR_ROOM_NOT_FOUND`) |
| `minTotalCents`, `maxTotalCents` | no | Filter on the total price as in 4a.2 |
| `currency` | no | Contract currency of the hotel; if the hotel prices in another currency or without a valid one: `422 ERR_CURRENCY_NOT_AVAILABLE` |
| `perBoard` | no | `true`: per date one cell **per board** (default `false`: one cell per date) |
| `category`, `regions`, `geo` | no | filters on hotel master data as in 4a.2, same effect: if the hotel lies outside, it is not part of the search space and the matrix has **no cell** (`cells` empty); if it lacks the value, every cell is `none` with `ERR_NO_CATEGORY`, `ERR_NO_REGION` or `ERR_NO_GEO` |

Cells = arrival days × lengths (× boards with `perBoard`), at most `matrixMaxCells`
(search profile, otherwise `422 ERR_SEARCH_TOO_BROAD`). There is no cursor. Every unknown
field: `422 ERR_UNKNOWN_FIELD`. The order of checks is that of the open search (4a.5), without cursor.

### 4b.3 Response
<!-- de:a7def6016c34 -->

| Field | Meaning |
|---|---|
| `hotel`, `currency` | Hotel and contract currency |
| `stand` | Data state as in 4a (same `stand` = same data) |
| `boards` | only with `perBoard`: the boards per date in contract order (first occurrence across the rooms, RO first, as in `/v1/prices`), in the order of the cells |
| `cells[]` | **dense** in the order `checkIn`, `nights` (with `perBoard` then board); every cell with `checkIn`, `checkOut`, `nights`, `status`. Empty only if the hotel lies outside the filters on hotel master data |
| `cells[].status = "offer"` | Offer: `room`, `board`, `boardType`, `totalCents`, `perTravellerCents`, `availability` (as for `/v1/price`, always `available: true`) – the cheapest room/board of the date, on a tie in contract order |
| `cells[].status = "none"` | no offer, reason in `reason`: the codes of `/v1/price`, the order of the open search (4a) – availability first (`ERR_NO_INVENTORY`, `ERR_NOT_AVAILABLE`), then sales rules, occupancy, season, price filter (`ERR_OUTSIDE_PRICE_FILTER`) or a contract error; with filters on hotel master data `ERR_NO_CATEGORY`, `ERR_NO_REGION`, `ERR_NO_GEO` (the hotel lacks the value, every cell) |
| `cells[].status = "unchecked"` | not checked because the time budget ran out – **never** read as “no offer” |
| `coverage` | `complete` (no cell `unchecked`), `timeBudgetExhausted`, `cells` = `offers` + `none` + `unchecked`, `roomErrors[]` (rooms with a contract error per code, 7.5) |
| `warnings[]` | Notes, e.g. on unchecked cells |

The cells run in blocks in the order of the matrix; once the profile's time budget is used
up, the remaining cells stay `unchecked` (the first block is always checked). Then narrow
the window or the lengths and ask again. If the matrix computes longer than 10 s:
`503 ERR_SEARCH_TIMEOUT`; not provable: `500 ERR_INTERNAL` (as in 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": []}
}
```

Per board, breakfast and half board only:

```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 arrival days × 14 lengths × 3 boards are more cells than the profile allows:

```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. Limits of the key: `GET /v1/limits`
<!-- de:51a6dfcc8750 -->

The effective limits of your **own** key, so that a website sets up date picker, length
selection and destinations instead of probing limits via `422`. No parameters (any: `422
ERR_UNKNOWN_FIELD`), does not count as a search.

| Field | Meaning |
|---|---|
| `rate` | Rate of all requests of the key (4.4): `perSecond`, `burst`, `scope` (`key` = per key and node; `testCircle` = shared pot of the test keys, 11) |
| `export.allowed` | Permission for the EDF delivery (10) |
| `content.allowed` | Access to the content API (`/v1/content/*`): the operator has enabled content for the tour operator **and** the key carries the content right – the same rule as there; without access `403 ERR_CONTENT_NOT_ALLOWED` |
| `openSearch.allowed` | Permission for the open search and the date matrix; without permission only this field is present |
| `openSearch.maxWindowDays`, `nightsMin`, `nightsMax`, `maxNightsSpan` | Arrival window, allowed lengths and length range per request (4a) |
| `openSearch.maxDestinations`, `maxHotels`, `maxCandidates`, `maxPageSize` | Destinations and hotels per request, hotels in the search scope, results per page (4a) |
| `openSearch.timeBudgetMs`, `rate`, `burst`, `concurrency` | Time budget per page or matrix, rate and concurrent searches (both endpoints together) |
| `openSearch.matrixMaxWindowDays`, `matrixMaxCells` | Window and cells of the date matrix (4b) |
| `openSearch.allDestinations`, `destinations` | all destinations allowed, otherwise the allowed destination codes – only those with hotels in the key's inventory (as `GET /v1/destinations`) |

The values are exactly those that `/v1/search/open` and `/v1/search/open/dates` apply: the
smallest of what the operator grants the tour operator and the key's profile. Which level
sets a limit is not part of the response. Changes to the profile take effect after a few
seconds.

```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}
}
```

A key without permission for the open search:

```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. Booking (B) and booking info
<!-- de:eca640daa2c5 -->

### 5.1 `POST /v1/book`
<!-- de:d7902ce86d55 -->

| Field | Required | Meaning |
|---|---|---|
| `hotel`, `room` | yes | Hotel and room code (booking is board-neutral; the board is in `priceCheck`). If `room` is missing: `400 ERR_INVALID_BUCKET`, with and without `priceCheck` |
| `checkIn`, `checkOut` | yes | Stay (section 1.3) |
| `quantity` | yes | Number of rooms, 1–1,000,000 (`400 ERR_QUANTITY_INVALID`) |
| `idemKey` | yes | your own unique key for the transaction (section 9), at most 128 characters; if missing: `400 ERR_INVALID_IDEM_KEY` |
| `reference` | no | your own booking reference, at most 128 characters; lets you read the booking later |
| `leadPaxName` | no | Name of the lead traveller, at most 255 characters |
| `metadata` | no | free-form JSON object, stored and returned by `/v1/booking`, not evaluated. `metadata.correlationId` (up to 64 characters) is adopted as the correlation ID. |
| `priceCheck` | no, **recommended** | Price check before the sale, see below |

Texts that are too long (`idemKey`, `reference`, `leadPaxName`, `metadata.correlationId`) are rejected by the API
before any sale with `422 ERR_VALIDATION`; `message` names field, length and limit. Nothing is
ever truncated.

`priceCheck`: `board`, `occupancy.travellers[]`, `expectedCents` (the price the customer
saw), `tolerancePercent` (allowed deviation in %, ≥ 0), optionally `currency` and
`now`. TourAPI recalculates the price like `/v1/price`; if it deviates by more than the tolerance:
`409 ERR_PRICE_DRIFT` with the current price in `message`, **nothing booked**. With
`priceCheck`, TourAPI stores the checked price on the booking (`totalCents`, `currency`,
`board` in `/v1/booking`). Without `priceCheck` the booking is a pure allotment sale without
a price (`totalCents: null`). Even then `/v1/book` only sells hotels that `/v1/price` knows for the
key: without a valid contract there is no hotel (`404 ERR_HOTEL_NOT_FOUND`), even if
an allotment is set up.

Response: `booked`, `alreadyBooked` (`true` = retry, nothing newly sold), `reference`
(our booking reference `TA-…`), `correlationId`. `correlationId` is the
`metadata.correlationId` sent along; if absent, TourAPI assigns a UUID and stores it on the record.
A retry (`alreadyBooked=true`) always returns the stored `correlationId` of the
first call – even if it sends none or a different one.

```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 Retrying is safe
<!-- de:81e75ea42a1c -->

The same request with the same `idemKey` again – e.g. after a network error – does not
sell again but confirms the existing booking (`alreadyBooked: true`, same
reference).

```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"}
```

The same `idemKey` with different booking data (hotel, room, stay, quantity) is an
error in the caller:

```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 Why a booking fails
<!-- de:b1c81ac323ca -->

If the sale fails on a night, `message` names the first affected night. Nothing
is booked (all nights or none).

| Code | Status | Meaning |
|---|---|---|
| `ERR_SOLD_OUT` | 422 | Night sold out |
| `ERR_STOP_SALE` | 422 | Tour operator has stopped sales |
| `ERR_INVENTORY_CLOSED` | 422 | Night closed or on request only |
| `ERR_NO_INVENTORY` | 422 | no capacity set up for a night |
| `ERR_GROUP_LIMIT` | 422 | the customer group's allocation is exhausted (or not present for room and night) |
| `ERR_HOTEL_NOT_FOUND` | 404 | Hotel does not exist for this key (also: no valid contract, with and without `priceCheck`; customer group without an offer, 1.1) |
| `ERR_PRICE_DRIFT` | 409 | Price deviates from the `priceCheck` |
| `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 | a sales rule of the room excludes the stay (3.4); nothing is booked |
| `ERR_BOARD_NOT_AVAILABLE` | 422 | the board of the `priceCheck` is not sold for this travel party (3.5); nothing is booked |

```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"}
```

A key with a customer group with an allocation books against its group's allocation, even in
nights on free sale. Here `TEST-PARTNER-BASE` has 5 allocated for the night:

```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"}
```

A price group books from the general inventory like a key without a group:

```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=…` – booking info
<!-- de:61342849d606 -->

`ref` is our reference (`TA-…`) **or** your own `reference` from the booking; our
reference wins. If your own `reference` matches several visible bookings (including
cancelled ones): `409 ERR_REFERENCE_AMBIGUOUS`, `message` names the matching `TA-…` references
(newest first, at most 10) – then read with our reference from the booking response. Response:
`reference`, `customerReference`, `hotel`, `room`, `group` (only for customer group bookings; group code
from `A-Z a-z 0-9 . _ -`, 1 to 64 characters),
`checkIn`, `checkOut`, `quantity`, `status` (`confirmed` | `released`), `bookedAt`,
`updatedAt` (last change, for a cancellation the cancellation time; both RFC 3339 in UTC, e.g.
`2026-09-26T08:15:03Z`), `metadata`, `totalCents`, `currency`, `board`. The API does not return
travellers' personal data.

| Booked … | then in the booking info |
|---|---|
| with `priceCheck` | `totalCents` = checked price, `currency` = contract currency, `board` = board from the `priceCheck` |
| without `priceCheck` | `totalCents: null`, `currency: ""`, `board: ""` (empty strings, no price recorded) |
| without `reference` | `customerReference` absent |
| without `metadata` | `metadata` absent |
| with a key without a customer group | `group` absent |

Visibility: a key with a customer group sees only its group's bookings. A key on the
base contract sees all bookings of the tour operator, including those of the customer groups – but it can
only cancel its own (section 6). Unknown or not visible:
`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"
}
```

Via your own reference:

```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"
}
```

A booking without `priceCheck`, without `reference` and without `metadata` – the response carries a
server-assigned `correlationId`, the booking info no price:

```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": ""
}
```

The same own reference on a second booking makes it ambiguous:

```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. Cancellation (S): `POST /v1/cancel`
<!-- de:6345c44aa08c -->

Only field: the booking's `idemKey` (if missing: `400 ERR_INVALID_IDEM_KEY`; any other
field: `422 ERR_UNKNOWN_FIELD`). The allotment of all nights is returned, the
status becomes `released`. Only what was booked with **the same key scope** can be
cancelled: a key with a customer group cancels its group's bookings, a key on the
base contract the base contract's bookings. Anything else is `404 ERR_BOOKING_NOT_FOUND`.
The API does not calculate cancellation fees.
Every cancellation is logged at the tour operator with the time and the identifier (key_id) of the
cancelling key and shown in its booking view; a
retry (`alreadyReleased`) does not create a second entry.

Response: `released` (`true`), `alreadyReleased` (`true` = was already cancelled).

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

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

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

Retrying is safe:

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

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

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

The booking stays readable, with `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"
}
```

A cancelled `idemKey` is used up. For a new booking use a new key:

```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. Error catalogue
<!-- de:6f7561db7012 -->

Column "Caller": **Fix the request** = do not retry, the error is in the
request. **Retry** = send the same request again later (with `Retry-After` no earlier
than after that many seconds). **Report** = report to the tour operator/operator; retrying does not
help. A code can occur at several endpoints; status and meaning stay the same.

### 7.1 Access and transport
<!-- de:d24a8fb6b649 -->

| Code | Status | Meaning | Caller |
|---|---|---|---|
| `ERR_UNAUTHORIZED` | 401 | Key missing, unknown or revoked | Check the key, do not retry (retries are delayed, section 8) |
| `ERR_TENANT_SUSPENDED` | 403 | Key valid, tour operator suspended | Ask the tour operator, do not retry |
| `ERR_KEY_GROUP_INACTIVE` | 403 | Key's customer group deactivated | Ask the tour operator |
| `ERR_MODE_MISMATCH` | 403 | `X-TourAPI-Require-Mode` requires a different mode than the key's (e.g. live key in a test environment); nothing executed (section 11.1) | Swap the key, do not retry |
| `ERR_SCENARIO_NOT_ALLOWED` | 422 | `X-TourAPI-Sandbox-Scenario` with a live key, unknown scenario or at an endpoint where it has no effect (section 11.3) | Fix the request |
| `ERR_METHOD_NOT_ALLOWED` | 405 | wrong HTTP method | Fix the request |
| `ERR_BAD_REQUEST` | 400 | Body not JSON, too large (> 1 MiB), `ref` missing, `priceCheck.tolerancePercent` negative, `priceCheck.currency` longer than 3 characters (shorter or unknown: `422 ERR_CURRENCY_NOT_AVAILABLE`); Content API: `since` missing, duplicate parameter, `lang` with more than 5 or duplicate languages | Fix the request |
| `ERR_UNKNOWN_FIELD` | 422 | unknown field in `occupancy` (queries) or anywhere (`/v1/search/open`, `/v1/search/open/dates`, `/v1/book`, `/v1/cancel`, body of `/v1/export/edf/ack`); unknown query parameter on `/v1/export/edf/*` and `/v1/limits` | Fix the request |
| `ERR_OPEN_SEARCH_NOT_ALLOWED` | 403 | the key has no permission for the open search and the date matrix (4a.1; off for new keys; `GET /v1/limits` shows it) | Ask the tour operator |
| `ERR_DESTINATION_NOT_ALLOWED` | 403 | open search: the destination (`destinations[i]`) or the hotel (`hotels[i]`, date matrix: `hotel`) lies outside the allowed destinations of the search profile | Take a destination from the search profile (`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 Request (fields and limits)
<!-- de:9383cf69603c -->

| Code | Status | Meaning | Caller |
|---|---|---|---|
| `ERR_BAD_DATE` | 422 | Date missing or not `JJJJ-MM-TT` (`message` names the field) | Fix the request |
| `ERR_EMPTY_STAY` | 422 | `checkOut` ≤ `checkIn` | Fix the request |
| `ERR_STAY_TOO_LONG` | 422 | more than 30 nights | Fix the request |
| `ERR_STAY_IN_PAST` | 422 | Arrival before the reference date | Fix the request |
| `ERR_STAY_TOO_FAR` | 422 | Arrival more than 732 days after the reference date | Fix the request |
| `ERR_NO_TRAVELLERS` | 422 | no travellers | Fix the request |
| `ERR_TOO_MANY_TRAVELLERS` | 422 | more than 20 travellers | Fix the request |
| `ERR_INVALID_AGE` | 422 | Age negative or above 120 | Fix the request |
| `ERR_BOARD_MISSING` | 422 | `board` missing (`/v1/price`, `/v1/search`, `priceCheck`) | Fix the request |
| `ERR_NOW_MISMATCH` | 422 | `priceCheck.now` is not the reference date | Omit the field |
| `ERR_VALIDATION` | 422 | Text too long: `idemKey`, `reference` (128), `leadPaxName` (255), `metadata.correlationId` (64); `message` names field and limit | Fix the request |
| `ERR_QUANTITY_INVALID` | 400 | `quantity` outside 1–1,000,000 | Fix the request |
| `ERR_INVALID_IDEM_KEY` | 400 | `idemKey` missing (`/v1/book`, `/v1/cancel`) | Fix the request |
| `ERR_INVALID_BUCKET` | 400 | `room` missing (`/v1/book`, with and without `priceCheck`) | Fix the request |
| `ERR_INVALID_STAY` | 400 | Stay invalid (safeguard in the sale; the API checks beforehand with `ERR_BAD_DATE`/`ERR_EMPTY_STAY`) | Fix the request |
| `ERR_BAD_PAGE_SIZE` | 422 | `pageSize` outside 1–100 (`/v1/search`; open search: 1 up to the search profile) or 1–1000 (`/v1/content/*`) | Fix the request |
| `ERR_BAD_CURSOR` | 422 | Cursor unreadable, modified or expired (e.g. after a server restart; open search: 15 minutes after issue); Content API: `cursor`/`since` unreadable, modified or from another key | start over without `cursor` or fetch the directory again |
| `ERR_CURSOR_MISMATCH` | 422 | Cursor belongs to a different request or a different key (`/v1/search`, `/v1/search/open`) | Fix the request |
| `ERR_UNKNOWN_DESTINATION` | 422 | `/v1/search` or `/v1/search/open` without `cursor`: there is no hotel for `destination` or `destinations[i]` for this key (case-sensitive) | Take a code from `GET /v1/destinations` (4.5) |
| `ERR_BAD_WINDOW` | 422 | open search and date matrix: `arrivalFrom`/`arrivalTo` missing or `arrivalTo` before `arrivalFrom` | Fix the request |
| `ERR_BAD_NIGHTS` | 422 | open search and date matrix: `nightsMin`/`nightsMax` missing, < 1 or `nightsMin` > `nightsMax` | Fix the request |
| `ERR_BAD_TARGET` | 422 | open search: `destinations` and `hotels` together, empty list, empty code, or neither although the search profile only allows individual destinations; date matrix: `hotel` missing | Fix the request |
| `ERR_BAD_SORT` | 422 | open search: `sort` unknown (`price`, `pricePerNight`, `hotel`) | Fix the request |
| `ERR_BAD_FILTER` | 422 | open search and date matrix: empty `boards`/`boardTypes`/`rooms` list or empty entry, unknown board type, price filter negative or `minTotalCents` > `maxTotalCents`, `currency` not a code of three capital letters; filter on hotel master data outside its format (`category`, `regions`, `geo`, 4a.2) – the message names the field | Fix the request |
| `ERR_WINDOW_TOO_WIDE` | 422 | open search or date matrix: arrival window wider than the search profile allows (`maxWindowDays` or `matrixMaxWindowDays`, `message` names the limit) | split or narrow the window |
| `ERR_NIGHTS_NOT_ALLOWED` | 422 | open search: length outside the allowed lengths or range `nightsMax - nightsMin + 1` too wide; date matrix: length outside the allowed lengths (`message` names the limit) | adjust the length |
| `ERR_SEARCH_TOO_BROAD` | 422 | open search: too many `destinations` or `hotels` in the request or too many hotels in the search scope; date matrix: more cells than `matrixMaxCells` (`message` names the limit) | narrow down |
| `ERR_CURRENCY_REQUIRED` | 422 | open search: the hotels of the search scope price in several contract currencies, `currency` is missing (`message` names them) | set `currency` |

```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)"}
```

Texts that are too long are rejected before the sale, never truncated:

```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)"}
```

A misspelled field outside the occupancy is not rejected but named – here
the required field is missing as a result, and the response says both:

```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?)"]}
```

Warnings in a successful response:

```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 Inventory, price, sale
<!-- de:48d12ea5845b -->

| Code | Status | Meaning | Caller |
|---|---|---|---|
| `ERR_HOTEL_NOT_FOUND` | 404 | Hotel does not exist for this key (also: another tour operator, not published, customer group special price cannot be calculated – see 1.1) | Fix the request; for an otherwise known hotel inform the tour operator |
| `ERR_ROOM_NOT_FOUND` | 404 | Room does not exist in this hotel (date matrix: `rooms[i]`) | Fix the request |
| `ERR_BOOKING_NOT_FOUND` | 404 | Booking unknown or not visible for this key | Check reference/key |
| `ERR_REFERENCE_AMBIGUOUS` | 409 | your own `reference` matches several bookings (`/v1/booking`); `message` names the `TA-…` references (test key: `SB-…`) | read with the TourAPI reference; keep your own references unique |
| `ERR_TENANT_NOT_FOUND` | 404 | Tour operator not (or no longer) active, sale/cancellation only | Report |
| `ERR_BOARD_NOT_OFFERED` | 422 | Board is not offered (code exactly as in the contract; `/v1/price`, `/v1/prices`, `priceCheck`; open search: `boards[i]` offered by no hotel of the search scope; date matrix: `boards[i]` not in the hotel or no board matches `boards`/`boardTypes`); in search a reason in `diagnostics.reasons` or `coverage.reasons` | Fix the request |
| `ERR_OCCUPANCY_NOT_ALLOWED` | 422 | Occupancy fits no room (or not the requested one) | different occupancy/different room |
| `ERR_STAY_LENGTH_NOT_ALLOWED` | 422 | a sales rule of the room requires a different length of stay (3.4); in search a reason in `diagnostics.reasons` | change the length |
| `ERR_ARRIVAL_DAY_NOT_ALLOWED` | 422 | a sales rule requires a different arrival/departure weekday (3.4) | shift the travel dates |
| `ERR_TRAVEL_DATES_NOT_ALLOWED` | 422 | the stay lies outside a rule's sales window (3.4) | different period |
| `ERR_BOARD_NOT_ALLOWED` | 422 | the board is not sold for this stay (3.4) | different board |
| `ERR_LEAD_TIME_NOT_ALLOWED` | 422 | the arrival lies within the release period of a sales rule, counted from the reference date (3.4) | later arrival |
| `ERR_BOARD_NOT_AVAILABLE` | 422 | the board is not sold for this travel party, its surcharge requires a different composition (3.5); in `/v1/prices` an entry in `rooms[].errors[]`, in search a reason in `diagnostics.reasons` | different board/occupancy |
| `ERR_ROOM_RESTRICTION_INVALID` | 422 | a sales rule in the contract cannot be evaluated | Report |
| `ERR_NO_SECTION` | 422 | there is no price for a night (season not in the contract); if another room calculates, in `/v1/prices` an entry in `rooms[].errors[]`, with `/v1/price` without `room` in `warnings`, in search in `diagnostics.roomErrors` | different period |
| `ERR_NO_PRICE` | 422 | no bookable room for the request, without a more specific reason | different period/different occupancy |
| `ERR_OCCUPANCY_NIGHT_UNCOVERED` | 422 | The contract's occupancy rules do not cover a night | Report |
| `ERR_OCCUPANCY_INCONSISTENT_MCA` | 422 | Minimum occupancy changes within the stay (not supported) | shorter period or Report |
| `ERR_OCCUPANCY_INCONSISTENT_CHILDREN` | 422 | a traveller is a child at one point and an adult at another within the stay (the room's child age band changes with the season; not supported) | shorter period or Report |
| `ERR_OCCUPANCY_INCONSISTENT_INFANTS` | 422 | an infant counts towards occupancy at one point and not at another within the stay (the room's occupancy rule changes with the season), and a surcharge or discount depends on the number of persons; ambiguous | shorter period or Report |
| `ERR_CHILDREN_ORDER_MISSING` | 422 | the contract does not specify whether the oldest or the youngest child counts first, and for these children that makes a price difference (contract error) | Report |
| `ERR_INVALID_AMOUNT` | 422 | an amount or percentage in the contract is not readable | Report |
| `ERR_AMOUNT_OVERFLOW` | 422 | the price exceeds the representable cent range (contract error) | Report |
| `ERR_CURRENCY_NOT_AVAILABLE` | 422 | `priceCheck.currency` does not match the contract currency, or the latter is unknown; in the open search a reason in `coverage.reasons` (hotel prices in a currency other than the requested one or in an unknown one); date matrix: the same as 422 | Fix the request or Report |
| `ERR_PRICE_DRIFT` | 409 | current price deviates from the `priceCheck` | show the new price, book with a new `expectedCents` |
| `ERR_SOLD_OUT` | 422 | Night sold out | do not retry |
| `ERR_STOP_SALE` | 422 | Stop sale | do not retry |
| `ERR_INVENTORY_CLOSED` | 422 | Night closed or on request only | do not retry |
| `ERR_NO_INVENTORY` | 422 | no capacity set up for at least one night (same for booking and search reason) | do not retry |
| `ERR_NOT_AVAILABLE` | – | only as a reason in search: every night has capacity, but not every night is open (booking: `ERR_SOLD_OUT`, `ERR_STOP_SALE`, `ERR_INVENTORY_CLOSED`) | – |
| `ERR_OUTSIDE_PRICE_FILTER` | – | only as a reason in the open search (every offer of the hotel) or the date matrix (every offer of the cell) lies outside `minTotalCents`/`maxTotalCents` | – |
| `ERR_NO_CATEGORY` | – | only as a reason (open search; date matrix: every cell of the hotel): filter `category`, the hotel has no official category in the hotel master data | Tour operator: maintain the category |
| `ERR_NO_REGION` | – | only as a reason (open search; date matrix: every cell of the hotel): filter `regions`, the hotel has no region in the hotel master data | Tour operator: maintain the region |
| `ERR_NO_GEO` | – | only as a reason (open search; date matrix: every cell of the hotel): filter `geo`, the hotel has no coordinates in the hotel master data | Tour operator: maintain the coordinates |
| `ERR_GROUP_LIMIT` | 422 | Customer group's allocation exhausted or not present for room and night | do not retry |
| `ERR_IDEMPOTENCY_MISMATCH` | 409 | `idemKey` already used with different booking data | Error in the caller: assign unique keys |
| `ERR_IDEM_KEY_RELEASED` | 409 | `idemKey` belongs to a cancelled booking | use a new `idemKey` |
| `ERR_SANDBOX_LIMIT` | 422 | Test key: more than 5,000 open test bookings for this access (section 11.2) | Cancel test bookings |

```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"}
```

The same applies to `/v1/price` – a board the room does not offer is never calculated as the
price without board:

```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 Load and operations
<!-- de:f971621e9969 -->

| Code | Status | Meaning | Caller |
|---|---|---|---|
| `ERR_RATE_LIMITED` | 429 | too many requests from this key (section 8); open search and date matrix: rate of the search profile exceeded (both together) | Retry after `Retry-After` |
| `ERR_SEARCH_BUSY` | 429 | too many concurrent searches by the tour operator (open search: also by the key according to the search profile) | Retry after `Retry-After` (1 s) |
| `ERR_SEARCH_TIMEOUT` | 503 | Search exceeded the time limit (10 s) | narrow down (`destination`/`destinations`, smaller window), then retry |
| `ERR_PRICE_TIMEOUT` | 503 | `/v1/price` without `room` or `/v1/prices` exceeded the time limit (2 s) | switch to `/v1/price` with `room` (the limit always applies to `/v1/prices`, even with `room` and `boards`), then retry |
| `ERR_BOOKING_DISABLED` | 503 | Sale/cancellation not enabled on this node (test key: sandbox not enabled – a test key never books live) | Retry; if persistent: Report |
| `ERR_BOOKING_BUSY` | 503 | Booking/cancellation did not go through due to concurrent operations on the same hotel; nothing booked or cancelled | Retry after `Retry-After` (1 s) with **the same** `idemKey` |
| `ERR_INTERNAL` | 500 | internal error, e.g. database unreachable (a violated invariant in the calculation core comes as 422 at the query endpoints); open search: result not provable (4a.4) | Retry with a pause; for `/v1/book` with **the same** `idemKey` |
| `ERR_INVENTORY_DRIFT` | 500 | Allotment invariant violated, nothing sold | Report |
| `ERR_INVENTORY_STATUS_UNKNOWN` | 500 | unknown daily status in the allotment, nothing sold | Report |
| `ERR_RELEASE_DRIFT` | 500 | Cancellation blocked due to an allotment invariant, nothing cancelled | Report |

### 7.5 Contract data (errors at the tour operator)
<!-- de:90be264a1901 -->

These codes come from the calculation core when the hotel's contract contains a rule that
TourAPI does not calculate (or not in that form). They should not occur after publication,
because the contract is checked beforehand. Status always **422**. Caller: **Report** (with hotel,
period and `message`); in search they appear as a reason in `diagnostics.reasons`
(hotel dropped) or in `diagnostics.roomErrors` (single room skipped). With
`/v1/price` without `room`, an affected room is skipped and named in `warnings`
as long as another room calculates; `/v1/prices` names the affected boards in
`rooms[].errors[]` and prices the rest (section 3.2). `ERR_NEGATIVE_TRAVELLER_PRICE` and
`ERR_NEGATIVE_PERCENT_BASE` depend on occupancy and board: the write gate warns about them
but accepts the contract; they can therefore also occur after publication. `/v1/prices`
then skips only the affected board (`rooms[].errors[]`, section 3.2).

| Code | Meaning |
|---|---|
| `ERR_NO_BASECHARGE` | Base price missing |
| `ERR_SECTION_BAD_DATE`, `ERR_BOARD_BAD_DATE`, `ERR_OCCUPANCY_BAD_DATE` | invalid date in the contract |
| `ERR_OCCUPANCY_INCOMPLETE` | Occupancy rule incomplete |
| `ERR_AMBIGUOUS_BOARDCHARGE` | two board surcharges can hit the same person in the same night; which one applies is ambiguous (the write gate does not allow this, legacy data only) |
| `ERR_AMBIGUOUS_BASECHARGE`, `ERR_AMBIGUOUS_SECTION` | two base prices of the same type in one season, or two seasons for the same day; which price applies is ambiguous (the write gate does not allow this, legacy data only) |
| `ERR_AMBIGUOUS_FREENIGHT` | two free night offers can apply to the same stay; which nights the second one waives is undetermined (the write gate does not allow this, legacy data only) |
| `ERR_UNSUPPORTED_FREENIGHT`, `ERR_UNSUPPORTED_REDUCTION_MODE` | Free night offer in a form TourAPI does not calculate (e.g. fixed amount instead of percentage, person restriction, night selection "greater/less than") |
| `ERR_NEGATIVE_TRAVELLER_PRICE` | after all surcharges and discounts a traveller would pay less than 0 (e.g. a fixed discount per person on a child that costs nothing, or stacked discounts above 100 %). A traveller never pays less than 0; `message` names the traveller and the surcharge/discount. Percentage discounts apply to the amount the traveller owes after child/person reduction and do not trigger the error on their own |
| `ERR_NEGATIVE_PERCENT_BASE` | a child/person discount (fixed amount) is greater than the daily price or the board it applies to; a percentage discount on it would become a surcharge, a free night (e.g. "7=6") would make the stay more expensive. `message` names the surcharge/discount or free night, traveller and night |
| `ERR_UNSUPPORTED_OCCUPANCY_PRICEBLOCK` | Base price per occupancy (from a third-party delivery, e.g. “exactly 1 person” / “2 persons or more”) in a form TourAPI does not calculate — or an infant travels in such a room (the supplier does not sell it with an infant) |
| `ERR_UNSUPPORTED_GUESTCHARGE_OBJECT` | Person reduction (e.g. child discount) on a room price (per-unit price): there is no per-person price it could apply to (the write gate does not allow this, legacy data only) |
| `ERR_UNSUPPORTED_COMBIGROUP` | an offer is exclusive in combination group 0; in EDF, group 0 also stands for all offers without a group, so an EDF recipient would calculate differently (the write gate does not allow this, legacy data only) |
| `ERR_COMPATIBLE_WITH_INVALID` | an offer's “combinable only with groups” list is ambiguous: a group listed twice, outside 0..2147483647 or combined with “only one per group” (the write gate does not allow this, legacy data only) |
| `ERR_CALCMODE_MISSING`, `ERR_CALCMODE_UNSUPPORTED` | Calculation mode of the room missing or not supported |
| `ERR_BASE_BOARD_INVALID`, `ERR_BASE_BOARD_CHARGED` | Base board of the room (board included in the base price) invalid or carrying its own surcharge (the write gate does not allow this, legacy data only) |
| `ERR_MINCHARGEDPERSONS_MISSING`, `ERR_INVALID_MIN_CHARGED_PERSONS` | Minimum number of paying persons missing or invalid |
| `ERR_INVALID_ENUM`, `ERR_INVALID_WEEKDAY_MASK` | invalid enumeration value or weekday mask |
| `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` | Surcharge or discount incomplete or contradictory |
| `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` | Contract rule TourAPI does not calculate |

### 7.6 EDF delivery (`/v1/export/edf/*`, section 10)
<!-- de:f4326be3cefd -->

| Code | Status | Meaning | Caller |
|---|---|---|---|
| `ERR_EXPORT_BAD_CURSOR` | 400 | `epoch`/`since` missing or unreadable, `until` not a valid chain key, `max_bytes` not a number ≥ 65536, duplicate parameter, page in the middle of a state without `until`, follow-up page of the `full` without `epoch`/`since`/`until` | Fix the request or restart the chain |
| `ERR_EXPORT_NOT_ALLOWED` | 403 | Key without export permission | Ask the tour operator, do not retry |
| `ERR_EXPORT_EPOCH` | 409 | `epoch` does not match (feed rebuilt) or the state (`since`, `until`, `seq` of the acknowledgement) is **above** the current delivery state (greater than the last delivered state) | fetch `full` |
| `ERR_EXPORT_CURSOR_EXPIRED` | 410 | State older than the retention period | fetch `full` |
| `ERR_EXPORT_NOT_READY` | 503 | Delivery for this key not built yet, temporarily not current (delivery lagging more than 2 minutes behind) or not set up on the node | Retry after `Retry-After`; if persistent: Report |

Also on `/v1/export/edf/*`: `401`/`403` from 7.1 (`ERR_TENANT_SUSPENDED`,
`ERR_KEY_GROUP_INACTIVE`), `422 ERR_UNKNOWN_FIELD` (unknown parameter or acknowledgement field)
and `429 ERR_RATE_LIMITED` for the cadence (10.3; `Retry-After` up to 3600 s for `full` – do not
wait blindly, plan the next cycle instead).

### 7.7 Hotel content (`/v1/content/*`, section 12)
<!-- de:14adc4697ab3 -->

| Code | Status | Meaning | Caller |
|---|---|---|---|
| `ERR_CONTENT_NOT_ALLOWED` | 403 | Content not enabled for the tour operator (also: test environment not served on this installation) or key without content right | Ask the tour operator, do not retry |
| `ERR_LANGUAGE_NOT_OFFERED` | 422 | `lang` names a language that is not one of the tour operator's content languages (for the catalogue: not a label language); the offered ones are listed in `warnings` | Fix the request |
| `ERR_CONTENT_CURSOR_EXPIRED` | 410 | `since` lies before the feed's retention horizon (30 days) | Fetch the directory again, continue with its `feedToken` |
| `ERR_CONTENT_NOT_READY` | 503 | Content or image addresses not set up on this node | Retry after `Retry-After`; if persistent: Report |

Also on `/v1/content/*`: `401`/`403` from 7.1, `404 ERR_HOTEL_NOT_FOUND` (hotel not in the
key's directory), `400 ERR_BAD_REQUEST`, `422 ERR_BAD_PAGE_SIZE`, `ERR_BAD_CURSOR`
and `429 ERR_RATE_LIMITED` (rate per key, section 8). Unknown
query parameters are ignored and named in `warnings`.

---

## 8. Fairness: rate limit and search gate
<!-- de:208b309ad0eb -->

| Guard | Limit (default) | Response |
|---|---|---|
| Requests per API key | 200 per second, briefly up to 400 (token bucket) | `429 ERR_RATE_LIMITED` + `Retry-After` |
| Concurrent searches per tour operator | 8 (computing time: one quarter of the cores, at least 1) | `429 ERR_SEARCH_BUSY` + `Retry-After: 1` |
| Computing time of one search | 10 s | `503 ERR_SEARCH_TIMEOUT` |
| Computing time of `/v1/price` without `room` and `/v1/prices` | 2 s | `503 ERR_PRICE_TIMEOUT` |

- The limits apply **per server node**. They are a protection against loops and
  load spikes, not a billable quota. The operator can change them.
- `Retry-After` is in whole seconds (at least 1). Do not retry before; afterwards retry with
  the same request (for `/v1/book` with the same `idemKey`).
- If you send again before `Retry-After` has elapsed and are rejected again, you receive the
  `429` only after a delay (until the end of the announced wait time, at most 1 s). The wait time
  applies per API key: if several processes work with the same key in parallel, the
  delay hits every one that keeps sending after a `429`, regardless of how many connections.
  The connection stays open.
- Rejected keys (`401`, `403 ERR_TENANT_SUSPENDED`, `403 ERR_KEY_GROUP_INACTIVE`) are counted
  per sender IP: after 20 rejections in quick succession only 5 per second are answered immediately,
  further ones only with a delay (at most 1 s), regardless of how many connections. Status and
  response stay the same. Requests with a valid key are never affected.
- Unknown paths (`404`) and wrong methods (`405 ERR_METHOD_NOT_ALLOWED`) count towards
  the same per-sender-IP limit as rejected keys.
- `/v1/health` has its own limit per sender IP: 50 calls immediately, then 10 per
  second immediately, further ones only with a delay (at most 1 s). The response is always the
  current state, never a rejection.
- Rejected requests do not count as usage.
- Truncated search pages are not an error, see 4.3.

This is what the rate limiting looks like (the examples run against an instance with 1 request per 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"}
```

Accompanying header: `Retry-After: 10`.

---

## 9. Idempotency and concurrency
<!-- de:d7acc022c4ba -->

**Booking:**

- `idemKey` is required and belongs to the caller. It applies per tour operator **and** key scope
  (customer group or base contract): two customer groups can use the same key without
  interfering with each other.
- Same `idemKey`, same booking data (hotel, room, stay, quantity) = the same
  booking: `200`, `alreadyBooked: true`, same reference. `reference`, `metadata`,
  `leadPaxName` and `priceCheck` of a retry are neither compared nor
  adopted – the first booking applies.
- Same `idemKey`, different booking data: `409 ERR_IDEMPOTENCY_MISMATCH`.
- Cancelled `idemKey`: `409 ERR_IDEM_KEY_RELEASED`.
- The idempotency check comes **before** reference date, limits and `priceCheck`: if the
  booking exists, the retry confirms it even after midnight (arrival now in the
  past, `priceCheck.now` no longer the reference date) or after a price change.
  Only a new `idemKey` goes through these checks. Formal errors in the request (unknown
  fields, missing required fields, texts too long) are still rejected beforehand.
- If the first booking of an `idemKey` is still running, a retry waits for its outcome
  and then confirms it (`200`, `alreadyBooked: true`); if the first one fails, the
  retry runs as a new booking. If waiting takes too long: `503 ERR_BOOKING_BUSY` with
  `Retry-After` – retry with the same `idemKey`.
- Two concurrent bookings of the same room can never overbook the allotment: each
  night is counted atomically, and a booking takes all nights or none.
- If a booking or cancellation does not go through due to concurrent operations on the same hotel:
  `503 ERR_BOOKING_BUSY` with `Retry-After` (section 7.4). Nothing is booked or
  cancelled; after the wait time retry with the same `idemKey`.
- If a hotel is deleted while a booking for it is running, order decides: if the
  booking came first, it remains; otherwise `404 ERR_HOTEL_NOT_FOUND`, nothing booked.
- After a timeout or `5xx` it is unclear whether a booking was made: **retry with the same
  `idemKey`**, never with a new one.

**Cancellation:**

- Addressed via the booking's `idemKey`. A double cancellation is a success
  (`alreadyReleased: true`). Concurrent cancellations of the same booking return the allotment
  exactly once.

---

## 10. EDF delivery (cache export)
<!-- de:8e14c6d07d7c -->

Purpose: a buyer keeps the prices and availabilities of all hotels of its key as
EDF files in its own cache and queries live before booking (`/v1/price`, then
`/v1/book`). `/v1/book` is always binding; the cache is an offer, not inventory.
The complete delivery contract (canonical form of the manifest, limits) is available from the
operator on request.

| Method/path | Response |
|---|---|
| `GET /v1/export/edf/full[?max_bytes=N]` | `200` zip with the full snapshot (for large inventories the first page) |
| `GET /v1/export/edf/full?epoch=E&since=S&until=K[&max_bytes=N]` | `200` zip with the next page of the full snapshot |
| `GET /v1/export/edf/changes?epoch=E&since=S[&until=K][&max_bytes=N]` | `200` zip with all changes after state `S`; `204` if nothing is new |
| `POST /v1/export/edf/ack` `{"epoch": E, "seq": T}` | `204`; reports "processed" (only for monitoring at the tour operator) |

- **Scope only via the key:** the key determines what is delivered – the same hotels
  and prices as `/v1/search` and `/v1/price` for this key (base contract, customer group with
  its price, allotment group only hotels with an allocation). There is no parameter that
  selects tour operator or group; an unknown parameter is `422 ERR_UNKNOWN_FIELD`.
- **Export permission:** to be enabled per key (tour operator admin, in the console on the
  key row "Export erteilen"); without permission `403 ERR_EXPORT_NOT_ALLOWED`.
- **Published data only:** drafts never change the delivery. Changes (publication,
  stop sale, booking, promotion, allocation) appear in the feed after about one minute at the latest.

### 10.1 Package
<!-- de:646450ed51d0 -->

A zip with `manifest.json` as the first entry, then per hotel one price file
(`hotels/hotelonly/EDF----<tenant>-<hotel>.xml`, EDF 5.1.6) and one allotment file
(`hotels/hotelonly/allotment/EDF----<tenant>-<hotel>.xml`, HotelAllotmentRoot 1.012).
Identity comes from the manifest or `BasicData`, never from the file name (codes may contain
`-`). Every file has its sha256 checksum and length in the manifest; a package is applied
in full or not at all.

One entry in `objects` and one 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:** withdrawn hotels are listed under `removed` with a reason
  (`withdrawn:deleted`, `withdrawn:variant_error`, `withdrawn:not_exportable`,
  `withdrawn:no_currency`, `withdrawn:not_in_universe`). The buyer deletes them from its
  cache. A `full` always has `removed: []` – it replaces the cache entirely.
- **Pattern of the allotment file:** two characters per night (`PatternLength="2"`, like the
  MTS deliveries): `00`–`99` = free units for this key, `**` = more than 99 free or
  free sale, `SS` = stop sale, `RR` = on request; a night without capacity is `00`.
  If an allotment file is missing, the hotel counts as not available. Structure per room:
  `<Allotments RoomCode="DZ"><Allotment Start="JJJJ-MM-TT" End="JJJJ-MM-TT" Pattern="…"/>`; the
  first two characters of `Pattern` belong to the night `Start`, each further pair to the following night.
- **Pattern and `minFree`:** `SS`, `RR` and `00` explicitly result in `minFree` `0`
  (not bookable); only `**` does not lower `minFree`, and `-1` means: every night `**`. The
  minimum across the nights of a stay is `availability.minFree` of `/v1/price` for
  the same key (table in section 3.3) – both come from the same per-night source.

### 10.2 Full snapshot, changes, acknowledgement
<!-- de:b72e8777641d -->

At startup and after `409`/`410` the buyer fetches the full snapshot. From the manifest it records
`epoch` and `to_seq`. If the full snapshot does not fit in one package (more than 10,000 files, more than
1 GiB unpacked or more than `max_bytes`), it comes in pages, see "Pages" below:

```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": []
}
```

Headers of every `200`/`204` response: `X-Export-Epoch`, `X-Export-Seq` (last
complete state), `X-Export-From` (state from which delivery started), `X-Export-More`;
for a `changes` package and for a `full` page with `more: true` additionally
`X-Export-Until` (chain key, see below). If a page ends in the middle of a state (`to_after`
in the manifest), `X-Export-Seq` names the state before it – so on the first pages of a `full`
`0`. Authoritative for `since`, applying and acknowledgement are `to_seq`/`to_after` from the manifest. A
customer group's key receives its group's state (`scope`), with the group's prices:

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

After that it polls for changes on a cadence. `since` is the `to_seq` of the last applied
package; if it is already at the current state, the response is `204` without content:

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

**Pages (`max_bytes`):** `full` and `changes` are page chains. A page has at most
10,000 files and 1 GiB unpacked; with `max_bytes=N` the server additionally cuts pages of
at most `N` bytes (at least one file per page); `N` is at least 65536 (64 KiB), smaller
is `400 ERR_EXPORT_BAD_CURSOR`. The first page (`full` without parameters or `changes` without
`until`) fixes the target of the chain (the current state) and returns it as the
**chain key** in `X-Export-Until` (for `full` only if `more: true`): an
opaque, sealed string (`u1.…` for `changes`, `f1.…` for `full`), bound
to key and `epoch`. The buyer requests follow-up pages with `since=<to_seq>` or
`since=<to_seq>:<to_after>` (if the manifest carries `to_after`) **and**
`until=<X-Export-Until>` (value of the previous page, URL-encoded, unchanged), for `full` additionally with
`epoch=<epoch>` (value of the first page), until `more` is `false`. Every page returns the key afresh;
it is valid for 15 minutes after the last page, for `full` only for exactly the state of the
next page. A self-chosen `until` (number), a foreign or expired key is
`400 ERR_EXPORT_BAD_CURSOR` – then restart the chain. A `full` page never carries
`removed`; the first starts at `from_seq: 0`, each further one at the `to` of the previous page. Only at the
end of the chain is the state consistent: that is where it is applied (swapped), even if the
tour operator keeps making changes in the meantime – the chain ends exactly at its target.

**Abort and retry:** if a package fails after `200` has already been sent (e.g. because
a file at the tour operator is now missing), the server aborts the connection. The
buyer then sees a transport error (`unexpected EOF`, connection reset), never
a cleanly terminated, shortened package. Nevertheless it **must** verify every package against its manifest
before applying it: every file from `objects` present, length (`bytes`) and `sha256`
match, no extra file. A streaming reader (e.g. Java's `ZipInputStream`) does not by itself recognise a
zip that ends at an entry boundary as incomplete. A follow-up page
that fails briefly (transport error, aborted or unreadable package, `5xx`) is requested again with
the same `epoch`/`since`/`until` (the key is valid for 15 minutes, `503` after
`Retry-After`) instead of restarting the chain – a restart costs the cadence (`full`:
one hour). Only after `400` does it restart the chain; after `409`/`410` it fetches `full`. If
a chain aborts, the old state remains.

After applying, the buyer acknowledges the state. The acknowledgement is voluntary and does not change
anything about the delivery; it lets the tour operator see which state the buyer has
processed:

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

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

### 10.3 Errors and cadence
<!-- de:e49c1625c8dc -->

| Status | Code | When | Buyer does |
|---|---|---|---|
| 400 | `ERR_EXPORT_BAD_CURSOR` | `epoch`/`since` missing or unreadable, `until` not a valid chain key (foreign, expired, number, for `full` for a different state), `max_bytes` not a number ≥ 65536, page in the middle of a state without `until`, state above the chain target, follow-up page of the `full` without `epoch`/`since`/`until` | Fix the request or restart the chain |
| 403 | `ERR_EXPORT_NOT_ALLOWED` | Key without export permission | Ask the tour operator |
| 403 | `ERR_KEY_GROUP_INACTIVE` | Key's customer group deactivated (never silently the base contract) | Ask the tour operator |
| 409 | `ERR_EXPORT_EPOCH` | `epoch` does not match (feed rebuilt) or `since`/`until` or the acknowledgement's `seq` is **above** the current delivery state (greater than the last delivered state; an older `since` is allowed and delivers everything after it) | `full` |
| 410 | `ERR_EXPORT_CURSOR_EXPIRED` | State older than the retention period (14 days) | `full` |
| 429 | `ERR_RATE_LIMITED` | Cadence exceeded, see below | after `Retry-After` |
| 503 | `ERR_EXPORT_NOT_READY` | Delivery for this key never built yet (new key, new group), or the delivery is temporarily not current (it lags more than 2 minutes behind the data) | after `Retry-After`, the old state remains valid |

```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"}
```

**Cadence per key:** a new `full` at most once per hour, a new `changes` chain
at most once per minute. Follow-up pages of a chain (`full` or `changes`, with the
chain key) and acknowledgements have their own generous cadence (5 per second, 50 at
once). Which call starting a chain uses up the cadence:

| Response | uses up the cadence |
|---|---|
| `400 ERR_EXPORT_BAD_CURSOR`, `422 ERR_UNKNOWN_FIELD` (invalid request, checked before any work) | no |
| `503 ERR_EXPORT_NOT_READY` (delivery never built or temporarily not current) | no – if you follow `Retry-After`, you get no 429 |
| `200` (including an aborted download), `204` ("nothing new"), `409 ERR_EXPORT_EPOCH` | yes |

The cadence check comes **before** the check of `epoch` and state. A buyer with an outdated
`epoch` first sees `429` within the cadence and only after its `Retry-After` the `409`.
Example flow for `changes` (measured, cadence 1 min):

| Time | Request | Response | Buyer does |
|---|---|---|---|
| 0 s | `changes?epoch=E&since=S&foo=1` (typo) | `422 ERR_UNKNOWN_FIELD` | fix it; cadence not used up |
| 0 s | `changes?epoch=E&since=S` | `204` | cadence used up; next chain in 60 s at the earliest |
| 0 s | `changes?epoch=E&since=S`, the feed has been rebuilt in the meantime (`E` outdated) | `429 ERR_RATE_LIMITED`, `Retry-After: 60` | wait for `Retry-After` |
| 60 s | the same call | `409 ERR_EXPORT_EPOCH` | fetch `full` (own cadence, 1 per hour) |

In addition, the key's rate limit applies (section 8). A second `full` within the same hour:

```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 Applying and calculating from the cache (reference receiver)
<!-- de:dbf5ec93710d -->

TourAPI has a reference receiver that implements exactly these rules and is checked on every build
against the real API: the `edf-empfaenger` tool (available from the operator on request)
(`pull`, `apply`, `stand`, `rechne`, `reset`; the key comes only from the environment variable
`EDF_EMPFAENGER_KEY`). What it calculates from the delivery matches, for every request in the test set,
`/v1/price` of the same key to the cent – total price, price per traveller, selected room,
`available` and `minFree`, and on rejection the same code; this also holds for invalid and
multiply invalid requests (which limit applies first). The reference receiver and
`make e2e-export` live in the TourAPI source tree and are not part of the delivery: as a
customer you receive a key and an access package and implement the rules of this section in your
system – the reference receiver is the proof that they work out.

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

`anfragen.json` is a list; per request `hotel`, `room` (optional, missing = cheapest
available room), `board`, `checkIn`, `checkOut` and the travellers' ages as `ages`
(not `occupancy.travellers` as with `/v1/price`), e.g.
`[{"hotel": "TEST-HOTEL-BASE", "room": "DZ", "board": "RO", "checkIn": "2026-10-27",
"checkOut": "2026-10-29", "ages": [40, 38]}]`. The response names per request `anfrage` (index),
`room`, `currency`, `totalCents`, `perTravellerCents`, `availability` (`available`, `minFree`)
or `errorCode`.

- **All or nothing:** verify the package (sha256 and length per file), build the new state
  completely next to the old one, then switch atomically; only then are `epoch`
  and `to_seq` considered stored. A chain with pages (`full` as well as `changes`) is only switched at the
  end of the chain (`more: false`); if it aborts, the old state remains. A
  `full` chain always starts with `from_seq: 0`; a `full` follow-up page only attaches to the
  open chain, never to a stored state – even if its `from_seq` equals the
  stored `to_seq` (a `full` carries no `removed`; deleted hotels would
  otherwise remain).
- **Never backwards:** what counts is the target of the chain, i.e. the `to_seq` of the last page
  (`more: false`), not that of the first. The first page of a `full` chain often carries a
  smaller `to_seq` than the stored state (e.g. after `410`: the oldest files come
  first) and is still not a step backwards. A `full` chain of the same `epoch` whose target is
  smaller than the stored state, or a `full` of an older `epoch` (the `epoch`
  is a ULID, sorted by time) is rejected – an old package delivered late
  does not reset the cache. `changes` must attach seamlessly to the state
  (`from_seq` = stored `to_seq`). A `full` of another tenant or scope
  (swapped key) is also rejected, as a scope error before the backwards check.
- **Rejected `full`:** the old state remains, the cache keeps calculating on it but no longer
  fetches anything new. This is intentionally loud – never silently jump back. If there is no swapped
  key and the delivery really is on an older `epoch` (e.g. after a restore
  on a machine whose clock is behind: the new `epoch` ULID is then smaller) or a
  smaller `to_seq`, deliberately reset the state (reference receiver:
  `edf-empfaenger reset`) and fetch a new `full`. If in doubt, ask the operator
  first.
- **Reading during the fetch:** a fetch may take time (large chain, waiting on `429`). Prices from
  the cache meanwhile continue on the old state and after the switch on the
  new one; delete the old state only when no one is reading from it anymore. The reference receiver
  allows one writer (`pull`, `apply`, `reset`) and any number of readers (`stand`,
  `rechne`) per inventory. For `429` it waits at most `--max-warten` per response and at most
  `--max-warten-gesamt` in total per fetch, then aborts (state unchanged).
- **Retry instead of restarting:** the reference receiver fetches a follow-up page with a brief error
  up to three times with the same parameters (rule in 10.2); it does not retry the
  start of a chain.
- **Price from the cache:** EDF 5.1.6 with the declared rules, plus the API's rules:
  the same request limits (appendix, same error codes; reference date = today in
  Europe/Berlin); with a room the cheapest offer of this room code, without a room the
  cheapest **available** room, and if none is available the cheapest priceable one (as in
  section 3.1); a room that does not offer the board is not priced.
- **Declared rules:** every hotel file states in `SellingData` the child order
  (`ChildrenAgeOrder`, `Descending` = oldest child first: who is the "1st child", which child
  takes a free full-payer slot), `FullPayerDoesNotAffectBoardCharges/PersonType=C` (a
  child on the full-payer slot pays the full base price, board at the child rate)
  and `Rounding Mode="Commercial" DecimalPlace="2" Scope="Person"` (one rounding per traveller,
  section 1.4). Where the schema leaves room for interpretation, TourAPI calculates as follows – the cache must
  do the same, otherwise it deviates: `MinCount`/`MaxCount` count children per person type from the
  first child, adults from the first one after the full payers; an infant counts in a
  person condition ("from 3 persons") only if the room counts it towards occupancy
  (`Infants/@ApplyToOccupancy` `Min`, `Max` or `Yes`); a missing `ExtraMaxApply` means 1; several
  date windows with `Operator="AND"` apply as their intersection; percentage surcharges and discounts alongside a
  free night apply only to the nights not waived; a fixed amount per person (`PS`,
  `PN`) also applies per person with a per-unit price; room line items (per-unit price, unoccupied
  full-payer slot) belong to the oldest traveller and are rounded with them.
- **Availability from the pattern:** minimum across the nights `[checkIn, checkOut)` of the
  room as in 10.1; a night the file does not mention is closed (never "open because
  nothing was delivered"). The `configured` field of `/v1/price` has no equivalent in the cache:
  a room without an allotment appears there as `00`.
- **Live before booking:** the cache can be one state behind the API; `/v1/book` rejects a night
  that is open in the cache but has since been blocked or sold out
  (`ERR_STOP_SALE`, `ERR_SOLD_OUT`).

---

## 11. Sandbox: test keys and test bookings
<!-- de:f669d7e8b4cf -->

A **test key** (`tk_test_…`) is the twin of a live key: the same tour operator, the same
customer group, the same permissions (export permission, later search profile) — taken over from the live key
at runtime. It reads **the same data** as the live key (hotels, prices, destinations,
availability, EDF delivery), but **bookings end up in the sandbox**: never at the
tour operator, no allotment, no export, no booking list in the console. This lets you test
booking, booking info, cancellation, idempotency and retry logic with real offers.

- The tour operator issues the test key for the live key in the console (button "Test-Key" on the
  key row) and passes it on with its own access package – or you issue it yourself in the
  partner portal (7, 30 or 90 days) if the tour operator allows this for your access. Valid for
  30 days (at most 90), at most 5 active test keys per access.
- If the live key is revoked or the test key has expired: `401 ERR_UNAUTHORIZED`. Customer group
  deactivated: `403 ERR_KEY_GROUP_INACTIVE` as in live. After a rotation of the live key the test
  key keeps working with the new live key; it never ends later than its live key.
- The mode is determined by the assignment at the tour operator, not by the key's prefix.

### 11.1 Labelling and protection against mix-ups
<!-- de:3b299d9dd0db -->

Every response to an accepted key carries the header **`X-TourAPI-Mode: test`** or
**`live`** (compare header names case-insensitively, as usual in HTTP: the server writes
`X-Tourapi-Mode`). Booking, booking info and cancellation of a test key additionally carry the field
**`"sandbox": true`**, and the reference starts with **`SB-`** (live `TA-`). Reading with the test key
returns the same response as with the 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 environments (CI, agents, developer machines) send the header **`X-TourAPI-Require-Mode:
test`**. If a live key then arrives, the API rejects **every** request and executes nothing – so a
live key used by mistake cannot book (the check is on the server, not
in the client). `X-TourAPI-Require-Mode: live` conversely requires a live key; any other value
is `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 Test booking, booking info, cancellation
<!-- de:56e30d172e7a -->

`/v1/book` with a test key runs through **the same checks in the same order** as live
(fields, retry, hotel, reference date and limits, sales rules, `priceCheck`,
availability) and returns the same error codes. Availability is checked but not
consumed:

```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 and live worlds are separate: a test key sees and cancels only test bookings, a
live key only real ones. The same `idemKey` books independently in both worlds.

```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}
```

- Test bookings are deleted 30 days after creation. At most 5,000 open (not
  cancelled) test bookings per access; beyond that `422 ERR_SANDBOX_LIMIT`.
- TourAPI logs a test key's calls (method, path, status, `errorCode`, duration, request and response)
  for 7 days so that the tour operator can help with troubleshooting.
  Therefore: use **test names** in test bookings.
- EDF delivery with a test key: fetches (`full`, `changes`) and acknowledgements (`ack`) appear only in
  this log of test calls, **never in the tour operator's delivery log** – they do not count
  as a pickup or processing in the delivery's health.

### 11.3 Scenarios: forcing error cases
<!-- de:3ec5e343e1de -->

With the header **`X-TourAPI-Sandbox-Scenario`** a test key forces an error case – for
testing retry logic (sections 8 and 9). With a live key, an unknown name
or at an endpoint where the scenario has no effect: `422 ERR_SCENARIO_NOT_ALLOWED`.

| Scenario | Endpoints | Effect |
|---|---|---|
| `booking_busy` | `/v1/book`, `/v1/cancel` | first attempt per `idemKey` and endpoint (booking and cancellation count separately): `503 ERR_BOOKING_BUSY` with `Retry-After: 1`, nothing booked or cancelled; the retry with **the same** `idemKey` goes through |
| `price_drift` | `/v1/book` with `priceCheck` | first attempt per `idemKey`: `409 ERR_PRICE_DRIFT`; the price itself does not change (`message` states the current and the expected price, both equal); fetch the new price, book again |
| `sold_out` | `/v1/book` | always `422 ERR_SOLD_OUT` (after all other checks) |
| `rate_limited` | all | first request per test key and endpoint: `429 ERR_RATE_LIMITED` with `Retry-After: 1` |
| `search_busy` | `/v1/search` | first request per test key: `429 ERR_SEARCH_BUSY` with `Retry-After: 1` |
| `price_timeout` | `/v1/price`, `/v1/prices` | first request per test key and endpoint: `503 ERR_PRICE_TIMEOUT` |

"First request" applies for 10 minutes; every retry within this time goes through normally. The
memory is kept per API node.

```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 Test key limits
<!-- de:2703b1987e23 -->

All test keys of an access share **one** bucket: 20 requests/s, burst 40 (the live key's
limit remains unaffected). A tour operator's test keys occupy at most 2
concurrent searches. The EDF delivery cadence (`full` 1/h, new `changes` chain 1/min)
applies per access, not per test key.

### 11.5 What the sandbox does not prove
<!-- de:59e8a96897d8 -->

- **Allotment and races for the last room:** test bookings consume nothing. A
  test booking can succeed where live someone else was faster.
- **Processes at the tour operator** after the booking (confirmation, modification, invoice).
- **Behaviour under live load** (own, smaller bucket, see 11.4).

### 11.6 AI agents: MCP server in the portal
<!-- de:47144e35bd7f -->

The portal offers an MCP server (Model Context Protocol, transport “Streamable HTTP”,
stateless, tools only) at `/mcp`. An AI agent such as Claude Code, Codex or Antigravity uses it
to call the API through tools instead of writing HTTP code. Every tool is **exactly one** `/v1`
call with the key of the MCP request (`X-Api-Key` or `Authorization: Bearer`) and
`X-TourAPI-Require-Mode: test` – behaviour, limits, isolation and error codes are those of the
API. **Test keys only:** a live key gets `ERR_MODE_MISMATCH` (403), nothing is executed. The
portal stores no key.

| Tool | Call | Arguments |
|---|---|---|
| `list_destinations` | `GET /v1/destinations` | none |
| `search_hotels` | `POST /v1/search` | request body; pages via `cursor` |
| `price_offer` | `POST /v1/price` | request body |
| `price_all_rooms` | `POST /v1/prices` | request body |
| `sandbox_book` | `POST /v1/book` | request body, `idemKey` required |
| `get_booking` | `GET /v1/booking` | `ref` |
| `sandbox_cancel` | `POST /v1/cancel` | request body |
| `get_limits` | `GET /v1/limits` | none |
| `open_search` | `POST /v1/search/open` | request body |
| `open_search_dates` | `POST /v1/search/open/dates` | request body |
| `hotel_details` | `GET /v1/content/hotels/{code}` | `code`, `lang` |

Tools with a request body pass their arguments unchanged to the API as the JSON body (schema =
OpenAPI of the endpoint). The others take only the arguments listed; any other gives
`ERR_UNKNOWN_FIELD`, an invalid value `ERR_BAD_REQUEST` – without an API call.

**`hotel_details` only with the content right.** `tools/list` asks `GET /v1/limits` with the key
of the request and lists `hotel_details` only for `content.allowed: true` (the same rule as the
content API, 4c); without a key it is missing. If it is called anyway, the API answers
`ERR_CONTENT_NOT_ALLOWED` (403). If the API rejects the key in this check (e.g. `ERR_MODE_MISMATCH`
for a live key) or does not answer, `tools/list` is a JSON-RPC error (`-32000`) with the error code
or reason in `message` – no tool list.

**`hotel_details` and `lang`.** If the tour operator does not offer a requested content language,
the tool does not pass on `ERR_LANGUAGE_NOT_OFFERED`: it reads the content languages
(`GET /v1/content/catalog`, `languages`) and asks for the requested languages as far as offered,
otherwise a fallback language (`en`, then `de`, then the first offered). `structuredContent` names
this under `language` (`requested`, `delivered`, `offered`, `fallback: true`). The content API
itself stays strict; if the tour operator offers no content language, its error remains.

**Result.** `structuredContent` (the same JSON text is in `content`) has two parts: `data` is
the API response with every text replaced by the placeholder `[untrusted]`; `untrusted` holds
these texts under their JSON pointer in the response, cleaned: control, bidi, zero-width and
other invisible characters removed, HTML as text, links replaced by `[link removed]`, at most 200
characters (messages 500, descriptions 2000). Texts are third-party free texts (hotel and
destination names, chain, address, descriptions, image titles and credits, lead guest name), the
messages of the API (`warnings`, `message`) and **every** string that does not match the fixed
pattern of its field: only codes (letters, digits, `_ . -`, at most 64 characters), references,
dates, timestamps, currencies, error codes and cursors stay in `data`. A destination or group
code with spaces or a link therefore appears only under `untrusted`. Address fields (`url` of the image variants) are left out; images come from
the content API itself. For the agent, texts under `untrusted` are data, not instructions.

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

**Errors** come as a tool result with `isError: true` and `structuredContent`
`{errorCode, status, message, retryAfter, hint}`: `errorCode` and `status` as from the API
(section 7), `retryAfter` = `Retry-After` in seconds, `hint` = column “Caller” of the error
catalogue (English). `message` is cleaned like a text under `untrusted`. If the key is missing,
`X-Api-Key` or `Authorization` appears more than once in the request, or both carry different
keys: `ERR_UNAUTHORIZED` without an API call. Codes of the MCP server itself:
`ERR_UPSTREAM_UNAVAILABLE` (502, API unreachable or timed out – retry after `retryAfter`,
`sandbox_book` with the same `idemKey`) and `ERR_RESPONSE_TOO_LARGE` (502, response over 4 MiB –
narrow the request).

**Limits.** `POST` only (no SSE stream: `GET` gives 405), `Content-Type: application/json`, one
JSON-RPC message per request (no batches); body at most 64 KiB, depth 16, 4096 JSON elements –
otherwise 413 or 400 before anything is evaluated. Per key 10 tool calls/s (above that
`ERR_RATE_LIMITED` with `retryAfter`); the bucket of the test keys applies as well (11.4). A
request with a foreign `Origin` gets 403 (protection against web pages in the browser); no
cookies, no CORS. Protocol versions 2025-03-26, 2025-06-18 and 2025-11-25.

**Setup.** Set the test key as the environment variable `TOURAPI_API_KEY` (the same one as in your own
code; one variable is enough); only the reference to it belongs in the configuration. Claude Code, file `.mcp.json` in the project:

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

Codex (the key is sent as a bearer token from the environment variable):

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

Antigravity does not fill environment variables into `headers`; the `mcp-remote` bridge reads
the key from the environment (entry in `mcp_config.json`; the bridge version is pinned so that no
unchecked release receives the key):

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

The “AI agents” page in the portal shows the same entries with the address of your portal.

---

## 12. Hotel content: `/v1/content/*`
<!-- de:2da15ec87ce4 -->

The Content API delivers the tour operator's hotel content for the consumers' websites and
catalogues: master data (accommodation type, chain, address), location (coordinates),
categories, facts, texts, images and amenities. It is a separate path next to price and
booking: content never changes a price or availability.

| Route | Purpose |
|---|---|
| `GET /v1/content/hotels` | Directory of the key's hotels, ascending by `code`, paged |
| `GET /v1/content/hotels/{code}` | Content of one hotel (`ETag`, `If-None-Match` → `304`) |
| `GET /v1/content/changes?since=…` | Changes since a state, in the order in which they were saved |
| `GET /v1/content/catalog` | Catalogues with labels (accommodation types, category schemes, text and image types, amenities) |

### 12.1 Access and scope
<!-- de:d890a03a2b08 -->

- The Content API only answers if **both** apply: the platform operator has enabled content
  for the tour operator, and the key carries the **content right** (granted by the tour
  operator admin under API access, off by default). Otherwise `403 ERR_CONTENT_NOT_ALLOWED`.
  A test key has the right of its live key and reads the same content.
- A tour operator that the platform operator runs as a test environment is only served by the
  installation set up for it. Everywhere else it counts as not enabled: the same answer
  `403 ERR_CONTENT_NOT_ALLOWED`.
- Which hotels a key sees is determined by the key as for `/v1/destinations` (1.1): without a
  customer group and with a price group all hotels of the tour operator, with an allotment
  group only hotels with an allotment – independent of availability. Every other hotel is
  `404 ERR_HOTEL_NOT_FOUND`.
- Only visible texts in the tour operator's **content languages** and visible, fully
  processed images are delivered. The hotel's **contact details** (phone, mail, web) do not
  exist in the Content API, not even as an empty field.
- Images are served from public, unguessable addresses
  (`<base>/m/<key>/<size>.jpg`, retrievable without a key, cacheable for 1 day).
  Next to every image there is `credit`; with `attributionRequired: true` the credit must be
  shown next to the image.
- Keys do not belong in browser code (1.1): the website reads the Content API on the server,
  only the image addresses go to the browser.

### 12.2 Directory
<!-- de:5353c4c32522 -->

```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 (default 500). The next page is fetched with `cursor=<nextCursor>`;
  without `nextCursor` the directory is complete.
- `contentVersion` counts every change to what is delivered for a hotel; `0` = no content yet
  (then `updatedAt` is missing). `category` is the official national category, `geo` the
  location – both only if maintained. `websiteReady` and `missing` as in a hotel's content
  (12.3).
- `feedToken` is the state from which `/v1/content/changes` continues. All pages of one
  directory pass carry the same state (that of the first page).
- `scopeHash` changes as soon as the key's set of hotels changes (new or deleted hotel,
  allotment added or removed). This is not in the feed: then fetch the directory again.
- `cursor`, `feedToken` and `next` are opaque and bound to the key: with another key,
  modified or after the server changed its key `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 Content of a hotel
<!-- de:f3187966234f -->

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

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

- **Languages:** without `lang` all texts in all of the tour operator's content languages are
  returned. With `lang` (1–5 languages, ISO 639-1 lower case, comma-separated) there is
  exactly one entry per text type and requested language. If the text is missing in that
  language, the tour operator's default language steps in, then English; the entry then
  carries `fallbackFrom` with the requested language (here: no text in Turkish, German is
  returned). If there is none there either, the entry is missing. Titles (`titles`) and
  alternative texts (`alts`) of the images follow the same rule. A language the tour operator
  does not offer: `422 ERR_LANGUAGE_NOT_OFFERED` (the active languages are listed in
  `warnings`).
- `html` contains only `p`, `br`, `b`, `strong`, `i`, `em`, `ul`, `ol`, `li` without
  attributes. `machineTranslated: true` marks a machine translation.
- `categories`: `kind` `official` (national category) or `operator` (the tour operator's
  rating), `scheme` `stars` or `keys`, `value` 1–5 in half steps; "4 Superior" is
  `value: 4, superior: true`, never 4.5.
- `geo.precision`: `address`, `street`, `locality` or `unknown`.
- `media` in display order, `order: 1` is the main image. Per image `variants` with the
  generated widths (`w320` to `w2048`, never wider than the original), each with width,
  height, bytes and `url`; `focus` (x, y in percent) is the most important image point for
  your own crops. `id` is stable as long as the image stays with the hotel.
- `amenities`: amenities with a code from the catalogue (12.5). `available: false` means
  explicitly not present; a missing amenity is unknown. Depending on the amenity with
  `count`, `distanceM`, `areaM2`, `ref` (airport code for the distance) and, for amenities
  that can be chargeable, `charge` (`included`, `extra`, `unknown`).
- `name`, `destination` and `giataCode` come from the hotel's contract. If one of them
  changes, the hotel appears in the feed (12.4, `reason: contract`), and from then on detail
  and directory return the new value.
- `websiteReady` and `missing`: the "website ready" maturity level – whether the operator has
  maintained enough content for a website. A key figure only; it changes neither sales nor
  delivery. `missing` lists the unmet criteria in a fixed order, empty = website ready:
  `general_text` (general text in the operator's default language), `geo` (location),
  `category` (category or accommodation type), `images` (at least 5 delivered images including
  the main image), `amenities` (at least 10 maintained amenities, explicit "not available"
  included). The assessment only changes with the content (new `contentVersion`); the
  operator's console shows the same one. Ignore unknown values in `missing` (criteria may be
  added). The directory carries both fields as well.

Every response carries an `ETag`. It changes with the content, the language selection and
the presentation. With `If-None-Match` the answer is `304` without a body as long as nothing
has changed:

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

Without `lang` all content languages (here `en` as a machine translation):

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

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

### 12.4 Changes
<!-- de:3a01f66e9c6b -->

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

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

- `since` is the directory's `feedToken` or `next` of the previous response (mandatory,
  otherwise `400 ERR_BAD_REQUEST`). `pageSize` 1–1000 (default 500).
- Per entry `code`, `change` (`upsert` = content changed, fetch again; `removed` = hotel
  deleted), for `upsert` the current `contentVersion`, and optionally `reason`
  (`languages` = the tour operator's content languages have changed, `media_ready` = an
  image has been fully processed, `contract` = `name`, `destination` or `giataCode` has
  changed with the contract, `deleted` for `removed`). Ignore unknown values.
- In the order in which the changes were saved, without gaps; a hotel appears at most once
  per page (with its latest state). `more: true` = continue reading with `next` right away.
- Only the key's hotels. A change of the set of hotels is not in the feed but in the
  `scopeHash`.
- The feed goes back 30 days. An older state: `410 ERR_CONTENT_CURSOR_EXPIRED` – then fetch
  the directory again and continue with its `feedToken`.

### 12.5 Catalogue
<!-- de:84b72293a015 -->

```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` are the tour operator's content languages, `defaultLanguage` its default
  language. `lang` selects the label languages (`de`, `en`, `tr`; without `lang` all).
- Per amenity `group`, `valueType` (`flag`, `anzahl`, `meter`, `flaeche_m2`,
  `meter_mit_bezug`), `unit` and `chargeable`. A code is never reinterpreted; `locked: true`
  means discontinued (stays readable). `version` changes with every new catalogue; the
  catalogue carries an `ETag`.

### 12.6 Synchronisation for consumers
<!-- de:c81e8058f698 -->

1. **Initial load:** fetch the directory page by page, remember `feedToken` and `scopeHash`,
   fetch the content of each hotel and store the `ETag`.
2. **Ongoing** (roughly every few minutes): `changes?since=<token>` (on the first call
   `feedToken`, afterwards the last remembered `next`), fetch the
   changed hotels again, delete `removed`, remember `next`. If `scopeHash` changes: step 3
   right away.
3. **Daily and on `410`:** fetch the directory again and reconcile it with your own data
   (delete missing hotels, fetch new ones, compare `contentVersion`).
4. Always fetch content with `If-None-Match`.

Access and scope errors:

```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. Planned and limits
<!-- de:646fc31e9488 -->

**Planned:** Within `/v1` only additive changes are made (section 1.9); every change is
listed with its date in the changelog. No change is currently announced that would require
an existing integration to be adapted.

**Limits:** The numeric values (sizes, rates, time limits) are in the appendix "Limits at a
glance"; what the sandbox does not cover is in 11.5.

---

## Appendix: Limits at a glance
<!-- de:9fb3c803996a -->

| What | Value |
|---|---|
| Body per request | 1 MiB |
| Nights per stay | 1–30 |
| Arrival | reference date to reference date + 732 days |
| Travellers per request, age | 1–20, 0–120 |
| Rooms per booking (`quantity`) | 1–1,000,000 |
| `idemKey`, `reference` | 128 characters (longer: `422 ERR_VALIDATION`) |
| `leadPaxName` | 255 characters (longer: `422 ERR_VALIDATION`) |
| `metadata.correlationId` | 64 characters (longer: `422 ERR_VALIDATION`) |
| Rate per key | 200/s, burst 400 |
| Test keys per access | at most 5, valid up to 90 days; together 20/s, burst 40; 2 concurrent searches per tour operator |
| Test bookings | 5,000 open per access, deleted 30 days after creation; log of test calls 7 days |
| Search time limit | 10 s |
| Search page | default 50, at most 100 hotels |
| Open search | per key according to the search profile (window, lengths, destinations, page, time budget, rate; date matrix: window and cells), retrievable with `GET /v1/limits`; page 20 if omitted; cursor 15 min |
| Connection | 15 s read, 15 s write |
| EDF delivery per key | `full` 1/h, new `changes` chain 1/min, follow-up pages/acknowledgements 5/s (burst 50); package responses may write for up to 10 min |
| Content API | directory and feed page 1–1000 (default 500), `lang` 1–5 languages, feed 30 days |
