# API Reference > See also: [Architecture](ARCHITECTURE.md) | [Operations](OPERATIONS.md) | [Development](DEVELOPMENT.md) ## Base URL | Context | URL | |---------|-----| | Local development | `http://localhost:3001` | | Docker internal (web → backend) | `http://backend:3001` | | External access (via host port) | `http://:3001` | ## Authentication None. All endpoints are public and unauthenticated. The scrape trigger endpoint is also unprotected -- restrict access at the network/proxy level if needed. ## Rate limiting Every endpoint is gated by a fixed-window per-IP counter (`backend/src/middleware/rate-limit.ts`). | Header / Body | Value | |---------------|-------| | Limit | `RATE_LIMIT_PER_MIN` env var, default `60` requests/minute | | Window | 60 seconds, per client IP (resolved via `x-forwarded-for` → `x-real-ip` → socket address) | | Over-limit response | `429 Too Many Requests` | | Body | `{ "error": "Too many requests" }` | | Response header | `Retry-After: ` — how long until the window resets | Behind a reverse proxy, make sure `x-forwarded-for` is set or every request will appear to come from the proxy's own IP. --- ## Endpoints ### GET /api/calendar/week Returns a 7-day calendar for all parks, starting from the given Sunday. **Query Parameters:** | Param | Required | Format | Description | |-------|----------|--------|-------------| | `start` | Yes | `YYYY-MM-DD` | Week start date (should be a Sunday) | **Cache:** `Cache-Control: public, max-age=120, stale-while-revalidate=300` **Response:** ```json { "weekStart": "2026-04-19", "weekDates": ["2026-04-19", "2026-04-20", "2026-04-21", "2026-04-22", "2026-04-23", "2026-04-24", "2026-04-25"], "today": "2026-04-23", "isCurrentWeek": true, "data": { "cedarpoint": { "2026-04-23": { "isOpen": true, "hoursLabel": "10am – 8pm", "specialType": null }, "2026-04-24": { "isOpen": false, "hoursLabel": null, "specialType": null } } }, "rideCounts": { "cedarpoint": 42, "greatadventure": 38 }, "coasterCounts": { "cedarpoint": 12, "greatadventure": 9 }, "openParkIds": ["cedarpoint", "greatadventure"], "closingParkIds": [], "weatherDelayParkIds": [], "hasCoasterData": true, "scrapedCount": 168 } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `weekStart` | `string` | Echo of the `start` parameter | | `weekDates` | `string[]` | Array of 7 date strings (Sun-Sat) | | `today` | `string` | Current date (with 3 AM switchover) | | `isCurrentWeek` | `boolean` | Whether this week contains today | | `data` | `Record>` | Schedule data keyed by park ID and date | | `rideCounts` | `Record` | Number of open rides per park (only for currently operating parks with rides reporting) | | `coasterCounts` | `Record` | Number of open coasters per park | | `openParkIds` | `string[]` | Parks currently within their operating window | | `closingParkIds` | `string[]` | Parks past close but within 1-hour wind-down | | `weatherDelayParkIds` | `string[]` | Parks within window but all rides closed | | `hasCoasterData` | `boolean` | Always `true` (coaster data is static) | | `scrapedCount` | `number` | Total day records returned (sanity check for empty database) | **Errors:** | Status | Body | Condition | |--------|------|-----------| | 400 | `{ "error": "Missing or invalid ?start=YYYY-MM-DD" }` | Missing or malformed `start` parameter | --- ### GET /api/calendar/:parkId/month Returns a month calendar for a single park. **Path Parameters:** | Param | Description | |-------|-------------| | `parkId` | Park identifier (e.g. `cedarpoint`, `greatadventure`) | **Query Parameters:** | Param | Required | Format | Description | |-------|----------|--------|-------------| | `month` | Yes | `YYYY-MM` | Month to fetch | **Cache:** `Cache-Control: public, max-age=300, stale-while-revalidate=600` **Response:** ```json { "parkId": "cedarpoint", "year": 2026, "month": 6, "monthData": { "2026-06-01": { "isOpen": true, "hoursLabel": "10am – 10pm", "specialType": null }, "2026-06-02": { "isOpen": true, "hoursLabel": "10am – 8pm", "specialType": null } }, "today": "2026-04-23" } ``` If viewing the current month, today's data is fetched live from the Six Flags API and merged into the response. **Errors:** | Status | Body | Condition | |--------|------|-----------| | 400 | `{ "error": "Missing or invalid ?month=YYYY-MM" }` | Missing or malformed `month` parameter | | 400 | `{ "error": "Month must be 1-12" }` | Month value out of range | --- ### GET /api/parks Returns metadata for all 24 parks. **Cache:** `Cache-Control: public, max-age=3600` **Response:** ```json { "parks": [ { "id": "cedarpoint", "apiId": 70, "name": "Cedar Point", "shortName": "Cedar Point", "chain": "sixflags", "slug": "cedarpoint", "region": "Midwest", "location": { "lat": 41.4784, "lng": -82.6834, "city": "Sandusky", "state": "OH" }, "timezone": "America/New_York", "website": "https://www.sixflags.com" } ] } ``` --- ### GET /api/parks/:id Returns metadata for a single park. **Path Parameters:** | Param | Description | |-------|-------------| | `id` | Park identifier | **Cache:** `Cache-Control: public, max-age=3600` **Response:** A single `Park` object (same shape as one element of the `/api/parks` array). **Errors:** | Status | Body | Condition | |--------|------|-----------| | 404 | `{ "error": "Park not found" }` | Unknown park ID | --- ### GET /api/parks/:id/rides Returns live ride status or schedule fallback for a park. **Path Parameters:** | Param | Description | |-------|-------------| | `id` | Park identifier | **Cache:** `Cache-Control: public, max-age=60, stale-while-revalidate=120` **Response:** ```json { "parkId": "cedarpoint", "today": "2026-04-23", "parkOpenToday": true, "withinWindow": true, "isWeatherDelay": false, "liveRides": { "rides": [ { "name": "Steel Vengeance", "slug": "steel-vengeance", "isOpen": true, "waitMinutes": 45, "fastLaneMinutes": 10, "hasFastLane": true, "lastUpdated": "2026-04-23T18:30:00.000Z", "isCoaster": true }, { "name": "Millennium Force", "slug": "millennium-force", "isOpen": false, "waitMinutes": 0, "fastLaneMinutes": null, "hasFastLane": true, "lastUpdated": "2026-04-23T18:30:00.000Z", "isCoaster": true } ], "fetchedAt": "2026-04-23T18:35:00.000Z" }, "scheduleFallback": null } ``` Each ride is enriched from two sources: Queue-Times.com supplies `isOpen` and the base `waitMinutes`, then Six Flags' wait-times feed is joined by name to fill in `fastLaneMinutes` and `hasFastLane`. When both sources have a regular wait for the same ride, the Six Flags value wins (Queue-Times lags around park open). `fastLaneMinutes` is `null` when the ride is closed or has no Fast Lane line. `slug` is the URL-safe identifier used by `/api/parks/:id/rides/:slug`. **Response fields:** | Field | Type | Description | |-------|------|-------------| | `parkId` | `string` | Echo of the park ID | | `today` | `string` | Current date (with 3 AM switchover) | | `parkOpenToday` | `boolean` | Whether the park has hours scheduled today | | `withinWindow` | `boolean` | Whether current time is within operating hours | | `isWeatherDelay` | `boolean` | Park is open but all rides are closed | | `liveRides` | `LiveRidesResult \| null` | Queue-Times live data (null if park has no mapping or is outside window) | | `scheduleFallback` | `RidesFetchResult \| null` | Six Flags schedule data (only populated when liveRides is null) | **Data priority:** 1. If a Queue-Times mapping exists and the park is tracked, `liveRides` is populated. 2. If outside the operating window, all rides in `liveRides` are forced to `isOpen: false`. 3. If no live data is available, `scheduleFallback` is fetched from the Six Flags schedule API for the nearest open date. **Errors:** | Status | Body | Condition | |--------|------|-----------| | 404 | `{ "error": "Park not found" }` | Unknown park ID | --- ### GET /api/parks/:parkId/rides/:slug Returns metadata + history for a single ride: today's 5-minute wait samples and daily aggregates over the last 7 and 30 calendar days. Everything ships in one round-trip — the frontend renders the Today / 7d / 30d tabs from this single payload. **Path Parameters:** | Param | Description | |-------|-------------| | `parkId` | Park identifier (e.g. `cedarpoint`) | | `slug` | Ride slug, as returned in `LiveRide.slug` or stored in the `rides.slug` column | **Cache:** `Cache-Control: public, max-age=60, stale-while-revalidate=120` **Response:** ```json { "park": { "id": "cedarpoint", "name": "Cedar Point", "shortName": "Cedar Point", "timezone": "America/New_York" }, "ride": { "qtRideId": 257, "slug": "steel-vengeance", "name": "Steel Vengeance", "isCoaster": true, "hasFastLane": true, "firstSeen": "2026-03-15T14:05:00.000Z", "lastSeen": "2026-04-23T18:35:00.000Z" }, "live": { "isOpen": true, "waitMinutes": 45, "hasFastLane": true, "fastLaneMinutes": 10, "lastUpdated": "2026-04-23T18:30:00.000Z" }, "todayLocal": "2026-04-23", "today": [ { "recordedAt": "2026-04-23T14:05:12.000Z", "localTime": "10:05", "isOpen": true, "waitMinutes": 15, "fastLaneMinutes": 5 } ], "last7d": [ { "localDate": "2026-04-17", "avgWait": 38.4, "maxWait": 90, "avgFastLane": 9.1, "maxFastLane": 25, "uptimePct": 0.94, "sampleCount": 132 } ], "last30d": [], "coverage": { "daysWith7d": 6, "daysWith30d": 23, "todaySampleCount": 1 } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `park` | `{ id, name, shortName, timezone }` | Park identity (timezone is the IANA tz used for sample bucketing) | | `ride` | `RideRecord` | Canonical row from the `rides` table | | `live` | `LiveRideSummary \| null` | Best-effort current state pulled from the shared in-memory cache. No upstream fetch — populated by the rides route and Tier-5 sampler. `null` if no recent observation exists. | | `todayLocal` | `string` | Today's date in the park's timezone | | `today` | `DailySample[]` | Per-sample series for `todayLocal`, ordered by `recordedAt` | | `last7d` | `DailyAggregate[]` | One row per `local_date` over the last 7 calendar days (inclusive of today) | | `last30d` | `DailyAggregate[]` | Same aggregates over 30 days | | `coverage.daysWith7d` | `number` | Distinct dates with samples in the 7-day window — use to gate the 7d tab | | `coverage.daysWith30d` | `number` | Distinct dates with samples in the 30-day window | | `coverage.todaySampleCount` | `number` | Number of samples already collected today | `DailySample` and `DailyAggregate` shapes are listed under [Data Types](#data-types). **Errors:** | Status | Body | Condition | |--------|------|-----------| | 404 | `{ "error": "Park not found" }` | Unknown park ID | | 404 | `{ "error": "Ride not found or no history yet" }` | Slug doesn't match any row in `rides` for this park (Tier-5 hasn't seen the ride yet, or the slug is wrong) | --- ### GET /api/status Health check endpoint with database statistics. **Response:** ```json { "status": "ok", "uptime": 86400, "parks": 24, "database": { "totalDays": 8760, "lastScrape": "2026-04-23T14:00:12.000Z" }, "lastScrapeResult": { "scope": "today", "fetched": 24, "skipped": 0, "errors": 0, "updated": 3, "startedAt": "2026-04-23T14:00:00.000Z", "finishedAt": "2026-04-23T14:00:12.000Z" } } ``` | Field | Type | Description | |-------|------|-------------| | `status` | `string` | Always `"ok"` | | `uptime` | `number` | Process uptime in seconds | | `parks` | `number` | Number of tracked parks (24) | | `database.totalDays` | `number` | Total rows in `park_days` table | | `database.lastScrape` | `string \| null` | ISO timestamp of the most recent `scraped_at` value | | `lastScrapeResult` | `ScrapeResult \| null` | Result of the last completed scrape (null if none has run yet) | --- ### POST /api/scrape/trigger Manually triggers a data scrape. See [Operations > Manual Scraping](OPERATIONS.md#manual-scraping) for detailed usage. **Query Parameters:** | Param | Required | Default | Values | Description | |-------|----------|---------|--------|-------------| | `scope` | No | `today` | `today`, `month`, `upcoming`, `full`, `force` | What to scrape | **Scope details:** | Scope | What it scrapes | Staleness check | Inter-park delay | |-------|----------------|-----------------|------------------| | `today` | Today's hours only | No (uses diff-before-write) | 500ms | | `month` | Current month | Yes (skips if fresh) | 1000ms | | `upcoming` | Current + next month | Yes | 1000ms | | `full` | All 12 months | Yes | 1000ms | | `force` | All 12 months | **No** (ignores staleness) | 1000ms | **Response:** ```json { "scope": "today", "fetched": 24, "skipped": 0, "errors": 0, "updated": 3, "startedAt": "2026-04-23T14:00:00.000Z", "finishedAt": "2026-04-23T14:00:12.000Z" } ``` **Errors:** | Status | Body | Condition | |--------|------|-----------| | 400 | `{ "error": "Invalid scope. Use: today, month, upcoming, full, force" }` | Unknown scope value | --- ## Data Types ### DayData Core schedule data for a single park on a single day. ```typescript interface DayData { isOpen: boolean; // Whether the park is open hoursLabel: string | null; // e.g. "10am – 6pm", null when closed specialType: string | null; // "passholder_preview" or null } ``` ### Park Park metadata (static, defined in `lib/parks.ts`). ```typescript interface Park { id: string; // Unique identifier (e.g. "cedarpoint") apiId: number; // Six Flags CloudFront API park ID name: string; // Full display name shortName: string; // Abbreviated name chain: string; // "sixflags" slug: string; // URL-safe slug region: string; // Geographic region location: { lat: number; lng: number; city: string; state: string; }; timezone: string; // IANA timezone (e.g. "America/New_York") website: string; // Park website URL } ``` ### LiveRide A single ride from the Queue-Times.com API. ```typescript interface LiveRide { name: string; // Ride display name slug: string; // URL-safe slug for /api/parks/:id/rides/:slug isOpen: boolean; // Currently operating waitMinutes: number; // Current regular wait (0 if closed) fastLaneMinutes?: number | null; // Fast Lane wait (null when closed or no Fast Lane line) hasFastLane?: boolean; // Ride has a Fast Lane offering per Six Flags lastUpdated: string; // ISO 8601 timestamp from Queue-Times isCoaster: boolean; // Classified as a roller coaster via RCDB data } ``` ### LiveRidesResult Container for live ride data. ```typescript interface LiveRidesResult { rides: LiveRide[]; // All rides, sorted: open first, then alphabetical fetchedAt: string; // ISO timestamp of when we fetched from Queue-Times } ``` ### RidesFetchResult Schedule-based ride data (fallback when live data is unavailable). ```typescript interface RidesFetchResult { rides: RideStatus[]; // Rides with scheduled open/close times dataDate: string; // YYYY-MM-DD the data came from (may differ from requested date) isExact: boolean; // true if dataDate matches requested date parkHoursLabel?: string; // Park-level hours for the data date } interface RideStatus { name: string; isOpen: boolean; // Has scheduled operating hours hoursLabel?: string; // e.g. "10am – 10pm" } ``` ### DailySample A single wait-time observation recorded by the Tier-5 sampler. ```typescript interface DailySample { recordedAt: string; // ISO 8601 UTC timestamp localTime: string; // HH:MM in the park's timezone isOpen: boolean; // Ride open at this sample waitMinutes: number | null; // Regular wait, null when unobserved fastLaneMinutes: number | null; // Fast Lane wait, null when no Fast Lane or unobserved } ``` ### DailyAggregate Per-day statistics computed in SQL from `ride_wait_samples`. Only open samples contribute to wait averages. ```typescript interface DailyAggregate { localDate: string; // YYYY-MM-DD in the park's timezone avgWait: number | null; // Mean wait_minutes across open samples maxWait: number | null; // Highest wait_minutes across open samples avgFastLane: number | null; // Mean fast_lane_minutes across open samples maxFastLane: number | null; // Highest fast_lane_minutes across open samples uptimePct: number; // Fraction of samples with is_open=1 (0..1) sampleCount: number; // Total samples for the day } ``` ### ScrapeResult Result of a scraping operation. ```typescript interface ScrapeResult { scope: string; // What was scraped (e.g. "today", "months(2026-04)") fetched: number; // API calls made successfully skipped: number; // Skipped due to staleness or null response errors: number; // Failed API calls updated: number; // Database rows written startedAt: string; // ISO timestamp finishedAt: string; // ISO timestamp } ```