Skip to main content

CustomLinc TOL → Queue-it Availability JSON

Overview

Workflow IDT76MXW3QRGcoIBbo
Webhook URLhttps://n8n.pbr.org.au/webhook/availability/tol
n8n editorhttps://n8n.pbr.org.au/workflow/T76MXW3QRGcoIBbo
TriggerHTTP GET/POST webhook
Season2026-06-26 → 2026-07-19 (24 dates)
ProductTOL (18:00 Lakeside Twilight Train)
Base capacity256 pax / date
WC capacity3 slots / date
StatusActive (published)
Active version391004ab-dcc3-48e9-92e7-62cc2a70b478
Last updated2026-05-27
AuthorMitch Fraser / Claude AI

Purpose

This n8n workflow is the backend for PBR's Queue-it integration for the 2026 TOL season. Queue-it queries this endpoint to determine which dates are sold, limited, or available, and uses the data to gate the onsale queue appropriately.

The endpoint combines two independent data sources:

  • CustomLinc availability API — real-time checkout availability (apiPax): what the next customer can actually book right now
  • CustomLinc SQL (Txn/Booking tables) — confirmed booking truth: what has actually been paid and is genuinely sold

This dual-source approach is required because the CustomLinc availability API is influenced by in-cart (pending) bookings, operator sales caps, and Manifest counter drift — none of which should drive the public SOLD/LIMITED classification.


Architecture: Node Flow

Availability Webhook
  → CustomLinc Login  (parallel)
  → Query Bookings    (SQL via Microsoft SQL node)
  → Ensure One Item   (passthrough - ensures Classify fires even if SQL returns 0 rows)
  → Build Date Chunks (splits season into 10-day API request chunks)
  → Loop Chunks
      → Fetch CustomLinc Availability  (POST per chunk)
      → [loop back]
  → Classify          (Code node - cross-joins SQL + API, outputs payload)
  → Respond JSON      (HTTP 200 with JSON body)

The CustomLinc Login and Query Bookings nodes run in parallel at workflow start. Both results are available by the time Classify executes after the loop completes.


SQL Query: Query Bookings

The SQL query runs against the CustomLinc MS SQL database (subscriber 22). It joins Manifest (per-date capacity) against confirmed (statusType=0) and pending (statusType=6) Txn/Booking records.

Key design decisions

  • Manifest is the primary table (LEFT JOIN). Dates with no bookings still appear (pax values default to 0 via COALESCE).
  • statusType IN (0, 6) — includes both Confirmed (0) and Pending Web Payment (6) bookings as separate columns.
  • WC (Wheelchair Add-on fare code) is counted separately from standard pax (ADULT, CHILD, TODDLER).
  • Booking counts are tracked per status for ops monitoring.
  • Hardcoded season window: 2026-06-26 to 2026-07-20 (exclusive upper bound).

Output columns

ColumnDescription
travel_dateDate of departure
pax_maxManifest.max — base capacity (typically 256)
manifest_totalPaxManifest counter (cumulative, never decremented on cancel — known drift issue)
manifest_totalAvailableManifest available counter
pax_confirmedPaid pax from statusType=0 bookings (ADULT+CHILD+TODDLER)
wc_confirmedPaid WC slots from statusType=0 bookings
pax_pendingIn-cart pax from statusType=6 bookings (Pending Web Payment)
wc_pendingIn-cart WC slots from statusType=6 bookings
booking_count_confirmedNumber of confirmed booking records
booking_count_pendingNumber of pending booking records

Manifest counter drift

The Manifest.totalPax counter increments on booking but does not decrement on cancellation. This is a confirmed CustomLinc bug. The endpoint does not rely on Manifest counters for classification — it uses direct Txn aggregation instead. Manifest mismatches are flagged in monitoring.manifestMismatches[] for ops awareness only.


Classification Model: Pending-Blind

Sold/limited classification is driven exclusively by confirmed seats (statusType=0). Pending bookings (statusType=6, i.e. in-cart / Pending Web Payment) are tracked and reported in monitoring but do not affect the public classification or the dynamic message.

Classification inputs per date

VariableSourceDescription
paxConfirmedSQL statusType=0Paid seats — the revenue truth
paxPendingSQL statusType=6Seats in active checkout carts
apiPaxCustomLinc APISeats available to buy right now (reduced by confirmed + pending + any operator cap)
isAvailableAPI .isAvailableWhether the operator has stopped sales

effectiveCap derivation

If the API reports fewer seats than max - confirmed - pending, an operator-set sales cap is inferred:

remainingIfNoCap = max(paxMax - paxConfirmed - paxPending, 0)
if isAvail AND apiPax < remainingIfNoCap:
    effectiveCap = apiPax + paxConfirmed + paxPending
else:
    effectiveCap = null

