n8n Automation Workflows

Documentation for all n8n automation workflows running at Puffing Billy Railway, including integration details, data flows, credentials, and maintenance notes.

Overview and Workflow Index

Overview

This book documents all n8n automation workflows currently deployed at Puffing Billy Railway (PBR). n8n is PBR's self-hosted workflow automation platform, running at https://n8n.pbr.org.au. It integrates internal systems, third-party APIs, and AI services to automate routine IT and operational processes.

FieldValue
Platformn8n (self-hosted, Docker)
URLhttps://n8n.pbr.org.au
OwnerIT Team — Mitch (IT Manager)
StatusActive

Workflow Index

WorkflowStatusTriggerPurpose
BetterImpact > Swift User IngestActiveScheduled — every 24 hoursSyncs people records from BetterImpact into Swift Digital contact groups by engagement type
Jitbit External Tool — search_bookstackActiveWebhook (Jitbit AI External Tool)Searches BookStack documentation on behalf of the Jitbit AI assistant
Jitbit Auto-Triage — Type Field + BookStack + Tech NoteActiveWebhook (Jitbit automation rule)Classifies new helpdesk tickets by ITIL type, sets a custom field, and posts a private tech note with BookStack references
Delete All Swift Users From GroupInactive — manual run onlyManual triggerUtility workflow to bulk-remove all contacts from a specified Swift Digital group

Credentials Overview

The following credential types are used across these workflows. All credentials are stored in n8n and referenced by name — no secrets are stored in workflow parameters.

Credential NameTypeUsed By
Anthropic API KeyHTTP Header Auth (x-api-key)Jitbit Auto-Triage
Jitbit API TokenHTTP Bearer AuthJitbit Auto-Triage, search_bookstack
BookStack TokenHTTP Header Auth (Authorization: Token id:secret)Jitbit Auto-Triage, search_bookstack
BetterImpact APIHTTP Basic AuthBetterImpact > Swift User Ingest
Swift Digital OAuth2OAuth2BetterImpact > Swift User Ingest, Delete All Swift Users

Maintenance Notes

BetterImpact > Swift User Ingest

Overview

FieldValue
Workflow IDDmoxddEkpx854rYj
n8n URLhttps://n8n.pbr.org.au/workflow/DmoxddEkpx854rYj
StatusActive
TriggerSchedule — every 24 hours
SystemsBetterImpact (source), Swift Digital (destination), n8n Data Table (audit log)
PredecessorBetterImpact>Swift User Ingest_old (inactive, superseded)

Purpose

This workflow keeps Swift Digital contact groups in sync with BetterImpact, PBR's people management system. It runs once every 24 hours and processes all BetterImpact users whose records have been updated in the past 48 hours.

BetterImpact is treated as the source of truth. For each recently changed person, the workflow determines their engagement type (Volunteer, Staff, Board/Sub-Committee, or Guild Member) and ensures their Swift Digital contact record and group memberships reflect that classification. If a person no longer qualifies for a group they are in, they are removed. If they are not yet in a group they should be in, they are added. If they do not exist in Swift Digital at all, a new contact record is created.


Data Sources

BetterImpact API

Authenticates via HTTP Basic Auth. Two separate API calls are made at the start of each run:

Both result sets are merged and aggregated before processing. The wider 72-hour window for Guild users is intentional, accounting for the fact that Guild membership changes may be recorded slightly later in BetterImpact.

Fields Extracted from BetterImpact

For each user, the following fields are extracted and normalised:

FieldSource in BetterImpactNotes
BI_user_iduser_idBetterImpact's unique user identifier
first_name, last_name, titleTop-level fields
emailemail_addressParsed with extractEmail() to strip display-name formatting
mobilecell_phoneSpaces removed
volunteer_statusmemberships[0].volunteer_statusUsed to determine active vs archived status. Value must be Accepted to be considered active.
engagement_typeCustom field category PBR VOLUNTEER TYPEDetermines which Swift group the person belongs to
org_namememberships[0].organization_nameUsed to distinguish Guild users in the GUILD org from those appearing in Whole Team
branch, sub_branchCustom fields Branch and Sub-BranchAmpersands (&) are replaced with and before sending to Swift Digital, which does not accept the & character in these fields
countryLooked up via n8n Data Table mapping from country_nameConverted to ISO country code(s) for Swift Digital compatibility

