> ## Documentation Index
> Fetch the complete documentation index at: https://mcp.theoryofevolution.com.au/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Booking system workflows (A / B / C)

> Different booking systems need different call sequences. Pick the workflow from `farePrices.bookingSystem`, then follow the right A, B, or C steps.

Every fare carries a `bookingSystem` value under `farePrices[].bookingSystem`. That value determines which API call sequence you must follow to configure, price, and book the product. Three workflows exist:

| `bookingSystem` | Workflow | Notes |
| - | - | - |
| *(null / missing)* | **A** | Pre-tagged rows. Safe default. |
| `Generic` | **A** | Lowest-common-denominator shape for external integrations. |
| `Clockwork` | **A** | |
| `CMS` | **A** | |
| `SOAAccommodation` | **A** | |
| `Respaxv2` | **B** | |
| `IBIS` | **B** | |
| `CustomLinc` | **B** | |
| `Rezdy` | **B** | Added 2026-10 (originally classified under Workflow A). TDMS native integration to Rezdy — same enhanced discovery pair as Respaxv2 / IBIS / CustomLinc. PPDIDs that previously appeared as `Generic` are being progressively retagged. |
| `Fareharbor` | **C** | Added 2026-10. New flow — see below. |

Always branch on `farePrices.bookingSystem` BEFORE choosing a workflow. Treat unknown or missing values as Workflow A (safe default).

## Workflow A — Standard booking systems

`Generic` · `Clockwork` · `CMS` · `SOAAccommodation`

```
1. GET /apiv1/bookingdetails/{productPricesDetailsId}
            ↓
2. GET /apiv1/checkavailabilityrange/{productPricesDetailsId}/{timeId}/{startDate}/{days}?cached=1
```

**Step 1** returns the configurable fields for the fare — booking times, pickup locations, optional fields, supplier info. The schema is essentially static per PPDID; cache aggressively (we hold a 24h TTL per `(branch, env, ppdid)`).

**Step 2** returns live availability for the picked `timeId` on `{startDate}` + the next `{days}` days. Add `?cached=1` for the cached result (lower latency, acceptable for most read-paths). Omit it (or `cached=0`) when you need a real-time number — e.g. just before placing an order.

> [!NOTE]
>
> TDMS's `/checkavailabilityrange` requires the date segment in `DD-Mon-YYYY` format. ISO dates are rejected with a misleading 400 error (`"ProductPricesDetail ... is not a valid price."`). The sibling `/detailsToConfirmAvailability` endpoint accepts ISO — only this one is fussy.

### Example — step 1 request

```http theme={null}
GET /apiv1/bookingdetails/20895150
Authorization: Bearer {token}
Accept: application/json
```

### Example — step 1 response (abridged — large pickup list truncated)

