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
(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, PPDID20895150) 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 inproductPricingData, 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
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, thefields[name=timeId]entry carries selectableoptions(each withexternalId+displayName+numAvailable). For single-time Respax / CustomLinc the inlineBookingTimeIDIS the time, andpickupId/dropoffIdfields are populated inline from the same response. For IBIS, step 1 returns an emptyfields[]plus a top-levelBookingTimeIDsession key — you’ll need step 2 to resolve pickups / dropoffs. - A
RequiresFurtherDetailsToConfirm: trueflag indicating you must call step 2 before placing an order.
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
Example — Rezdy step 2 request
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 PPDID23530772 (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.
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.externalIdfor 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.
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/ordersucceeds. - Supplier-side price changes rewrite the SAME row in place. Calling
/reservationagain with identical inputs returns the samedatePriceCacheIdbut with the latest supplier price. Treat the id as stable for its selection fingerprint, but re-call/reservationonce 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
/reservationfor 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). /validateCartthreading. If your flow calls/validateCartbetween/reservationand/order, pass the samedatePriceCacheIdon both — a missing or stale id yieldsalertLevel: "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, PPDID23501261), 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.
Example — step 2a request (fallback, no timeId)
Called ONLY when step 1 didn’t returnfields[name=timeId] inline. Rare in practice — Fareharbor usually provides inline timeId in step 1.
Example — step 2a response (future date with availability)
Returns a flatfields[] 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 outerfields[] 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).
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-1datePriceCacheId.
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.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.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.
Example — step 4 order response (success)
Real response from a successfulprocessAsQuote: 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:
- Wrong key name on the booking — using
additionalFields: [...]instead ofoptionalFields: [...]. 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 namedadditionalFields[i][...], so a parser written against the old docs sends a key TDMS no longer recognises at either end — both endpoints now useoptionalFields.) - Wrong
datePriceCacheId— using the snapshot id from?cached=1step-1, or a stale id from a prior selection set. - Selection set drift — the lock was minted against selection set X, but the order submits selection set Y.
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.
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./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.
/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:
/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 viaaddon_branch_code). Learned the hard way during Phase C of the Group Booker Addon Portal integration — all have test coverage in that project.
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 TitleCasePickupID / 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.
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".
- Per-booking
datePriceCacheIdcan differ between bookings — each travel-date can have its own lock. Top-levelproducts[0].datePriceCacheIdmatches one of them; the per-booking id is authoritative. datePriceCacheIdis NOT exclusive to Workflow C. Any supplier that returnedRequiresFurtherDetailsToConfirm: truewith a cache id on step-1 can require threading it through/order. FareHarbor is the specific case that ALSO needs a/reservationlock + the Workflow-C-only extras.commences: "Daily Availability"— a human-readable time label; co-exists withtimeId.bookingDetailsComment(notbookingComment) — 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, productId67594). 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, productId54347). 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/orderresponse 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’sbookingSystem. Workflow-specific nuances live only in which calls you make, not in what comes back when something fails.
Implementation tips
- Always inspect
farePrices.bookingSystemfirst. 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
/bookingdetailsresponses per PPDID. The schema is essentially static; a 24h TTL per(branch, env, ppdid)eliminates per-click round-trips. - Use
?cached=1on/checkavailabilityrangefor most read-paths. Omit (or setcached=0) when the availability number itself is load-bearing (right before an order). The response shape (includingproductPricingData) is identical either way; only freshness differs. - Read
productPricingData.RRPand.NetRateoff the same/checkavailabilityrangerow that carriesNumAvailable. 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
datePriceCacheIdfrom the final/reservationcall — it reflects the chosen priced options. Reusing an older id against a changed selection will return wrong pricing on the final booking. - Watch for
RequiresFurtherDetailsToConfirmandRequiresReservationCheckflags in step 1 / step 2 responses. Both are explicit “you need another call before you can order” signals.
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/checkAvailabilityProductalready 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.
- The reservation lock (Workflow C).
datePriceCacheIdis 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: truefields, pricing is a function of the customer’s answer set. No cache can replace the live computation.
Three places the abstraction can live
(a) Facade at TDMS. TDMS exposes a single order endpoint that internally doesreservation → 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.