Users without an email address are filtered out early and never sent to Swift Digital.


Engagement Type Classification

Each person is routed to one of four Swift Digital groups based on their engagement_type value from BetterImpact:

Engagement TypeSwift Digital GroupMatch Logic
Contains VolunteerPBR_VolsString contains check
Contains StaffPBR_StaffString contains check
Equals Board Member or Sub-Committee MemberPBR_BoardSubcommitteesExact match (OR)
Equals PBR Guild Member AND org is PBR - GUILDPBR_Guild (AllGuild)Both conditions must match

Each person is evaluated against all four type checks independently in parallel. A person could theoretically match more than one (e.g. if their engagement type changes mid-run), though in practice a person should match only one.


Processing Logic — Per User

Once a user's engagement type is determined, the workflow follows this decision tree for each type. The logic is identical across all four types — described here using Volunteer as the example:

Active User Path (volunteer_status = Accepted)

  1. Does the user exist in Swift Digital? (checked via contact_ids — a sentinel value of zzzz69363c11d9fa7821 indicates no match was found from the email search)
    • Yes — user exists: Update their contact details in Swift Digital (name, email, mobile, country, engagement type, BetterImpact user ID). Then check whether they are already a member of the correct group.
      • If not in the group: add them to it.
      • If already in the group: no group action needed.
    • No — user does not exist: Create a new Swift Digital contact record with all their details, and assign them to the correct group in the same API call.

Inactive/Archived User Path (volunteer_status != Accepted)

  1. Does the user exist in Swift Digital?
    • No — user does not exist: Nothing to do, exit.
    • Yes — user exists: Check whether they are a member of the group they should no longer be in.
      • Not in the group: Nothing to do, exit.
      • In the group — and it is their only group: Delete the contact from Swift Digital entirely.
      • In the group — and they are in other groups too: Remove them from this specific group only (do not delete the contact).

The "last group" check (delete vs group-remove) is done by checking whether group_ids.length <= 1. If a contact is only in one group and should be removed from it, deleting the contact entirely is the correct action.


Guild User Edge Case

Guild members appear in two BetterImpact API results: the PBR - Whole Team response and the PBR - GUILD response. To prevent a Guild member from being incorrectly processed as a Whole Team volunteer, the workflow uses a deduplication merge that prefers the PBR - Whole Team record when a user appears in both. The Is Guild? check then additionally requires that org_name = PBR - GUILD, ensuring only genuine Guild organisation records are routed to the Guild group.

There is also a specific filter called Catch Guild User Archived in BI Whole Team. This handles a known data pattern in BetterImpact where a Guild member who has been archived in the GUILD organisation still appears in the Whole Team result. This filter passes the user through to the Guild group removal logic, preventing them from being incorrectly left in the Swift Digital Guild group after archival.


Duplicate Contact Detection

After main processing, the workflow runs a secondary check for duplicate Swift Digital contacts. For each processed user, it searches Swift Digital by BetterImpact User ID (Internal_BIUserId) to see if more than one contact record is associated with that ID. If duplicates are found, the workflow:

  1. Retrieves full details for all duplicate contacts.
  2. Identifies the earliest-created contact by create_stamp.
  3. Deletes the earliest contact, keeping the most recently created one.

The premise is that BetterImpact is the source of truth — if a single BI user ID maps to multiple Swift contacts, the oldest is assumed to be stale and the newest is retained.


n8n Data Table (Audit Log)

After processing, each user record is upserted into an n8n internal Data Table named BI_Users (project ID: JMezsOufXlA5w9MB, table ID: ps5tTQfbPJ8IMtfQ). This provides an internal record of the last-known state of each processed user including their Swift contact ID, group IDs, engagement type, and email. The upsert matches on BI_user_id.

This table is used as a debugging and audit reference — it is not consumed by any downstream automated workflow.


Custom Fields Written to Swift Digital

When creating or updating a contact in Swift Digital, the following custom internal fields are populated:

Swift FieldValue
Internal_EngagementTypeThe person's engagement type from BetterImpact
Internal_BIUseridThe person's numeric BetterImpact user ID
Internal_BranchBranch (omitted if null)
Internal_SubBranchSub-branch (omitted if null)

