# 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 attach `X-Firebase-AppCheck` (see `createFirebaseCommand` disassembly at `bundle.hasm` offset `0x017ed673`). 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/` — 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: 1. Sign in via Firebase Auth REST with the `stafftraveler-prod` API key above, or via the Firebase iOS SDK configured for the same project. 2. `getIdToken()` produces a JWT with ~1h TTL. 3. Use the token as `Authorization: Bearer ` 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 ` **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`): ```js { 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/` ### Confirmed Commands (built via `createFirebaseCommand(name)`) Endpoint = `POST {api_commands_url}/` | 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: ```json { "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: ```json { "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): ```json { "flightCode": "UA123", "dates": ["", ...] } ``` 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`. ```json [ { "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: ```json { "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: 1. **Embed the `stafftraveler-prod` Firebase config** in a second `FirebaseApp` instance (iOS supports multiple named apps). Use the API key + app ID + sender ID from above. Name it something like `StaffTravelerFirebaseApp` so it doesn't collide with Flights' own Firebase project. 2. **Sign in** with the user's StaffTraveler credentials using `Auth.auth(app: stApp).signIn(withEmail:password:)`. Store the refresh token in Keychain. 3. **Fetch Remote Config** once on first use to get `api_commands_url`, `search_typesense_api_key`, etc. 4. **Search a route** → `POST https://api.stafftraveler.com/v1/commands/searchFlightsByRoute` with the payload shape documented above and `Authorization: Bearer `. 5. **Request loads** → `POST .../createLoadsRequests` with the flight IDs from step 4. 6. **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 X-Firebase-GMPID: 1:628258099825:web:35b20eaab4d441894041d0 ?key=AIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc (as URL query, web key) ``` ### Remaining nuances 1. **Account eligibility** — `addAirlineDetails` still required for the account to participate. Your existing account is verified (WN / `st_SWA`), so no blocker. 2. **Credits / access model** — `createLoadsRequests` charges for *new* requests only. Idempotent per `(user, flight)` — `isDuplicateForUser: true` returns current state with no charge. **Firestore `derivedLoadsReports` reads are gated by an active `trackedFlights/{flightId}` doc**, which is only created by `createLoadsRequests`. 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. 3. **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 `FirebaseApp` configured against `stafftraveler-prod` with 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 in `firestore_schema.md`. - Writes (request loads, submit report) keep using `POST https://api.stafftraveler.com/v1/commands/user/`.