Skip to main content
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: 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
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.
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

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.
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

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).

Workflow B — Enhanced booking systems

Respaxv2 · IBIS · CustomLinc · Rezdy
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

Example — step 1 response (Respax multi-time)

Example — step 2 request

Example — step 2 response

Rezdy — hybrid envelope on step 2

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) 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

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.
Parser mapping — normalise Rezdy’s nested shape to the common Workflow B field shape: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.
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.
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

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.
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.

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.

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.

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

Example — step 2b request (with timeId)

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

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).
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).
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.
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.

Example — step 3 reservation request (with optional fields)

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.

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.
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.
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.
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.
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).
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.
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.
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.
  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).
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.
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.
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.
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.
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

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.
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.
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).
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.
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:
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.
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.
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.
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.
/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.
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.
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.
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.
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.
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.
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.

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.

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.
Observation: top-level products[] can carry different productPricesDetailsIds 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".
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

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.
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.
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.
“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

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.

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.
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 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.
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. 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.