Note: Branch and Sub-Branch fields were present in an earlier version of the workflow but are not written in the current active version's update/create payloads. They remain in the BetterImpact data extraction step.


API Rate Limiting

Swift Digital API calls are batched at 100 requests per batch with a 7,500ms interval between batches for search and group-read operations. Contact creation for Volunteers uses a 5,000ms interval. This prevents hitting Swift Digital API rate limits during large sync runs.


Credentials

CredentialTypeUsed For
BetterImpact APIHTTP Basic AuthAll BetterImpact API calls
Swift Digital OAuth2OAuth2All Swift Digital API calls

Maintenance Notes

Jitbit External Tool — search_bookstack

Overview

FieldValue
Workflow IDqLw7S1Rr0eznKDhi
n8n URLhttps://n8n.pbr.org.au/workflow/qLw7S1Rr0eznKDhi
StatusActive
TriggerWebhook — called by Jitbit AI as a registered External Tool
Webhook URLhttps://n8n.pbr.org.au/webhook/jitbit-search-bookstack
SystemsJitbit Helpdesk (caller), BookStack (search target)

Purpose

This workflow acts as a bridge between the Jitbit AI assistant and PBR's internal BookStack documentation wiki. It is registered in Jitbit as an External Tool named search_bookstack. When the Jitbit AI is answering a helpdesk ticket and determines that relevant internal documentation may exist, it automatically calls this tool, passing search keywords extracted from the ticket. The workflow queries BookStack and returns a formatted list of matching pages — titles, types, URLs, and content previews — which the AI incorporates into its response to the technician.


How It Works

  1. Jitbit AI calls the webhook via HTTP POST with a JSON body containing a query parameter — search keywords extracted by the AI from the ticket subject and body.
  2. Search BookStack — the workflow calls GET /api/search on BookStack with the query string, requesting up to 8 results. Authentication uses the BookStack API token.
  3. Build response — a Set node formats the results into a plain-text string. Each result includes: name, content type (page, chapter, or book), full URL, and a content preview snippet (up to 200 characters, HTML tags stripped).
  4. Respond to Jitbit — the workflow returns a JSON response to the Jitbit AI containing the formatted result string and a total_found count.

Jitbit External Tool Configuration

This workflow is registered in Jitbit under Admin > AI Features > External Tools with the following settings:

FieldValue
Namesearch_bookstack
URLhttps://n8n.pbr.org.au/webhook/jitbit-search-bookstack
DescriptionSearch PBR's internal IT documentation wiki (BookStack) for relevant articles, configuration guides, troubleshooting procedures, and technical documentation. Use this when a ticket involves a known system, technology, or procedure that may be documented internally.

Parameters

NameDescriptionRequired
querySearch terms extracted from the ticket — keywords describing the system or issue (e.g. "Proxmox iSCSI timeout" or "PA-440 IPsec VPN")Yes

Response Format

The workflow returns a JSON object to the Jitbit AI:

{
  "result": "Page Title (page): https://bookstack.pbr.org.au/books/book-slug/page/page-slug
  Preview: content snippet...

Another Page (book): https://...",
  "total_found": 29
}

The result field is a formatted plain-text string the AI can read directly. total_found is the total number of matching results in BookStack (not just the 8 returned).


Nodes

NodeTypePurpose
Receive from Jitbit AIWebhook (POST, responseMode: responseNode)Entry point — receives the search query from Jitbit AI
Search BookStackHTTP Request (GET)Calls https://bookstack.pbr.org.au/api/search with the query and count=8
Build Jitbit ResponseSetFormats the BookStack results array into a plain-text string; strips HTML tags from preview_html.content; falls back to "No preview available" if content is empty
Respond to JitbitRespond to WebhookReturns the JSON result to the Jitbit AI caller

Credentials

CredentialTypeUsed For
BookStack TokenHTTP Header Auth (Authorization: Token id:secret)BookStack search API

Maintenance Notes

Jitbit Auto-Triage — Type Field + BookStack + Tech Note

Overview

FieldValue
Workflow IDllP1pezJvYAGKjYA
n8n URLhttps://n8n.pbr.org.au/workflow/llP1pezJvYAGKjYA
StatusActive
TriggerWebhook — called by a Jitbit automation rule on every new ticket
Webhook URLhttps://n8n.pbr.org.au/webhook/jitbit-ticket-triage
SystemsJitbit Helpdesk (caller), Anthropic Claude API (classifier), BookStack (documentation search), Jitbit API (write-back)
AI Modelclaude-haiku-4-5-20251001

