openapi: "3.1.0"
info:
  title: MinuteTemp API
  version: "1.17.0"
  description: |
    Real-time, minute-level weather data for MinuteTemp stations.

    This OpenAPI document covers the public, API-key-authenticated contract under `/api/v1/*`.
    MinuteTemp also has first-party frontend endpoints under `/ui/v1/*`, but those are internal
    web-app routes used by the product UI and are not part of the stable external API contract.

    ## Getting Started
    1. Sign up at [minutetemp.com](https://minutetemp.com) and generate an API key from your profile.
    2. Include your key in every request via `X-API-Key` header or `Authorization: Bearer <key>`.
    3. Browse endpoints below or try them interactively.

    ```bash
    curl -H "X-API-Key: your_api_key" https://api.minutetemp.com/api/v1/stations
    ```

    ## WebSocket (Real-Time Push)
    Paid API WebSocket streams deliver **1-minute** observation events in realtime. See the
    [WebSocket docs](/docs/ws/) for connection details, payload shapes, and the separate product UI
    stream (`/ws/ui`) where free users receive the same 1-minute data on a 3-minute delay.

    ## Bot Startup Recipe
    1. Fetch baseline snapshots via REST (`/api/v1/stations/{station_id}/forecast/runs` and `/api/v1/markets/{market_id}/history`).
    2. Open WebSocket and send `subscribe` for live invalidations.
    3. Persist `sequence` from WS envelopes.
    4. On disconnect or detected gap, catch up via cursor endpoints, then resume streaming.

    ## Changelog
    - v1.17 behavior change:
      - In `GET /api/v1/markets`, the `updated_at` of a Kalshi or Polymarket market is when
        MinuteTemp last updated the market's record (its metadata), not when its prices were last
        polled, which every market of a platform shared. The list also includes open markets
        that have no price yet. The fields served are otherwise unchanged.
    - v1.16 behavior change:
      - The public API no longer serves market prices, for any platform (Kalshi, Polymarket,
        ForecastEx). Markets in `GET /api/v1/markets` and `GET /api/v1/markets/{market_id}` omit
        `yes_price`, `no_price` and `volume`; a ForecastEx market also omits `updated_at`. Market
        history points keep their timestamps with `yes_price`, `no_price` and `volume` null. The
        `ladder` of `GET /api/v1/hourly-indexes/{city}` keeps its strikes with every quote
        (`yes_bid`, `yes_ask`, `no_bid`, `no_ask`, `last_price`, `volume`) null. These fields
        stay in the schema, deprecated. Markets, strikes, statuses, settlement points and settled
        temperatures are served as before.
      - Markets and settlement points add `settlement_result` (`yes` or `no`) once a market
        settles, in place of the settled prices.
    - v1.15 behavior change:
      - ForecastEx price display is now enabled by default, so ForecastEx markets carry
        `yes_price` and `no_price` (the last traded prices, not a bid or ask), `volume` and
        `updated_at`, and a ForecastEx settlement point in the history carries `yes_price`
        (1 or 0) and `no_price`. MinuteTemp can still turn price display off, and these fields
        are then absent again.
    - v1.14 additive:
      - Markets add `platform: forecastex`: the daily high and low contracts of ForecastEx, which
        Robinhood and IBKR broker, in `GET /api/v1/markets` (and `platform=forecastex`),
        `GET /api/v1/markets/{market_id}` and its history (the settlement point only). Their
        `expires_at`, `status` and `computed_outcome` are always present; `yes_price`,
        `no_price`, `volume` and `updated_at` (when the prices were seen) only while ForecastEx
        price display is enabled.
    - v1.13 behavior change:
      - `GET /api/v1/markets/{market_id}/history` for a Polymarket market returns only the
        settlement point, when the market has resolved inside the requested range, and an empty
        `points` list otherwise. Polymarket price history is no longer stored. Kalshi markets are
        unchanged.
    - v1.12 additive:
      - `GET /api/v1/limits` adds `ws_subscriptions` with the caller's plan's WebSocket market and
        neighbour subscription limits (`null` means unlimited).
    - v1.11 additive:
      - External WebSocket: observation-only (neighbour) stations have their own subscription
        quota (Starter 30, Pro 300, Clanker unlimited) beside the unchanged market quota; see
        **Plan Limits** and the [WebSocket docs](/docs/ws/).
    - v1.10 additive:
      - Hourly index data files (see **Hourly index data files**): each index city's forecast,
        both models, as a current file and one day file per UTC date. `GET /api/v1/data/access`
        adds `hourly_indexes`, `hourly_index_days` and `public.hourly_indexes_prefix`, and the
        WebSocket adds `index_file_updated` and the snapshot's `file` (see the
        [WebSocket docs](/docs/ws/)).
    - v1.10 deprecation:
      - `GET /api/v1/hourly-indexes/{city}/forecast` and
        `GET /api/v1/hourly-indexes/{city}/forecast/history` are deprecated: read the hourly index
        data files instead. Both keep working with unchanged responses.
    - v1.9 behavior change:
      - Forecast runs follow the plan's history window (see **Plan Limits**). A run is readable
        while the UTC date it was fetched is today or one of the `max_history_days` dates before
        it. `GET /api/v1/stations/{station_id}/forecast` leaves out a model whose newest run is
        older and answers `403` when no model is left;
        `GET /api/v1/stations/{station_id}/forecast/runs/{run_id}` answers `403` for an older run;
        `GET /api/v1/stations/{station_id}/forecast/runs` returns runs from the start of the window
        and answers `403` when the whole range is older.
    - v1.9 deprecation:
      - `GET /api/v1/stations/{station_id}/forecast` and
        `GET /api/v1/stations/{station_id}/forecast/runs/{run_id}` are deprecated: read forecasts
        as files (see **Getting forecast data**). Both keep working with unchanged responses.
    - v1.9 additive:
      - Forecast data files (see **Forecast data files**): forecast run summaries from
        `GET /api/v1/stations/{station_id}/forecast/runs` and
        `GET /api/v1/stations/{station_id}/forecast/runs/{run_id}` add `object_path`, the path of
        the run's file, for runs that have one; the field is absent for the others. Every other
        field of those responses is unchanged.
      - RRFS next to HRRR in the hourly index forecast. `GET /api/v1/hourly-indexes/{city}/forecast`,
        `/forecast/history` and `/forecast-accuracy` take an optional `model`
        (`ncep_hrrr_conus_15min`, the default, or `ncep_rrfs_conus_15min`; anything else is 400) and
        return `model_id` (new on `/forecast` and `/forecast-accuracy`). Each model is computed from
        its own member runs with the same formula, calibration and quorum, and has its own bias,
        history and accuracy; one is never filled in from the other. A model without a forecast for
        a city answers `forecast: null` (RRFS is published only where the collector has it enabled
        and the members have RRFS runs; an RRFS forecast with no step left, or whose members lost
        their RRFS forecasts, is removed rather than served). Without `model`, every response is
        what it was.
      - The city body (and the WebSocket snapshot) adds `forecasts`: the other models' forecasts by
        model ID, next to the unchanged HRRR `forecast`. The key is absent while there are none.
      - Hourly index minutes add `restated_f` and `restated_at`: a Kalshi restatement of an official
        value no longer replaces `official_f`/`official_at` (the first official value, which the
        settlement, the forecast history's `adjusted_f` and the forecast accuracy use).
      - Hourly index settlements add the status `incomplete_data` (our minutes have a gap after the
        last official minute; the gap is in `missing_minutes`), and `settle_source` (`computed` or
        `kalshi_expiration`). A market city's `incomplete_data` hour takes Kalshi's final value at
        reconciliation, with `match` null, and is left out of the match rate.
      - Forecast history `adjusted_f` takes its bias only from minutes already official at each
        run's fetch, so it never uses a value known later.
      - The hourly index `current_hour` carries `feed_conditions`: degradations of the city's
        index feed over its last 30 final minutes (a member on its METAR fallback, excluded, or on
        a QC substitute; quorum at risk; unavailable minutes). The city body adds
        `contract_terms_url`, the Kalshi contract terms of the city's hourly series.
      - Added `/api/v1/hourly-indexes/{city}/forecast-accuracy?window=7d|30d`: MAE, mean signed
        error and strike match share of the bias-adjusted and raw HRRR index forecast at 60, 30 and
        15 minutes before each determined hour's close.
      - The hourly index `current_hour` places values against the ladder: `settles_now_as` adds
        `implied_floor_strike`, `nearest_strike_f`, `distance_to_strike_f` and `strike_side`, and
        the adjusted forecast settle adds `forecast_adjusted_nearest_strike_f`,
        `forecast_adjusted_distance_to_strike_f` and `forecast_adjusted_strike_side` (null for
        index-only cities).
      - The hourly index forecast (`GET /api/v1/hourly-indexes/{city}/forecast`, the city body's
        `forecast`) carries the bias-adjusted forecast: `bias_f`, `bias_as_of_minute`,
        `adjusted_f` and `member_bias`, and each `settles[]` entry `adjusted_value_f` and
        `adjusted_floor_strike`.
      - Hourly index data is never delayed: the UI's free delayed hourly view is gone (in the UI,
        market cities need a paid plan, UI and above with trials included, and index-only cities
        are open in realtime). The API was already realtime and still requires Starter, so API
        responses are unchanged.
      - Removed `trend_settle_f` and `trend_floor_strike` from the hourly index `current_hour`
        (the 15-minute trend line); use `forecast_adjusted_settle_f` for where the hour is heading.
      - Member readings in hourly index minutes (`include=stations`, the city snapshot's minutes)
        carry per-station metrics: `pull_f` (leave-one-out pull on the index), `change_5m_f`,
        `hour_high_f` and `hour_low_f`.
    - v1.8 additive:
      - Added `/api/v1/hourly-indexes/{city}/forecast/history`: the stitched HRRR index forecast
        for a window (up to 48 hours), each member's 15-minute temperature taken from its newest
        run fetched at or before the step, with per-member series and the as-forecast
        bias-adjusted series `adjusted_f`.
      - Added the bias-adjusted HRRR forecast settle to the hourly index `current_hour`:
        `forecast_settle_f`, `forecast_bias_f`, `forecast_adjusted_settle_f` and
        `forecast_adjusted_floor_strike` (null without a forecast covering the hour, a valued
        minute, or quorum).
    - v1.7 additive:
      - Added `/api/v1/hourly-indexes/{city}/forecast`: the city's index forecast from its member
        stations' HRRR 15-minute temperatures (Kalshi's formula and current calibration), with
        per-member series, the forecast settle of the next top-of-hours and the implied strike.
        The same object is the `forecast` field of the city body and the WebSocket
        `forecast_index` event.
    - v1.6 additive:
      - Added **Hourly Indexes**: `/api/v1/hourly-indexes` (city list), `/{city}` (current state,
        the WebSocket snapshot body), `/{city}/minutes`, `/{city}/settlements`,
        `/{city}/calibrations` and `/{city}/match-rate`. Live updates use the WebSocket
        `hourly_indexes` subscription (1 subscription slot per city) with a per-city `seq` and
        `resume`; see the [WebSocket docs](/docs/ws/).
    - v1.5 additive:
      - Added **Plan Limits** section documenting per-subscription caps for history, oracle scores,
        observations, reports, forecast models, WebSocket subscriptions, and rate limits.
      - Clarified paid REST observation `resolution` is always `1m`; external WebSocket uses realtime
        1-minute events (`/ws/api/5m` is a legacy path with the same cadence).
      - Added `/api/v1/stations/{station_id}/oracle-score-days` for daily oracle score components
        that can be locally aggregated into arbitrary rolling windows.
    - v1.4 additive:
      - Added optional `day_mode` on latest station observations.
      - Added `temperature_day_mode`, `temperature_day_date`, `wu_day_mode`, and `wu_day_date` on latest observation payloads.
    - v1.3 additive:
      - Added station weather events endpoint for active and recently ended METAR/SPECI-driven event state.
    - v1.2 additive:
      - Added station report endpoints for official CLI/DSM/METAR reports.
      - Added report provenance fields on observation payloads.
      - Added 5-minute observation resolution enum for starter tier.
    - v1.1 additive:
    - Added market history endpoint with cursor pagination.
    - Added forecast run list/detail endpoints for deterministic replay.
    - Added limits introspection endpoint.
    - Added `is_locf` on observation payloads.

    ## Rate Limits

    Per-subscription request caps are listed under **Plan Limits** below. Limits are shared across
    all API keys for the same user account.

    Additional IP safeguard on `/api/v1/*` (before API key auth):
    - 20 requests/second sustained
    - burst up to 100 requests

    Effective limit is whichever guard is hit first (IP or per-user tier).
    Per-user plan limits use a token bucket that refills at the plan's requests/minute rate and stores up to one minute of tokens while idle. The listed burst value is the maximum a fully idle client can spend across the next minute.

    `GET /api/v1/limits` returns a region-scoped snapshot for the request that served it.

    Rate limit headers:
    - `X-RateLimit-Remaining` — immediate stored tokens remaining after the request
    - `X-RateLimit-Reset` — seconds until the immediate token bucket fully refills
    - `Retry-After` — seconds to wait (only on 429)

    ## Plan Limits

    The external API requires a **Starter, Pro, or Clanker** subscription and a valid API key.
    Free and UI-only accounts receive `403` on `/api/v1/*` routes.

    `GET /api/v1/limits` returns the authenticated key's effective values, including live
    rate-limit snapshots. Static caps by subscription:

    ### Rate limits

    | Plan    | Requests/min | Burst after idle |
    |---------|--------------|------------------|
    | Starter | 15           | 30               |
    | Pro     | 100          | 200              |
    | Clanker | 250          | 500              |

    Limits are shared across all API keys for the same user account.

    ### Historical data window (`max_history_days`)

    Maximum inclusive calendar-day span for time-bounded history endpoints: market snapshot
    history, forecast run lists, report history date ranges, and oracle score lookback.

    | Plan    | Max history days |
    |---------|------------------|
    | Starter | 7                |
    | Pro     | 30               |
    | Clanker | 90               |

    Forecast runs are also limited by age: a run is readable while the UTC date of its
    `fetched_at` is today (UTC) or one of the `max_history_days` UTC dates before it. This applies
    to the latest forecast, the run list and a run fetched by ID. Requests for older runs only
    receive `403`.

    ### Oracle scores

    | Plan    | Max numeric `days` | `days=all` (full retained history) |
    |---------|--------------------|------------------------------------|
    | Starter | 7                  | No                                 |
    | Pro     | 30                 | No                                 |
    | Clanker | 90                 | Yes                                |

    When `days` is omitted, the default lookback is 7 scored local days. Numeric values are
    clamped to the plan maximum. Use `/api/v1/stations/{station_id}/oracle-score-days` to fetch
    daily score components once and derive exact local rolling windows from included rows.

    ### REST observations

    Historical `GET /api/v1/stations/{station_id}/observations` returns **1-minute** points for all
    paid API plans. The response field `resolution` is always `"1m"`.

    | Plan    | Resolution | Max `start`–`end` span per request |
    |---------|------------|-----------------------------------|
    | Starter | 1 minute   | 7 days                            |
    | Pro     | 1 minute   | 7 days                            |
    | Clanker | 1 minute   | 7 days                            |

    ### Official reports

    All paid API subscriptions (Starter, Pro, Clanker) may call `/api/v1/stations/{station_id}/reports*`.
    Report history date ranges use the same `max_history_days` window as other history endpoints.

    ### Forecast models

    Paid API subscribers receive every forecast model available at a station on forecast endpoints.

    `GET /api/v1/forecasts/models` exposes `is_public` for the three models also available on the
    free web UI without a paid subscription: `nws_gridpoint`, `ncep_nbm_conus`, and `gfs_global`.
    Calling forecast endpoints through the external API still requires a paid plan.

    ### External WebSocket (real-time push)

    Paid API keys receive **realtime 1-minute** observation events. `/ws/api/1m` is the canonical
    stream; `/ws/api/5m` is a legacy compatibility path with the **same** unthrottled 1-minute
    cadence (the name is not a 5-minute aggregation tier).

    | Plan    | Endpoints                    | Max market subscriptions (all connections) | Max neighbour subscriptions (all connections) |
    |---------|------------------------------|--------------------------------------------|-----------------------------------------------|
    | Starter | `/ws/api/1m`, `/ws/api/5m`   | 6                                          | 30                                            |
    | Pro     | `/ws/api/1m`, `/ws/api/5m`   | 60                                         | 300                                           |
    | Clanker | `/ws/api/1m`, `/ws/api/5m`   | 200                                        | Unlimited                                     |

    Market subscriptions are cities, market stations and hourly indexes. Neighbour subscriptions
    are observation-only stations (weather-map and nowcast neighbours, not listed by
    `GET /api/v1/stations`); they do not use market slots. Stations are subscribed one code at a
    time and the server picks the quota for each code. Within the `stations` list, the newly
    requested market stations are accepted or rejected together, and so are the newly requested
    neighbours; `cities`, `stations` and `hourly_indexes` are checked in turn, so one field can be
    accepted and a later one rejected. A full quota rejects with a reason naming it and its
    limit, e.g. `neighbour subscription limit exceeded (limit 30)`.

    Each hourly index city subscribed with `hourly_indexes` costs 1 subscription (member
    observations and the forecast station's forecast and oracle events come with it). Poll the
    hourly index REST endpoints only for cold starts and history; a `429` there means the
    WebSocket is the live path.

    Obtain a ticket via `POST /api/v1/ws-ticket`. Payload shapes and limits are in
    [WebSocket docs](/docs/ws/). Product UI streaming (`/ws/ui`, including the free-tier 3-minute
    delivery delay) is documented there but is not part of the external API contract.

    ## Forecast data files

    Forecasts are read as files. Every forecast run is published as one JSON file on the data host
    `https://data.minutetemp.com`, served through a CDN. A file holds the whole run: its metadata
    and every forecast step with every weather field collected. A file never changes once it is
    written, and file requests do not count against the API rate limits.

    `GET /api/v1/stations/{station_id}/forecast` and
    `GET /api/v1/stations/{station_id}/forecast/runs/{run_id}` are deprecated in favour of the
    files. They keep working with unchanged responses. `GET /api/v1/stations/{station_id}/forecast/runs`
    is not deprecated: it is how you find the files of past runs.

    ### Getting forecast data

    Three pieces fit together:

    - The WebSocket event `forecast_updated` says a new run exists and carries `path`, the path of
      its file.
    - `GET /api/v1/data/access` returns `base_url` and one token (`query`) per UTC date your plan
      may read.
    - A file's URL is `base_url` + `path` + `?` + the `query` of the grant for the path's date.

    **1. Follow live forecasts over the WebSocket.** Connect to `/ws/api/1m` and subscribe to
    cities or stations as described in the [WebSocket docs](/docs/ws/). Each new run arrives as:

    ```json
    {"type": "forecast_updated", "slug": "chi", "station_id": "KMDW", "model_id": "ncep_hrrr_conus",
     "version": "2026-10-12T17:04:11Z", "run_id": "0199d2a0-0000-7000-8000-000000000001",
     "path": "/private/v1/forecasts/2026/10/12/KMDW/ncep_hrrr_conus/0199d2a0-0000-7000-8000-000000000001.json"}
    ```

    Get the tokens once and keep them while they are valid:

    ```bash
    curl -s -H "X-API-Key: $MINUTETEMP_API_KEY" https://api.minutetemp.com/api/v1/data/access
    ```

    ```json
    { "data": {
        "base_url": "https://data.minutetemp.com",
        "expires_at": "2026-10-12T18:00:00Z",
        "forecasts": [
          { "date": "2026-10-12", "prefix": "/private/v1/forecasts/2026/10/12/", "query": "1760288400-c2lnbmF0dXJl" },
          { "date": "2026-10-13", "prefix": "/private/v1/forecasts/2026/10/13/", "query": "1760288400-b3RoZXJzaWc" } ],
        "public": { "chart_prefix": "/public/v1/chart/", "forecasts_prefix": "/public/v1/forecasts/" } } }
    ```

    (The `forecasts` list is shortened here; it has one entry per readable date.) Then fetch the
    file named by the event:

    ```bash
    curl -s "https://data.minutetemp.com/private/v1/forecasts/2026/10/12/KMDW/ncep_hrrr_conus/0199d2a0-0000-7000-8000-000000000001.json?1760288400-c2lnbmF0dXJl"
    ```

    In code:

    ```text
    access = GET /api/v1/data/access

    on WebSocket message m with m.type == "forecast_updated":
        if m.path is absent: skip           # no file advertised for this run
        run = fetch_file(m.path)

    fetch_file(path):
        if now > access.expires_at - 2 minutes: access = GET /api/v1/data/access
        grant = the entry of access.forecasts whose prefix path starts with
        if no grant:
            access = GET /api/v1/data/access; look again
            if still no grant: return OUTSIDE_PLAN    # the date is not in your plan's window
        response = GET access.base_url + path + "?" + grant.query
        if response is 429: wait until the block ends (60 seconds), then GET again
        if response is 403:
            access = GET /api/v1/data/access; take the date's grant again; GET once more
            if still 403: return DENIED              # token refused on a granted date: report it
        if response is 404: return GONE              # expired (oldest dates) or never published
        if response is not 200: return FAILED        # retry later
        return parse_json(response)                  # only a 200 body is a forecast file
    ```

    A path is sent once, with the event of a newly stored run. After a reconnect, list the runs
    you missed (step 2) instead of waiting for events.

    **2. Pull history for your plan window.** List runs with
    `GET /api/v1/stations/{station_id}/forecast/runs` and fetch each run's `object_path` the same
    way.

    Two limits apply, and they differ. The dates you may read run from UTC midnight
    `max_history_days` days before today through now (the table below), which is longer than
    `max_history_days` days. One request's `start`–`end` span may be at most `max_history_days`
    days, and `end` defaults to now, so a request from the start of the window without an `end`
    is `400`. Ask for the window in bounded chunks, for example one UTC day per request, with
    `start` and `end` both set, and page each chunk with `cursor`:

    ```bash
    curl -s -H "X-API-Key: $MINUTETEMP_API_KEY" \
      "https://api.minutetemp.com/api/v1/stations/KMDW/forecast/runs?model_id=ncep_hrrr_conus&start=2026-10-05T00:00:00Z&end=2026-10-05T23:59:59.999Z"
    ```

    ```text
    for each UTC date D from the first date of access.forecasts through today:
        page = GET .../forecast/runs?start=D T00:00:00Z&end=D T23:59:59.999Z
        repeat until page.next_cursor is null:
            for each run in page:
                if run.object_path is absent: skip     # no file for this run
                result = fetch_file(run.object_path)
                if result is a forecast file: save(result)
            page = GET the same URL with cursor=page.next_cursor
    ```

    A file's date (`YYYY/MM/DD` in its path) is the UTC date the run was fetched (`fetched_at`).
    `forecasts` has one grant per UTC fetch date inside the plan's history window (the same
    window the forecast run endpoints apply; see **Plan Limits**), through tomorrow (UTC), so a
    response taken before midnight still covers runs fetched after it:

    | Plan    | UTC fetch dates readable                |
    |---------|-----------------------------------------|
    | Starter | today and the 7 dates before it         |
    | Pro     | today and the 30 dates before it        |
    | Clanker | today and the 90 dates before it        |

    Private files are deleted 90 days after they are written, so a file on the oldest dates of a
    90-day window may already be gone (`404`). Downloading a long history runs into the data
    host's request limit (step 3): keep under 100 requests a minute from one IP address.

    **3. Refresh tokens and handle errors.**

    - A token is valid for one UTC date, shared by every file of that date, and stops working at
      `expires_at` (at most one hour after it was issued). The access response may be reused for
      up to five minutes (`Cache-Control: private, max-age=N`, never past the next UTC midnight);
      do not share it between API keys.
    - Fetch a new access response shortly before `expires_at`, when the date you need is not
      listed (a new date appears at UTC midnight), and after a file request answers `403`.
    - `query` is opaque and must be the whole query string: no parameter name and no other
      parameters.
    - Whether your plan may read a date is decided by the access response: a date outside the
      plan's window has no entry in `forecasts`. The data host only checks that a token is a
      valid, unexpired signature for the file's date.
    - `403`: the request under `/private/` had no valid token for that file's date (missing,
      expired, or for another date). The body is an HTML page from the CDN, not JSON. Refresh
      the access response and retry once with the date's new grant. A `403` that persists for a
      date the access response grants is an authorization failure, not a plan limit: stop
      retrying and report it.
    - `404`: no file at that path: on the oldest dates of a 90-day window the file has expired
      (private files are kept 90 days), otherwise the path was never published. The body is not
      JSON. Do not retry.
    - `429`: the data host allows 100 requests per 60 seconds per IP address. Over that, requests
      from the address are blocked for 60 seconds with `429`; wait, then continue more slowly.
    - `GET /api/v1/data/access` answers `404` (`DATA_ACCESS_NOT_ENABLED`) if file delivery is
      switched off. Until it answers `200` again, the deprecated forecast endpoints still serve
      the runs.

    **4. Chart files.** The access response also carries a `chart` grant and
    `public.chart_prefix`. They are for the per-station chart file of the MinuteTemp web app. Its
    format is not part of this API and may change, and the API WebSocket has no event for it.
    Use the run files.

    **5. Free public files.** A run of one of the three free models (`nws_gridpoint`,
    `ncep_nbm_conus`, `gfs_global`) also gets a copy under `public.forecasts_prefix`
    (`/public/v1/forecasts/`), at the same path below the prefix as its private file, when the
    file is written within 24 hours of `fetched_at` (a backfilled run included). A public file
    needs no token:

    ```bash
    curl -s "https://data.minutetemp.com/public/v1/forecasts/2026/10/12/KMDW/gfs_global/0199d2a0-0000-7000-8000-000000000002.json"
    ```

    It has the same format with `hourly` cut 48 hours after `fetched_at`. Public files are
    deleted one day after they are written, so read them within 24 hours; use the private file
    for anything older.

    ### Paths

    ```
    /private/v1/forecasts/YYYY/MM/DD/{station}/{model}/{run}.json
    /public/v1/forecasts/YYYY/MM/DD/{station}/{model}/{run}.json
    ```

    `YYYY/MM/DD` is the UTC date the run was fetched (`fetched_at`), `{station}` the station code
    (for example `KMDW`), `{model}` the model ID and `{run}` the run ID.

    Do not build a private path yourself: take it from `path` on the WebSocket `forecast_updated`
    event or from `object_path` on a forecast run summary
    (`GET /api/v1/stations/{station_id}/forecast/runs`). A run without a path has no file (some
    runs stored before file delivery began); read it through the deprecated
    `GET /api/v1/stations/{station_id}/forecast/runs/{run_id}`.

    ### Format (`minutetemp.forecast_run/v1`)

    The file is the `ForecastRunFile` schema of this document:

    ```json
    {
      "schema": "minutetemp.forecast_run/v1",
      "run": { "id": "0199d2a0-0000-7000-8000-000000000001", "revision": 1,
        "station_id": "5f0c1d3e-7a52-4c59-9d0b-3f1f4f1c2a10", "station_code": "KMDW",
        "model_id": "ncep_hrrr_conus",
        "forecast_time": "2026-10-12T05:00:00Z", "fetched_at": "2026-10-12T17:04:11.123456Z",
        "timezone": "America/Chicago", "utc_offset_seconds": -18000,
        "data_hash": "…", "step_seconds": 3600, "backfilled": false },
      "units": { "temperature_2m_c": "C", "temperature_2m_f": "F", "wind_speed_10m": "km/h",
        "pressure_msl": "hPa", "precipitation": "mm", "visibility": "m" },
      "count": 168,
      "hourly": [ { "time": "2026-10-12T05:00:00Z", "temperature_2m_c": 11.2, "temperature_2m_f": 52.2,
        "relative_humidity_2m": 71, "wind_speed_10m": 14.0, "precipitation": 0, "weather_code": 2 } ]
    }
    ```

    - `hourly` holds every step of the run in time order, `count` of them. `step_seconds` is the
      model's step (3600, or 900 for 15-minute models).
    - `temperature_2m_c` and `temperature_2m_f` are always present and are `null` when the model
      gave no temperature for the step. Any other weather field is left out when it has no value;
      zero is a value.
    - `units` gives the unit of each weather field by name. Unlike the forecast endpoints, wind
      speeds are in km/h and the file carries fields those endpoints leave out.
    - `revision` is always 1. `backfilled` is true for a run whose file was written some time
      after the run was fetched.
    - Parse by `schema` and ignore fields you do not know: fields may be added to `v1`.

    ### Caching

    A run's file never changes once written. Private files are served with
    `Cache-Control: public, max-age=31536000, immutable` and public files with
    `Cache-Control: public, max-age=86400`, so a file you have stored never needs to be fetched
    again. The token is checked before the CDN cache and is not part of the cache identity: a
    file fetched again with a newer token is the same cached file, and a cached file still needs a
    valid token. Keep the token out of your own cache keys too. `403` and `404` responses are not
    cached.

    ## Hourly index data files

    The hourly index forecast of every index city is also published as JSON files on the data
    host `https://data.minutetemp.com`, the same host, CDN and tokens as the forecast data files.
    A city's file holds both forecast models, HRRR 15-minute (`ncep_hrrr_conus_15min`) and RRFS
    15-minute (`ncep_rrfs_conus_15min`). File requests do not count against the API rate limits.

    `GET /api/v1/hourly-indexes/{city}/forecast` and `GET /api/v1/hourly-indexes/{city}/forecast/history`
    are deprecated in favour of these files. They keep working with unchanged responses.
    `GET /api/v1/hourly-indexes/{city}/forecast-accuracy` and the rest of **Hourly Indexes** are not
    deprecated.

    ### Getting hourly index data

    Three pieces fit together:

    - The WebSocket event `index_file_updated` says a city's current file was rewritten and carries
      its `path` and `generation`. The `hourly_index_snapshot` names the current file in `file`.
    - `GET /api/v1/data/access` returns `base_url`, the `hourly_indexes` grant for the current files
      and one `hourly_index_days` grant per UTC date your plan may read.
    - A file's URL is `base_url` + `path` + `?` + the `query` of the grant whose `prefix` the path
      starts with.

    **1. Follow live index forecasts over the WebSocket.** Connect to `/ws/api/1m` and subscribe
    with `hourly_indexes` as described in the [WebSocket docs](/docs/ws/):

    ```json
    {"type": "subscribe", "hourly_indexes": ["chicago"]}
    ```

    The `hourly_index_snapshot` carries the current file, and every rewrite arrives as:

    ```json
    {"type": "index_file_updated", "city": "chicago", "generation": 88123,
     "path": "/private/v1/hourly-indexes/chicago.json", "as_of": "2026-10-14T17:15:00Z"}
    ```

    `index_file_updated` has no `seq`: it is not part of the city's event order and is never
    replayed by `resume`, so leave it out of gap detection. Fetch the file with the
    `hourly_indexes` grant of the access response (see **Getting forecast data** for the access
    response itself; `forecasts` and most day grants are left out here):

    ```json
    { "data": {
        "base_url": "https://data.minutetemp.com",
        "expires_at": "2026-10-14T18:00:00Z",
        "hourly_indexes": { "prefix": "/private/v1/hourly-indexes/", "query": "1760461200-c2lnbmF0dXJl" },
        "hourly_index_days": [
          { "date": "2026-10-13", "prefix": "/private/v1/hourly-index-days/2026/10/13/", "query": "1760461200-ZGF5MTM" },
          { "date": "2026-10-14", "prefix": "/private/v1/hourly-index-days/2026/10/14/", "query": "1760461200-ZGF5MTQ" } ],
        "public": { "chart_prefix": "/public/v1/chart/", "forecasts_prefix": "/public/v1/forecasts/",
                    "hourly_indexes_prefix": "/public/v1/hourly-indexes/" } } }
    ```

    ```bash
    curl -s "https://data.minutetemp.com/private/v1/hourly-indexes/chicago.json?1760461200-c2lnbmF0dXJl"
    ```

    In code:

    ```text
    held = {}                                   # city -> the file you hold

    on hourly_index_snapshot s with s.file:     read_current(s.city, s.file.generation)
    on index_file_updated m:                    read_current(m.city, m.generation)
    every 5 minutes, for each city:             read_current(city, 0)

    read_current(city, announced):
        if access has no hourly_indexes grant: use the deprecated endpoints for now
        file = fetch_file("/private/v1/hourly-indexes/" + city + ".json")   # as in Getting forecast data
        if file is not a file: keep what you hold
        if file.generation <= held[city].generation: ignore it   # older or the same
        else if now - file.as_of > 1 hour: ignore it           # the publisher is behind
        else: held[city] = file
        if held[city].generation < announced: read_current(city, announced) again in 62 seconds, once
    ```

    The CDN keeps a current file up to 60 seconds, so a read right after an event can return the
    previous generation; that is what the one re-read 62 seconds later is for. The 5-minute
    revalidation covers an event lost in a reconnect.

    The socket stays authoritative for what it carries: `forecast_index` and `forecast_bias` are
    the live forecast and its bias, and the file is authoritative for the stitched past.
    A file's `live` curve is the forecast the socket published, without bias. Use it only for a
    model the socket has not delivered; never let a file undo a newer `forecast_bias`, or bring
    back a model that a `forecast_index` removed.

    **2. Pull history for your plan window.** A day file holds one UTC date of one city, both
    models, at `/private/v1/hourly-index-days/YYYY/MM/DD/{city}.json`, read with the grant of its
    date in `hourly_index_days`:

    ```bash
    curl -s "https://data.minutetemp.com/private/v1/hourly-index-days/2026/10/13/chicago.json?1760461200-ZGF5MTM"
    ```

    ```text
    for each grant g of access.hourly_index_days:
        day = fetch_file(g.prefix + city + ".json")
        if day is a file: save(day)
        if 404 and g.date is today or yesterday: the current file covers it (span_start below)
    ```

    A date's file is written once the date is over (from about 00:05 UTC the next day), so today has
    no day file and yesterday's may not be written yet. The private current file covers both: it
    starts at `span_start`, 00:00 UTC yesterday (or the day before, while that day's file is not
    written yet), and runs to the last forecast step. A day file can be rewritten in place when a past run is
    corrected (a higher `generation`); re-read it if you need the latest.

    | Plan    | UTC dates with a day-file grant   |
    |---------|-----------------------------------|
    | Starter | today and the 7 dates before it   |
    | Pro     | today and the 30 dates before it  |
    | Clanker | today and the 90 dates before it  |

    Day files are kept 92 days. Dates before 2026-10-12 are marked `backfilled: true` and can be
    partial: fewer member stations had every run then.

    Each file's series is the answer of the deprecated history endpoint for its range. Day series
    put end to end can differ from one history request across a midnight or a calibration
    change, because a request's bias and member set are taken over its own window. For an exact
    answer over another window, recompute from `points` (the stitched member temperatures each
    series was built from) or ask the deprecated endpoint.

    **3. Refresh tokens and handle errors.** Tokens, `403`, `404` and `429` work as described in
    **Getting forecast data**, with two differences:

    - The current files' token covers every city's current file and is not tied to a date. It is
      absent from the access response when the plan has no file access, while hourly index files
      are not offered, and while the files are not known to be current. Without it, use the
      deprecated endpoints.
    - `404` for a day file of today or yesterday means it is not written yet (see step 2); for an
      older date, that the date has no file (expired, or no runs).

    If `GET /api/v1/data/access` answers `404` (`DATA_ACCESS_NOT_ENABLED`), or has no
    `hourly_indexes` grant and no `hourly_index_days`, hourly index files are not offered: the
    deprecated endpoints still serve the forecast.

    **4. Free public copies.** Index-only cities (no Kalshi hourly series yet) also have a public
    copy of the current file under `public.hourly_indexes_prefix`, which needs no token:

    ```bash
    curl -s "https://data.minutetemp.com/public/v1/hourly-indexes/dfw.json"
    ```

    It has the same format without `points`, and its `stitched` series starts 24 hours before its
    `as_of`. It is rewritten at most every 15 minutes and deleted one day after it is written;
    ignore a copy whose `as_of` is more than an hour old. A city that gains a Kalshi series loses its
    public copy. There are no public day files.

    ### Hourly index paths

    ```
    /private/v1/hourly-indexes/{city}.json                  current file, every active index city
    /public/v1/hourly-indexes/{city}.json                   public copy, index-only cities
    /private/v1/hourly-index-days/YYYY/MM/DD/{city}.json    day file, UTC date of the steps
    ```

    `{city}` is the index city ID of `GET /api/v1/hourly-indexes` (for example `chicago`).

    ### Hourly index format

    Current file (`minutetemp.hourly_index/v1`):

    ```json
    { "schema": "minutetemp.hourly_index/v1", "city": "chicago", "generation": 88123,
      "as_of": "2026-10-14T17:15:00Z", "span_start": "2026-10-13T00:00:00Z",
      "models": [
        { "model_id": "ncep_hrrr_conus_15min",
          "stitched": { "steps": ["2026-10-13T00:00:00Z"], "value_f": [61.4], "adjusted_f": [61.9],
                        "quorum_met": [true], "partial": [false], "members": [],
                        "points": { "stations": ["KMDW"], "steps": ["2026-10-13T00:00:00Z"],
                                    "fetched_at": ["2026-10-12T23:47:10Z"], "temp_c": [[16.3]], "run": [[0]] } },
          "live": { "run_time": "2026-10-14T17:00:00Z", "fetched_at": "2026-10-14T17:04:11Z",
                    "config_version": "chicago-temperature-v1.0-cal-20261012", "missing_members": [],
                    "steps": [], "value_f": [], "members": [], "settles": [] } } ] }
    ```

    Day file (`minutetemp.hourly_index_day/v1`):

    ```json
    { "schema": "minutetemp.hourly_index_day/v1", "city": "chicago", "date": "2026-10-13",
      "generation": 87990, "as_of": "2026-10-14T00:05:02Z", "backfilled": false,
      "models": [ { "model_id": "ncep_hrrr_conus_15min", "steps": [], "value_f": [], "adjusted_f": [],
                    "quorum_met": [], "partial": [], "members": [], "points": {} } ] }
    ```

    - Every model has an entry. A series (`stitched`, or a day file's model) has the fields of the
      deprecated `GET /api/v1/hourly-indexes/{city}/forecast/history` response, aligned to `steps`;
      a step with no member forecast is left out. A day file's series covers 00:00 to 23:45 UTC of
      its date.
    - `points` are the stitched member temperatures (°C) the series was computed from: per station
      of `stations`, aligned to `points.steps`, `temp_c` and `run` (an index into `fetched_at`, the
      run the temperature came from); null where the station has none. Present in day files and
      the private current file only.
    - `live` is the model's latest forecast curve as published on the WebSocket, without bias;
      null when the model has no forecast now.
    - `generation` orders the files of a city (or of a city and date); `as_of` means every change
      committed before it is in the file.
    - Parse by `schema` and ignore fields you do not know: fields may be added to `v1`.

    ### Hourly index caching

    Current files are served with `Cache-Control: public, max-age=15, s-maxage=60`, day files with
    `public, max-age=300, s-maxage=3600`. Tokens are not part of the cache identity, as for the
    forecast files.

servers:
  - url: https://api.minutetemp.com
    description: Production

security:
  - apiKeyHeader: []
  - bearerAuth: []

tags:
  - name: Cities
    description: City metadata and slugs for filtering
  - name: Stations
    description: Weather station metadata and locations
  - name: Observations
    description: Real-time and historical weather observations
  - name: Reports
    description: Official station reports (CLI, DSM, METAR)
  - name: Weather Events
    description: Active and recently ended station weather events derived from METAR/SPECI data
  - name: Forecasts
    description: Temperature forecasts from multiple weather models
  - name: Markets
    description: Kalshi and Polymarket prediction market data
  - name: Hourly Indexes
    description: Kalshi Weather Index cities, per-minute index values, hour settlements and calibrations
  - name: WebSocket
    description: WebSocket ticket for real-time push
  - name: Limits
    description: Effective API limits for the authenticated key; see Plan Limits in the API overview

components:
  securitySchemes:
    apiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: API key passed in X-API-Key header
    bearerAuth:
      type: http
      scheme: bearer
      description: API key passed as Bearer token

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              example: STATION_NOT_FOUND
            message:
              type: string
              example: "Station with ID 'xyz' was not found"
            request_id:
              type: string
              example: req_abc123

    Station:
      type: object
      properties:
        id:
          type: string
          format: uuid
        station_id:
          type: string
          description: ICAO code
          example: KMDW
        name:
          type: string
          example: Chicago Midway Airport
        latitude:
          type: number
          example: 41.7861
        longitude:
          type: number
          example: -87.7522
        kalshi_active:
          type: boolean
        robinhood_active:
          type: boolean
        polymarket_active:
          type: boolean
          description: Settlement station of a Polymarket (polymarket.com) temperature market.
        polymarket_us_active:
          type: boolean
          description: Settlement station of a Polymarket US (CFTC-regulated US exchange) temperature market. These settle on the NWS Daily Climate Report (CLI).
        ibkr_active:
          type: boolean
        wu_enabled:
          type: boolean
          description: True when Weather Underground location mapping is configured for this station.

    StationFlat:
      type: object
      description: Station with parent city context
      properties:
        id:
          type: string
          format: uuid
        station_id:
          type: string
          description: ICAO code
          example: KMDW
        name:
          type: string
          example: Chicago Midway Airport
        latitude:
          type: number
          example: 41.7861
        longitude:
          type: number
          example: -87.7522
        kalshi_active:
          type: boolean
        robinhood_active:
          type: boolean
        polymarket_active:
          type: boolean
          description: Settlement station of a Polymarket (polymarket.com) temperature market.
        polymarket_us_active:
          type: boolean
          description: Settlement station of a Polymarket US (CFTC-regulated US exchange) temperature market. These settle on the NWS Daily Climate Report (CLI).
        ibkr_active:
          type: boolean
        wu_enabled:
          type: boolean
          description: True when Weather Underground location mapping is configured for this station.
        city_slug:
          type: string
          example: chi
        city_name:
          type: string
          example: Chicago

    City:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        slug:
          type: string
        state:
          type: string
        timezone:
          type: string
        stations:
          type: array
          items:
            $ref: "#/components/schemas/Station"
      example:
        id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
        name: New York City
        slug: nyc
        state: NY
        timezone: America/New_York
        stations:
          - id: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
            station_id: KNYC
            name: New York City Central Park
            latitude: 40.781578
            longitude: -73.950516
            kalshi_active: true
            robinhood_active: false
            polymarket_active: false
            polymarket_us_active: true
            ibkr_active: true
          - id: "c3d4e5f6-a7b8-9012-cdef-123456789012"
            station_id: KLGA
            name: LaGuardia Airport
            latitude: 40.7772
            longitude: -73.8726
            kalshi_active: false
            robinhood_active: true
            polymarket_active: true
            polymarket_us_active: false
            ibkr_active: false

    Observation:
      type: object
      properties:
        observation_time:
          type: string
          format: date-time
        preliminary:
          type: boolean
          description: True when this is a low-latency observation emitted before the final durable flush.
          default: false
        temperature_f:
          type: number
          nullable: true
          description: Temperature in Fahrenheit
          example: 72.5
        temperature_c:
          type: number
          nullable: true
          description: Temperature in Celsius
          example: 22.5
        dewpoint:
          type: number
          nullable: true
          description: Dew point in Celsius
        heat_index:
          type: number
          nullable: true
        wind_chill:
          type: number
          nullable: true
        relative_humidity:
          type: number
          nullable: true
        visibility:
          type: number
          nullable: true
          description: Visibility in meters. Omitted when unknown.
        barometric_pressure:
          type: number
          nullable: true
        sea_level_pressure:
          type: number
          nullable: true
        wind_speed:
          type: number
          nullable: true
        wind_direction:
          type: number
          nullable: true
        wind_gust:
          type: number
          nullable: true
        text_description:
          type: string
          example: Partly Cloudy
        precipitation_1h:
          type: number
          nullable: true
        precipitation_3h:
          type: number
          nullable: true
        precipitation_6h:
          type: number
          nullable: true
        weather_fields_observed_at:
          type: string
          format: date-time
          nullable: true
          description: >
            Set when the non-temperature fields (dewpoint, humidity, pressure, wind,
            precipitation) were carried forward from an earlier measurement
            rather than measured at `observation_time`. Holds the observation time of
            the oldest carried value. Absent when every field was measured at
            `observation_time`. Stations with 1-minute temperature and slower weather
            fields (5-minute HFMETAR, or a 30- to 60-minute METAR) carry the latest
            values until the next update, for at most 75 minutes.
        is_locf:
          type: boolean
          description: Compatibility field retained for older clients. New backend-produced observation payloads set this to false.
          default: false
        is_from_report:
          type: boolean
          description: True when this observation originates from an official station report.
          default: false
        report_type:
          allOf:
            - $ref: "#/components/schemas/ReportType"
          nullable: true
          description: Report type source when `is_from_report` is true.
        source_report_id:
          type: string
          nullable: true
          description: Internal `station_reports.id` for provenance linking.
        temp_min_f:
          type: number
          nullable: true
          description: >
            Lower bound of the temperature precision range (Fahrenheit).
            Derived from integer Celsius measurement precision (+/-0.5C).
        temp_max_f:
          type: number
          nullable: true
          description: >
            Upper bound of the temperature precision range (Fahrenheit).
            Derived from integer Celsius measurement precision (+/-0.5C).
        temp_min_c:
          type: number
          nullable: true
          description: >
            Lower bound of the temperature precision range (Celsius).
            Derived from integer Celsius measurement precision (+/-0.5C).
        temp_max_c:
          type: number
          nullable: true
          description: >
            Upper bound of the temperature precision range (Celsius).
            Derived from integer Celsius measurement precision (+/-0.5C).

    ReportType:
      type: string
      description: Official station report type.
      enum: [cli, dsm, metar_tgroup, metar_6hr]

    StationReport:
      type: object
      properties:
        report_id:
          type: string
          description: Internal report row identifier.
        report_revision:
          type: integer
          description: Monotonic revision for report corrections.
        report_updated_at:
          type: string
          format: date-time
          description: Last update timestamp for this report row.
        report_type:
          $ref: "#/components/schemas/ReportType"
        report_date:
          type: string
          format: date
          description: Target local calendar date represented by the report.
          example: 2026-02-27
        issuance_time:
          type: string
          format: date-time
          description: Time the source system issued this report.
        wmo_emit_time:
          type: string
          format: date-time
          nullable: true
          description: WMO text-product emit time when available; distinct from report observation/issuance time.
        fetched_at:
          type: string
          format: date-time
          description: Time MinuteTemp fetched this report.
        live_published_at:
          type: string
          format: date-time
          nullable: true
          description: Time MinuteTemp handed this report to Redis Pub/Sub when available.
        max_temp_f:
          type: number
          nullable: true
        max_temp_c:
          type: number
          nullable: true
        max_temp_time_utc:
          type: string
          format: date-time
          nullable: true
          description: UTC timestamp when the report says the daily high occurred.
        min_temp_f:
          type: number
          nullable: true
        min_temp_c:
          type: number
          nullable: true
        min_temp_time_utc:
          type: string
          format: date-time
          nullable: true
          description: UTC timestamp when the report says the daily low occurred.
        temp_f:
          type: number
          nullable: true
          description: Point temperature for `metar_tgroup` reports.
        temp_c:
          type: number
          nullable: true
          description: Point temperature for `metar_tgroup` reports.
        provider:
          type: string
          description: Upstream provider identifier.
          example: nwscli
        source_url:
          type: string
          nullable: true
          description: Source URL fetched to produce the report.

    WeatherEventSource:
      type: object
      properties:
        metar_type:
          type: string
        flight_category:
          type: string
        wx_string:
          type: string
        wx_token:
          type: string
        wind_speed_kt:
          type: number
          nullable: true
        wind_gust_kt:
          type: number
          nullable: true
        peak_wind_kt:
          type: number
          nullable: true
        peak_wind_direction:
          type: integer
          nullable: true
        visibility_mi:
          type: number
          nullable: true
        cb_location:
          type: string

    WeatherEvent:
      type: object
      properties:
        id:
          type: string
          format: uuid
        event_type:
          type: string
          example: thunderstorm
        tier:
          type: string
          enum: [tier1, tier2, tier3]
        state:
          type: string
          enum: [active, ended]
        name:
          type: string
        badge:
          type: string
        detail:
          type: string
        summary:
          type: string
        started_at:
          type: string
          format: date-time
        last_confirmed_at:
          type: string
          format: date-time
        ended_at:
          type: string
          format: date-time
          nullable: true
        source:
          $ref: "#/components/schemas/WeatherEventSource"

    StationWeatherEvents:
      type: object
      properties:
        city:
          $ref: "#/components/schemas/City"
        station:
          $ref: "#/components/schemas/Station"
        active:
          type: array
          items:
            $ref: "#/components/schemas/WeatherEvent"
        recent_ended:
          type: array
          items:
            $ref: "#/components/schemas/WeatherEvent"
        generated_at:
          type: string
          format: date-time

    ReportClockSchedule:
      type: object
      description: Clock-based report schedule definition.
      properties:
        basis:
          type: string
          enum: [utc, local]
          description: Clock basis for `hour` and `minute`.
        hour:
          type: integer
          minimum: 0
          maximum: 23
        minute:
          type: integer
          minimum: 0
          maximum: 59
        utc_hour:
          type: integer
          minimum: 0
          maximum: 23
        utc_minute:
          type: integer
          minimum: 0
          maximum: 59
        local_hour:
          type: integer
          minimum: 0
          maximum: 23
        local_minute:
          type: integer
          minimum: 0
          maximum: 59
        label:
          type: string

    ReportIntervalSchedule:
      type: object
      description: Interval-based report schedule definition.
      properties:
        interval_minutes:
          type: integer
          minimum: 1
        basis:
          type: string
          enum: [utc, local]
          description: Clock basis for `minute`.
        minute:
          type: integer
          minimum: 0
          maximum: 59
        utc_minute:
          type: integer
          minimum: 0
          maximum: 59
        local_minute:
          type: integer
          minimum: 0
          maximum: 59
        label:
          type: string
      required: [interval_minutes]

    ReportMultiHourSchedule:
      type: object
      description: Multi-hour schedule definition for repeated daily clock slots.
      properties:
        utc_hours:
          type: array
          items:
            type: integer
            minimum: 0
            maximum: 23
        local_hours:
          type: array
          items:
            type: integer
            minimum: 0
            maximum: 23
        utc_minute:
          type: integer
          minimum: 0
          maximum: 59
        local_minute:
          type: integer
          minimum: 0
          maximum: 59
        label:
          type: string

    ReportSchedules:
      type: object
      description: |
        Per-report-type expected MinuteTemp UI arrival schedules from station configuration,
        including normal source and ingestion delay. Values can be a single schedule object or
        an array of schedules.
      additionalProperties:
        anyOf:
          - $ref: "#/components/schemas/ReportClockSchedule"
          - $ref: "#/components/schemas/ReportIntervalSchedule"
          - $ref: "#/components/schemas/ReportMultiHourSchedule"
          - type: array
            items:
              anyOf:
                - $ref: "#/components/schemas/ReportClockSchedule"
                - $ref: "#/components/schemas/ReportIntervalSchedule"
                - $ref: "#/components/schemas/ReportMultiHourSchedule"

    ForecastModel:
      type: object
      properties:
        id:
          type: string
          example: ncep_hrrr_conus
        name:
          type: string
          example: HRRR CONUS
        provider:
          type: string
          example: NCEP
        resolution:
          type: string
          example: 3km
        max_forecast:
          type: integer
          description: Maximum forecast hours
          example: 48
        is_public:
          type: boolean
          description: |
            True for models available on the free web UI without a paid subscription
            (`nws_gridpoint`, `ncep_nbm_conus`, `gfs_global`). Paid API subscribers receive all models.

    HourlyForecast:
      type: object
      description: |
        Slim forecast point returned by the external `/api/v1/*` forecast routes.
        Temperature fields are the primary guaranteed values when a point is present. Secondary
        weather fields may be null or omitted when the upstream model did not return them or when
        that station capability did not request them.
      properties:
        time:
          type: string
          format: date-time
        temperature_2m_f:
          type: number
          nullable: true
          description: Temperature at 2m in Fahrenheit
        temperature_2m_c:
          type: number
          nullable: true
          description: Temperature at 2m in Celsius
        apparent_temperature_f:
          type: number
          nullable: true
        relative_humidity_2m:
          type: number
          nullable: true
        dew_point_2m:
          type: number
          nullable: true
        pressure_msl:
          type: number
          nullable: true
        wind_speed_10m:
          type: number
          nullable: true
        wind_direction_10m:
          type: number
          nullable: true
        wind_gusts_10m:
          type: number
          nullable: true
        cloud_cover:
          type: number
          nullable: true
        precipitation_probability:
          type: number
          nullable: true
        weather_code:
          type: integer
          nullable: true

    ForecastBundle:
      type: object
      description: Forecast data grouped by model and run
      properties:
        model_id:
          type: string
          example: ncep_hrrr_conus
        forecast_run:
          type: object
          properties:
            id:
              type: string
            fetched_at:
              type: string
              format: date-time
            timezone:
              type: string
              example: America/Chicago
            utc_offset_seconds:
              type: integer
        hourly:
          type: array
          items:
            $ref: "#/components/schemas/HourlyForecast"

    OracleScoreMode:
      type: string
      enum: [overall, day_ahead, day_of]
      description: |
        `overall` evaluates all forecast runs that targeted the scored local day.
        `day_ahead` evaluates only runs fetched during the previous local day.
        `day_of` evaluates only runs fetched during the scored local day.

    OracleRankBy:
      type: string
      enum: [combined, high, low]
      description: Which MAE metric controls the returned ranking order.

    OracleErrorDistribution:
      type: object
      description: |
        Mergeable signed temperature errors in Fahrenheit (forecast minus observed; positive is warm).
        Each qualifying forecast run contributes one high and one low sample. There are 23 counters:
        underflow below -10.5°F, 21 zero-centered 1°F bands, and overflow at or above +10.5°F.
        Interior bins include their lower edge and exclude their upper edge. Sum matching counters
        across days, not daily percentiles. Unlike MAE/bias (equal daily weighting), this is run-weighted.
        Null means unavailable, never zero error. Older scores are not fabricated after forecasts expire.
        Coverage is partial when this day_count is less than the model score's day_count.
      required: [version, bin_edges_f, high_counts, low_counts, sample_count, day_count]
      properties:
        version:
          type: string
          enum: [signed-error-f-v1]
        bin_edges_f:
          type: array
          minItems: 22
          maxItems: 22
          description: Finite boundaries -10.5, -9.5, ..., +10.5, in Fahrenheit.
          items:
            type: number
        high_counts:
          type: array
          minItems: 23
          maxItems: 23
          items:
            type: integer
            format: int64
            minimum: 0
        low_counts:
          type: array
          minItems: 23
          maxItems: 23
          items:
            type: integer
            format: int64
            minimum: 0
        sample_count:
          type: integer
          format: int64
          minimum: 1
          description: Sum of high_counts, also equal to the sum of low_counts; not their combined sum.
        day_count:
          type: integer
          minimum: 1
          description: Scored days with histogram coverage (one for a daily row).

    OracleModelScore:
      type: object
      properties:
        model_id:
          type: string
          example: ncep_hrrr_conus
        model_name:
          type: string
          example: HRRR CONUS
        is_public:
          type: boolean
        high_mae:
          type: number
          example: 1.4
        low_mae:
          type: number
          example: 2.1
        high_bias:
          type: number
          example: 0.8
        low_bias:
          type: number
          example: -0.6
        combined_mae:
          type: number
          example: 1.75
        day_count:
          type: integer
          example: 7
        error_distribution:
          # Explicit OAS 3.1 null; extensions keep the existing oapi-codegen output typed.
          nullable: true
          x-go-type: OracleErrorDistribution
          oneOf:
            - $ref: "#/components/schemas/OracleErrorDistribution"
            - enum: [null]

    OracleScoreData:
      type: object
      properties:
        station_id:
          type: string
          example: KMDW
        range_start:
          type: string
          format: date
        range_end:
          type: string
          format: date
        days_requested:
          type: integer
          example: 7
        all_time:
          type: boolean
          description: |
            Present and true when `days=all` was requested (Clanker tier). Indicates aggregates
            span from the earliest to latest retained scoring day available for the station.
        score_mode:
          $ref: "#/components/schemas/OracleScoreMode"
        rank_by:
          $ref: "#/components/schemas/OracleRankBy"
        scores:
          type: array
          items:
            $ref: "#/components/schemas/OracleModelScore"

    OracleScoreDayRow:
      type: object
      description: |
        One daily station/model/mode oracle scoring component row. Included rows contain enough
        components to recompute aggregate MAE and bias metrics locally:
        `high_mae = avg(high_abs_error_f)`, `low_mae = avg(low_abs_error_f)`,
        `combined_mae = avg(combined_abs_error_f)`, `high_bias = avg(high_error_f)`,
        `low_bias = avg(low_error_f)`, and `day_count = count(included rows)`.
        In `oracle-v1`, `combined_abs_error_f = (high_abs_error_f + low_abs_error_f) / 2`.
        `forecast_high_f` and `forecast_low_f` are daily mean forecast extrema across contributing
        runs when observed high/low values are available; `forecast_run_id` is null for multi-run
        daily averages.
      properties:
        valid_date:
          type: string
          format: date
          example: "2026-06-23"
        score_mode:
          $ref: "#/components/schemas/OracleScoreMode"
        model_id:
          type: string
          example: ncep_hrrr_conus
        model_name:
          type: string
          example: HRRR CONUS
        is_public:
          type: boolean
        forecast_high_f:
          type: number
          nullable: true
          example: 91.2
        forecast_low_f:
          type: number
          nullable: true
          example: 72.4
        observed_high_f:
          type: number
          nullable: true
          example: 90.0
        observed_low_f:
          type: number
          nullable: true
          example: 71.0
        high_error_f:
          type: number
          nullable: true
          description: Signed daily high error in Fahrenheit (`forecast_high_f - observed_high_f`).
          example: 1.2
        low_error_f:
          type: number
          nullable: true
          description: Signed daily low error in Fahrenheit (`forecast_low_f - observed_low_f`).
          example: 1.4
        high_abs_error_f:
          type: number
          nullable: true
          example: 1.2
        low_abs_error_f:
          type: number
          nullable: true
          example: 1.4
        combined_abs_error_f:
          type: number
          nullable: true
          description: |
            Formula version `oracle-v1`: `(high_abs_error_f + low_abs_error_f) / 2`.
          example: 1.3
        forecast_run_id:
          type: string
          nullable: true
          description: Present only when a daily row maps to a single contributing forecast run.
        forecast_issued_at:
          type: string
          format: date-time
          nullable: true
          description: Present only when a daily row maps to a single contributing forecast run.
        observation_source:
          type: string
          nullable: true
          example: asos
        included:
          type: boolean
          description: Whether this row contributes to aggregate oracle scoring.
        exclusion_reason:
          type: string
          nullable: true
          description: Reason the row is excluded when `included=false`.
          example: missing_model_score
        error_distribution:
          nullable: true
          x-go-type: OracleErrorDistribution
          oneOf:
            - $ref: "#/components/schemas/OracleErrorDistribution"
            - enum: [null]

    OracleScoreDaysData:
      type: object
      properties:
        station_id:
          type: string
          example: KMDW
        range_start:
          type: string
          format: date
        range_end:
          type: string
          format: date
        days_requested:
          type: integer
          description: Effective requested scored-day window after plan capping.
          example: 30
        max_history_days:
          type: integer
          description: Maximum numeric `days` or explicit date span allowed by the authenticated plan.
          example: 30
        all_time:
          type: boolean
          description: Present and true when `days=all` was requested by a Clanker key.
        modes:
          type: array
          items:
            $ref: "#/components/schemas/OracleScoreMode"
        formula_version:
          type: string
          enum: [oracle-v1]
        rows:
          type: array
          items:
            $ref: "#/components/schemas/OracleScoreDayRow"

    Market:
      type: object
      properties:
        id:
          type: string
          description: |
            Market identifier. For Kalshi markets this is the market ticker
            (e.g. HIGHNYC-26FEB11-T33). For Polymarket markets this is the
            internal UUID. For ForecastEx markets (the contracts Robinhood and
            IBKR broker) this is the contract ID (e.g. UHLAX_101626_100).
            Use this value with GET /api/v1/markets/{market_id}.
        platform:
          type: string
          enum: [kalshi, polymarket, forecastex]
        city:
          type: string
        event_type:
          type: string
          enum: [high, low]
        event_ticker:
          type: string
          description: Kalshi event ticker for contract grouping; for ForecastEx, the product ID (e.g. UHLAX).
        event_date:
          type: string
          format: date
          description: Contract date represented by the market.
        strike_type:
          type: string
          description: |
            Kalshi strike semantics (e.g. between, greater_or_equal). ForecastEx contracts are
            thresholds: `greater` (YES when the temperature exceeds `floor_strike`, a daily high)
            or `less` (YES when it is below `cap_strike`, a daily low).
        floor_strike:
          type: number
          nullable: true
          description: Inclusive or exclusive lower strike bound depending on strike_type.
        cap_strike:
          type: number
          nullable: true
          description: Inclusive or exclusive upper strike bound depending on strike_type.
        strike_value:
          type: number
        condition_id:
          type: string
          description: Polymarket condition ID (only present for Polymarket markets)
        slug:
          type: string
          description: Polymarket market slug (only present for Polymarket markets)
        yes_price:
          type: number
          deprecated: true
          description: "Deprecated: the public API serves no market prices (since v1.16); always absent."
        no_price:
          type: number
          deprecated: true
          description: "Deprecated: the public API serves no market prices (since v1.16); always absent."
        volume:
          type: integer
          deprecated: true
          description: "Deprecated: the public API serves no market prices (since v1.16); always absent."
        settlement_result:
          type: string
          enum: ["yes", "no"]
          description: The side the market settled on, once it settles; absent before.
        updated_at:
          type: string
          format: date-time
          description: |
            When the market was last updated (Kalshi and Polymarket). In `GET /api/v1/markets`, when
            MinuteTemp last updated the market's record, not a price time (since v1.17). Absent for
            ForecastEx markets.
        expires_at:
          type: string
          format: date-time
          description: ForecastEx markets only. When the contract expires.
        status:
          type: string
          enum: [open, closed, settled]
          description: ForecastEx markets only. Open until its last trade, closed until ForecastEx settles it.
        computed_outcome:
          type: string
          nullable: true
          description: ForecastEx markets only. MinuteTemp's own settlement of the contract from its observations, once computed.

    CursorPagination:
      type: object
      properties:
        limit:
          type: integer
          minimum: 1
          maximum: 5000
          example: 500
        next_cursor:
          type: string
          nullable: true
          description: Opaque cursor for the next page. Null when there are no more rows.

    MarketHistoryPoint:
      type: object
      properties:
        timestamp:
          type: string
          format: date-time
        is_settlement:
          type: boolean
          description: True on the settlement point.
        computed_outcome:
          type: string
          nullable: true
          description: ForecastEx markets only. MinuteTemp's own settlement of the contract, once computed.
        settlement_result:
          type: string
          enum: ["yes", "no"]
          description: On a settlement point, the side the market settled on.
        yes_price:
          type: number
          nullable: true
          deprecated: true
          description: Deprecated, always null (since v1.16 the public API serves no market prices).
        no_price:
          type: number
          nullable: true
          deprecated: true
          description: Deprecated, always null (since v1.16 the public API serves no market prices).
        volume:
          type: number
          nullable: true
          deprecated: true
          description: Deprecated, always null (since v1.16 the public API serves no market prices).

    ForecastRunSummary:
      type: object
      properties:
        id:
          type: string
          format: uuid
        station_id:
          type: string
          format: uuid
        model_id:
          type: string
          example: ncep_hrrr_conus
        forecast_time:
          type: string
          format: date-time
        fetched_at:
          type: string
          format: date-time
        timezone:
          type: string
          example: America/Chicago
        utc_offset_seconds:
          type: integer
        data_hash:
          type: string
        object_path:
          type: string
          description: |
            Path of the run's file on the data host (see **Forecast data files**). The file's URL is
            the `base_url` of `GET /api/v1/data/access` + this path + `?` + the `query` of the grant
            for the run's fetch date. Absent for a run that has no file. See **Getting forecast
            data** for the full flow.
          example: /private/v1/forecasts/2026/10/12/KMDW/ncep_hrrr_conus/0199d2a0-0000-7000-8000-000000000001.json

    ForecastRunFile:
      type: object
      description: |
        A forecast data file: one forecast run with all its steps (schema
        `minutetemp.forecast_run/v1`). It is served by the data host, not by this API; see
        **Forecast data files**.
      required: [schema, run, units, count, hourly]
      properties:
        schema:
          type: string
          example: minutetemp.forecast_run/v1
        run:
          type: object
          required:
            [id, revision, station_id, station_code, model_id, forecast_time, fetched_at, timezone, utc_offset_seconds, data_hash, step_seconds, backfilled]
          properties:
            id:
              type: string
              format: uuid
            revision:
              type: integer
              description: Always 1.
            station_id:
              type: string
              format: uuid
            station_code:
              type: string
              example: KMDW
            model_id:
              type: string
              example: ncep_hrrr_conus
            forecast_time:
              type: string
              format: date-time
            fetched_at:
              type: string
              format: date-time
            timezone:
              type: string
              example: America/Chicago
            utc_offset_seconds:
              type: integer
            data_hash:
              type: string
            step_seconds:
              type: integer
              example: 3600
            backfilled:
              type: boolean
              description: True when the file was written some time after the run was fetched.
        units:
          type: object
          description: Unit of each weather field of `hourly`, by field name. Fields without a unit have no entry.
          additionalProperties:
            type: string
          example:
            temperature_2m_c: C
            temperature_2m_f: F
            wind_speed_10m: km/h
        count:
          type: integer
          description: Number of entries in `hourly`.
        hourly:
          type: array
          items:
            $ref: "#/components/schemas/ForecastRunFilePoint"

    ForecastRunFilePoint:
      type: object
      description: |
        One forecast step. The two temperatures are always present, `null` when the model gave
        none. Every other weather field is present only when it has a value; the fields listed here
        are the ones with a unit in `units`, and a file may carry others (for example
        `weather_code`, `surface_pressure`, `is_day`).
      required: [time, temperature_2m_c, temperature_2m_f]
      properties:
        time:
          type: string
          format: date-time
        model_id:
          type: string
          description: Present only when the step comes from another model than the run's.
        temperature_2m_c:
          type: [number, "null"]
        temperature_2m_f:
          type: [number, "null"]
        apparent_temperature_c:
          type: number
        apparent_temperature_f:
          type: number
        relative_humidity_2m:
          type: number
        dew_point_2m:
          type: number
        pressure_msl:
          type: number
        cloud_cover:
          type: number
        wind_speed_10m:
          type: number
        wind_direction_10m:
          type: number
        wind_gusts_10m:
          type: number
        precipitation:
          type: number
        precipitation_probability:
          type: number
        visibility:
          type: number
        weather_code:
          type: integer

    EffectiveLimits:
      type: object
      description: |
        Effective limits for the authenticated API key's subscription tier. Static caps match the
        **Plan Limits** tables in the API overview; this endpoint adds live rate-limit snapshots.
      properties:
        tier:
          type: string
          enum: [starter, pro, clanker]
          description: Paid subscription tier for this API key.
        requests_per_minute:
          type: integer
          description: Sustained per-minute rate limit (Starter 15, Pro 100, Clanker 250).
        burst_size:
          type: integer
          description: Maximum requests a fully idle client can spend across the next minute for the user's plan tier.
        bucket_capacity:
          type: integer
          description: Maximum immediate stored token capacity for the user's plan tier.
        max_history_days:
          type: integer
          description: |
            Maximum calendar-day span for time-bounded history endpoints (market snapshot history,
            forecast run lists, report history, oracle score lookback). Starter 7, Pro 30, Clanker 90.
          example: 30
        ws_subscriptions:
          type: object
          description: |
            Per-user subscription limits on the API-key WebSocket (`/ws/api/*`), across all of the
            user's connections. See **Plan Limits**.
          properties:
            market:
              # Explicit OAS 3.1 null; extensions keep the existing oapi-codegen output typed.
              nullable: true
              x-go-type: int
              oneOf:
                - type: integer
                - enum: [null]
              description: |
                Market subscription limit: cities, market stations and hourly indexes.
                Starter 6, Pro 60, Clanker 200. `null` means unlimited.
              example: 60
            neighbour:
              # Explicit OAS 3.1 null; extensions keep the existing oapi-codegen output typed.
              nullable: true
              x-go-type: int
              oneOf:
                - type: integer
                - enum: [null]
              description: |
                Neighbour subscription limit: observation-only stations not listed by
                `GET /api/v1/stations`. Starter 30, Pro 300, Clanker unlimited (`null`).
              example: 300
        ip_guard:
          type: object
          properties:
            requests_per_second:
              type: integer
            burst:
              type: integer
        rate_limit_remaining:
          type: integer
          nullable: true
          description: Immediate stored tokens remaining in the request-serving region, after counting this request, if available.
        rate_limit_reset_seconds:
          type: integer
          nullable: true
          description: Seconds until the immediate token bucket fully refills in the request-serving region, if available.
        served_by_region:
          type: string
          nullable: true
          description: Runtime region identifier for the service that served this request, when available.

    DataAccessGrant:
      type: object
      required: [prefix, query]
      properties:
        prefix:
          type: string
          description: Every file path starting with this prefix is covered by `query`.
          example: /private/v1/chart/
        query:
          type: string
          description: Opaque token. Append it to the file URL as the whole query string.
          example: 1760288400-c2lnbmF0dXJl

    DataAccess:
      type: object
      description: |
        Where the forecast data files are and the tokens that open the ones the caller's plan may read.
        A file's URL is `base_url` + its path + `?` + the `query` of the grant whose `prefix` the path
        starts with. Treat `query` as an opaque string. Paths under the `public` prefixes need no query.
      required: [base_url, forecasts, public]
      properties:
        base_url:
          type: string
          example: https://data.minutetemp.com
        expires_at:
          type: string
          format: date-time
          description: |
            When the tokens in this response stop working (at most one hour after they were issued).
            Fetch a new response shortly before it. Absent when the response carries no tokens.
        chart:
          allOf:
            - $ref: "#/components/schemas/DataAccessGrant"
          description: |
            Grant for the per-station chart files of the MinuteTemp web app, whose format is not
            part of this API (see **Getting forecast data**). Absent when the plan has no file
            access, and whenever the chart files are not confirmed current.
        forecasts:
          type: array
          description: |
            One grant per UTC date the plan may read forecast run files for: from the start of the
            plan's history window (`max_history_days`) through tomorrow, oldest first. A run's date
            is the UTC date it was fetched. Empty when the plan has no file access.
          items:
            allOf:
              - $ref: "#/components/schemas/DataAccessGrant"
              - type: object
                required: [date]
                properties:
                  date:
                    type: string
                    format: date
                    example: "2026-10-12"
        hourly_indexes:
          allOf:
            - $ref: "#/components/schemas/DataAccessGrant"
          description: |
            Grant for the current hourly index files, one per index city, under
            `/private/v1/hourly-indexes/`. Absent when the plan has no file access, while hourly
            index files are not offered, and whenever the files are not confirmed current.
            See **Hourly index data files**.
        hourly_index_days:
          type: array
          description: |
            One grant per UTC date the plan may read hourly index day files for, under
            `/private/v1/hourly-index-days/YYYY/MM/DD/`: from the start of the plan's history window
            (`max_history_days`) through today, oldest first. A day file holds the steps of one UTC
            date. Absent when the plan has no file access or while hourly index files are not
            offered.
          items:
            allOf:
              - $ref: "#/components/schemas/DataAccessGrant"
              - type: object
                required: [date]
                properties:
                  date:
                    type: string
                    format: date
                    example: "2026-10-12"
        public:
          type: object
          description: Prefixes of the files that need no token.
          required: [chart_prefix, forecasts_prefix]
          properties:
            chart_prefix:
              type: string
              example: /public/v1/chart/
            forecasts_prefix:
              type: string
              example: /public/v1/forecasts/
            hourly_indexes_prefix:
              type: string
              description: Public hourly index files of the cities without a Kalshi series. Absent while hourly index files are not offered.
              example: /public/v1/hourly-indexes/

    HourlyIndexQuorum:
      type: object
      description: |
        Minimum contributing members and weight share for a minute to count. `published` is false
        for cities where Kalshi hasn't published a quorum rule; their settlements are informational.
      properties:
        min_members:
          type: integer
          example: 4
        min_weight:
          type: number
          example: 0.8
        published:
          type: boolean

    HourlyIndexMember:
      type: object
      properties:
        station_id:
          type: string
          example: KFLL
        weight:
          type: number
          example: 0.2
        offset_c:
          type: number
          example: 0.0

    HourlyIndexStationReading:
      type: object
      description: One member's reading inside a city-minute.
      properties:
        station_id:
          type: string
        temp_f:
          type: number
          nullable: true
        temp_c:
          type: number
          nullable: true
        code:
          type: string
          description: Kalshi reading code (`ok`, `missing`, `late`, `range`, `rate_spatial`, `extreme`, `pending`).
        source:
          type: string
          nullable: true
          description: "`hf_asos` (1-minute ASOS) or `metar` (fallback)."
        obs_time:
          type: string
          format: date-time
          description: Observation time of a METAR fallback reading.
        received_at:
          type: string
          format: date-time
          description: Kalshi's receipt time of the reading.
        primary_code:
          type: string
        offset_c:
          type: number
          nullable: true
        weight:
          type: number
        contributes:
          type: boolean
        pull_f:
          type: number
          nullable: true
          description: |
            Leave-one-out pull on the index at this minute, °F: the index minus the index recomputed
            without this station (Kalshi's formula over the contributing members). Null when the
            station doesn't contribute or is the only contributor.
        change_5m_f:
          type: number
          nullable: true
          description: |
            This reading minus the station's reading 5 minutes earlier (or at the nearest earlier
            minute with a reading within 10 minutes of that), °F. Null without either reading.
        hour_high_f:
          type: number
          nullable: true
          description: Highest reading of the station in this minute's settle hour (after hour_end − 60 min) through this minute.
        hour_low_f:
          type: number
          nullable: true
          description: Lowest reading of the station in this minute's settle hour through this minute.

    HourlyIndexMinute:
      type: object
      description: |
        One city-minute. `value_f` is `official_f` when `phase` is `official`, else `provisional_f`.
        Keep the highest `revision` per minute; `final` minutes never go back to provisional.
      properties:
        minute:
          type: string
          format: date-time
        phase:
          type: string
          enum: [provisional, official, missing]
        final:
          type: boolean
        revision:
          type: integer
        value_f:
          type: number
          nullable: true
        official_f:
          type: number
          nullable: true
        recomputed_f:
          type: number
          nullable: true
        provisional_f:
          type: number
          nullable: true
        kalshi_status:
          type: string
          nullable: true
          enum: [normal, degraded, incomplete]
        config_version:
          type: string
        contributors:
          type: integer
        weight_share:
          type: number
        quorum_met:
          type: boolean
        restated:
          type: boolean
          description: >-
            Kalshi changed the value after it was official. `official_f` and `official_at` keep the
            first official value (the one the settlement used); the latest restated value is in
            `restated_f`, seen at `restated_at`.
        restated_f:
          type: number
          nullable: true
        restated_at:
          type: string
          format: date-time
          nullable: true
        receipt_basis:
          type: string
          nullable: true
          description: Set on Kalshi historical backfill points, which never settle.
        calibration_pending:
          type: boolean
        provisional_at:
          type: string
          format: date-time
          nullable: true
        official_at:
          type: string
          format: date-time
          nullable: true
        stations:
          type: array
          description: Member readings; only with `include=stations` (and always on the WebSocket).
          items:
            $ref: "#/components/schemas/HourlyIndexStationReading"

    HourlyIndexMinuteValue:
      type: object
      properties:
        minute:
          type: string
          format: date-time
        value_f:
          type: number

    HourlyIndexStrikeDistance:
      type: object
      description: |
        Where a value sits against the open event's ladder (N.99 floor strikes): the nearest
        strike, the signed distance to it and the side. All null for an index-only city.
      properties:
        nearest_strike_f:
          type: number
          nullable: true
          example: 65.99
        distance_to_strike_f:
          type: number
          nullable: true
          description: The value minus `nearest_strike_f` (°F); positive above it.
          example: 0.08
        strike_side:
          type: string
          nullable: true
          enum: [above, below, null]
          description: "`above` when the value is above the strike (the strike's \"greater\" market wins), else `below`."

    HourlyIndexHour:
      type: object
      description: |
        The open (`open`) or closed-but-undetermined (`pending`) hour. `settles_now_as` is the last
        official minute in the lookback. The `forecast_*` fields come from the latest HRRR index forecast:
        `forecast_bias_f` is the newest valued index minute minus the forecast at that minute
        (linearly interpolated between 15-minute steps; the first step when the minute is
        earlier), applied as a constant to the forecast at `hour_end`.
      properties:
        hour_end:
          type: string
          format: date-time
        status:
          type: string
          enum: [open, pending]
        event_ticker:
          type: string
          nullable: true
        settles_now_as:
          allOf:
            - $ref: "#/components/schemas/HourlyIndexMinuteValue"
            - $ref: "#/components/schemas/HourlyIndexStrikeDistance"
            - type: object
              properties:
                implied_floor_strike:
                  type: number
                  nullable: true
                  description: The open event's highest floor strike strictly below `value_f` (the bracket it settles in now); null for an index-only city.
          nullable: true
        high_f:
          type: number
          nullable: true
        forecast_settle_f:
          type: number
          nullable: true
          description: The HRRR index forecast at hour_end (raw, not bias-adjusted).
        forecast_bias_f:
          type: number
          nullable: true
          description: Newest valued index minute minus the HRRR index forecast at that minute.
        forecast_adjusted_settle_f:
          type: number
          nullable: true
          description: forecast_settle_f + forecast_bias_f.
        forecast_adjusted_floor_strike:
          type: number
          nullable: true
          description: The "greater" strike the adjusted settle implies under the N.99 convention.
        forecast_adjusted_nearest_strike_f:
          type: number
          nullable: true
          description: The open event's floor strike nearest the adjusted settle (null for an index-only city).
        forecast_adjusted_distance_to_strike_f:
          type: number
          nullable: true
          description: forecast_adjusted_settle_f minus forecast_adjusted_nearest_strike_f (°F, signed).
        forecast_adjusted_strike_side:
          type: string
          nullable: true
          enum: [above, below, null]
          description: "`above` when the adjusted settle is above the nearest strike (its \"greater\" market wins), else `below`."
        low_f:
          type: number
          nullable: true
        final_at:
          type: string
          format: date-time
          description: hour_end + 5 min, when the top-of-hour minute is official.
        determine_by:
          type: string
          format: date-time
          description: hour_end + 8 min, the latest time the settle is determined.
        feed_conditions:
          type: array
          description: |
            Degradations of the city's index feed over the final minutes of the 30 minutes ending
            at the newest final minute; empty when the feed is clean. Under Kalshi's methodology a
            member without its 1-minute reading falls back to its METAR/SPECI (at most 75 minutes
            old) and a QC-rejected reading is substituted the same way; both minutes still count.
            A member with no usable fallback is excluded, and a minute below quorum is unavailable
            (no value, nothing carried forward).
          items:
            $ref: "#/components/schemas/HourlyIndexFeedCondition"

    HourlyIndexFeedCondition:
      type: object
      required: [type, severity, station_id, minutes, window_minutes, share, since, last_seen]
      properties:
        type:
          type: string
          enum: [persistent_fallback, intermittent_fallback, member_excluded, qc_substitution, quorum_risk, unavailable_minutes]
          description: |
            `persistent_fallback`: a member on its METAR fallback for at least 90% of the window's
            official minutes; `intermittent_fallback`: on it for fewer (at least 2 minutes);
            `member_excluded`: a member out of the index with no usable fallback (at least 2
            minutes); `qc_substitution`: a member's 1-minute reading failed QC and its METAR was
            used; `quorum_risk`: the newest official minute has a member out and losing the
            heaviest remaining member would break the published quorum (only for cities with a
            published quorum); `unavailable_minutes`: final minutes below quorum.
        severity:
          type: string
          enum: [warning, critical]
          description: "`critical` for `quorum_risk` and `unavailable_minutes`."
        station_id:
          type: string
          nullable: true
          description: The member station; null for `quorum_risk` and `unavailable_minutes`.
          example: KDPA
        minutes:
          type: integer
          description: Minutes affected.
          example: 8
        window_minutes:
          type: integer
          description: Minutes considered (official minutes for member conditions, final minutes for `unavailable_minutes`, 1 for `quorum_risk`).
          example: 30
        share:
          type: number
          description: minutes / window_minutes, rounded to 3 places.
          example: 0.267
        since:
          type: string
          format: date-time
          description: The first affected minute in the window.
        last_seen:
          type: string
          format: date-time
          description: The last affected minute in the window.
        codes:
          type: array
          items:
            type: string
          description: "`member_excluded`: the member's codes (missing, late, range, rate_spatial, extreme); `qc_substitution`: the rejected reading's QC codes (range, rate_spatial, extreme, provider_qc)."
        contributors:
          type: integer
          description: "`quorum_risk`: contributing members in the newest official minute."
        members:
          type: integer
          description: "`quorum_risk`: all members."
        weight_share:
          type: number
          description: "`quorum_risk`: the contributing weight share."

    HourlyIndexLadderMarket:
      type: object
      properties:
        ticker:
          type: string
        status:
          type: string
        strike_type:
          type: string
        floor_strike:
          type: number
          nullable: true
        cap_strike:
          type: number
          nullable: true
        yes_sub_title:
          type: string
        result:
          type: string

    HourlyIndexEvent:
      type: object
      description: An hourly Kalshi event with its ladder.
      properties:
        event_ticker:
          type: string
        series_ticker:
          type: string
        status:
          type: string
          enum: [listed, open, closed]
        strike_time:
          type: string
          format: date-time
        open_time:
          type: string
          format: date-time
          nullable: true
        close_time:
          type: string
          format: date-time
          nullable: true
        ladder:
          type: array
          items:
            $ref: "#/components/schemas/HourlyIndexLadderMarket"

    HourlyIndexMarketQuote:
      type: object
      description: |
        One strike of the ladder. Its quote fields are deprecated and always null: since v1.16 the
        public API serves no market prices.
      properties:
        ticker:
          type: string
        event_ticker:
          type: string
        floor_strike:
          type: number
          nullable: true
        cap_strike:
          type: number
          nullable: true
        yes_bid:
          type: integer
          nullable: true
          deprecated: true
        yes_ask:
          type: integer
          nullable: true
          deprecated: true
        no_bid:
          type: integer
          nullable: true
          deprecated: true
        no_ask:
          type: integer
          nullable: true
          deprecated: true
        last_price:
          type: integer
          nullable: true
          deprecated: true
        volume:
          type: number
          nullable: true
          deprecated: true

    HourlyIndexSettlement:
      type: object
      description: |
        Our settlement of one hour. `event_ticker` is null for index-only cities; `informational`
        marks cities without a published quorum rule. `kalshi_expiration_value` and `match` are null
        until Kalshi finalizes the market.

        `status` is `determined` (the last official, non-backfill minute in
        [hour_end − 60 min, hour_end]), `no_value` (every minute of the window is known and none is
        official: Rule 7.1), or `incomplete_data`: our stored minutes have a gap after the last
        official minute (listed in `missing_minutes`), so we never settle on an earlier minute. For a
        market city, an `incomplete_data` hour takes Kalshi's `expiration_value` as `settle_value_f`
        (with its `winning_floor_strike`) once Kalshi finalizes it, with `settle_source`
        `kalshi_expiration` and `match` null; such hours are in neither side of the match rate. An
        index-only city's `incomplete_data` hour gets one refetch of its missing minutes in the
        daily audit and becomes `determined` or `no_value` if that fills the gap.
      properties:
        hour_end:
          type: string
          format: date-time
        status:
          type: string
          enum: [determined, no_value, incomplete_data]
        settle_source:
          type: string
          enum: [computed, kalshi_expiration]
          description: >-
            Where `settle_value_f` comes from: `computed` from our index minutes, or
            `kalshi_expiration` when an `incomplete_data` hour took Kalshi's final value.
        missing_minutes:
          type: array
          nullable: true
          description: The minutes without a final value in our data (`incomplete_data` only; otherwise null).
          items:
            type: string
            format: date-time
        event_ticker:
          type: string
          nullable: true
        settle_minute:
          type: string
          format: date-time
          nullable: true
        settle_value_f:
          type: number
          nullable: true
        hour_high_f:
          type: number
          nullable: true
        hour_low_f:
          type: number
          nullable: true
        winning_floor_strike:
          type: number
          nullable: true
        informational:
          type: boolean
        determined_at:
          type: string
          format: date-time
        kalshi_expiration_value:
          type: number
          nullable: true
        kalshi_finalized_at:
          type: string
          format: date-time
          nullable: true
        match:
          type: boolean
          nullable: true
        revision:
          type: integer

    HourlyIndexForecastSettle:
      type: object
      description: Forecast settle of an upcoming hour (the step at its top-of-hour).
      properties:
        hour_end:
          type: string
          format: date-time
        value_f:
          type: number
          nullable: true
        implied_floor_strike:
          type: number
          nullable: true
          description: Highest N.99 floor strike strictly below value_f (Kalshi's hourly ladder convention).
          example: 83.99
        quorum_met:
          type: boolean
        partial:
          type: boolean
        adjusted_value_f:
          type: number
          nullable: true
          description: "`value_f` + the forecast's `bias_f`; null without either."
        adjusted_floor_strike:
          type: number
          nullable: true
          description: Highest N.99 floor strike strictly below `adjusted_value_f`.
          example: 84.99

    HourlyIndexForecastMember:
      type: object
      properties:
        station_id:
          type: string
          example: KMIA
        weight:
          type: number
        offset_c:
          type: number
        temp_f:
          type: array
          description: The forecast model's 15-minute temperature per step, aligned to `steps` (null when missing).
          items:
            type: number
            nullable: true

    HourlyIndexForecast:
      type: object
      description: |
        The index forecast from the members' latest 15-minute runs of one forecast model
        (`model_id`: HRRR, or RRFS where requested): each step's index computed
        from the members' temperatures with Kalshi's formula, the calibration effective at the step
        and the city's quorum. Every per-step array (`value_f`, `contributors`, `weight_share`,
        `quorum_met`, `partial`, `members[].temp_f`) is aligned to `steps`. The first step is the
        15-minute step at or before the refresh (compare it with the latest observation for
        bias); the rest run up to 18 hours ahead. `value_f` is null below quorum; `partial` marks
        steps where some members had no forecast. Recomputed when a member's run or a calibration
        changes (about hourly); replace the whole object when a newer `run_time` or `fetched_at`
        arrives.

        Bias-adjusted forecast: `bias_f` is the newest valued index minute
        (`bias_as_of_minute`) minus the forecast interpolated at it, and `adjusted_f[i]` =
        `value_f[i]` + `bias_f` (null where `value_f` is null or there is no bias). The bias
        follows every new or revised valued minute and calibration recompute (the WebSocket
        `forecast_bias` event); the curve itself changes only with a new run.
      properties:
        model_id:
          type: string
          example: ncep_hrrr_conus_15min
        run_time:
          type: string
          format: date-time
          description: First step of the newest member run (the 15-minute step it was fetched in).
        fetched_at:
          type: string
          format: date-time
        config_version:
          type: string
        members_total:
          type: integer
        missing_members:
          type: array
          description: Members with no forecast in this run.
          items:
            type: string
        steps:
          type: array
          items:
            type: string
            format: date-time
        value_f:
          type: array
          items:
            type: number
            nullable: true
        contributors:
          type: array
          items:
            type: integer
        weight_share:
          type: array
          items:
            type: number
        quorum_met:
          type: array
          items:
            type: boolean
        partial:
          type: array
          items:
            type: boolean
        members:
          type: array
          items:
            $ref: "#/components/schemas/HourlyIndexForecastMember"
        settles:
          type: array
          description: The next (up to 6) top-of-hour steps after the refresh.
          items:
            $ref: "#/components/schemas/HourlyIndexForecastSettle"
        bias_f:
          type: number
          nullable: true
          description: Newest valued index minute minus the forecast at that minute (the first step for an earlier minute); null without a valued minute or forecast value there.
        bias_as_of_minute:
          type: string
          format: date-time
          nullable: true
          description: The index minute `bias_f` was measured at.
        adjusted_f:
          type: array
          description: "`value_f[i]` + `bias_f`, aligned to `steps`; null where either is null."
          items:
            type: number
            nullable: true
        member_bias:
          type: object
          nullable: true
          description: Per member, its reading at `bias_as_of_minute` minus its forecast temperature there (°F); null when either is missing. Null without a bias.
          additionalProperties:
            type: number
            nullable: true

    HourlyIndexCity:
      type: object
      description: |
        A city's current state; also the body of the WebSocket `hourly_index_snapshot`. `seq` is the
        last event folded in: apply WebSocket events with a higher `seq`. Index-only cities have
        `hourly_series_ticker: null` and an empty `ladder`.
      properties:
        city:
          type: string
          example: miami
        display_name:
          type: string
          example: Miami
        page_slug:
          type: string
        timezone:
          type: string
          example: America/New_York
        seq:
          type: integer
          format: int64
          example: 48213
        config_version:
          type: string
          example: miami-temperature-v1.0-cal-20260928
        calibration_pending:
          type: boolean
        hourly_series_ticker:
          type: string
          nullable: true
          example: KXTEMPMIAH
        forecast_station_id:
          type: string
          nullable: true
          example: KMIA
        contract_terms_url:
          type: string
          nullable: true
          description: The Kalshi contract terms (PDF) of the city's hourly series, from Kalshi's series listing; null for index-only cities.
          example: https://kalshi-public-docs.s3.amazonaws.com/contract_terms/WINDEX.pdf
        quorum:
          $ref: "#/components/schemas/HourlyIndexQuorum"
        members:
          type: array
          items:
            $ref: "#/components/schemas/HourlyIndexMember"
        latest:
          allOf:
            - $ref: "#/components/schemas/HourlyIndexMinute"
          nullable: true
          description: >-
            Newest minute, without member readings. Its value_f is null while a
            provisional minute is below quorum.
        latest_valued:
          allOf:
            - $ref: "#/components/schemas/HourlyIndexMinute"
          nullable: true
          description: >-
            Newest minute with a value (provisional or official), without member
            readings. The same minute as latest unless latest has no value.
        current_hour:
          allOf:
            - $ref: "#/components/schemas/HourlyIndexHour"
          nullable: true
        open_event:
          allOf:
            - $ref: "#/components/schemas/HourlyIndexEvent"
          nullable: true
        next_event:
          allOf:
            - $ref: "#/components/schemas/HourlyIndexEvent"
          nullable: true
        ladder:
          type: array
          description: Quotes for the open event's strikes (list endpoint omits it).
          items:
            $ref: "#/components/schemas/HourlyIndexMarketQuote"
        recent_settlements:
          type: array
          description: Up to 3 newest settlements, newest first.
          items:
            $ref: "#/components/schemas/HourlyIndexSettlement"
        forecast:
          allOf:
            - $ref: "#/components/schemas/HourlyIndexForecast"
          nullable: true
          description: Latest HRRR 15-minute index forecast (null until the first run).
        forecasts:
          type: object
          description: |
            The other forecast models' latest index forecasts, by model ID (today only
            `ncep_rrfs_conus_15min`), each computed like `forecast` from that model's own member
            runs. Absent while no other model has a forecast for the city; a forecast with no step
            left is not included. `forecast` stays HRRR.
          additionalProperties:
            $ref: "#/components/schemas/HourlyIndexForecast"

    HourlyIndexCalibration:
      type: object
      properties:
        city:
          type: string
        config_version:
          type: string
        effective_at:
          type: string
          format: date-time
        published_at:
          type: string
          format: date-time
        window_start:
          type: string
          format: date-time
          nullable: true
        window_end:
          type: string
          format: date-time
          nullable: true
        change_reason:
          type: string
        city_reference_c:
          type: number
        stations:
          type: array
          description: Members as Kalshi published them (station_id, weight, offset_c, update_note).
          items:
            type: object
            additionalProperties: true

    HourlyIndexMatchRate:
      type: object
      properties:
        city:
          type: string
        window:
          type: string
          enum: [7d, 30d]
        matched:
          type: integer
          description: Reconciled hours where our settle matched Kalshi's final value.
        reconciled:
          type: integer
          description: >-
            Hours Kalshi has finalized in the window that we settled ourselves; `incomplete_data`
            hours settled from Kalshi's value (`settle_source: kalshi_expiration`) are excluded from
            both counts.
        rate:
          type: number
          nullable: true
          description: matched / reconciled, null when nothing is reconciled.

    HourlyIndexForecastAccuracy:
      type: object
      properties:
        city:
          type: string
        model_id:
          type: string
          description: The forecast model scored.
          example: ncep_hrrr_conus_15min
        window:
          type: string
          enum: [7d, 30d]
        hours:
          type: integer
          description: Determined hours scored in the window.
        leads:
          type: array
          description: One entry per lead (60, 30 and 15 minutes before hour_end).
          items:
            type: object
            properties:
              lead_minutes:
                type: integer
                enum: [60, 30, 15]
              adjusted:
                $ref: "#/components/schemas/HourlyIndexForecastScore"
              raw:
                $ref: "#/components/schemas/HourlyIndexForecastScore"

    HourlyIndexForecastScore:
      type: object
      description: |
        One forecast (bias-adjusted or raw) at one lead. Errors are forecast minus settle (°F).
        Scores are null when `n` is 0.
      properties:
        n:
          type: integer
          description: Hours with a forecast at this lead.
        mae_f:
          type: number
          nullable: true
          description: Mean absolute error (°F).
        bias_f:
          type: number
          nullable: true
          description: Mean signed error (°F); positive means the forecast ran warm.
        strike_match_rate:
          type: number
          nullable: true
          description: Share of hours whose forecast fell in the settle's strike bracket (N.99 convention), 0-1.

    WsTicket:
      type: object
      properties:
        ticket:
          type: string
          description: JWT token valid for 90 seconds

  parameters:
    HourlyIndexForecastModel:
      name: model
      in: query
      description: |
        Forecast model of the index forecast. Defaults to HRRR. RRFS is a separate forecast with
        its own bias, history and accuracy, never a fallback for HRRR or the reverse.
      schema:
        type: string
        enum: [ncep_hrrr_conus_15min, ncep_rrfs_conus_15min]
        default: ncep_hrrr_conus_15min
    HourlyIndexCityID:
      name: city
      in: path
      required: true
      schema:
        type: string
      example: miami
      description: Kalshi index city ID (see `GET /api/v1/hourly-indexes`).

