Surfaces features that landed after the last big docs pass: per-ride history pages, Fast Lane wait times, outage shading on the today chart, Tier-5 wait-time sampler, production-hardening pieces (rate limiter, structured logger, env validation, graceful shutdown), and the new rides + ride_wait_samples tables. Also corrects the weather-delay rule to match the "open" vs "closing" gate now in rides.ts.
18 KiB
API Reference
See also: Architecture | Operations | Development
Base URL
| Context | URL |
|---|---|
| Local development | http://localhost:3001 |
| Docker internal (web → backend) | http://backend:3001 |
| External access (via host port) | http://<host>: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: <seconds> — 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:
{
"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<parkId, Record<date, DayData>> |
Schedule data keyed by park ID and date |
rideCounts |
Record<parkId, number> |
Number of open rides per park (only for currently operating parks with rides reporting) |
coasterCounts |
Record<parkId, number> |
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:
{
"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:
{
"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:
{
"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:
- If a Queue-Times mapping exists and the park is tracked,
liveRidesis populated. - If outside the operating window, all rides in
liveRidesare forced toisOpen: false. - If no live data is available,
scheduleFallbackis 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:
{
"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.
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:
{
"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 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:
{
"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.
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).
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.
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.
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).
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.
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.
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.
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
}