effectiveCap is suppressed when !isAvail (stop-sold) because in that case apiPax=0 is caused by operator action, not a cap.

confirmedRemaining (classification source)

effectiveCeiling = effectiveCap ?? paxMax
confirmedRemaining = max(effectiveCeiling - paxConfirmed, 0)

confirmedRemaining is the key classifier input. It represents how many confirmed seats are genuinely still available, ignoring pending carts.

Classification rules

ConditionResult
!isAvail (operator stop-sold)sold[] + stopSold[]
confirmedRemaining === 0sold[] (truly sold out by confirmed bookings)
confirmedRemaining <= 20limited[]
confirmedRemaining > 20plenty (not in any list)

apiBlocked flag (monitoring only)

isApiBlocked = (apiPax === 0) AND (confirmedRemaining > 0) AND isAvail

When pending bookings fill remaining seats, the API returns apiPax=0 but confirmed seats remain. This is an ops signal — pending bookings will expire and release those seats. The date does not appear in sold[] or dynamicMessage. It is reported in monitoring.apiBlocked[].

boundBy field

ValueMeaning
"capacity"No bookings on this date
"confirmed"Confirmed bookings are the limiting factor
"cap"Operator-set sales cap is binding (effectiveCap is set)

Wheelchair classification (pending-blind)

WC follows the same pending-blind rule. WC cap = 3 seats. WC limited threshold = 1 remaining.

wcConfirmedRemaining = max(3 - wcConfirmed, 0)
effectiveWcRemaining = dateSold ? 0 : wcConfirmedRemaining
if effectiveWcRemaining <= 0  → wheelchairSold[]
if effectiveWcRemaining === 1  → wheelchairLimited[]

Output Payload

Public classification arrays

FieldTypeDescription
soldstring[]Tokens for sold-out dates (e.g. "26/6"). Driven by confirmedRemaining=0 or stop-sold.
limitedstring[]Tokens for limited dates (confirmedRemaining 1–20).
stopSoldstring[]Subset of sold[] where isAvailable=false (operator stop-sold).
apiBlockedstring[]Dates where apiPax=0 but confirmedRemaining>0. Ops monitoring only — not in dynamic message.
detailobjectToken → apiPax. Live checkout figure for Queue-it. Reflects confirmed + pending + cap.
wheelchairSoldstring[]WC sold-out dates.
wheelchairLimitedstring[]WC limited dates (1 remaining).
wheelchairDetailobjectToken → wcConfirmedRemaining (pending-blind).
dynamicMessagestringQueue-it dynamic message string. Format: "SOLD: d1, d2 LIMITED: d3 WC: d4". Empty if no sold/limited dates.
updatedAtISO 8601Timestamp of this response.

dynamicMessage format

SOLD: 26/6, 4/7
LIMITED: 11/7
WC: 26/6

Lines are only included if the respective array is non-empty. The message is blank if all dates are available. Queue-it reads this field to populate availability messaging on the waiting room page.

monitoring object

The monitoring object is not consumed by Queue-it but is available for ops dashboards and troubleshooting.

monitoring.season (season-level aggregates)

FieldDescription
totalCapacitySum of pax_max across all dates
totalConfirmedPaxTotal paid pax across season
totalConfirmedWcTotal paid WC slots
totalConfirmedBookingsBooking record count (confirmed)
totalPendingPaxTotal in-cart pax across season
totalPendingBookingsBooking record count (pending)
totalInflightPaxAlias of totalPendingPax
percentSoldtotalConfirmedPax / totalCapacity × 100
datesTotalDates processed by the classifier
datesWithBookingsDates with at least one confirmed booking
datesWithPendingDates with at least one pending booking
datesSold / datesLimitedClassification counts
datesStopSoldOperator stop-sold count
datesApiBlockedDates where apiPax=0 but confirmedRemaining>0
datesManifestMismatchDates where Txn sum ≠ Manifest counter
datesCappedDates with an operator-set sales cap detected

monitoring.byDate

Per-date object keyed by token (e.g. "26/6") with full classification detail:

{
  "26/6": {
    "max": 256,
    "confirmed": 2,          // paid pax
    "wcConfirmed": 0,
    "pending": 4,            // in-cart pax
    "wcPending": 0,
    "bookingsConfirmed": 1,
    "bookingsPending": 1,
    "inflight": 4,           // alias of pending (from SQL statusType=6)
    "confirmedRemaining": 254, // classification source
    "apiPax": 250,           // live checkout figure
    "apiBlocked": false,
    "effectiveCap": null,    // operator cap if detected
    "boundBy": "confirmed",  // "capacity" | "confirmed" | "cap"
    "manifestTotalPax": 237,
    "manifestTotalAvailable": 19,
    "manifestDelta": -231,   // negative = Manifest counter ahead of Txn (cancelled bookings not decremented)
    "percentSold": 0.78,
    "isAvailable": true,
    "availabilityString": "plenty"
  }
}

