- AirlineLoadService: pass airport DB for timezone-aware date strings, add browser-shaped headers for United, expand JetBlue/Alaska/Emirates signatures to take origin, log/parse fixes for Korean Air. - FlightsApp: build AirlineLoadService with the airport DB and inject it. - JSX: continued WebView-based fetcher work plus updated JSX_NOTES. - Docs: add AIRLINE_INTEGRATION_GUIDE.md, drop the old AIRLINE_API_SPEC.md, add api_docs/ (StaffTraveler reverse-engineering captures + findings). - Scripts: jsx_cdp_probe, jsx_live_monitor, jsx_swift_smoke for JSX protocol exploration. - .gitignore: exclude airlines/ (local-only APK/IPA reverse-engineering). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 KiB
JSX / JetSuiteX — Reverse Engineering Notes
How to extract per-flight loads from jsx.com from an iOS app (WKWebView) or
a command-line harness (Playwright). Findings from debugging this on real
hardware against the live production site in April 2026.
TL;DR
- JSX's website runs on Navitaire. The data you want (seats available per
fare class, prices per class, all flights for the day) lives in the
POST /api/nsk/v4/availability/search/simpleresponse body. - You cannot call that endpoint from outside a loaded
jsx.compage — Akamai will reject the request with an HTTP/2 protocol error based on the TLS fingerprint of whatever's making the call. - Real Chrome succeeds when you actually drive the full one-way flow:
select
One Way, fill origin and destination, pick the depart date, clickDONE, then clickFIND FLIGHTS. That firesPOST /api/nsk/v4/availability/search/simpleand lands on/booking/select. - In WKWebView, the safest runtime strategy is layered: drive the same UI
flow first, try the real
FIND FLIGHTSbutton, then fall back to the component'ssearch()method, then finally to a direct in-pagefetch()if page state still does not materialize.
The working pattern is therefore:
Navigate browser → jsx.com (real page load)
Wait for SPA bootstrap (station buttons exist, token call finishes)
Select One Way / route / date
Click DONE
Click FIND FLIGHTS
Wait for booking/select or Angular availability state
Fallback: component.search() or direct in-page POST if needed
Parse the response
Endpoints
Base: https://api.jsx.com
| Method | Path | Purpose |
|---|---|---|
POST |
/api/nsk/v2/token |
Anonymous auth token. Fired by the SPA automatically on page load. Response body is { data: { token: "eyJhbGci...", idleTimeoutInMinutes: 15 } }. |
POST |
/api/v2/graph/primaryResources |
Route network / station list. SPA bootstrap. |
POST |
/api/v2/graph/secondaryResources |
Extra static resources. SPA bootstrap. |
POST |
/api/v2/graph/setCulture |
Locale setup. SPA bootstrap. |
GET |
/api/nsk/v1/resources/contents?Type=GeneralReference |
Reference data. SPA bootstrap. |
GET |
/api/nsk/v1/availability/lowfare/estimate?origin=X&destination=Y&... |
Per-day cheapest fare for a route across a date range. Fires when the user selects origin+destination. Useful as a warm-up / liveness check. |
POST |
/api/nsk/v4/availability/search/simple |
The data you want. Fired when the user clicks Find Flights. Contains every flight for the route/date plus per-fare-class prices and counts. |
PUT |
/api/nsk/v2/token |
Token refresh. Happens after a successful search. |
All endpoints require the browser's Akamai cookies from a real jsx.com page load. The search/simple endpoint additionally requires the Authorization header set to the token (raw, no Bearer prefix — just the JWT string).
Auth
The SPA stores its anonymous token in sessionStorage["navitaire.digital.token"] as JSON:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"idleTimeoutInMinutes": 15
}
Read it from inside the page:
const raw = sessionStorage.getItem("navitaire.digital.token");
const token = raw ? (JSON.parse(raw).token || "") : "";
Wait up to ~3 seconds after page load for it to appear — the POST /api/nsk/v2/token call that populates it is async.
Use it as the Authorization header value on the search/simple POST:
fetch("https://api.jsx.com/api/nsk/v4/availability/search/simple", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"Authorization": token
},
body: JSON.stringify(requestBody)
});
Request body (/availability/search/simple)
Byte-identical to what the JSX website's own search() method posts when
its form is valid. Confirmed by intercepting the real call in Playwright.
{
"beginDate": "2026-04-15",
"destination": "HOU",
"origin": "DAL",
"passengers": { "types": [{ "count": 1, "type": "ADT" }] },
"taxesAndFees": 2,
"filters": {
"maxConnections": 4,
"compressionType": 1,
"sortOptions": [4],
"fareTypes": ["R"],
"exclusionType": 2
},
"numberOfFaresPerJourney": 10,
"codes": { "currencyCode": "USD" },
"ssrCollectionsMode": 1
}
beginDate is YYYY-MM-DD. No endDate needed for one-way. origin and destination are IATA codes.
Response shape (/availability/search/simple)
The response nests per-flight data inside a by-market dict, with fare pricing split into a separate top-level dict that you join via key. Here's the bit that matters:
data
├── results[]
│ └── trips[]
│ ├── date: "2026-04-15T00:00:00"
│ └── journeysAvailableByMarket
│ └── "DAL|HOU": [ ← key is `${origin}|${destination}`
│ {
│ stops: 0,
│ designator: { origin, destination, departure, arrival },
│ segments: [
│ {
│ identifier: { carrierCode: "XE", identifier: "280" },
│ designator: { origin, destination, departure, arrival },
│ legs: [{
│ legInfo: { equipmentType: "ER4", ... }
│ }]
│ }
│ ],
│ fares: [
│ {
│ fareAvailabilityKey: "MH5Rfn5YRX5R...",
│ details: [{ availableCount: 1, status: 1, ... }]
│ }
│ ]
│ },
│ ... more journeys
│ ]
└── faresAvailable ← keyed by fareAvailabilityKey
└── "MH5Rfn5YRX5R...": {
totals: { fareTotal: 339, revenueTotal: 329.2, ... },
fares: [
{
classOfService: "Q",
productClass: "HO", ← HO = Hop-On, AI = All-In
fareBasisCode: "Q00AHOXA",
passengerFares: [{ passengerType: "ADT", fareAmount: 339, ... }]
}
]
}
To build a per-flight load summary, for each journey in journeysAvailableByMarket[<market>]:
- Flight number =
segments[0].identifier.carrierCode + segments[0].identifier.identifier(e.g."XE" + "280"→"XE280"). For multi-segment journeys concatenate all segments' identifiers. - Origin / destination / times come from
journey.designator(or fall back tosegments[0].designator/segments[last].designator). - Equipment type is on
segments[0].legs[0].legInfo.equipmentType. - For each entry in
journey.fares[], look upfareAvailabilityKeyindata.faresAvailable. From that record:totals.fareTotal= price including taxestotals.revenueTotal= base farefares[0].classOfService= single-letter fare codefares[0].productClass= "HO" (Hop-On) or "AI" (All-In)
- Sum
details[].availableCountfor that fare bucket — that's the seats sellable at that price point. - Total sellable seats for the flight = sum across all fare buckets on that journey.
- Lowest price =
minof allfareTotalvalues.
JSX flies ERJ-145 and Embraer 135 variants with ~30 seats configured 1x2. availableCount numbers in the single digits are normal.
Example parsed output for DAL → HOU on a given day:
XE280 DAL→HOU 07:15→08:30 stops=0 seats=8 from=$317
class=N bundle=HO count=4 fare=$317 rev=$307.2
class=N bundle=AI count=4 fare=$479 rev=$469.2
XE292 DAL→HOU 10:35→11:50 stops=0 seats=2 from=$409
class=W bundle=HO count=1 fare=$409 rev=$399.2
class=W bundle=AI count=1 fare=$649 rev=$639.2
The jsx.com SPA UI — what the user does
This is only relevant if you're driving the UI (not doing a direct API call). Useful for smoke testing that the page is functional and the anonymous token is fresh, but see the WKWebView caveats below.
Structure of the one-way search form at https://www.jsx.com/home/search:
- Trip type — a Material
mat-selectwith combobox role. Options: "Round Trip", "One Way", "Multi City". Default is Round Trip. - Origin station —
button[aria-label='Station select'](index 0). Opens a dropdown with a search input (placeholder "Airport or city") and a list ofli[role='option'].station-options__itementries. - Destination station —
button[aria-label='Station select'](index 1). Same structure as origin. - Depart date —
input[aria-label='Depart Date']opens a custom two-month range picker overlay on click. - Return date — only visible when trip type is Round Trip.
- Passengers — defaults to 1 adult, fine to leave alone.
- Find Flights button — submits; Angular's
search()handler POSTs/availability/search/simpleif the form validates.
The custom datepicker
JSX does not use Angular Material's mat-calendar. It's a custom
component and none of the standard Material selectors match:
mat-calendar,.mat-calendar,mat-datepicker-content,[class*='mat-datepicker']— all return null..mat-calendar-period-button— doesn't exist. There's no period button to parse "April 2026" out of..mat-calendar-body-cell— doesn't exist. Day cells are something else entirely.
What does work:
- The picker mounts under
.cdk-overlay-containerin a pane that visibly contains month-name text (the Angular CDK overlay system is Material-ish even if the picker isn't). - Day cells carry
aria-labelattributes in the format"Saturday, April 11, 2026"— weekday prefix included. That's why an exact match against"April 11, 2026"fails but a loose contains-month-AND-year-AND-day-word-boundary match succeeds. - Two months are shown side by side (e.g. April + May in April). Clicking a day cell doesn't close the picker.
- There's a "DONE" button in the bottom right of the picker. It must be clicked to commit the selection. Without it, the picker stays open and the depart-date input value isn't set (when the picker is open there are two depart-date inputs in the DOM; after DONE there's one).
The picker renders in two phases
First phase ~150–400 ms after click: overlay shell with month-name text (so selectors matching on month-name text succeed). Second phase a few hundred ms later: the actual day cells with aria-labels become queryable.
If you have separate script evaluations for "open picker" and "click day cell" (i.e. cross-bridge calls in WKWebView), the second call can race the second phase and see no cells. Always poll for at least one [aria-label] matching /\b(January|…|December)\s+\d{1,2},?\s+\d{4}/ before trying to click a specific cell. Polling timeout of ~5 seconds is sufficient.
Mat-select One Way option
The trip-type combobox needs a multi-strategy opener. None of these alone is reliable across browser engines:
- Plain
.click()on the combobox focus()+keydownofEnter,Space, orArrowDown- Walk
__ngContext__and call the Angular mat-select instance's.open()method
Once a visible mat-option appears, click the "One Way" option with the full
event sequence (pointerdown → mousedown → pointerup → mouseup →
click → element.click()). If the return-date input is still visible after
that, fall back to walking __ngContext__ on the mat-option for
_selectViaInteraction() or select() and calling it directly.
Station picker
- Click the
button[aria-label='Station select']at the desired index (0 = origin, 1 = destination). - The dropdown renders
li[role='option'].station-options__itementries plus a searchinput[placeholder='Airport or city']. - Type the IATA code into the search input via the native value setter +
input/changeevents, wait ~500 ms for filtering, then click the option whose text contains the code at a word boundary. - Fall back to
__ngContext__._selectViaInteraction()/.select()if the click doesn't update the station button's text.
Osano cookie banner
JSX embeds Osano. The banner is a div[role="dialog"] with class
osano-cm-window / osano-cm-dialog. Two problems:
- It intercepts clicks on the page behind it.
- Its
role="dialog"matches any selector that looks for open dialogs, so code trying to find the datepicker via[role='dialog']will match Osano instead.
Fix: after accepting it (class-based: .osano-cm-accept-all, .osano-cm-accept; or text-based: "Accept", "Accept All", "I Agree", "Got it"), force-remove the banner node:
document.querySelectorAll(".osano-cm-window, .osano-cm-dialog").forEach(el => el.remove());
Find Flights button
Visible on the main form. Angular click handler calls search(), which:
- Checks
form.invalid. If true, marks touched and returns without firing a request. - Otherwise builds the request body (same shape documented above) and POSTs
/availability/search/simple.
The button being visually enabled does not mean Angular thinks the form
is valid — Angular rechecks on every click. If search() silently bails, it
won't surface any error to the DOM.
Browser validation and WKWebView strategy
The earlier "WKWebView cannot submit the form" theory was too strong. The
browser probe that only failed POST /search/simple had not actually driven
the same successful user flow Chrome uses.
What is confirmed
- Real Chrome does succeed when the automation performs the whole flow:
One Way-> origin -> destination -> depart date ->DONE->FIND FLIGHTS. - That path produces a live
POST /api/nsk/v4/availability/search/simplewith HTTP200and advances the page tohttps://www.jsx.com/booking/select. - The response contains per-flight availability counts that the app can map
back to
XEflights.
What remains true in WKWebView
Synthetic events can still be brittle. A visually-filled form is not enough; the page may still reject the search if the component's internal state is not fully committed. Because of that, the iOS fetcher should not rely on exactly one trigger path.
Current iOS runtime strategy
Flights/Services/JSXWebViewFetcher.swift step 17 now does this:
- Prime the Angular search component with the target date.
- Click the real
FIND FLIGHTSbutton and wait for availability state. - If that does not materialize data, call
component.search(). - If that still fails, issue the direct in-page
fetch()POST as the last fallback.
That keeps the app aligned with the proven Chrome behavior without betting the entire integration on one fragile DOM event path.
Anti-bot surface (Akamai)
JSX fronts api.jsx.com with Akamai Bot Manager. Observed behavior:
| Request source | Result |
|---|---|
Plain curl, fetch from Node, any external HTTP client |
Blocked. Almost all endpoints return HTML challenge page or Akamai error. |
Playwright's built-in chromium.launch() (both bundled chromium and channel: "chrome") |
GET requests succeed, but POST /availability/search/simple specifically returns ERR_HTTP2_PROTOCOL_ERROR. Playwright injects enough automation bits for Akamai to flag the TLS/H2 fingerprint. |
Real Chrome spawned as a plain process + Playwright attached via chromium.connectOverCDP() |
Works reliably. Chrome has the expected fingerprint and Playwright is only driving it via CDP, not altering it. |
| WKWebView on macOS / iOS | Same-origin traffic from a real loaded jsx.com page can work, but the most robust app strategy is still layered because DOM-driven submission can be sensitive to page state and timing. |
| WKWebView with the current app flow | Try real FIND FLIGHTS first, then component.search(), then direct in-page fetch(). This is the runtime path the app now uses. |
Observations:
- The Akamai block is on the request fingerprint, not on specific URLs. The same binary can reach some endpoints and not others because different endpoints attract different scrutiny.
- Cookies set by a real page load are transferable to
fetch()calls made from the same page context viacredentials: "include", and Akamai accepts them. - The
lowfare/estimateendpoint is a useful liveness probe — it fires automatically when the SPA has origin+destination, and if it succeeds you know the Akamai session is good.
Network observation
For the app runtime, prefer passive observation over wrapping fetch() or
XMLHttpRequest. The current iOS flow uses a PerformanceObserver to record
api.jsx.com resource entries without touching the request pipeline.
That gives enough signal to answer:
- Did the page attempt
/availability/search/simpleat all? - Did the page advance to
/booking/select? - Did Angular availability state appear after the search trigger?
See the window.__jsxProbe setup in
Flights/Services/JSXWebViewFetcher.swift step 4 for the current
implementation.
Per-step verification
Every step in the WKWebView flow has an action and one or more post-condition verifications. The action runs and returns success or failure; then every verification runs regardless and logs its result. This matters because a step can have an action that returns "ok" but actually left the page in a broken state — e.g. step 12 can return "datepicker open" because it found the overlay shell, while step 13's check reveals the day cells haven't rendered yet. Running all the verifications and surfacing their results per-line in the log (rather than stopping at the first failure) lets you see the full state the failing step produced, not just the first mismatch.
On failure, the step runner dumps:
- The action's returned data fields (e.g.
action.sample: [...],action.monthMatches: [...]) — any diagnostic payload the action JS chose to include gets surfaced automatically. - Page state: current URL, visible
[role='combobox']count, visiblemat-optioncount, Find Flights button disabled/aria-disabled state, recently initiated and completed api.jsx.com calls, and any elements matchingng-invalid/mat-form-field-invalid/errorclasses so form validation errors are visible.
Code references
-
iOS runtime flow:
Flights/Services/JSXWebViewFetcher.swift— the 19-step WKWebView-based flow with per-step verification. ReturnsJSXSearchResult { flights: [JSXFlight], rawSearchBody, error }with oneJSXFlightentry per unique flight in the search response. -
iOS call site:
Flights/Services/AirlineLoadService.fetchJSXLoad— invokes the fetcher, logs every returned flight, and picks the caller-requested flight number via digit match. -
Playwright reference implementation:
scripts/jsx_playwright_search.mjs— standalone command-line harness that drives the real jsx.com SPA from a real Chrome attached viachromium.connectOverCDP(). Used as the source of truth for the UI flow and request/response shapes that the Swift rewrite mirrors.npx playwright install chromium # one-time node scripts/jsx_playwright_search.mjs --origin DAL --destination HOU --date 2026-04-16 --headfulArtifacts land in
/tmp/jsx-playwright/(rawsearch-simple.json, thelowfare.jsonlow-fare estimate, and acalls.jsonlog of everyapi.jsx.comURL/status the session saw).