Historical capture from a Rezdy product (Kuranda: Scenic Rail, Skyrail Rainforest Cableway and Hartley's Crocodile Adventures, PPDID `20895150`) back when Rezdy was classified under Workflow A. Rezdy has since moved to Workflow B — see the Workflow B examples below for the current Rezdy response shape. The example here still illustrates the Workflow A envelope accurately for Generic / Clockwork / CMS / SOAAccommodation fares. Note the **PascalCase field names** on `bookingTimes[]` and `pickupLocations[]` — essentially how TDMS surfaces this endpoint's shape.

```json theme={null}
{
  "bookingTimes": [
    { "BookingTime": "07:00 AM", "BookingTimeID": "07:00:00", "Default": false }
  ],
  "pickupLocations": [
    {
      "PickupID": "Big 4 Ingenia Holidays Cairns Coconut",
      "PickupLocation": "Big 4 Ingenia Holidays Cairns Coconut, 23/51 Anderson Rd, Woree, Bus Zone On Anderson Rd Near Entrance, at: 05:10 AM",
      "PickupTime": "05:10 AM"
    },
    {
      "PickupID": "Cairns Central Plaza Apartment - Bus Zone on McLeod St",
      "PickupLocation": "Cairns Central Plaza Apartment - Bus Zone on McLeod St, 58 McLeod St, Cairns, Bus Zone on McLeod St near Intersection with Aplin St, at: 05:25 AM",
      "PickupTime": "05:25 AM"
    },
    {
      "PickupID": "Skyrail Rainforest Cableway",
      "PickupLocation": "Skyrail Rainforest Cableway, 6 Skyrail Drive, Smithfield, Bus Zone at the front of Skyrail Terminal, at: 06:22 AM",
      "PickupTime": "06:22 AM"
    }
  ],
  "supplier": {
    "name": "Down Under Tours (Australia) Pty Ltd",
    "phone": "07 4047 9066",
    "freePhone": "07 4047 9066"
  },
  "optionalFields": [
    {
      "commonName": null,
      "optionalFieldId": 2311498,
      "title": "Boarding Pass Language Option",
      "mandatory": false,
      "supplierRequired": false,
      "type": "select",
      "options": ["English", "German", "French", "Spanish", "Italian", "Japanese", "Chinese"],
      "apiName": ""
    }
  ],
  "imagePath": "https://api.websitetravel.com/upload/sm/670f05074572d_364_06c30c9348844ac5923cf93b624c1c6ahartleys2015.jpg",
  "autoCheckPackageDates": false
}
```

> [!NOTE]
>
> **Workflow A `optionalFields` shape differs from Workflow C step 2b.** Workflow A uses `{ optionalFieldId (integer), title, mandatory, supplierRequired, type, options: [string] }` with options as a flat string array. Workflow C step 2b uses `{ name (string prefix like `optionalFields\_cl\_\*`), displayName, required, isPriced, options: [{ externalId, displayName, price, note }] }` with per-option metadata. Two separate parsers.

### Example — step 2 request

```http theme={null}
GET /apiv1/checkavailabilityrange/20895150/07:00:00/21-Oct-2026/1?cached=1
Authorization: Bearer {token}
Accept: application/json
```

### Example — step 2 response

Real response from the same product above (captured when Rezdy was Workflow A — kept as a reference capture; the envelope illustrated here still matches live Workflow A fares). The envelope is **identical to Workflow C's step 1 response** — pricing lives in `productPricingData`, with Workflow A signaled by `datePriceCacheId: null` + `RequiresFurtherDetailsToConfirm: false` (no reservation lock + no further details call needed).

```json theme={null}
[
  {
    "datePriceCacheId": null,
    "productPricesDetailsId": 20895150,
    "BookingDate": "21-Oct-2026",
    "EndDate": "21-Oct-2026",
    "BookingTimeID": "07:00:00",
    "NumAvailable": 2,
    "NumAvailableDisplay": "2",
    "Note": null,
    "RequiresFurtherDetailsToConfirm": false,
    "DateRestriction": null,
    "productPricingData": {
      "RRP": 759.0,
      "RRPWas": 759.0,
      "NetRate": 584.43,
      "FareCurrencyRRP": 759.0,
      "FareCurrencyNetRate": 584.43,
      "IgnorePpdMargins": false,
      "addOnPricing": []
    },
    "fields": [],
    "extraInfo": {}
  }
]
```

## Workflow B — Enhanced booking systems

`Respaxv2` · `IBIS` · `CustomLinc` · `Rezdy`

```
1. GET /apiv1/checkavailabilityrange/{productPricesDetailsId}/0/{startDate}/{days}?cached=1
            ↓
2. GET /apiv1/bookingdetails/detailsToConfirmAvailability/{productPricesDetailsId}/{date}/{timeId}
```

**Step 1** uses `timeId=0` as a sentinel — tells the enhanced systems "give me availability and the time selector inline". The response includes:

* A `fields[]` array. For Respax / CustomLinc multi-time products, the `fields[name=timeId]` entry carries selectable `options` (each with `externalId` + `displayName` + `numAvailable`). For single-time Respax / CustomLinc the inline `BookingTimeID` IS the time, and `pickupId` / `dropoffId` fields are populated inline from the same response. For IBIS, step 1 returns an empty `fields[]` plus a top-level `BookingTimeID` session key — you'll need step 2 to resolve pickups / dropoffs.
* A `RequiresFurtherDetailsToConfirm: true` flag indicating you must call step 2 before placing an order.

**Step 2** uses the specific `timeId` from step 1 to retrieve the pickup / dropoff / optional-field set for that particular time slot. Supplier option sets can vary per-time, so always scope by `timeId` rather than unioning across times.

### Example — step 1 request

```http theme={null}
GET /apiv1/checkavailabilityrange/19975231/0/22-Nov-2026/1?cached=1
Authorization: Bearer {token}
Accept: application/json
```

### Example — step 1 response (Respax multi-time)

```json theme={null}
[
  {
    "BookingDate": "22-Nov-2026",
    "BookingTimeID": "T1",
    "NumAvailable": 10,
    "RequiresFurtherDetailsToConfirm": true,
    "fields": [
      {
        "name": "timeId",
        "type": "radio",
        "options": [
          { "externalId": "T1", "displayName": "9:00 AM", "numAvailable": 5 },
          { "externalId": "T2", "displayName": "2:00 PM", "numAvailable": 7 }
        ]
      }
    ]
  }
]
```

### Example — step 2 request

```http theme={null}
GET /apiv1/bookingdetails/detailsToConfirmAvailability/19975231/22-Nov-2026/T2
Authorization: Bearer {token}
Accept: application/json
```

### Example — step 2 response

```json theme={null}
{
  "fields": [
    { "name": "pickupId", "options": [
      { "externalId": "HOTEL-A", "displayName": "Hotel A" },
      { "externalId": "HOTEL-B", "displayName": "Hotel B" }
    ] },
    { "name": "dropoffId", "options": [
      { "externalId": "DROP-B", "displayName": "Drop B" }
    ] }
  ]
}
```

### Rezdy — hybrid envelope on step 2

> [!WARNING]
>
> **Rezdy is a Workflow B hybrid**. Discovery runs through the enhanced pair (step 1 + step 2), but Rezdy's step 2 response **does NOT carry `optionalFields` at the top level**. Instead, `optionalFields` are nested inside `fields[name="optionalFields"].fields[]` — the SAME envelope [Workflow C (FareHarbor)](#workflow-c-fareharbor) step 2b uses. Field keys also differ from the IBIS / Respax / CustomLinc shape: `name` instead of `optionalFieldId`, `displayName` instead of `title`, `required` instead of `mandatory`.
>
> A parser that only reads top-level `payload.optionalFields` will see nothing for Rezdy fares and the customer-facing UI will render no inputs. Validation at `/validateCart` then surfaces agent-portal jargon back to the customer (e.g. *"Product now also requires - Please provide ages of participants. Re-check the date with Edit Availability to enter it."*) — which looks like a configuration bug but is actually the step 2 parser missing the nested envelope.

### Example — Rezdy step 2 request

```http theme={null}
GET /apiv1/bookingdetails/detailsToConfirmAvailability/23530772/2026-11-14/14:00:00
Authorization: Bearer {token}
Accept: application/json
```

Note the `timeId` segment is a plain time string (`14:00:00`) — unlike IBIS session-key tokens (`departure.YYYYMMDDT...`) or CustomLinc opaque IDs. Encoding tip: pass through `encodeTdmsPathSegment` (or equivalent) so the colons survive URL normalisation byte-for-byte.

### Example — Rezdy step 2 response (nested envelope)

Captured 2026-10-28 against PPDID `23530772` (Dolphin Kayaking + Great Beach Drive Adventure — Rainbow Beach / Epic Ocean Adventures). Note how `optionalFields` sits at `fields[2].fields[0]`, not at the top level.

```json theme={null}
{
  "productPricesDetailsId": 23530772,
  "fields": [
    {
      "date": "2026-11-14",
      "type": "radio",
      "name": "timeId",
      "displayName": "Time",
      "required": true,
      "options": [
        {
          "externalId": "14:00:00",
          "displayName": "2:00pm",
          "numAvailable": 47,
          "note": ""
        }
      ]
    },
    {
      "date": "2026-11-14",
      "type": "select",
      "name": "pickupId",
      "displayName": "Pickup",
      "required": true,
      "options": [
        {
          "externalId": "Bus Stop Rainbow Beach - Kirchner St (Opposite The Deck @ Sea Salt)",
          "name": "Bus Stop Rainbow Beach - Kirchner St (Opposite The Deck @ Sea Salt)",
          "displayName": "Bus Stop Rainbow Beach - Kirchner St (Opposite The Deck @ Sea Salt) - 33XR+9Q Rainbow Beach Queensland, Australia"
        }
      ]
    },
    {
      "date": "2026-11-14",
      "name": "optionalFields",
      "displayName": null,
      "fields": [
        {
          "name": "rezdy_please_provide_ages_of_participants_e19b3e78",
          "displayName": "Please provide ages of participants",
          "type": "text",
          "required": true,
          "apiName": "Please provide ages of participants",
          "options": []
        }
      ]
    }
  ]
}
```

> [!NOTE]
>
> **Parser mapping — normalise Rezdy's nested shape to the common Workflow B field shape**:
>
> | Common key | IBIS / Respax / CustomLinc | Rezdy nested |
> | - | - | - |
> | `optionalFieldId` | `optionalFieldId` | `name` |
> | `title` | `title` | `displayName` (fallback `apiName`) |
> | `mandatory` | `mandatory` | `required` |
> | `type` | `type` | `type` |
> | `commonName` | present (used for shared-field fan-out) | absent — set to `null` |
> | `options` (select type) | `[string]` | `[{externalId, displayName}]` → flatten to `displayName` strings |
>
> Prefer the flat top-level `payload.optionalFields` when present and non-empty. Fall back to the nested wrapper only when the flat shape is empty — that keeps IBIS / Respax / CustomLinc on the canonical path and only exercises the nested parser for Rezdy (and any future Workflow B system TDMS might ship under the nested shape).

## Workflow C — FareHarbor

`Fareharbor` *(added 2026-10)*

FareHarbor has a richer native API than the Workflow A / B systems. Optional fields can be time-dependent AND can affect the final price (`isPriced=true`), which means every booking needs a dedicated **reservation lock** before you can POST `/order`.

```
1. GET /apiv1/checkavailabilityrange/{productPricesDetailsId}/0/{startDate}/{days}?cached=1
            ↓
2a. If step 1 did NOT return a fields entry whose name is timeId:
    GET /apiv1/bookingdetails/detailsToConfirmAvailability/{productPricesDetailsId}/{date}
    (no timeId segment) — select an options.externalId from the returned timeId field
            ↓
2b. GET /apiv1/bookingdetails/detailsToConfirmAvailability/{productPricesDetailsId}/{date}/{timeId}
    — select / enter every required optional field
            ↓
3. GET /apiv1/checkAvailability/{productPricesDetailsId}/reservation
   — pass bookingTimeId, bookingDate, and each selected / entered field
            ↓
4. POST /apiv1/order with datePriceCacheId on each affected product + booking
```

**Step 1** — same shape as Workflow B's step 1. If the response includes `fields[name=timeId]` with options, skip to step 2b.

**Step 2a** — details WITHOUT a timeId segment. Pull `fields[name=timeId]` from the response; each `options.externalId` is a selectable time.

**Step 2b** — details WITH the picked timeId. Returns the pickup / dropoff / optional-field set for that specific time. Inner field names beginning `optionalFields_bl_` are **booking-level** (one per booking); names beginning `optionalFields_cl_` are **customer-level** (one per passenger). Fields can be priced or non-priced (`isPriced=true` on priced ones).

**Step 3 — reservation lock.** Call `/apiv1/checkAvailability/{ppdid}/reservation` with:

* `bookingTimeId` + `bookingDate`
* Every selected / entered optional field as paired, explicitly indexed entries:
  * `optionalFields[0][optionalFieldId]` + `optionalFields[0][fieldText]`
  * `optionalFields[1][optionalFieldId]` + `optionalFields[1][fieldText]`
  * etc.
  * Send `options.externalId` for select / radio fields; send typed text for text fields.
* All required fields, all priced selections (`isPriced=true`), and lodging MUST be sent. Non-priced optional answers can be deferred to the order itself.

The response carries a `datePriceCacheId` and the final `rrp`. The `datePriceCacheId` is bound to this exact combination of `(date, timeId, priced selections, lodging)` — do NOT reuse it for a different date, time, priced selection or lodging.

**Step 4 — order.** POST `/apiv1/order` with the `datePriceCacheId` from step 3 on BOTH the product record AND each booking record. Non-priced answers can be added or changed in the order payload without re-running step 3.

> [!NOTE]
>
> **Price-cache lifetime** *(confirmed with TDMS dev 2026-10-27)*.
>
> * **No TTL, no price lock, no availability hold.** The name "reservation" is a misnomer — the row only maps `(ppdid, date, timeId, priced selections, lodging)` → `(datePriceCacheId, rrp)`. The slot stays bookable by anyone until `/order` succeeds.
> * **Supplier-side price changes rewrite the SAME row in place.** Calling `/reservation` again with identical inputs returns the same `datePriceCacheId` but with the latest supplier price. Treat the id as stable for its selection fingerprint, but re-call `/reservation` once more just before payment to pick up any supplier-side price drift.
> * **Different inputs → new row.** Any change to ppdid, priced selection, or lodging mints a fresh cache id. Non-priced answers (comments, names) can still ride on the existing id for `/order`.
> * **Housekeeping.** Rows unused in any quote or order AND not touched by `/reservation` for 3 days are garbage-collected. Keep the cart's effective TTL well below that (we treat anything over \~1 hour as stale and refetch availability + reservation fresh — operator-side state has usually moved by then).
> * **`/validateCart` threading.** If your flow calls `/validateCart` between `/reservation` and `/order`, pass the same `datePriceCacheId` on both — a missing or stale id yields `alertLevel: "alert-danger"` on the fare.

### Example — step 1 request

```http theme={null}
GET /apiv1/checkavailabilityrange/23501261/0/20-Oct-2026/7?cached=1
Authorization: Bearer {token}
Accept: application/json
```

### Example — step 1 response

Real response from a Fareharbor product (Prima — 2 day 1 night Whitsunday Sailing, PPDID `23501261`), 7-day window. Note the per-day structure, `datePriceCacheId` baked into each day with availability, and `fields[name=timeId]` inline (the common path — step 2a's fallback below rarely fires). Dates are DD-Mon-YYYY on this endpoint, NOT ISO.

> [!WARNING]
>
> **Workflow signal.** The combination `datePriceCacheId != null` + `RequiresFurtherDetailsToConfirm: true` is the clearest at-a-glance marker for Workflow C. Workflow A returns `datePriceCacheId: null` + `RequiresFurtherDetailsToConfirm: false`.

```json theme={null}
[
  {
    "datePriceCacheId": 13119087,
    "productPricesDetailsId": 23501261,
    "BookingDate": "20-Oct-2026",
    "EndDate": "21-Oct-2026",
    "BookingTimeID": "1836909165-7716219505",
    "NumAvailable": 2,
    "NumAvailableDisplay": "2",
    "Note": null,
    "RequiresFurtherDetailsToConfirm": true,
    "DateRestriction": null,
    "productPricingData": {
      "RRP": 1304.0,
      "RRPWas": 1304.0,
      "NetRate": 1004.08,
      "FareCurrencyRRP": 1304.0,
      "FareCurrencyNetRate": 1004.08,
      "IgnorePpdMargins": false,
      "addOnPricing": []
    },
    "fields": [
      {
        "date": "2026-10-20",
        "type": "radio",
        "name": "timeId",
        "displayName": "Time Slot",
        "required": true,
        "options": [
          { "externalId": "1836909165-7716219505", "displayName": "08:30 - 16:00", "numAvailable": 2 }
        ]
      }
    ],
    "extraInfo": {}
  },
  {
    "datePriceCacheId": null,
    "productPricesDetailsId": 23501261,
    "BookingDate": "21-Oct-2026",
    "EndDate": "22-Oct-2026",
    "BookingTimeID": null,
    "NumAvailable": 0,
    "NumAvailableDisplay": "0",
    "Note": "No availability for this date",
    "RequiresFurtherDetailsToConfirm": false,
    "DateRestriction": null,
    "productPricingData": {
      "RRP": 1304.0, "RRPWas": 1304.0, "NetRate": 1004.08,
      "FareCurrencyRRP": 1304.0, "FareCurrencyNetRate": 1004.08,
      "IgnorePpdMargins": false, "addOnPricing": []
    },
    "fields": [],
    "extraInfo": {}
  }
]
```

### Example — step 2a request (fallback, no timeId)

Called ONLY when step 1 didn't return `fields[name=timeId]` inline. Rare in practice — Fareharbor usually provides inline timeId in step 1.

```http theme={null}
GET /apiv1/bookingdetails/detailsToConfirmAvailability/23501261/2026-10-20
Authorization: Bearer {token}
Accept: application/json
```

### Example — step 2a response (future date with availability)

Returns a flat `fields[]` with one `timeId` radio field; each `options.externalId` is a selectable timeId. Use these values when calling step 2b.

```json theme={null}
{
  "productPricesDetailsId": 23501261,
  "fields": [
    {
      "date": "2026-10-20",
      "type": "radio",
      "name": "timeId",
      "displayName": "Time Slot",
      "required": true,
      "options": [
        { "externalId": "1836909165-7716219505", "displayName": "08:30 - 16:00", "numAvailable": 2 }
      ]
    }
  ]
}
```

### Example — step 2a response (past / unavailable date)

```json theme={null}
{
  "productPricesDetailsId": 23501261,
  "fields": []
}
```

### Example — step 2b request (with timeId)

Returns the time-specific additional fields — lodging + per-passenger customer-level fields + any booking-level fields.

```http theme={null}
GET /apiv1/bookingdetails/detailsToConfirmAvailability/23501261/2026-10-20/1836909165-7716219505
Authorization: Bearer {token}
Accept: application/json
```

### Example — step 2b response (abridged — large lodging list truncated)

Note the **nested shape**: the outer `fields[]` carries a wrapper entry whose `name` is `optionalFields`, and the actual field list is inside that wrapper's own `fields[]`. **Find the wrapper by name rather than by index.** It is the only entry in this capture, but step 2a returns a `timeId` entry in the same array, so position is not part of the contract and `fields[0].fields[]` is one schema change away from reading the wrong list. Each inner field carries `isPriced` (all false here — this product has no priced extras).

> [!WARNING]
>
> **Per-passenger field duplication for multi-pax fares.** On a 2-pax fare like Prima's "Private Double Cabin (Sleeps 2 guests)", TDMS encodes "ask this question for each passenger" by **repeating the field entry** inside the `optionalFields` wrapper. The real step-2b response for this product shows `optionalFields_cl_710104` (Dietary Requirements) listed TWICE — once between the Full Name entries, once after `cl_690457` ("Full Name of Second Passenger"). The supplier is pre-indexing per-passenger slots.
>
> Parsers must decide: dedupe by `name` (ignoring the per-pax intent, treating repeat entries as noise) or preserve the duplicates (rendering each occurrence as a separate input). For the single-redeemer `/order` path (each addon purchased as qty=1), deduping is safe. For a multi-pax fare where the fare itself covers N guests, each occurrence should get its own answer. The reservation lock and `/order` manifest both appear to behave the same regardless (both 4-field and 5-field variants of this product returned the SAME `datePriceCacheId` from `/reservation`).

```json theme={null}
{
  "productPricesDetailsId": 23501261,
  "fields": [
    {
      "date": "2026-10-20",
      "name": "optionalFields",
      "fields": [
        {
          "date": "2026-10-20",
          "type": "text",
          "name": "optionalFields_cl_690456",
          "displayName": "Full Name",
          "required": false,
          "timeId": "1836909165-7716219505",
          "isPriced": false
        },
        {
          "date": "2026-10-20",
          "type": "select",
          "name": "optionalFields_cl_710104",
          "displayName": "Dietary Requirements",
          "required": false,
          "timeId": "1836909165-7716219505",
          "isPriced": false,
          "options": [
            { "externalId": "6707890", "name": "None",      "displayName": "None",      "price": 0, "note": "" },
            { "externalId": "6707891", "name": "Lactose Intolerant", "displayName": "Lactose Intolerant", "price": 0, "note": "" },
            { "externalId": "6707894", "name": "Vegetarian", "displayName": "Vegetarian", "price": 0, "note": "" }
          ]
        },
        {
          "date": "2026-10-20",
          "type": "select",
          "name": "optionalFields_lodging",
          "displayName": "Lodging",
          "required": false,
          "timeId": "1836909165-7716219505",
          "isPriced": false,
          "options": [
            { "externalId": "64309", "name": "Airlie Beach Hotel",      "displayName": "Airlie Beach Hotel",      "SubProductDisplayName": "Airlie Beach Hotel" },
            { "externalId": "64365", "name": "Port of Airlie (Ferry Terminal)", "displayName": "Port of Airlie (Ferry Terminal)", "SubProductDisplayName": "Port of Airlie (Ferry Terminal)" }
          ]
        }
      ]
    }
  ]
}
```

> [!NOTE]
>
> **Field naming convention.** Inner field names beginning `optionalFields_cl_` are **customer-level** (per passenger); `optionalFields_bl_` are **booking-level** (one per booking); the sentinel `optionalFields_lodging` is the lodging dropdown. **Lodging options carry `SubProductDisplayName` instead of the standard `price` + `note` keys** — branch your renderer on the field name.

> [!NOTE]
>
> **Per-passenger duplication.** A select field like Dietary Requirements may appear MULTIPLE times inside the `optionalFields` wrapper for multi-pax products — once per passenger slot. The outer caller decides whether to dedupe + collect once, or render once-per-passenger. Watch for duplicate `name` values.

### Example — step 3 reservation request (minimal, no optional fields)

Most Fareharbor products have no priced optional fields and no required fields. In those cases the minimal reservation call works — TDMS reuses the step-1 `datePriceCacheId`.

```http theme={null}
GET /apiv1/checkAvailability/23501261/reservation?bookingTimeId=1836909165-7716219505&bookingDate=2026-10-20
Authorization: Bearer {token}
Accept: application/json
```

### Example — step 3 reservation request (with optional fields)

```http theme={null}
GET /apiv1/checkAvailability/23501261/reservation
    ?bookingTimeId=1836909165-7716219505
    &bookingDate=2026-10-20
    &optionalFields[0][optionalFieldId]=optionalFields_cl_690456
    &optionalFields[0][fieldText]=Alex%20Lee
    &optionalFields[1][optionalFieldId]=optionalFields_cl_710104
    &optionalFields[1][fieldText]=6707890
    &optionalFields[2][optionalFieldId]=optionalFields_lodging
    &optionalFields[2][fieldText]=64309
Authorization: Bearer {token}
Accept: application/json
```

### Example — step 3 response (minimal call, no optional fields)

Real response. `datePriceCacheId: 12938709` matches the canonical "no-fields" cache id for this `(ppdid, date, timeId)` — the id TDMS also returns from `/apiv1/checkAvailabilityProduct` for the same slot.

```json theme={null}
{
  "datePriceCacheId": 12938709,
  "rrp": 1304,
  "wholesale": 1004.08,
  "bookingFee": 0,
  "fareharborGroupedBooking": true,
  "pickupDetails": "",
  "fareCurrencyNetRate": 1004.08,
  "fareCurrencyRrp": 1304,
  "fareCurrencyBookingFee": 0
}
```

### Example — step 3 response (full call, optional fields supplied)

Same product, same date, same timeId — but with all four optional fields filled. `datePriceCacheId: 13119045` differs from the minimal call's `12938709` because the selection fingerprint changed (0 fields → 4 fields). Prices + `fareharborGroupedBooking` are unchanged.

```json theme={null}
{
  "datePriceCacheId": 13119045,
  "rrp": 1304,
  "wholesale": 1004.08,
  "bookingFee": 0,
  "fareharborGroupedBooking": true,
  "pickupDetails": "",
  "fareCurrencyNetRate": 1004.08,
  "fareCurrencyRrp": 1304,
  "fareCurrencyBookingFee": 0
}
```

> [!NOTE]
>
> **Cache ids are DETERMINISTIC, not time-bound tokens.** TDMS returns the same `datePriceCacheId` for the same `(ppdid, date, timeId, selection fingerprint)` across repeated calls. For products with no `isPriced` options, further-adding non-priced fields often yields the SAME id (verified against Prima Whitsunday — 4 vs 5 fields both returned `13119045`). Don't rely on the id to detect selection drift; always re-call `/reservation` whenever any field changes, and send its returned id on `/order`.

> [!WARNING]
>
> **Cache ids differ across endpoints — use the right source.** For the SAME `(ppdid, date, timeId)` the three endpoints returned three different ids in our verification (per-PPDID `/checkavailabilityrange?cached=1` → `13119087`; bulk `/checkAvailabilityProduct` → `12938709`; `/reservation` minimal → `12938709`). **Always source the id for `/order` from `/reservation`** (or an uncached `/checkAvailabilityProduct`). The `?cached=1` step-1 id is a snapshot id for display only — do NOT thread it onto `/order`.

> [!WARNING]
>
> **The step-1 cache id can only be reused on `/order` when zero optional field answers accompany the booking.** The moment any field answer is sent (lodging, Full Name, Dietary — anything, even non-priced), you MUST call `/reservation` first and use the id it returns. Sending a stale or wrong id with populated optional fields produces a 400 "FareHarbor options for (date) do not match the confirmed price" from TDMS — do NOT attempt a fallback, abort the submission and re-run `/reservation`.

### Example — step 3 error response (bad lodging externalId)

HTTP 500 — thrown when a submitted option externalId isn't in the server's current option list (either a crafted request OR operator-side drift between page load and submit). The supplier context ("company shortname") is leaked in the message — sanitise before surfacing to end users.

```json theme={null}
{
  "code": null,
  "errors": [],
  "message": "Reservation check failed, not bookable. Reason: 99999 is not a valid lodging pk for company shortname 'explorewhitsundays'",
  "status": "error",
  "statusCode": 500,
  "statusText": "Internal Server Error"
}
```

> [!WARNING]
>
> **Use `optionalFields` at BOTH ends — `additionalFields` is the trap.** The same selections travel in two different carriers, and only the carrier changes:
>
> * **Step 3 `/reservation`** — selections go as URL **query params** named `optionalFields[i][optionalFieldId]` + `optionalFields[i][fieldText]`.
> * **Step 4 `/order`** — the same selections go as a JSON **body** property named `bookings[0].optionalFields: [{optionalFieldId, fieldText}, ...]`. **Same key name as Workflow A/B** — not `additionalFields`.
>
> Sending `bookings[0].additionalFields: [...]` on `/order` for a Fareharbor product means TDMS reads zero recognised answers and rejects with "FareHarbor options for (date) do not match the confirmed price" even when `/reservation` succeeded with the identical selections — because the reservation lock expected N field answers to accompany the order and the `additionalFields` key is silently ignored (like `fieldValue` is on A/B).

> [!WARNING]
>
> **`datePriceCacheId` MUST be mirrored on BOTH the per-redeemer product record AND each booking record.** Setting it on only one is a rejection path. The step-4 example below shows both — keep them in lockstep.

> [!NOTE]
>
> **When can `/reservation` be skipped?** Only when the operator/customer provided NO optional field answers at all. In that case, thread the step-1 `datePriceCacheId` straight to `/order`. Any field selection — including non-priced ones — requires a fresh `/reservation` call.

### Example — step 4 order payload (verified shape)

Verified against a real Fareharbor /order attempt + a cross-check against another dev's working-shape payload. **`datePriceCacheId` must appear in THREE positions** — top-level `products[]`, per-redeemer `products[]`, and each `bookings[]` — all with the SAME id from the preceding `/reservation` call.

```json theme={null}
{
  "bookingReference": "WT123456",
  "agentReference": "Group tour reference",
  "paymentMethod": "credit_card_pay_stripetest_gtm",
  "emailVouchers": 0,
  "processAsQuote": true,
  "totalCharged": 1304.00,
  "products": [
    {
      "productPricesDetailsId": "23501261",
      "datePriceCacheId": 13119045,
      "qty": 1
    }
  ],
  "redeemers": [
    {
      "emailAddress": "alex@test.example.com",
      "firstName": "Alex",
      "lastName": "Lee",
      "phone": "0400000000",
      "redeemerCountry": 36,
      "products": [
        {
          "productPricesDetailsId": "23501261",
          "datePriceCacheId": 13119045,
          "redeemerQty": 1,
          "bookWithFirstRedeemer": false,
          "optionalFields": null,
          "bookings": [
            {
              "datePriceCacheId": 13119045,
              "pickupId": "0",
              "timeId": "1836909165-7716219505",
              "travelDate": "20-Oct-2026",
              "optionalFields": [
                { "optionalFieldId": "optionalFields_cl_690456", "fieldText": "Alex Lee" },
                { "optionalFieldId": "optionalFields_cl_710104", "fieldText": "6707890" },
                { "optionalFieldId": "optionalFields_cl_690457", "fieldText": "Sam Rao" },
                { "optionalFieldId": "optionalFields_lodging",   "fieldText": "64309" }
              ]
            }
          ]
        }
      ]
    }
  ]
}
```

> [!WARNING]
>
> **Fareharbor /order shape differs from Workflow A/B in seven ways:**
>
> 1. **`datePriceCacheId` on THREE positions** — top-level `products[]`, per-redeemer `products[]`, each `bookings[]`. All must be the same id from the preceding `/reservation`.
> 2. **`productPricesDetailsId` as STRING at both top-level and per-redeemer levels.** Workflow A/B sends it as a number at the top level; Fareharbor needs string everywhere.
> 3. **`pickupId: "0"` on each booking** — literal string "0" even though Fareharbor uses lodging (sent via step-3 reservation), not a pickup. TDMS treats missing `pickupId` as "resolve from the slot definition" which may fail for Fareharbor's grouped-booking semantics.
> 4. **`optionalFields: null`** on the per-redeemer product (literal null, not an empty array). This is the DIFFERENT slot from the booking-level `optionalFields` (point 7) — same key name, different position, different meaning.
> 5. **`bookWithFirstRedeemer: false`** on each per-redeemer product.
> 6. **Multi-passenger is N REDEEMERS, not N bookings on one redeemer.** Each additional passenger is a new entry in `redeemers[]` with its own `products: [{ redeemerQty: 1, bookings: [{...}] }]`. Each top-level `qty` is the sum of the `redeemerQty` of the redeemers sharing that line's `datePriceCacheId` — and there is one line per distinct priced combination, so a party split across two priced choices produces two top-level entries for one `productPricesDetailsId`. See [Multi-passenger bookings](#multi-passenger-bookings-and-reservation-locks).
> 7. **Field answers go on `bookings[0].optionalFields` — SAME key name as Workflow A/B.** The step-3 reservation URL takes the same selections as `optionalFields[i][optionalFieldId]/[fieldText]` query params; on `/order` the body carries them under `optionalFields: [{optionalFieldId, fieldText}, ...]`. **Do NOT use `additionalFields` on `/order` for Fareharbor** — TDMS silently ignores it (same contract as `fieldValue` being ignored for A/B) and the reservation lock then rejects the order with "options do not match the confirmed price" because the locked set couldn't be matched to any submitted answers. (Historical note: step-3 query params were previously named `additionalFields[i][...]`; WT support harmonised them with the /order body in late 2026 so both endpoints now use `optionalFields`.)

### Example — step 4 order response (success)

Real response from a successful `processAsQuote: true` Workflow C order against the proven shape above. Envelope is identical to Workflow A/B except for the `shouldValidateShortNotice: true` field (Fareharbor-specific — the supplier may enforce a short-notice booking cutoff that TDMS proxies).

```json theme={null}
{
  "id": 3656708,
  "hasCreditCardPayment": false,
  "eBookings": {
    "bookings": [],
    "bookingError": false
  },
  "shouldValidateShortNotice": true,
  "vouchers": {
    "voucherHTML": "https://tdms.websitetravel.com/apiv1/voucher/3656708.html",
    "voucherPDF": "https://tdms.websitetravel.com/apiv1/voucher/3656708.pdf"
  },
  "messages": [],
  "quoteUrl": "https://tdms.websitetravel.com/customerQuote/3656708/OjpXVFQ1Njk0LTE1NTI3OA../303fb0f3788838d5f26fe251a3257919",
  "bookingReference": "WTT5694-155278"
}
```

> [!NOTE]
>
> **`eBookings.bookings: []` on a quote is expected** — Fareharbor's native booking isn't committed until `processAsQuote: false` lands. For a quote the eBookings slot stays empty and `bookingError: false` confirms no supplier-side error. For a committed order the array populates with per-booking Fareharbor confirmations.

### Example — step 4 error response (price mismatch)

HTTP 400 — thrown when TDMS's manifest layer can't match the order's optionalFields to the selection set locked by `/reservation`. Causes we've reproduced:

1. **Wrong key name on the booking** — using `additionalFields: [...]` instead of `optionalFields: [...]`. TDMS silently ignores the wrong key, so from its view the order carries zero recognised answers; the locked set then fails to match. (Historically the step-3 query params were named `additionalFields[i][...]`, so a parser written against the old docs sends a key TDMS no longer recognises at either end — both endpoints now use `optionalFields`.)
2. **Wrong `datePriceCacheId`** — using the snapshot id from `?cached=1` step-1, or a stale id from a prior selection set.
3. **Selection set drift** — the lock was minted against selection set X, but the order submits selection set Y.

```json theme={null}
{
  "code": null,
  "errors": [],
  "message": "Product 'Prima - 2 day 1 night Whitsunday Sailing' FareHarbor options for (20-Oct-2026) do not match the confirmed price. Please recheck the reservation and use the returned datePriceCacheId.",
  "status": "error",
  "statusCode": 400,
  "statusText": "Bad Request"
}
```

Remediation: confirm `bookings[0].optionalFields` (not `additionalFields`) is being sent with the exact same entries sent to `/reservation`, and that `datePriceCacheId` on `/order` is the id just returned from `/reservation` (not from `?cached=1` step-1).

### Multi-passenger bookings and reservation locks

Call `/reservation` once per **distinct priced combination**, not once per passenger. "Priced" means a step-2b field carrying `isPriced: true`: it is what decides *how many* locks a party needs, because only a priced answer can put two passengers on different prices. Every other answer may differ between passengers without an ADDITIONAL lock.

> [!NOTE]
>
> **What the fingerprint actually contains** *(confirmed with TDMS dev 2026-10-27)*. The cache row maps `(ppdid, date, timeId, priced selections, lodging)` → `(datePriceCacheId, rrp)`. **Lodging is in the fingerprint in its own right, whatever its `isPriced` says.** Non-priced answers — names, comments, dietary — are NOT, which is why they can ride on an existing id and differ between passengers.
>
> Read the two step-3 captures above with that in mind: the full call returned `13119045` where the minimal call returned `12938709`, and it is the **lodging** that accounts for it, not the Full Name or Dietary sent alongside. Reading that id change as evidence that non-priced answers enter the fingerprint is the easy mistake — all four fields in that product happen to be `isPriced: false`, so lodging hides among them.

| Case | What to send |
| - | - |
| N passengers, same priced choices | One `/reservation` call, with the same `datePriceCacheId` on every redeemer's booking. Calling once per passenger returns that same id anyway, because identical quotes reuse one cache row. |
| N passengers, different priced choices (for example two wetsuits, one without) | Not possible on one cart line: the ids carry different prices and checkout refuses them. Split into one cart line per distinct priced combination — same `productPricesDetailsId`, separate `products[]` entries — each with its own `datePriceCacheId` and its own `qty`. |
| Booking-level fields (`optionalFields_bl_*`) or lodging | Quoted once per booking. Only the first redeemer's booking-level fields count, and the fee is charged once per date and availability, not per passenger. |
| Non-priced fields (names, comments) | Can differ between passengers freely, without an ADDITIONAL `/reservation` — the lock their group already holds covers them. They do not let you skip the lock: as the step-3 rules above state, any submitted answer at all, priced or not, retires the step-1 cache id. |

So the number of TDMS calls per booking equals the number of **distinct priced combinations**, not the number of passengers. For a typical party making the same choices, that is one call.

> [!TIP]
>
> **Group before you call.** Build a fingerprint per passenger from their priced answers alone, group the passengers who share one, then call `/reservation` once per group and carry that group's id onto every booking in it. If you key your top-level `products[]` lines on `(productPricesDetailsId, datePriceCacheId)` and sum `qty` from the redeemer entries rather than writing it alongside them, the split in row two falls out on its own and the money and the manifest cannot disagree.

### Example — step 4 order payload, three passengers across two priced combinations

> [!NOTE]
>
> Structural skeleton, not a capture — the verified single-combination payload is the example above. No captured product on this page has a priced optional field, so `optionalFields_cl_880100` below stands in for one.

Two passengers chose a priced option, the third did not. Each combination was locked by its own `/reservation` call, so one `productPricesDetailsId` appears as TWO top-level entries, and every redeemer's booking carries the id of the lock that priced it.

```json theme={null}
{
  "products": [
    { "productPricesDetailsId": "23501261", "datePriceCacheId": 13119045, "qty": 2 },
    { "productPricesDetailsId": "23501261", "datePriceCacheId": 13119112, "qty": 1 }
  ],
  "redeemers": [
    {
      "firstName": "Alex",
      "products": [{
        "productPricesDetailsId": "23501261",
        "datePriceCacheId": 13119045,
        "redeemerQty": 1,
        "bookWithFirstRedeemer": false,
        "optionalFields": null,
        "bookings": [{
          "datePriceCacheId": 13119045,
          "pickupId": "0",
          "timeId": "1836909165-7716219505",
          "travelDate": "20-Oct-2026",
          "optionalFields": [
            { "optionalFieldId": "optionalFields_cl_880100", "fieldText": "7712044" },
            { "optionalFieldId": "optionalFields_lodging",   "fieldText": "64309" }
          ]
        }]
      }]
    },
    {
      "firstName": "Sam",
      "products": [{
        "productPricesDetailsId": "23501261",
        "datePriceCacheId": 13119045,
        "redeemerQty": 1,
        "bookWithFirstRedeemer": false,
        "optionalFields": null,
        "bookings": [{
          "datePriceCacheId": 13119045,
          "pickupId": "0",
          "timeId": "1836909165-7716219505",
          "travelDate": "20-Oct-2026",
          "optionalFields": [
            { "optionalFieldId": "optionalFields_cl_880100", "fieldText": "7712044" }
          ]
        }]
      }]
    },
    {
      "firstName": "Jo",
      "products": [{
        "productPricesDetailsId": "23501261",
        "datePriceCacheId": 13119112,
        "redeemerQty": 1,
        "bookWithFirstRedeemer": false,
        "optionalFields": null,
        "bookings": [{
          "datePriceCacheId": 13119112,
          "pickupId": "0",
          "timeId": "1836909165-7716219505",
          "travelDate": "20-Oct-2026",
          "optionalFields": [
            { "optionalFieldId": "optionalFields_cl_880100", "fieldText": "7712051" }
          ]
        }]
      }]
    }
  ]
}
```

`optionalFields_lodging` appears on the FIRST redeemer's booking only. It is booking-level, so repeating it on Sam and Jo would not charge three lodging fees — but it is not what TDMS reads, and the second and third copies are ignored.

### Pre-order validation with `/validateCart` for Fareharbor

Fareharbor fares require the `datePriceCacheId` from a successful `/reservation` call to be threaded into `/validateCart` as well as `/order` — same id, same three positions (top-level `products[]`, per-redeemer `products[]`, each `bookings[]`). Calling `/validateCart` for a Fareharbor fare WITHOUT the cacheId — or with one that has gone stale after a selection change — does NOT return the top-level `errors[]` array that Workflow A/B rejections use. TDMS returns 200 OK with a per-PPDID keyed map of per-fare outcomes, each carrying `status`, `message`, and `alertLevel`.

> [!WARNING]
>
> **Silent-success trap.** A Fareharbor fare whose cacheId is missing or stale yields `alertLevel: "alert-danger"` INSIDE the per-PPDID envelope. There is no top-level `errors[]`, no HTTP 4xx, and no `requestStatus.success: false`. A parser written only against the Workflow A/B error shapes will treat this response as success and let the customer proceed to `/order`, which then fails with the "FareHarbor options for (date) do not match the confirmed price" response in the step-4 error example above. Parse the per-PPDID shape the same way `/validateQuote` responses are parsed and flag any fare whose `alertLevel` is `alert-danger` (or whose `status` is not `Available`).

```json theme={null}
{
  "21576614": {
    "status": "Details Required",
    "message": [
      {
        "error": "FareHarbor options for (10-Nov-2026) do not match the confirmed price. Please recheck the reservation and use the returned datePriceCacheId.",
        "bookingDate": "10-Nov-2026"
      }
    ],
    "alertLevel": "alert-danger",
    "nextAvailability": "",
    "productDetail": {
      "productId": 54987,
      "productPricesDetailsId": 21576614
    }
  }
}
```

Captured 2026-10-27 from a `/validateCart` call where a Fareharbor fare was threaded with the top-level `?cached=1` `datePriceCacheId` instead of the one returned from `/reservation`. On a successful validate every value in the map has `status: "Available"` + `alertLevel: "alert-success"` — same envelope, filled with the OK branch.

> [!NOTE]
>
> **Same envelope as `/validateQuote`.** The per-PPDID shape here is identical to the `/validateQuote` response examples in the OpenAPI spec. Top-level keys are the stringified `productPricesDetailsId` of each fare in the cart (not the booking reference used by `/validateQuote`). If you already parse `/validateQuote` responses, share the parser — just key on PPDID instead.

### Pre-order validation with `/validateCart` — live-rate enforcement (all workflows)

`/validateCart` enforces that the `rrp` + `agentRRP` your client sends in `products[]` matches TDMS's live view of the supplier rate for the `(productPricesDetailsId, bookingDate)` pair. Mismatch returns the same per-PPDID envelope as the Fareharbor case above, with `alertLevel: "alert-danger"` and `category: "fare"` on the message:

```json theme={null}
{
  "23530772": {
    "status": "Availability Issues",
    "message": [
      {
        "error": "Rate Updated for:<br><b>Dolphin Kayaking + Great Beach Drive Adventure - Rainbow Beach Adult - Travel Date 14-Nov-2026</b><br><br>The current rate (142.00) no longer matches the saved cart rate (122.00). Please remove this item from your cart and add it again to apply the latest price.",
        "category": "fare"
      }
    ],
    "alertLevel": "alert-danger",
    "nextAvailability": "",
    "productDetail": {
      "productId": 4308,
      "productPricesDetailsId": 23530772,
      "name": "Dolphin Kayaking + Great Beach Drive Adventure - Rainbow Beach Adult"
    }
  }
}
```

Captured 2026-10-28 against a Rezdy fare with seasonal pricing. The client sent the operator's stored base rate ($122); TDMS compared to the live Rezdy rate for 14-Nov-2026 ($142) and rejected.

> [!WARNING]
>
> **The error message is written for the TDMS agent UI's explicit cart basket.** "Please remove this item from your cart and add it again" has no literal equivalent in a customer portal — the fix is to re-fetch the live rate and re-send it on the next `/validateCart`. If your UI exposes only "untick + retick" on the tile, that affordance ONLY helps if the client-side state refreshes the price from `/checkavailabilityrange` on retick; a plain state toggle will re-send the same stored rate and loop.

**The source of truth** is `/apiv1/checkavailabilityrange/{ppdid}/{timeId}/{date}/{days}?cached=0`, which returns `productPricingData.RRP` + `.NetRate` on the same row as `NumAvailable`. One call = both signals. Thread the returned `RRP` into `products[].rrp` + `.agentRRP` on `/validateCart` and the mismatch class disappears.

> [!NOTE]
>
> **Fire the live-rate fetch where the price is also displayed to the customer.** If your UI shows the stored base at tile load, then refreshes to the live rate at `/validateCart` time, the customer sees a price change at Continue — a worse UX than a tile that paints with the live number up front. Fetch at load (via `/checkavailabilityrange?cached=0`) and also at Continue (defensive — rates can shift during a session). The second fetch is the one that guarantees `/validateCart` passes.

> [!NOTE]
>
> **Not all suppliers echo `productPricingData`.** Open-dated vouchers, free-sell inventory, and some Workflow A suppliers return the row with `NumAvailable` only. Fall back to the operator-stored base in that case — those classes genuinely don't have dynamic rates. Guard the overwrite with a strict numeric check (`Number(null)` is `0` and `Number("")` is `0`; both pass `Number.isFinite`, both will silently overwrite a stored rate with `$0` if accepted blindly). Only accept a real finite number or a nonblank numeric string.

> [!NOTE]
>
> **`/select` / `/order` must send the same number too.** `/validateCart` passing with the live rate is necessary but not sufficient — the subsequent `/order` payload's `products[].rrp` + top-level `totalCharged` and the `/pgw/authenticate` `paymentAmount` query parameter all need to carry the same post-refresh number. Compute `totalCharged` AFTER you refresh addon rates; otherwise TDMS stores a lower `order.totalCharged` than `/pgw/authenticate` tries to collect and rejects with *"Total Payments cannot be more than Total Charged."*

### Workflow C implementation invariants

The following apply when building a Workflow C integration against a multi-branch TDMS setup (one branch's tour products pointing to another branch's addon/payment account via `addon_branch_code`). Learned the hard way during Phase C of the Group Booker Addon Portal integration — all have test coverage in that project.

> [!WARNING]
>
> **1. All TDMS calls for a single Fareharbor booking MUST use the SAME TDMS identity.** `/checkavailabilityrange` + `/detailsToConfirmAvailability` (step-2b) + `/reservation` + `/bookingreference` + `/order` all need to authenticate under the SAME branch's TDMS account. The `datePriceCacheId` is **account-scoped**; a lock minted under branch A and sent to `/order` under branch B is rejected. For cross-branch setups, resolve the addon-side branch at the top of the request handler and route every TDMS call through those creds.

> [!WARNING]
>
> **2. Call `/reservation` BEFORE `/apiv1/bookingreference`.** TDMS mints a bookingReference on demand; it's a bookable "slot" against the account. If `/reservation` fails for any reason (step-2b drift, bad lodging, Fareharbor rejection), you'll have burned an unused bookingReference. Order: `/reservation` → `/bookingreference` → `/order`. The reference itself does not need to be known to `/reservation`.

> [!WARNING]
>
> **3. Validate the submitted selection set against the step-2b schema BEFORE calling `/reservation`.** `/reservation` will accept selections TDMS considers "well-formed," but a drifted operator config (field removed, option retired) between the customer's page load and submit can still land there. Catch unknown field names, forged option externalIds, missing required fields, and missing-when-required lodging server-side upfront — surface a specific 400 to the customer. The step-2b response is the authoritative schema at the moment of submit.

> [!NOTE]
>
> **4. Picker-side TDMS reads must use the addon branch identity too.** For a lazy-hydration customer portal (step-1 availability + step-2b fields fetched when the page loads), use the SAME identity those two picker-side calls will later use at `/select`/`/reservation`/`/order`. Mismatch produces the "customer sees branch A's schema but branch B's `/select` validator re-fetches a different schema and rejects a valid-looking answer" bug class.

> [!NOTE]
>
> **5. qty / redeemer model: a Fareharbor booking is N redeemers × (redeemerQty: 1), not one redeemer × bookings\[N].** Each passenger becomes a separate `redeemers[]` entry with its own `products[0].bookings[0]`. Each top-level line's `qty` is the sum of the `redeemerQty` of the redeemers on it. Single-passenger addon flows collapse to one redeemer with `qty: 1`.

> [!NOTE]
>
> **6. Lock per priced combination, not per passenger — and not per booking.** A party whose members made the same priced choices needs ONE `/reservation`; a party split across two priced choices needs two, and two top-level `products[]` entries for the one `productPricesDetailsId`. Sizing your lock calls by passenger count wastes TDMS quota and tells you nothing new; sizing them by booking silently charges one combination's price to everybody. See [Multi-passenger bookings](#multi-passenger-bookings-and-reservation-locks).

## Shared `/order` payload examples (cross-workflow)

`POST /apiv1/order` accepts a single union schema. Key names are identical across every workflow; only which fields are populated varies. The captures below are real `/order` bodies from the WTT test environment that illustrate shape variations the per-workflow sections above don't fully convey.

### Example — Workflow A/B shape, two redeemers × one booking each

Two travellers on the same fare, both with a time slot and a pickup location. Note the TitleCase `PickupID` / `PickupLocation` — see the field-casing note below.

```json theme={null}
{
  "bookingReference": "WTT5694-348411",
  "agentReference": "test",
  "paymentMethod": "cash",
  "emailVouchers": 0,
  "totalCharged": 32,
  "processAsQuote": false,
  "products": [
    { "productPricesDetailsId": 9066803, "qty": 2 }
  ],
  "redeemers": [
    {
      "emailAddress": "api+testtest@websitetravel.com",
      "firstName": "Hermione", "lastName": "Granger",
      "phone": "0123456789", "redeemerCountry": 554,
      "dateOfBirth": "9-Nov-2000",
      "products": [
        {
          "productPricesDetailsId": "9066803",
          "redeemerQty": 1,
          "bookings": [
            {
              "bookingComment": "Test Booking",
              "travelDate": "28-Mar-2026",
              "timeId": "1",
              "PickupID": "RAI",
              "PickupLocation": "Premier Bus Stop, Spectrum Street, Rainbow Beach"
            }
          ]
        }
      ]
    },
    {
      "emailAddress": "api+test@websitetravel.com",
      "firstName": "Mike", "lastName": "Granger",
      "phone": "012344289", "redeemerCountry": 554,
      "dateOfBirth": "19-Nov-2003",
      "products": [
        {
          "productPricesDetailsId": "9066803",
          "redeemerQty": 1,
          "bookings": [
            {
              "bookingComment": "Test Booking",
              "travelDate": "28-Mar-2026",
              "timeId": "1",
              "PickupID": "RAI",
              "PickupLocation": "Premier Bus Stop, Spectrum Street, Rainbow Beach"
            }
          ]
        }
      ]
    }
  ]
}
```

### Example — minimal shape, multi-product quote (`processAsQuote: true`)

One redeemer, two different products, no times, no pickups, no cache ids. The simplest possible `/order` body that still produces a valid quote.

```json theme={null}
{
  "bookingReference": "WTT5694-699368",
  "agentReference": "test",
  "paymentMethod": "credit_card_pay_stripetest_gtm",
  "emailVouchers": 0,
  "totalCharged": 2006,
  "processAsQuote": true,
  "payments": [],
  "products": [
    { "productPricesDetailsId": 11476906, "qty": 1 },
    { "productPricesDetailsId": 18132919, "qty": 1 }
  ],
  "redeemers": [
    {
      "emailAddress": "api+check@websitetravel.com",
      "firstName": "Hermione", "lastName": "Granger-Weasley",
      "phone": "0123456789", "redeemerCountry": 554,
      "dateOfBirth": "9-Nov-2000",
      "products": [
        {
          "productPricesDetailsId": "11476906",
          "redeemerQty": 1,
          "bookings": [
            { "bookingComment": "Test Booking", "travelDate": "21-May-2026" }
          ]
        },
        {
          "productPricesDetailsId": "18132919",
          "redeemerQty": 1,
          "bookings": [
            { "bookingComment": "Test Booking", "travelDate": "22-May-2026" }
          ]
        }
      ]
    }
  ]
}
```

Observation: top-level `products[]` can carry different `productPricesDetailsId`s on the same `/order` call. Each gets its own entry under the redeemer's `products[]`.

### Example — `datePriceCacheId` on a non-FareHarbor fare

One redeemer, `redeemerQty: 2`, two bookings on different dates — and each booking carries its OWN `datePriceCacheId`. This is NOT a Workflow C fare: no `bookWithFirstRedeemer`, no per-redeemer `optionalFields: null`, no `pickupId: "0"`.

```json theme={null}
{
  "bookingReference": "WTT23797-676567",
  "agentReference": "test",
  "paymentMethod": "credit_card_pay_stripetest_gtm",
  "emailVouchers": 0,
  "totalCharged": 102.9,
  "processAsQuote": false,
  "products": [
    { "productPricesDetailsId": 13317390, "qty": 2, "datePriceCacheId": 6810827 }
  ],
  "redeemers": [
    {
      "title": "Mr",
      "emailAddress": "support+test@websitetravel.com",
      "firstName": "Sasikala", "lastName": "WT Test",
      "phone": 2345234411, "redeemerCountry": "036",
      "dateOfBirth": "1990-10-11", "postcode": "123445",
      "products": [
        {
          "productPricesDetailsId": 13317390,
          "redeemerQty": 2,
          "bookings": [
            {
              "bookingDetailsComment": "Hey again buddy!!",
              "travelDate": "12-Apr-2025",
              "timeId": "1",
              "commences": "Daily Availability",
              "datePriceCacheId": 6810828
            },
            {
              "bookingDetailsComment": "Hey again buddy!!",
              "travelDate": "11-Apr-2025",
              "timeId": "1",
              "commences": "Daily Availability",
              "datePriceCacheId": 6810827
            }
          ]
        }
      ]
    }
  ]
}
```

Observations:

* **Per-booking `datePriceCacheId` can differ between bookings** — each travel-date can have its own lock. Top-level `products[0].datePriceCacheId` matches one of them; the per-booking id is authoritative.
* **`datePriceCacheId` is NOT exclusive to Workflow C.** Any supplier that returned `RequiresFurtherDetailsToConfirm: true` with a cache id on step-1 can require threading it through `/order`. FareHarbor is the specific case that ALSO needs a `/reservation` lock + the Workflow-C-only extras.
* **`commences: "Daily Availability"`** — a human-readable time label; co-exists with `timeId`.
* **`bookingDetailsComment` (not `bookingComment`)** — see comment-field note below.

### Cross-workflow field nuances

> [!NOTE]
>
> **Field casing is case-insensitive on TDMS.** The OpenAPI spec and our builders use lowercase (`pickupId`, `pickupLocation`, `dropoffId`); some WTT docs examples use TitleCase (`PickupID`, `PickupLocation`). Both forms are accepted — prefer lowercase for consistency with the spec, but don't be surprised to see TitleCase in captures.

> [!NOTE]
>
> **Two optional comment fields on each booking.** `bookingComment` and `bookingDetailsComment` are both valid per the OpenAPI spec and we've seen both used in real captures. They're separate string fields; both pass through to the voucher. Use whichever matches the operator's convention.

> [!NOTE]
>
> **`datePriceCacheId` is not FareHarbor-exclusive.** Any fare whose discovery call returned `RequiresFurtherDetailsToConfirm: true` with a cache id can require threading it onto `/order` — the third example above is one such non-FareHarbor case. The Workflow-C-only tells are `bookWithFirstRedeemer`, per-redeemer `optionalFields: null`, and `pickupId: "0"` on each booking.

> [!NOTE]
>
> **"Redeemers × N" vs "Bookings × N" is a modelling choice, not a workflow invariant.** FareHarbor explicitly requires redeemers × N because each passenger has per-passenger answers that must be attributed. A/B captures often use bookings × N (one redeemer with `redeemerQty: N`, multiple bookings) but can also use redeemers × N. Pick whichever matches your passenger-attribution model.

## Bulk product view — `/apiv1/checkAvailabilityProduct` (cross-workflow)

An alternative to the per-PPDID step-1 calls above: `/apiv1/checkAvailabilityProduct?productId={productId}&from={date}&days={N}` returns ALL fares for a product × ALL days × per-slot pricing + pickups + additionalFields + addOns in a single call. **The response shape is stable across all three workflows** — null-vs-populated fields signal which workflow applies.

Useful when you need a product-wide view (e.g. an operator dashboard showing availability for every fare of a product at once) instead of the per-PPDID per-step flow.

### Example — request

```http theme={null}
GET /apiv1/checkAvailabilityProduct?productId=67594&from=20-Oct-2026&days=7
Authorization: Bearer {token}
Accept: application/json
```

### Example — Rezdy response (abridged)

Historical capture from when Rezdy was classified under Workflow A (Kuranda tour, productId `67594`). Rezdy has since moved to **Workflow B** — the live response now matches the Workflow B shape (enhanced discovery pair, `dropoffId` on `/order`). Kept here to document the earlier format for anyone reading archived payloads. Note `datePriceCacheId: null` on every slot — Rezdy still doesn't use the price-cache lock. Pickups are populated at the slot level with their timestamped descriptions. `tourCode` carries the Rezdy internal product code.

```json theme={null}
{
  "productId": 67594,
  "name": "Kuranda: Scenic Rail, Skyrail Rainforest Cableway and Hartley's Crocodile Adventures incl Return Cairns Transfers",
  "tourCode": "PNQG6T",
  "image": "https://c.globaltravel.market/upload/sm/...jpg",
  "supplier": {
    "name": "Down Under Tours (Australia) Pty Ltd",
    "phone": "074047 9066",
    "email": "res@downundertours.com"
  },
  "fares": [
    {
      "productPricesDetailsId": 20895150,
      "fareName": "2 Adults + 2 Children",
      "rrp": 759.0,
      "hasAvailability": true,
      "dates": {
        "2026-10-21": [
          {
            "datePriceCacheId": null,
            "bookingDate": "2026-10-21",
            "bookingTime": "07:00 AM",
            "bookingTimeId": "07:00:00",
            "numAvailable": 11,
            "rrp": 759.0,
            "pickupLocations": [
              {
                "pickupId": "Big 4 Ingenia Holidays Cairns Coconut",
                "pickupLocation": "05:10 AM Big 4 Ingenia Holidays Cairns Coconut, 23/51 Anderson Rd, Woree, Bus Zone On Anderson Rd Near Entrance, at: 05:10 AM",
                "pickupTime": "N/A"
              }
            ],
            "addOns": [],
            "additionalFields": [
              {
                "additionalFieldId": "2311498",
                "type": "select",
                "label": "Boarding Pass Language Option",
                "options": [
                  { "externalId": "English", "displayName": "English", "name": "English", "default": false },
                  { "externalId": "German",  "displayName": "German",  "name": "German",  "default": false }
                ],
                "required": false
              }
            ]
          }
        ]
      }
    }
  ]
}
```

### Example — Fareharbor response (abridged)

Workflow C product (Prima Whitsunday, productId `54347`). Note `datePriceCacheId` populated on every slot — Fareharbor's price-cache lock mechanism. `pickupLocations: []` is empty — FareHarbor uses `optionalFields_lodging` inside `additionalFields` instead.

```json theme={null}
{
  "productId": 54347,
  "name": "Prima - 2 day 1 night Whitsunday Sailing",
  "tourCode": "",
  "image": "https://c.globaltravel.market/upload/sm/...jpg",
  "supplier": {
    "name": "North Queensland Travel Pty Ltd",
    "phone": "0749677555",
    "email": "res@explorewhitsundays.com"
  },
  "fares": [
    {
      "productPricesDetailsId": 23501261,
      "fareName": "Private Double Cabin no Ensuite (Sleeps 2 guests)",
      "rrp": 1304.0,
      "hasAvailability": true,
      "dates": {
        "2026-10-18": [
          {
            "datePriceCacheId": 12938706,
            "bookingDate": "2026-10-18",
            "bookingTime": "08:30 - 16:00",
            "bookingTimeId": "1836909162-7716219496",
            "numAvailable": 1,
            "rrp": 1304.0,
            "pickupLocations": [],
            "addOns": [],
            "additionalFields": [
              { "additionalFieldId": "optionalFields_cl_690456", "type": "text",   "label": "Full Name",            "required": false, "options": [] },
              { "additionalFieldId": "optionalFields_cl_710104", "type": "select", "label": "Dietary Requirements", "required": false, "options": [/* dietary options */] },
              { "additionalFieldId": "optionalFields_lodging",   "type": "select", "label": "Lodging",              "required": false, "options": [/* lodging options */] }
            ]
          }
        ]
      }
    }
  ]
}
```

> [!NOTE]
>
> **Workflow signal from the bulk view.** `datePriceCacheId: null` on every slot → Workflow A (no price-cache lock). `datePriceCacheId` populated → Workflow C (price-cache lock applies; call `/reservation` whenever any `additionalFields` are selected before `/order`). The per-PPDID step flow is still the canonical path for a booking; `/checkAvailabilityProduct` is the alternative when you want everything at once.

## What stays the same across all three workflows

Only the **request-side call sequence** varies by workflow. Everything downstream of a successful order is identical:

* **`POST /apiv1/order` response envelope** — order-level, not product-level. The shape returned after submission is the same whether the booking was Workflow A, B, or C. A Fareharbor product on an order responds with the same confirmation / booking-reference / voucher structure as a Generic product. Parse once, works everywhere.
* **Error envelopes** — error shapes are generic across all workflows. Any `/apiv1/*` call that returns a 4xx / 5xx surfaces the same payload format regardless of the fare's `bookingSystem`. Workflow-specific nuances live only in which calls you make, not in what comes back when something fails.

So Workflow C's additional complexity is contained to the request side — what you SEND to configure, lock price, and build the order payload. The response-parsing + error-handling you already have for Workflow A / B keeps working untouched.

## Implementation tips

* **Always inspect `farePrices.bookingSystem` first.** Routing on anything else drifts the moment TDMS re-tags a product.
* **Treat unknown / missing values as Workflow A** — safe default, matches pre-tagged rows.
* **Cache `/bookingdetails` responses per PPDID.** The schema is essentially static; a 24h TTL per `(branch, env, ppdid)` eliminates per-click round-trips.
* **Use `?cached=1`** on `/checkavailabilityrange` for most read-paths. Omit (or set `cached=0`) when the availability number itself is load-bearing (right before an order). The response shape (including `productPricingData`) is identical either way; only freshness differs.
* **Read `productPricingData.RRP` and `.NetRate` off the same `/checkavailabilityrange` row** that carries `NumAvailable`. They're the authoritative live date-specific rate — see [Pre-order validation with `/validateCart` — live-rate enforcement](#pre-order-validation-with-validatecart-live-rate-enforcement-all-workflows) for the full contract.
* **FareHarbor price-cache**: always use the `datePriceCacheId` from the final `/reservation` call — it reflects the chosen priced options. Reusing an older id against a changed selection will return wrong pricing on the final booking.
* **Watch for `RequiresFurtherDetailsToConfirm` and `RequiresReservationCheck` flags** in step 1 / step 2 responses. Both are explicit "you need another call before you can order" signals.

> [!WARNING]
>
> **PPDIDs are branch-scoped — not product-scoped.** The same supplier product (keyed by `productId`) has a different `productPricesDetailsId` per reseller branch. Calling any PPDID-keyed endpoint (`/checkavailabilityrange`, `/bookingdetails`, `/validateCart`, `/order`, etc.) with a token whose branch doesn't own that PPDID returns `500 Internal Server Error` with a generic `"An error occurred"` body — not a 404, not a cleaner 403. When Postman-testing an endpoint and reproducing a 500 that the production worker doesn't hit for the same URL, the first thing to check is whether your token's branch owns the PPDID. The production worker is almost certainly authenticating under a different branch.
>
> Live example: PPDID `23530772` is the WGB branch's view of Dolphin Kayaking Adult; `23456564` is WTT's view of the same supplier fare (`productId: 4308`). A token for WTT calling `/checkavailabilityrange/23530772/...` returns 500; the same token on `/23456564/...` succeeds.
>
> Cross-branch addon flows (one branch's tour products pointing to another branch's addon account) work because the addon branch is explicitly configured as a reseller of the tour branch inside TDMS. If that reseller mapping is absent, the cross-branch call will 500 the same way.

## Suggested approaches — collapsing three workflows into one

The three workflows aren't an API design artefact — they reflect genuine differences in how the three supplier families model inventory. Rezdy and friends are stateless; Respax exposes per-time schemas; Fareharbor requires a reservation lock because its own pricing can shift based on customer selections. TDMS has chosen the most literal pass-through of each supplier's native model, which is why the client sees three shapes.

That doesn't mean any client integrating against TDMS has to carry three workflows up through its own API surface. Below is how to think about where the abstraction should live.

### What's reducible vs irreducible

Most of the variance can be collapsed by pre-fetching and flattening:

* **Discovery** (times, pickups, per-time field schemas, lodging options) is pre-computable per fare. `/apiv1/checkAvailabilityProduct` already returns a flattened view of this across all three workflows.
* **Per-time schema fetches** (step 2b in Workflow B/C) can be pre-walked for every time slot and cached server-side.

Two things cannot be pre-computed and will always need to happen after the customer selects:

* **The reservation lock** (Workflow C). `datePriceCacheId` is bound to the exact `(date, timeId, selection set)` — minting one upfront would silently return the wrong price the moment a priced option is picked.
* **Priced-option recomputation**. Once a Fareharbor fare exposes `isPriced: true` fields, pricing is a function of the customer's answer set. No cache can replace the live computation.

So any "one workflow" story has to decide what to do about the irreducible reservation side-step — not eliminate it.

### Three places the abstraction can live

**(a) Facade at TDMS.** TDMS exposes a single order endpoint that internally does `reservation → order` for Fareharbor, invisibly. Client never sees a `datePriceCacheId`. Trade-offs: TDMS owns the lock state across the payment window, error specificity drops (generic "booking failed" vs today's "price mismatch — recheck reservation"), and integrators lose the ability to walk a reservation forward across sessions. Why TDMS likely hasn't done this today: payment windows can be minutes long; holding supplier inventory server-side across that window is risky.

**(b) Facade at the integrator worker.** Build the abstraction on your own side. Your worker exposes one clean workflow to its own clients (customer portals, mobile apps, downstream services); internally it does the reservation dance only for Fareharbor placements. Trade-off: you own the complexity instead of TDMS, but you keep the specific error signals and the ability to retry with context. This is the Group Booker Addon Portal's choice — the `fareharborReservations` map inside `handleSessionSelect` is where the abstraction lives, and the customer-facing portal never sees a cache id.

**(c) Normalise to the strictest shape.** Pretend every workflow needs a reservation lock. The integrator worker fakes one for Rezdy/Respax (reuses the step-1 id as "cache id"). Client code is uniform. Trade-off: 2× TDMS round-trips for A+B workflows that don't need them. Latency + load cost for no supplier-side benefit. Rarely worth it; only consider if the client is dumb enough that a single code path matters more than per-workflow performance.

### Recommended approach for a new integration

**Start with (b).** Hide the three workflows inside the server-side integration layer; expose one shape to whoever consumes it. The customer-facing surface (portal, app) sees uniform `/select` → `/order` semantics regardless of the underlying supplier. The three-workflow variance stays contained to one handler and one payload builder.

This is where the Group Booker Addon Portal landed after Phase A/B/C. The portal frontend code treats every selection the same way; the worker decides at `/select` time whether a reservation lock is needed and threads the cache id onto the TDMS payload transparently.

### When to revisit

Consider moving to (a) only when:

* Multiple independent integrators are each building the same facade (code duplication worth centralising); OR
* A new TDMS-supported supplier adds a fourth workflow variant that would otherwise force every integrator to patch again; OR
* Error-specificity trade-offs become acceptable because the customer-facing UX doesn't act on specific error codes.

Until any of those is true, (b) is the right place for the abstraction to live.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.