- 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>
16 KiB
StaffTraveler API
Extracted from com.stafftraveler.webview v3.12.0 (build 1880000346).
Source artifacts:
apps/com.stafftraveler.webview_3.12.0-1880000346_apkcombo.com.xapk/Users/m4mini/Desktop/code/airlines/extracted/stafftraveler/- Hermes disassembly:
extracted/stafftraveler/hermes_out/bundle.hasm
Architecture
React Native + Hermes bytecode. All business logic lives in assets/index.android.bundle. Native Android is a thin RN shell (MainActivity, MainApplication).
Stack
- Firebase Auth — user accounts, ID token issuance.
- Firebase Remote Config — runtime-fetched config (API base URL, Typesense host/key, feature flags, etc).
- Firebase Firestore — realtime store for flights, load requests, load reports, tips, user state.
- Firebase App Check — registered (
FirebaseAppCheckKtxRegistrar) and Play Integrity is bundled (com.google.android.play.core.integrity) — but the/v1/commands/*fetch path does NOT attachX-Firebase-AppCheck(seecreateFirebaseCommanddisassembly atbundle.hasmoffset0x017ed673). App Check enforcement on the HTTP API is not observed. Firestore rules may still consult App Check. - Typesense (
enhk2ji1vu6csxzrp-1.a1.typesense.net) — flight search index. The API key is delivered via Remote Config, not hardcoded. - Custom "commands" HTTP API —
POST https://api.stafftraveler.com/v1/commands/<commandName>— all mutating/reading operations go through this, auth'd with a Firebase ID token.
Firebase Project Config (from res/values/strings.xml)
| Key | Value |
|---|---|
| Project ID | stafftraveler-prod |
| Android App ID | 1:628258099825:android:5fb976ba6ad1bb05 |
| Sender ID | 628258099825 |
| Android API Key | AIzaSyC2zG6ArnguzzdWsLYV1qjQznma0zl1Q0s |
| Maps API Key | AIzaSyD2yg_WuGtrAC_fyyEwvAQgycMoP3Xf9cM |
| Storage Bucket | stafftraveler-prod.appspot.com |
| Realtime DB | https://stafftraveler-prod.firebaseio.com |
| Firestore | (default, project stafftraveler-prod) |
These are all public identifiers — Firebase security comes from rules + App Check, not from hiding them.
Remote Config Keys
Fetched at startup; controls every external URL. Full shape (from bundle.hasm at line 140129):
| Key | Purpose |
|---|---|
api_commands_url |
Base for the commands API (production: https://api.stafftraveler.com/v1/commands) |
search_typesense_hostname |
Typesense host |
search_typesense_api_key |
Typesense search-only key |
image_cdn_hostname |
Aircraft/airline image CDN |
airline_logos_hostname |
Airline logo CDN |
google_places_api_key, _details_url, _search_url, _photo_url |
Places integration |
weather_service_url (api.weatherapi.com), weather_service_api_key |
Weather |
location_service_url (pro.ip-api.com), location_service_api_key |
Geo-IP |
google_web_client_id |
Google Sign-In |
loads_jackpot_enabled_airline_ids, onboarding_invite_reward, notice_*, promotion_* |
Feature flags / content |
To get the real values, call Firebase Remote Config REST with the project config above:
POST https://firebaseremoteconfig.googleapis.com/v1/projects/stafftraveler-prod/namespaces/firebase:fetch?key=AIzaSyC2zG6ArnguzzdWsLYV1qjQznma0zl1Q0s
Auth
Standard Firebase Auth (email/password, Google, Apple, Facebook providers present in bundle strings). No custom auth flow.
To authenticate:
- Sign in via Firebase Auth REST with the
stafftraveler-prodAPI key above, or via the Firebase iOS SDK configured for the same project. getIdToken()produces a JWT with ~1h TTL.- Use the token as
Authorization: Bearer <idToken>on every commands-API call.
App logic in bundle.hasm (createFirebaseCommand generator, line 1827439) checks expirationTime and calls getIdToken() right before sending, so token refresh is transparent.
Commands API
Base: https://api.stafftraveler.com/v1/commands/user (from Remote Config api_commands_url — confirmed from captured traffic, 2026-04-22)
Auth: Authorization: Bearer <firebase-id-token>
Method: POST
Headers: Content-Type: application/json, x-stafftraveler-client: mobile
Body: JSON — shape is command-specific
Response: 200 OK with {payload: {...}} on success. Business errors raise isError/isNetworkError flags in the bundle.
Fetch options object literally constructed in the bundle (offset 0x017ed673):
{ method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + idToken }, body: JSON.stringify(payload) }
Full endpoint pattern: POST https://api.stafftraveler.com/v1/commands/user/<commandName>
Confirmed Commands (built via createFirebaseCommand(name))
Endpoint = POST {api_commands_url}/<command>
| Command | Purpose |
|---|---|
searchFlightsByRoute |
Flight search by origin+destination+date |
searchFlightsByCode |
Flight search by flight number + date |
createLoadsRequests |
Submit one or more load requests for selected flights (spends credits) |
deleteLoadsRequest |
Cancel a pending request |
reopenLoadsRequest |
Reopen an expired/cancelled request |
upgradeLoadsRequest |
Promote a request to Priority (uses extra credits) |
requestLockForLoadsRequest / unlockLoadsRequest |
Cooperative lock: while you're typing a load answer, others can't also submit |
submitLoadsReport |
Answer someone else's request (the reverse direction — you earn credits) |
reviseLoadsReport |
Edit your answer |
addComment / removeComment / reportComment |
Flight comments |
addAirlineDetails |
Provide airline employee credentials for verification |
addCreditsForPurchase |
IAP receipt validation → credits |
setAirlineStCodes |
Set user's airline ("ST code" = StaffTraveler internal airline code) |
Command names are stored in the bundle as plain string identifiers; the full inventory is in /tmp/st_fn_names.txt (extracted function names).
searchFlightsByRoute — confirmed shape (captured 2026-04-22)
Request body:
{
"originIata": "DFW",
"destinationIata": "LAS",
"dates": ["2026-04-22"],
"maxConnections": 0,
"allowNearbyDepartures": false,
"allowNearbyArrivals": false,
"payloadType": "passenger",
"includeMultipleCarriers": false
}
Field corrections from the bytecode guess: the key I thought was stops is actually maxConnections; payloadType takes the literal value "passenger" (not "route"); dates is an array of YYYY-MM-DD strings.
Response:
{
"payload": {
"directFlights": [ /* array of flight objects */ ],
"connectingFlights": [],
"numberOfDiscardedFlights": 0,
"settingsFilteredCount": 0,
"messages": []
}
}
Each flight object has three IDs (id, id_v2, id_v3 — the id_v3 is human-readable like AA_2998_DFW_2026_04_22), airline info (airlineId, airlineStCode, flightCode, flightNumber), local/UTC times, equipment (flightEquipmentId, flightEquipmentIata), durationMinutes, isCargoFlight, seatsTotal, and seatsByClass: {first, business, premiumEconomy, economy}.
Important: seatsByClass is the aircraft cabin configuration, not current availability. Current load data comes from Firestore (see below), not this response.
See api_docs/stafftraveler_captures/searchFlightsByRoute_DFW-LAS_*.json for the full live capture.
searchFlightsByCode — payload shape
From caller at bundle.hasm:1948510 (Function #38848):
{
"flightCode": "UA123",
"dates": ["<ISO date>", ...]
}
Returns flights matching the flight code on each listed date.
createLoadsRequests — confirmed shape (captured 2026-04-22)
Request body is a bare JSON array of flight objects (no outer wrapper). Each element is the full flight object as returned by searchFlightsByRoute, with one added field: isPriorityRequest: bool.
[
{
"id": "a23d6f8379f5109eddeb5bbf78bdccc1",
"id_v2": "0d9d0702b203d224ace3e6eb94531dde",
"id_v3": "AA_2178_DFW_2026_04_22",
"airlineId": "AA",
"flightNumber": 2178,
"flightCode": "AA2178",
"departureAirportId": "DFW",
"arrivalAirportId": "LAS",
"departureTimeLocal": "2026-04-22T16:45:00-05:00",
"departureTimeUtc": "2026-04-22T21:45:00Z",
"arrivalTimeLocal": "2026-04-22T17:46:00-07:00",
"arrivalTimeUtc": "2026-04-23T00:46:00Z",
"flightEquipmentId": "32Q",
"durationMinutes": 181,
"isCargoFlight": false,
"airlineStCode": "st_AAL",
"departureAirportIata": "DFW",
"arrivalAirportIata": "LAS",
"flightEquipmentIata": "32Q",
"seatsTotal": 196,
"seatsByClass": {"first": 0, "business": 20, "premiumEconomy": 0, "economy": 176},
"isPriorityRequest": false
}
]
Response:
{
"payload": {
"numberOfRequests": 0,
"numberOfRequestsPaidFor": 0,
"numberOfRequestsPaidForPriority": 0,
"hasSufficientCredits": true,
"verifiedRequests": [
{
"flightId": "AA_2178_DFW_2026_04_22",
"isExisting": true,
"isDuplicateForUser": true,
"isRequestUpdateForUser": false,
"hasDeparted": false,
"isCancelled": false,
"isPriorityRequest": false,
"hasRecentLoads": false,
"scheduledFlight": { /* echoed full flight obj */ }
}
]
}
}
Idempotent per (user, flight): on duplicate submission, numberOfRequests: 0, isDuplicateForUser: true, no credit charged. This makes the command safe to replay as a "what's the current state of my request for flight X" probe — you just pay for the first request on any given flight.
hasRecentLoads is the flag the app uses to decide whether load data is already available. When true, the Firestore listener is expected to deliver load-report documents; when false, there's nothing to show until someone in the community answers.
See api_docs/stafftraveler_captures/createLoadsRequests_AA2178_*.json for the full live capture.
Firestore Collections (realtime)
Once a request is created, load results stream back via Firestore listeners. Listener constructors in the bundle:
| Listener | What it subscribes to |
|---|---|
TrackedFlightsStoreListener (bundle.hasm:253969) |
The user's currently-tracked flights (including ones with pending requests) |
OpenRequestsStoreListener (bundle.hasm:443724) |
The user's open requests, filtered by useUserAirlineStCodes() |
ConnectingFlightsStoreListener |
Connecting-flight variants |
PinnedFlightsStoreListener |
User-pinned flights |
PriorityRequestCounterStoreListener |
Priority request quota counter |
OpenRequestsCountListener |
Open request count for UI badge |
DerivedLoadsReportsStoreListener |
Reports on flights the user is answering |
CreditsListener / UserSessionsListener / UserStoreListener |
Account state |
RemoteConfigListener |
Remote Config changes |
Exact collection paths are not plain strings in the bundle — they're built from user state (e.g. users/{uid}/flightRequests/{id} or users/{uid}/trackedFlights/{id}). To get them, run Firestore in debug (iOS SDK logs every collection path) or capture with Firebase Local Emulator proxy.
Typesense (search infrastructure)
Not directly hit by the client for flight search — searchFlightsByRoute goes through the commands API, which internally queries Typesense. However, the bundle also contains a typesense-instantsearch-adapter, and strings like searchTypesenseAndAdapt, searchTypesenseForFacetValuesAndAdapt, x-typesense-api-key (bundle.hasm:1995183) suggest direct Typesense queries are used for some facets (probably airline filtering, autocomplete on airports/airlines).
Host: enhk2ji1vu6csxzrp-1.a1.typesense.net
Key: from Remote Config search_typesense_api_key (search-only, scoped, safe to embed).
Integration plan for the iOS Flights app
The cleanest path — do not build a parallel HTTP client, just use Firebase directly:
- Embed the
stafftraveler-prodFirebase config in a secondFirebaseAppinstance (iOS supports multiple named apps). Use the API key + app ID + sender ID from above. Name it something likeStaffTravelerFirebaseAppso it doesn't collide with Flights' own Firebase project. - Sign in with the user's StaffTraveler credentials using
Auth.auth(app: stApp).signIn(withEmail:password:). Store the refresh token in Keychain. - Fetch Remote Config once on first use to get
api_commands_url,search_typesense_api_key, etc. - Search a route →
POST https://api.stafftraveler.com/v1/commands/searchFlightsByRoutewith the payload shape documented above andAuthorization: Bearer <idToken>. - Request loads →
POST .../createLoadsRequestswith the flight IDs from step 4. - Listen for answers →
Firestore.firestore(app: stApp).collection("users/\(uid)/flightRequests").document(requestId).addSnapshotListener { ... }(exact path TBD — confirm via traffic capture).
Firestore is the answer — schema captured (2026-04-22)
Loads and all real-time state live in Firestore, queried via the Listen/channel long-poll transport. Mobile iOS uses the gRPC variant (hard to mitm); the app.stafftraveler.com web client uses plain HTTP long-poll, which is fully inspectable. Playwright capture + URL-decoding gave us every addTarget body.
Full schema and query recipes: see api_docs/stafftraveler_captures/firestore_schema.md and the raw capture at api_docs/stafftraveler_captures/firestore_listen_targets_and_queries.txt.
The one query that renders loads:
collection: derivedLoadsReports
where: flightId == "{id_v3}" (e.g. "AA_2178_DFW_2026_04_22")
orderBy: createdAt DESC
Returns docs containing openSeats.{first,business,premiumEconomy,eco}.count, staffListing.count, creatorName, createdAt, seatsAvailabilityScore, etc. Example at api_docs/stafftraveler_captures/derivedLoadsReports_AA2178_response.json.
Required REST headers (earlier runQuery attempts were 403 because the API key wasn't attached):
Authorization: Bearer <firebase id token>
X-Firebase-GMPID: 1:628258099825:web:35b20eaab4d441894041d0
?key=AIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc (as URL query, web key)
Remaining nuances
- Account eligibility —
addAirlineDetailsstill required for the account to participate. Your existing account is verified (WN /st_SWA), so no blocker. - Credits / access model —
createLoadsRequestscharges for new requests only. Idempotent per(user, flight)—isDuplicateForUser: truereturns current state with no charge. FirestorederivedLoadsReportsreads are gated by an activetrackedFlights/{flightId}doc, which is only created bycreateLoadsRequests. So you must spend 1 credit per new flight to unlock load reads for it; after that, reads are free and unlimited. You cannot peek at other flights' loads without requesting. - App Check — not enforced on commands API or Firestore for this project (confirmed: web client makes all these requests with just ID token + API key).
Integration for iOS Flights app (final)
- Secondary
FirebaseAppconfigured againststafftraveler-prodwith the web API key + GMPID above. signInWithEmailAndPassword→ persist refresh token.Firestore.firestore(app: stApp).collection("derivedLoadsReports").whereField("flightId", isEqualTo: flightIdV3).order(by: "createdAt", descending: true).addSnapshotListener { ... }— realtime loads for any flight you know the id_v3 of.- Plus
trackedFlights,airlinesBySt,airportsByIata,flightEquipment,conversations/{flightId}subscriptions — full recipes infirestore_schema.md. - Writes (request loads, submit report) keep using
POST https://api.stafftraveler.com/v1/commands/user/<cmd>.