paths:
  /api/v1/today:
    get:
      tags: [Today]
      summary: "Today's consensus and observed data"
      description: |
        Returns forecast model consensus and observed high/low temperatures for
        all active stations for the current local calendar day. Data refreshes
        every 60 seconds.
      operationId: getToday
      responses:
        "200":
          description: "Today's data for all active stations"
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      date:
                        type: string
                        description: UTC date in YYYY-MM-DD format
                        example: "2026-03-28"
                      stations:
                        type: array
                        items:
                          type: object
                          properties:
                            city_name:
                              type: string
                              example: Chicago
                            city_slug:
                              type: string
                              example: chi
                            station_name:
                              type: string
                              example: KMDW
                            station_id:
                              type: string
                            timezone:
                              type: string
                              example: America/Chicago
                            platforms:
                              type: array
                              items:
                                type: string
                              example: [kalshi, polymarket]
                            observed:
                              type: object
                              nullable: true
                              properties:
                                high_f:
                                  type: number
                                  nullable: true
                                low_f:
                                  type: number
                                  nullable: true
                                as_of:
                                  type: string
                                  format: date-time
                            consensus:
                              type: object
                              nullable: true
                              properties:
                                high_f:
                                  type: number
                                  nullable: true
                                low_f:
                                  type: number
                                  nullable: true
                                spread_high_f:
                                  type: number
                                  nullable: true
                                model_count:
                                  type: integer
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Data temporarily unavailable (cache warming)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/cities:
    get:
      tags: [Cities]
      summary: List all cities
      description: Returns all active cities with their slugs and station metadata.
      operationId: listCities
      responses:
        "200":
          description: List of cities
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/City"
                  count:
                    type: integer
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/cities/{slug}:
    get:
      tags: [Cities]
      summary: Get city by slug
      description: Returns a single city with its stations by URL slug (e.g. chi, nyc).
      operationId: getCity
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
          example: chi
      responses:
        "200":
          description: City details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/City"
        "404":
          description: City not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/stations:
    get:
      tags: [Stations]
      summary: List all stations
      description: Returns a flat list of all active weather stations with their city association.
      operationId: listStations
      responses:
        "200":
          description: List of stations
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/StationFlat"
                  count:
                    type: integer
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/stations/{station_id}:
    get:
      tags: [Stations]
      summary: Get station details
      operationId: getStation
      parameters:
        - name: station_id
          in: path
          required: true
          schema:
            type: string
          example: KMDW
      responses:
        "200":
          description: Station with optional parent city
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      station:
                        $ref: "#/components/schemas/Station"
                      city:
                        allOf:
                          - $ref: "#/components/schemas/City"
                        nullable: true
                        description: Parent city when the station is city-linked; null for cityless stations.
        "404":
          description: Station not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/stations/{station_id}/observations/latest:
    get:
      tags: [Observations]
      summary: Get latest observation
      description: |
        Returns the most recent weather observation for a station, along with daily high/low.
        The optional `day_mode` query parameter controls how `daily_*` and
        `temperature_day_*` fields bucket the station-observed day. Weather Underground
        `wu_*` fields always use the station-local calendar day.

        `daily_*` fields are the current authoritative merged daily extremes for
        this station/day. They can be changed by persisted observations and by
        official station reports (`cli`, `dsm`, `metar_tgroup`, `metar_6hr`) when
        report-derived values supersede or correct the observed extreme.
        `asos_daily_*` fields are the ASOS/observation-only view and exclude
        report-derived promotions. `wu_daily_*` fields are Weather Underground
        historical daily page values for the station-local calendar day.
      operationId: getLatestObservation
      parameters:
        - name: station_id
          in: path
          required: true
          schema:
            type: string
          example: KMDW
        - name: day_mode
          in: query
          required: false
          schema:
            type: string
            enum: [calendar_day, nws_climate_day]
          description: |
            Optional temperature-day mode for `daily_*` and `temperature_day_*` fields.
            Omit to use the station default. `calendar_day` uses local midnight-to-midnight.
            `nws_climate_day` uses local standard-time climate-day semantics.
      responses:
        "200":
          description: Latest observation with daily extremes
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      city:
                        type: object
                        properties:
                          id: { type: string }
                          slug: { type: string }
                          name: { type: string }
                          timezone: { type: string }
                      station:
                        type: object
                        properties:
                          station_id: { type: string }
                          name: { type: string }
                          temperature_unit: { type: string, enum: [F, C] }
                          uses_nws_climate_day:
                            type: boolean
                            description: True when this response's `daily_*` fields use NWS climate-day semantics.
                      observation:
                        $ref: "#/components/schemas/Observation"
                      daily_high_f:
                        type: number
                        nullable: true
                        description: Authoritative merged daily high; may include report-derived values.
                      daily_low_f:
                        type: number
                        nullable: true
                        description: Authoritative merged daily low; may include report-derived values.
                      daily_high_c:
                        type: number
                        nullable: true
                        description: Authoritative merged daily high in Celsius; may include report-derived values.
                      daily_low_c:
                        type: number
                        nullable: true
                        description: Authoritative merged daily low in Celsius; may include report-derived values.
                      asos_daily_high_f:
                        type: number
                        nullable: true
                        description: ASOS-only daily high (excludes report-derived promotions).
                      asos_daily_low_f:
                        type: number
                        nullable: true
                        description: ASOS-only daily low (excludes report-derived promotions).
                      asos_daily_high_c:
                        type: number
                        nullable: true
                      asos_daily_low_c:
                        type: number
                        nullable: true
                      wu_current_temp_f:
                        type: number
                        nullable: true
                        description: Latest Weather Underground current temperature (F). Display only; WU readings never appear in `observations` or `latest_observation` and never affect the daily high/low.
                      wu_current_temp_c:
                        type: number
                        nullable: true
                        description: Latest Weather Underground current temperature (C).
                      wu_daily_high_f:
                        type: number
                        nullable: true
                        description: Weather Underground historical daily page high for the current local day (F).
                      wu_daily_low_f:
                        type: number
                        nullable: true
                        description: Weather Underground historical daily page low for the current local day (F).
                      wu_daily_high_c:
                        type: number
                        nullable: true
                        description: Weather Underground historical daily page high for the current local day (C).
                      wu_daily_low_c:
                        type: number
                        nullable: true
                        description: Weather Underground historical daily page low for the current local day (C).
                      wu_observation_time:
                        type: string
                        format: date-time
                        nullable: true
                        description: Timestamp of the latest Weather Underground observation used for these values.
                      wu_fetched_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Timestamp MinuteTemp fetched the latest Weather Underground observation.
                      temperature_day_mode:
                        type: string
                        enum: [calendar_day, nws_climate_day]
                        description: Day-bucketing mode used for `daily_*` and `temperature_day_date`.
                      temperature_day_date:
                        type: string
                        format: date
                        description: Station temperature-day label used for `daily_*` values.
                      wu_day_mode:
                        type: string
                        enum: [calendar_day]
                        description: Day-bucketing mode used for `wu_daily_*` values.
                      wu_day_date:
                        type: string
                        format: date
                        description: Station-local calendar date used for `wu_daily_*` values.
        "404":
          description: Station not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/stations/{station_id}/observations:
    get:
      tags: [Observations]
      summary: Get historical observations
      description: |
        Returns observations within a time range. Maximum range is 7 days for all paid API plans.
        All paid API subscriptions receive 1-minute resolution. See **Plan Limits**.
      operationId: getObservations
      parameters:
        - name: station_id
          in: path
          required: true
          schema:
            type: string
          example: KMDW
        - name: start
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: Start of time range (ISO 8601)
        - name: end
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: End of time range (ISO 8601)
      responses:
        "200":
          description: Observations with city/station context
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      city:
                        type: object
                        properties:
                          id: { type: string }
                          slug: { type: string }
                          name: { type: string }
                          timezone: { type: string }
                      station:
                        type: object
                        properties:
                          station_id: { type: string }
                          name: { type: string }
                          temperature_unit: { type: string, enum: [F, C] }
                          uses_nws_climate_day:
                            type: boolean
                            description: True when the station default day mode is NWS climate-day.
                      observations:
                        type: array
                        items:
                          $ref: "#/components/schemas/Observation"
                      count:
                        type: integer
                      resolution:
                        type: string
                        enum: ["1m"]
                        description: |
                          Observation bucket size for this response. Paid external API plans always
                          receive 1-minute points (`"1m"`).
                      tier:
                        type: string
                        description: Effective plan tier
        "400":
          description: Invalid time range
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Station not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/stations/{station_id}/reports/latest:
    get:
      tags: [Reports]
      summary: Get latest reports by type
      description: |
        Returns the latest report for each report type available on this station.
        Requires a paid API subscription (Starter, Pro, or Clanker); see **Plan Limits**.
        Official station reports can affect the authoritative `daily_*` high/low
        values returned by `GET /api/v1/stations/{station_id}/observations/latest`.
        Use `report_revision` and `report_updated_at` to detect corrections.
      operationId: getStationReportsLatest
      parameters:
        - name: station_id
          in: path
          required: true
          schema:
            type: string
          example: KMIA
      responses:
        "200":
          description: Latest reports plus station report schedules
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/StationReport"
                  report_schedules:
                    allOf:
                      - $ref: "#/components/schemas/ReportSchedules"
                    nullable: true
        "404":
          description: Station not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/stations/{station_id}/reports:
    get:
      tags: [Reports]
      summary: Get station reports
      description: |
        Returns station reports filtered by optional report type and date.
        Requires a paid API subscription (Starter, Pro, or Clanker); see **Plan Limits**.
        Official station reports can affect the authoritative `daily_*` high/low
        values returned by `GET /api/v1/stations/{station_id}/observations/latest`.
        Use `report_revision` and `report_updated_at` to detect corrections.
      operationId: getStationReports
      parameters:
        - name: station_id
          in: path
          required: true
          schema:
            type: string
          example: KMIA
        - name: type
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/ReportType"
          description: Optional report type filter.
        - name: date
          in: query
          required: false
          schema:
            type: string
            format: date
          description: Optional local calendar date filter (YYYY-MM-DD).
      responses:
        "200":
          description: Report list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/StationReport"
        "400":
          description: Invalid date format
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Station not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/stations/{station_id}/reports/history:
    get:
      tags: [Reports]
      summary: List historical station reports
      description: |
        Returns station reports for an inclusive `report_date` range with cursor pagination.
        Rows are ordered by `issuance_time` descending, then `id` descending.
        The maximum `start`–`end` span is limited by plan tier (`max_history_days`; see **Plan Limits**
        and `GET /api/v1/limits`).
        Dates are interpreted in the station's city timezone. Omitted `start`/`end` default to the last
        7 local calendar days through today.
        Historical reports can explain later changes to the authoritative merged
        `daily_*` high/low values. Report corrections are represented by stable
        `report_id` plus incrementing `report_revision` / updated `report_updated_at`.
      operationId: getStationReportsHistory
      parameters:
        - name: station_id
          in: path
          required: true
          schema:
            type: string
          example: KMIA
        - name: type
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/ReportType"
          description: Optional report type filter (e.g. cli).
        - name: start
          in: query
          required: false
          schema:
            type: string
            format: date
          description: Inclusive lower bound on `report_date` (local calendar day, YYYY-MM-DD).
        - name: end
          in: query
          required: false
          schema:
            type: string
            format: date
          description: Inclusive upper bound on `report_date` (local calendar day, YYYY-MM-DD).
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 5000
            default: 500
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque cursor returned by a previous page
      responses:
        "200":
          description: Report history page
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      city:
                        type: object
                        properties:
                          id: { type: string }
                          slug: { type: string }
                          name: { type: string }
                          timezone: { type: string }
                      station:
                        type: object
                        properties:
                          station_id: { type: string }
                          name: { type: string }
                          temperature_unit: { type: string, enum: [F, C] }
                      reports:
                        type: array
                        items:
                          $ref: "#/components/schemas/StationReport"
                      count:
                        type: integer
                      page:
                        type: object
                        properties:
                          limit: { type: integer }
                          next_cursor:
                            type: string
                            nullable: true
        "400":
          description: Invalid date range, limit, or cursor
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Reports require a paid plan
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Station not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/stations/{station_id}/weather-events:
    get:
      tags: [Weather Events]
      summary: Get station weather events
      description: Returns active and recently ended weather events derived from METAR/SPECI ingestion for the requested station.
      operationId: getStationWeatherEvents
      parameters:
        - name: station_id
          in: path
          required: true
          schema:
            type: string
          example: KMIA
      responses:
        "200":
          description: Weather event state for the station
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/StationWeatherEvents"
        "404":
          description: Station not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/forecasts/models:
    get:
      tags: [Forecasts]
      summary: List forecast models
      description: |
        Returns available weather forecast models. Paid API subscribers can fetch every model on
        forecast endpoints. The `is_public` flag marks the three models also available on the free
        web UI (`nws_gridpoint`, `ncep_nbm_conus`, `gfs_global`); see **Plan Limits**.
      operationId: listForecastModels
      responses:
        "200":
          description: List of models
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/ForecastModel"
                  count:
                    type: integer
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/stations/{station_id}/forecast:
    get:
      tags: [Forecasts]
      summary: Get latest forecast
      deprecated: true
      description: |
        Deprecated: read forecasts as files instead (see **Getting forecast data**). Follow
        `forecast_updated` on the WebSocket for new runs, list past runs with
        `GET /api/v1/stations/{station_id}/forecast/runs`, and fetch each run's file with a token
        from `GET /api/v1/data/access`. This endpoint keeps working and its response is unchanged.

        Returns the most recent forecast run for a station across all accessible models.
        A model whose most recent run was fetched before the plan's history window
        (`max_history_days`; see **Plan Limits**) is left out; if that leaves no model, the
        response is `403`.

      operationId: getStationForecast
      parameters:
        - name: station_id
          in: path
          required: true
          schema:
            type: string
          example: KMDW
        - name: model_id
          in: query
          schema:
            type: string
          description: Filter by specific model (e.g. ncep_hrrr_conus). Omit for all models.
        - name: If-None-Match
          in: header
          required: false
          schema:
            type: string
          description: Optional ETag from a previous forecast response for conditional requests.
      responses:
        "200":
          description: Forecast data grouped by model
          headers:
            ETag:
              description: Strong ETag for forecast payload caching.
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      city:
                        type: object
                        properties:
                          id: { type: string }
                          slug: { type: string }
                          name: { type: string }
                          timezone: { type: string }
                      station:
                        type: object
                        properties:
                          station_id: { type: string }
                          name: { type: string }
                          temperature_unit: { type: string, enum: [F, C] }
                      forecasts:
                        type: array
                        items:
                          $ref: "#/components/schemas/ForecastBundle"
                      count:
                        type: integer
        "304":
          description: Not modified (If-None-Match matched current forecast ETag)
          headers:
            ETag:
              description: Current strong ETag for the forecast payload.
              schema:
                type: string
        "403":
          description: Every requested forecast run is older than the plan's history window
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Station or forecast not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/stations/{station_id}/oracle-scores:
    get:
      tags: [Forecasts]
      summary: Get oracle forecast scores
      description: |
        Returns recent model-accuracy scores for a station.
        Use `mode=day_ahead` for trader-oriented day-ahead evaluation,
        `mode=day_of` for same-day forecast evaluation, and
        `rank_by=high` or `rank_by=low` to rank models for the specific market side.
        Numeric `days` is clamped by plan tier; `days=all` is Clanker-only. See **Plan Limits**.
      operationId: getStationOracleScores
      parameters:
        - name: station_id
          in: path
          required: true
          schema:
            type: string
          example: KMDW
        - name: days
          in: query
          required: false
          schema:
            type: string
            default: "7"
          description: |
            Lookback window as a decimal integer string (scored local days), or `all` for Clanker-tier
            API keys to aggregate every retained scoring day. Numeric values are clamped by plan tier
            (Starter 7, Pro 30, Clanker 90); see **Plan Limits**. `all` is rejected for non-Clanker tiers.
        - name: mode
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/OracleScoreMode"
            default: overall
          description: Which forecast-run set to evaluate.
        - name: rank_by
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/OracleRankBy"
            default: combined
          description: Which MAE metric determines the returned rank order.
      responses:
        "200":
          description: Oracle score table for the requested station
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/OracleScoreData"
        "404":
          description: Station not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/stations/{station_id}/oracle-score-days:
    get:
      tags: [Forecasts]
      summary: Get raw daily oracle score components
      description: |
        Returns per-day, per-station, per-model oracle evaluation rows so clients can fetch the
        maximum history allowed by their plan once and derive arbitrary local rolling windows
        (for example 4, 6, 7, 14, 30, or 90 days) without additional API calls.

        This endpoint preserves the existing aggregate `/oracle-scores` endpoint. Included rows
        contain high/low signed errors and absolute errors. In formula version `oracle-v1`,
        `combined_abs_error_f = (high_abs_error_f + low_abs_error_f) / 2`.

        Numeric `days` is capped by plan tier (Starter 7, Pro 30, Clanker 90). Explicit `start` and
        `end` date ranges must not exceed the plan window. `days=all` is Clanker-only. If `mode` is
        omitted, rows for `overall`, `day_ahead`, and `day_of` are returned. Excluded/missing model-day
        rows are omitted by default and returned only when `include_excluded=true`.
      operationId: getStationOracleScoreDays
      parameters:
        - name: station_id
          in: path
          required: true
          schema:
            type: string
          example: KMDW
        - name: days
          in: query
          required: false
          schema:
            type: string
            default: "7"
          description: |
            Lookback window as a decimal integer string (scored local days), or `all` for Clanker-tier
            API keys to return every retained scoring day. Numeric values are capped by plan tier.
        - name: mode
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/OracleScoreMode"
          description: Omit to return all modes.
        - name: model_id
          in: query
          required: false
          schema:
            type: string
          description: Optional forecast model ID filter. Omit to return all eligible models.
        - name: start
          in: query
          required: false
          schema:
            type: string
            format: date
          description: Inclusive scored local date lower bound (`YYYY-MM-DD`). Explicit ranges are plan-capped.
        - name: end
          in: query
          required: false
          schema:
            type: string
            format: date
          description: Inclusive scored local date upper bound (`YYYY-MM-DD`). Explicit ranges are plan-capped.
        - name: include_excluded
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: Include missing/excluded model-day rows with `included=false` and `exclusion_reason`.
      responses:
        "200":
          description: Daily oracle score component rows for the requested station
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/OracleScoreDaysData"
        "400":
          description: Invalid days, mode, model, or date range
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: "`days=all` requires a Clanker plan"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Station not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/markets:
    get:
      tags: [Markets]
      summary: List prediction markets
      description: |
        Returns active weather prediction markets from Kalshi, Polymarket and ForecastEx (the
        daily high and low contracts that Robinhood and IBKR broker), without their prices: the
        public API serves no market prices (since v1.16).
      operationId: listMarkets
      parameters:
        - name: platform
          in: query
          schema:
            type: string
            enum: [kalshi, polymarket, forecastex]
          description: Filter by platform
        - name: city
          in: query
          schema:
            type: string
          description: Filter by city slug (e.g. chi)
      responses:
        "200":
          description: List of markets
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Market"
                  count:
                    type: integer
        "404":
          description: City not found (when `city` filter is provided)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/markets/{market_id}:
    get:
      tags: [Markets]
      summary: Get market details
      operationId: getMarket
      parameters:
        - name: market_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Market details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Market"
        "404":
          description: Market not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/markets/{market_id}/history:
    get:
      tags: [Markets]
      summary: Get market snapshot history
      description: |
        Returns the snapshot times and settlement point of a single market. The points carry no
        prices: since v1.16 the public API serves none, and `yes_price`, `no_price` and `volume`
        are null. A settlement point carries `settlement_result`.
        For a Polymarket or ForecastEx market, only the settlement point is returned, when the
        market has resolved inside the requested range.
        Use `cursor` pagination for deterministic bot catch-up workflows.
        The maximum `start`–`end` span is limited by the authenticated plan tier (`max_history_days`;
        see **Plan Limits** and `GET /api/v1/limits`).
      operationId: getMarketHistory
      parameters:
        - name: market_id
          in: path
          required: true
          schema:
            type: string
          description: Kalshi market ticker, Polymarket market ID or ForecastEx contract ID
        - name: start
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Inclusive lower bound for snapshot timestamps (UTC)
        - name: end
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Inclusive upper bound for snapshot timestamps (UTC)
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 5000
            default: 500
          description: Maximum number of rows to return
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque cursor returned by a previous page
      responses:
        "200":
          description: Market snapshot page
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      market_id:
                        type: string
                      platform:
                        type: string
                        enum: [kalshi, polymarket, forecastex]
                      points:
                        type: array
                        items:
                          $ref: "#/components/schemas/MarketHistoryPoint"
                      count:
                        type: integer
                      page:
                        $ref: "#/components/schemas/CursorPagination"
        "400":
          description: Invalid cursor or time range
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Market not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/stations/{station_id}/forecast/runs:
    get:
      tags: [Forecasts]
      summary: List forecast runs
      description: |
        Returns forecast run metadata for a station, optionally filtered by model and time range.
        Use `cursor` pagination for deterministic catch-up.
        The maximum `start`–`end` span is limited by plan tier (`max_history_days`; see **Plan Limits**
        and `GET /api/v1/limits`). Runs are returned from the start of the plan's history window
        (UTC midnight, `max_history_days` days before today) even when `start` is earlier; a range
        that ends before the window is `403`.
      operationId: listForecastRuns
      parameters:
        - name: station_id
          in: path
          required: true
          schema:
            type: string
          example: KMDW
        - name: model_id
          in: query
          required: false
          schema:
            type: string
          description: Optional model ID filter (e.g. ncep_hrrr_conus)
        - name: start
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Inclusive lower bound for `fetched_at` (UTC)
        - name: end
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Inclusive upper bound for `fetched_at` (UTC)
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 5000
            default: 500
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque cursor returned by a previous page
      responses:
        "200":
          description: Forecast run page
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      city:
                        type: object
                        properties:
                          id: { type: string }
                          slug: { type: string }
                          name: { type: string }
                          timezone: { type: string }
                      station:
                        type: object
                        properties:
                          station_id: { type: string }
                          name: { type: string }
                          temperature_unit: { type: string, enum: [F, C] }
                      runs:
                        type: array
                        items:
                          $ref: "#/components/schemas/ForecastRunSummary"
                      count:
                        type: integer
                      page:
                        $ref: "#/components/schemas/CursorPagination"
        "400":
          description: Invalid cursor or time range
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: The requested range is older than the plan's history window
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Station not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/stations/{station_id}/forecast/runs/{run_id}:
    get:
      tags: [Forecasts]
      summary: Get forecast run details
      deprecated: true
      description: |
        Deprecated: read the run's file instead (see **Getting forecast data**). Take
        `object_path` from `GET /api/v1/stations/{station_id}/forecast/runs` (or `path` from the
        WebSocket `forecast_updated` event) and fetch it with a token from
        `GET /api/v1/data/access`. The file has every step and weather field of the run. This
        endpoint keeps working and its response is unchanged; it is the way to read a run that has
        no file.

        Returns a specific forecast run and its hourly points.
        A run fetched before the plan's history window (`max_history_days`; see **Plan Limits**)
        is `403`.

        A run's points never change, so a response read from the run's file carries a strong
        `ETag` and `Cache-Control: private, max-age=86400, immutable`. Send the ETag back in
        `If-None-Match` to get `304` without a body; the history window is checked first.

      operationId: getForecastRun
      parameters:
        - name: station_id
          in: path
          required: true
          schema:
            type: string
          example: KMDW
        - name: run_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Forecast run details
          headers:
            ETag:
              description: Strong ETag of the response.
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      city:
                        type: object
                        properties:
                          id: { type: string }
                          slug: { type: string }
                          name: { type: string }
                          timezone: { type: string }
                      station:
                        type: object
                        properties:
                          station_id: { type: string }
                          name: { type: string }
                          temperature_unit: { type: string, enum: [F, C] }
                      forecast_run:
                        $ref: "#/components/schemas/ForecastRunSummary"
                      hourly:
                        type: array
                        items:
                          $ref: "#/components/schemas/HourlyForecast"
                      count:
                        type: integer
        "304":
          description: Not modified (If-None-Match matched the run's ETag)
          headers:
            ETag:
              description: The run's strong ETag.
              schema:
                type: string
        "403":
          description: The forecast run is older than the plan's history window
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Station or forecast run not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/data/access:
    get:
      tags: [Forecasts]
      summary: Get access to forecast data files
      description: |
        Returns the base URL of the forecast data files and one access token per path prefix the
        plan may read. Tokens are valid until `expires_at`; a response may be reused for up to five
        minutes (see its `Cache-Control`) and must not be shared between API keys. Fetch a new one
        shortly before `expires_at`, when a date you need is not listed, or after a file request
        answers `403`. See **Getting forecast data** for the full flow.

        The same response carries the hourly index file grants (`hourly_indexes`,
        `hourly_index_days`); see **Hourly index data files**.

        If file delivery is switched off this endpoint answers `404` with code
        `DATA_ACCESS_NOT_ENABLED`; the deprecated forecast and hourly index forecast endpoints
        still serve the data then.
      operationId: getDataAccess
      responses:
        "200":
          description: Base URL and access tokens
          headers:
            Cache-Control:
              schema:
                type: string
              description: "`private, max-age=N`, N at most 300 and never past the next UTC midnight"
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/DataAccess"
        "401":
          description: Invalid or missing API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: The plan has no API access
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Forecast data file access is not enabled (`DATA_ACCESS_NOT_ENABLED`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/hourly-indexes:
    get:
      tags: [Hourly Indexes]
      summary: List hourly index cities
      description: |
        Every active Kalshi Weather Index city with its members, hourly series, newest minute,
        current hour and open event (read from the live cache). For live updates subscribe on
        the WebSocket with `hourly_indexes` instead of polling.
      operationId: listHourlyIndexes
      responses:
        "200":
          description: Active index cities, cities with an hourly market first
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/HourlyIndexCity"
        "429":
          description: Rate limit exceeded; use the WebSocket `hourly_indexes` subscription for live data
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/hourly-indexes/{city}:
    get:
      tags: [Hourly Indexes]
      summary: Get an hourly index city
      description: |
        The city's current state, the same body as the WebSocket `hourly_index_snapshot` without
        `minutes`. Use it for cold starts; live changes arrive on the WebSocket. The `ladder` lists
        the strikes without their quotes (null since v1.16: the public API serves no market prices).
      operationId: getHourlyIndex
      parameters:
        - $ref: "#/components/parameters/HourlyIndexCityID"
      responses:
        "200":
          description: City state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/HourlyIndexCity"
        "404":
          description: Unknown or inactive index city
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded; use the WebSocket `hourly_indexes` subscription for live data
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/hourly-indexes/{city}/minutes:
    get:
      tags: [Hourly Indexes]
      summary: Get hourly index minutes
      description: |
        Minutes with `from` <= minute <= `to`, oldest first (default: the last hour). The window is
        at most 6 hours with `include=stations` and 48 hours without, and `from` must be within
        the plan's `max_history_days`. The last 47 hours come from the live cache, older minutes
        from the database (kept 180 days).
      operationId: getHourlyIndexMinutes
      parameters:
        - $ref: "#/components/parameters/HourlyIndexCityID"
        - name: from
          in: query
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          schema:
            type: string
            format: date-time
        - name: include
          in: query
          schema:
            type: string
            enum: [stations]
          description: Include member readings per minute.
        - name: phase
          in: query
          schema:
            type: string
            enum: [provisional, official, missing]
      responses:
        "200":
          description: Minutes in the window
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      city:
                        type: string
                      minutes:
                        type: array
                        items:
                          $ref: "#/components/schemas/HourlyIndexMinute"
        "400":
          description: Invalid window or parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Unknown or inactive index city
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded; use the WebSocket `hourly_indexes` subscription for live data
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/hourly-indexes/{city}/settlements:
    get:
      tags: [Hourly Indexes]
      summary: Get hourly index settlements
      description: |
        Our hour settlements with `from` <= hour_end <= `to`, newest first (default: the last 24
        hours; at most 31 days, within `max_history_days`), with Kalshi's final value once the
        market is finalized. Settlements are kept forever.
      operationId: getHourlyIndexSettlements
      parameters:
        - $ref: "#/components/parameters/HourlyIndexCityID"
        - name: from
          in: query
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: Settlements in the window
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      city:
                        type: string
                      settlements:
                        type: array
                        items:
                          $ref: "#/components/schemas/HourlyIndexSettlement"
        "400":
          description: Invalid window
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Unknown or inactive index city
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/hourly-indexes/{city}/calibrations:
    get:
      tags: [Hourly Indexes]
      summary: Get hourly index calibrations
      description: The city's calibration timeline (member weights and offsets by config version), oldest first.
      operationId: getHourlyIndexCalibrations
      parameters:
        - $ref: "#/components/parameters/HourlyIndexCityID"
      responses:
        "200":
          description: Calibration records
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      city:
                        type: string
                      calibrations:
                        type: array
                        items:
                          $ref: "#/components/schemas/HourlyIndexCalibration"
        "404":
          description: Unknown or inactive index city
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/hourly-indexes/{city}/forecast:
    get:
      tags: [Hourly Indexes]
      summary: Get the hourly index forecast
      description: |
        Deprecated: follow the city's current hourly index data file instead (its `live` curve,
        announced by `index_file_updated`; see **Hourly index data files**). It keeps answering as
        below, unchanged.

        The city's latest index forecast from its members' HRRR 15-minute temperatures (the
        `forecast` field of the city body), or with `model=ncep_rrfs_conus_15min` from their RRFS
        15-minute temperatures (the city body's `forecasts` entry). Live refreshes arrive as the
        WebSocket `forecast_index` event. `forecast` is null when the model has no forecast for the
        city; the other model's is never returned in its place. An RRFS forecast with no step left
        is answered as null.
      operationId: getHourlyIndexForecast
      deprecated: true
      parameters:
        - $ref: "#/components/parameters/HourlyIndexCityID"
        - $ref: "#/components/parameters/HourlyIndexForecastModel"
      responses:
        "200":
          description: Latest forecast (null until the model's first run)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      city:
                        type: string
                      model_id:
                        type: string
                        description: The forecast model answered for.
                        example: ncep_hrrr_conus_15min
                      forecast:
                        allOf:
                          - $ref: "#/components/schemas/HourlyIndexForecast"
                        nullable: true
        "400":
          description: Unsupported model
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Unknown or inactive index city
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/hourly-indexes/{city}/forecast/history:
    get:
      tags: [Hourly Indexes]
      summary: Get the stitched hourly index forecast history
      description: |
        Deprecated: read the hourly index day files and the current file instead (see **Hourly
        index data files**). It keeps answering as below, unchanged, and is the exact answer for a
        window that crosses a midnight or a calibration change.

        The index forecast of the model (HRRR by default) as it stood when each 15-minute step in
        the window began, from that model's stored runs only: each
        member's temperature comes from its newest run fetched at or before the step
        (`fetched_at <= step`), so a later run only replaces steps from its own fetch on.
        The index uses the calibration effective at the step and the city's quorum. Arrays align
        to `steps`; steps with no member forecast are left out. Defaults to the 6 hours to now;
        the window may not exceed 48 hours.
      operationId: getHourlyIndexForecastHistory
      deprecated: true
      parameters:
        - $ref: "#/components/parameters/HourlyIndexCityID"
        - $ref: "#/components/parameters/HourlyIndexForecastModel"
        - name: from
          in: query
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: Stitched forecast steps in the window
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      city:
                        type: string
                      from:
                        type: string
                        format: date-time
                      to:
                        type: string
                        format: date-time
                      model_id:
                        type: string
                        description: The forecast model answered for.
                        example: ncep_hrrr_conus_15min
                      steps:
                        type: array
                        items:
                          type: string
                          format: date-time
                      value_f:
                        type: array
                        description: Index forecast per step; null below quorum.
                        items:
                          type: number
                          nullable: true
                      adjusted_f:
                        type: array
                        description: |
                          Bias-adjusted index forecast per step, as forecast: `value_f` plus the bias
                          known when the step's value was fetched: the newest index minute that was
                          already official by the newest member run's `fetched_at` (its `official_at`
                          at or before it, the minute within an hour), minus the stitched forecast at
                          that minute. Official values are never revised, so this uses nothing known
                          only later; it trails the live bias by about 5 minutes (a minute is official
                          5 minutes after it ends). `value_f` when no such minute exists; null when
                          `value_f` is null.
                        items:
                          type: number
                          nullable: true
                      quorum_met:
                        type: array
                        items:
                          type: boolean
                      partial:
                        type: array
                        description: Some members of the step's calibration had no forecast.
                        items:
                          type: boolean
                      members:
                        type: array
                        items:
                          $ref: "#/components/schemas/HourlyIndexForecastMember"
        "400":
          description: Invalid window or unsupported model
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Unknown or inactive index city
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/hourly-indexes/{city}/match-rate:
    get:
      tags: [Hourly Indexes]
      summary: Get the settlement match rate
      description: How often our hour settlement matched Kalshi's final value over the window (cached 5 minutes).
      operationId: getHourlyIndexMatchRate
      parameters:
        - $ref: "#/components/parameters/HourlyIndexCityID"
        - name: window
          in: query
          schema:
            type: string
            enum: [7d, 30d]
            default: 7d
      responses:
        "200":
          description: Match rate
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/HourlyIndexMatchRate"
        "404":
          description: Unknown or inactive index city
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/hourly-indexes/{city}/forecast-accuracy:
    get:
      tags: [Hourly Indexes]
      summary: Get the index forecast accuracy
      description: |
        How the 15-minute index forecast of the model (HRRR by default) did against the settle of
        each determined hour in the window, at 60, 30 and 15 minutes before `hour_end`, for the bias-adjusted and the raw
        forecast. Each lead rebuilds the forecast as it stood then: every member's newest run
        fetched by `hour_end − lead` (stitched with the calibration effective at each step), and the
        bias against the newest index minute already official then (`official_at` at or before
        `hour_end − lead`; provisional values are not used). Hours are scored once determined (within
        about 5 minutes); cached 5 minutes. Each model is scored from its own runs; RRFS scores
        exist only for hours determined while its index forecast was enabled.
      operationId: getHourlyIndexForecastAccuracy
      parameters:
        - $ref: "#/components/parameters/HourlyIndexCityID"
        - $ref: "#/components/parameters/HourlyIndexForecastModel"
        - name: window
          in: query
          schema:
            type: string
            enum: [7d, 30d]
            default: 7d
      responses:
        "200":
          description: Forecast accuracy
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/HourlyIndexForecastAccuracy"
        "400":
          description: Invalid window or unsupported model
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Unknown or inactive index city
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/limits:
    get:
      tags: [Limits]
      summary: Get effective limits for current authenticated user
      description: |
        Returns plan limits and current regional token bucket metadata for the authenticated user.
        Static caps match the **Plan Limits** section (tier, rate limits, `max_history_days`,
        WebSocket subscription limits).
        Limits are shared across that user's API keys, and remaining values are post-consumption
        snapshots for the request-serving region. Requires a paid API subscription.
      operationId: getLimits
      responses:
        "200":
          description: Effective limits for the authenticated user in the request-serving region
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/EffectiveLimits"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/ws-ticket:
    post:
      tags: [WebSocket]
      summary: Get a WebSocket ticket
      description: |
        Returns a short-lived JWT ticket (90 seconds) for WebSocket authentication.
        Requires Starter, Pro, or Clanker; subscription limits are in **Plan Limits**.
        Connect to `/ws/api/1m` (recommended) or legacy `/ws/api/5m` — both deliver realtime
        1-minute events. Use WebSocket subprotocols: first `bearer`, then the ticket.

        ```bash
        # 1. Get a ticket
        TICKET=$(curl -s -X POST -H "X-API-Key: your_key" \
          https://api.minutetemp.com/api/v1/ws-ticket | jq -r '.data.ticket')

        # 2. Connect via WebSocket
        wscat -c wss://api.minutetemp.com/ws/api/1m \
          -s bearer -s "$TICKET"
        ```
      operationId: createWsTicket
      responses:
        "200":
          description: WebSocket ticket
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/WsTicket"
        "401":
          description: Invalid or missing API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: API key does not have websocket scope for its plan
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