monitoring.topInflight / topConfirmed

Top-5 dates sorted by inflight (pending) pax or confirmed pax respectively. Useful for ops dashboards.

monitoring.manifestMismatches

Dates where the Txn-derived sum (confirmed + pending) differs from the Manifest counter. Reported for visibility of the CustomLinc counter drift bug.

monitoring.capped

Dates where an operator-set sales cap was detected (effectiveCap is non-null), sorted by cap size ascending.

monitoring.apiBlocked

Detailed array of dates where checkout is blocked by pending bookings (not truly sold). Each entry includes: confirmed, pending, confirmedRemaining.

monitoring.health

FieldDescription
sqlRowCountRows returned by SQL query
sqlExpectedExpected row count (24 for full season)
sqlOktrue if sqlRowCount === sqlExpected
apiResponseCountNumber of CustomLinc API chunk responses received
executionDurationMsClassify node execution time in ms
wcCapWC cap constant (3)
limitedThresholdLimited threshold constant (20)
confirmedFilterstatusType IN (0,6) — SQL filter in use
classificationSourceconfirmedRemaining
detailSourceapiPax
inflightSourcestatusType=6

Design Decisions & Rationale

Why pending-blind classification?

Pending bookings (statusType=6) represent seats in active checkout carts. They auto-expire after a timeout if payment is not completed. Classifying a date as SOLD because of pending bookings would:

  • Show false SOLD status to Queue-it and customers
  • Prevent genuine sales when carts expire
  • Cause a poor customer experience during high-demand onsales

The apiBlocked flag is provided for ops monitoring so the team is aware when this condition exists, without affecting public availability messaging.

Why apiPax in detail[] rather than confirmedRemaining?

The detail[token] value is the live checkout figure that Queue-it can pass to a checkout integration. It reflects what the next customer can actually book, accounting for confirmed + pending + operator cap. Using confirmedRemaining here would overstate available seats (it ignores in-cart bookings).

Why suppress effectiveCap when stop-sold?

When a date is stop-sold (!isAvail), apiPax is forced to 0 by operator action. The residual cap calculation would produce a false cap equal to confirmed pax. Suppressing effectiveCap in this case avoids confusing monitoring output.

Manifest counter not used for classification

The Manifest.totalPax counter is unreliable for classification because cancelled bookings do not decrement it. On 2026-06-26 the counter shows 237 (from earlier test bookings) while the actual Txn sum is 2 confirmed + 4 pending. The endpoint uses direct Txn aggregation (statusType 0 and 6) as the authoritative source. Manifest data is retained in monitoring for counter drift visibility.

SQL Manifest-join pattern

The SQL query uses Manifest as the primary table (not Booking), joined to Txn/Booking via LEFT JOINs. This ensures all 24 season dates appear in the result even if they have zero bookings. An earlier version grouped by b.dateTimeOfTravel which collapsed booking-less Manifest rows into NULLs.

Operator cap detection

CustomLinc allows operators to set a per-departure sales cap via the UI. The cap column was not found in any accessible schema table (Manifest, ManifestAllocation, Movement, ProductOption all checked). The cap is inferred from the residual: if apiPax < (max - confirmed - pending), the cap must equal (apiPax + confirmed + pending). This is a forward-compatible inference that works regardless of where the cap is stored.


Known Issues

Silent failure mode

If the CustomLinc availability API returns a response with null/empty productAvailability, the workflow still returns HTTP 200 with sqlOk: true but no date data in the payload. Queue-it would receive an empty availability structure. This edge case is not yet handled with an explicit error response. Status: open.

Manifest counter drift (CustomLinc bug)

Cancelling a booking does not decrement the Manifest.totalPax counter. This causes growing divergence between the counter and actual bookings on dates with cancellation activity. Does not affect classification (Txn is used directly). Reported to CustomLinc support. Status: vendor bug, monitoring only.

Pending booking expiry timing

Pending bookings (statusType=6) auto-expire on a CustomLinc internal timeout. The expiry duration is not documented. During high-demand onsales, many carts may be open simultaneously, causing apiBlocked dates to appear transiently. These resolve automatically when carts expire without payment.


Seasonal Maintenance

At the start of each new TOL season, the following constants must be updated in both the SQL query and the Build Date Chunks node:

  • SEASON_START — first date of the new season
  • SEASON_END — last date of the new season
  • SQL_EXPECTED_ROWS — number of dates in the season (for health check)
  • SQL WHERE clause date bounds (hardcoded in the Query Bookings node)

Also verify:

  • CustomLinc Auth credentials are still valid (Login node)
  • The SQL subscriber ID (22) has not changed
  • The product code (TOL) matches the new season's product
  • Queue-it dynamic message format matches the new season's campaign configuration