Purpose

This workflow automatically triages every new Jitbit helpdesk ticket. When a ticket is created, Jitbit fires an automation rule that POSTs the ticket details to this n8n webhook. The workflow then:

  1. Uses Claude Haiku (Anthropic API) to classify the ticket as an ITIL type and extract a triage summary and BookStack search keywords.
  2. Sets the ticket's Type custom field in Jitbit to the classified value.
  3. Searches BookStack for relevant internal documentation using the AI-extracted keywords.
  4. Posts a private tech-only comment to the ticket containing the ITIL type, a triage summary, and links to relevant BookStack pages.

This gives attending technicians an immediate structured summary and relevant documentation links before they even open the ticket.


Jitbit Automation Rule

The workflow is triggered by a Jitbit automation rule (not a Jitbit AI External Tool — the trigger is a direct HTTP call, not routed through the Jitbit AI assistant). The rule is configured as:

SettingValue
TriggerTicket is created
ActionSend HTTP request
MethodPOST
URLhttps://n8n.pbr.org.au/webhook/jitbit-ticket-triage
Post Dataticket_id=#ticketId#&subject=#subject#&body=#body#&category=#category#&tags=#tags#

Jitbit substitutes the #ticketId#, #subject#, #body#, #category#, and #tags# tokens with the actual ticket values before sending the POST request.


How It Works

Step 1 — Receive webhook from Jitbit

The webhook node receives the POST body from Jitbit containing ticket_id, subject, body, category, and tags.

Step 2 — Classify with Claude

The workflow POSTs to the Anthropic Messages API (https://api.anthropic.com/v1/messages) using the claude-haiku-4-5-20251001 model. The system prompt instructs Claude to act as an ITIL-aligned IT helpdesk triage assistant for PBR and respond with raw JSON only (no markdown). The user message contains the ticket subject, category, tags, and body.

Claude returns a JSON object with three fields:

FieldDescription
typeITIL classification — one of: Incident, Problem, Service Request, Change Request, Event
bookstack_query3-6 keyword search terms for finding relevant internal documentation
triage_summary2-3 sentence plain-English summary of the issue for the attending technician

The Parse Classification node strips any markdown code fences from the response (Claude Haiku occasionally wraps JSON in backticks despite instructions) before parsing the JSON.

Step 3 — Parallel branches

After parsing, two branches run in parallel:

Both branches feed into a Merge node that waits for both to complete before continuing.

Step 4 — Build and post tech note

The Build Tech Note node constructs a private comment body combining the ITIL type, triage summary, and formatted BookStack links with content previews. The ticket_id and triage fields are read directly from the Parse Classification node output (not from the merge) to ensure they are never overwritten by the empty response body from the SetCustomField API call.

The comment is posted to Jitbit via POST /api/comment with forTechsOnly=true, making it visible only to technicians.

Step 5 — Respond

The workflow returns a JSON confirmation to Jitbit: { "result": "Triage complete. Type set to: X. Tech note posted to ticket." }.


ITIL Type to Custom Field Option ID Mapping

The Jitbit Type custom field (Field ID: 1) uses dropdown option IDs. The mapping is hardcoded in the Parse Classification node:

ITIL TypeOption IDDefinition
Incident1Unplanned interruption to service (e.g. outage, crash, failure, offline, broken)
Problem28Underlying root cause investigation of one or more recurring incidents
Service Request3Routine pre-approved request (e.g. password reset, new laptop, software install)
Change Request4Planned alteration, addition, or removal of IT systems
Event29Automated monitoring alert (e.g. server monitoring trigger)

If Claude returns an unrecognised type value, the option ID defaults to 1 (Incident).


Example Tech Note Output

🤖 AI Triage

Type: Incident

Summary: The network printer at Belgrave station has been offline since 8am, preventing 
staff from printing boarding passes. Immediate investigation of printer connectivity 
and network status is required.

📚 Relevant Documentation:
• Fault Finding - Belgrave Ticket Printers (page)
  https://bookstack.pbr.org.au/books/printers/page/fault-finding-belgrave-ticket-printers
  Issues with any of the Zebra ZD421 Belgrave Ticket printers where it is not possible to ge...

• Printer Setup Guide (page)
  https://bookstack.pbr.org.au/books/endpoint-devices/page/printer-setup
  Steps to configure network printers at PBR sites...

Nodes

NodeTypePurpose
Receive from Jitbit AIWebhook (POST, responseMode: responseNode)Receives ticket data from Jitbit automation rule
Classify with ClaudeHTTP Request (POST)Calls Anthropic API with ticket content; returns ITIL type, triage summary, and search query
Parse ClassificationSetStrips markdown fences; parses JSON; maps type string to option ID; extracts ticket_id and subject from webhook input
Set Type Custom FieldHTTP Request (POST)Calls Jitbit /api/SetCustomField to set Field ID 1 to the classified type option ID
Search BookStackHTTP Request (GET)Searches BookStack with AI-extracted keywords; returns up to 5 results
Combine Triage + DocsMerge (combineByPosition)Waits for both parallel branches to complete
Build Tech NoteSetAssembles the private comment body; reads triage fields from Parse Classification node directly to avoid merge overwrite
Post Tech Note to JitbitHTTP Request (POST)Posts private tech-only comment to the ticket via Jitbit /api/comment
Respond to JitbitRespond to WebhookReturns confirmation JSON to Jitbit

Credentials

CredentialTypeUsed For
Anthropic API KeyHTTP Header Auth (x-api-key)Claude Haiku API calls
Jitbit API TokenHTTP Bearer AuthSetCustomField and comment POST calls to Jitbit
BookStack TokenHTTP Header Auth (Authorization: Token id:secret)BookStack search API

Known Quirks


Maintenance Notes

Delete All Swift Users From Group

Overview

FieldValue
Workflow IDl0KQdZd8IGiJNuLa
n8n URLhttps://n8n.pbr.org.au/workflow/l0KQdZd8IGiJNuLa
StatusInactive — manual execution only
TriggerManual (Execute Workflow button in n8n)
SystemsSwift Digital

Purpose

This is a utility workflow used to bulk-delete all contacts from a specified Swift Digital contact group. It is not scheduled and must be manually executed. Before running, the target group ID must be set in the n8n Global Constants node.

Warning: This workflow calls the Swift Digital contact DELETE API, which permanently removes the contacts from Swift Digital entirely — it does not merely remove them from the group. Use with care.


How It Works

  1. Manual trigger — workflow is started manually from within n8n.
  2. Global Constants — reads the target group ID(s) from the n8n Global Constants node. The constants field is expected to contain one or more group IDs.
  3. Split Out group IDs — if multiple group IDs are present in constants, they are split into individual items for iteration.
  4. Get Users in Group — calls GET https://v3.api.swiftdigital.com.au/request/mailhouse/mailgroup/readmembers with the group ID to retrieve all contact IDs in the group.
  5. Split Out contact IDs — splits the returned array of contact IDs into individual items.
  6. Remove Users from Swift — calls DELETE https://v3.api.swiftdigital.com.au/request/mailhouse/contact/delete for each contact ID, permanently deleting them from Swift Digital.

Usage Instructions

  1. Open the workflow in n8n: https://n8n.pbr.org.au/workflow/l0KQdZd8IGiJNuLa
  2. Open the Global Constants node and set the constants value to the Swift Digital group ID(s) you want to clear.
  3. Save the workflow.
  4. Click Execute Workflow.
  5. Monitor the execution to confirm all contacts were removed.

Do not activate (publish) this workflow. It should always remain inactive and only be run on demand.


Nodes

NodeTypePurpose
When clicking Execute workflowManual TriggerEntry point — started manually only
Global ConstantsGlobal ConstantsProvides the target group ID(s)
Split Out1Split Out (constants)Iterates over group IDs if multiple are configured
Get Users in GroupHTTP Request (GET)Retrieves all contact IDs in the target group from Swift Digital
Split OutSplit Out (contact_ids)Splits the contact ID array into individual items
Remove Users from SwiftHTTP Request (DELETE)Permanently deletes each contact from Swift Digital

Credentials

CredentialTypeUsed For
Swift Digital OAuth2OAuth2All Swift Digital API calls

Maintenance Notes

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:

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

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:

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:

Also verify: