# AUTO-GENERATED — DO NOT EDIT BY HAND. Region: road511.
# Source of truth: backend/docs/openapi.yaml (full internal spec).
# Regenerate with: go generate ./docs/...  (from backend/)
# Shipped as a static asset by deploy-landing.sh; served at /openapi.yaml on the landing host.

openapi: 3.1.0
info:
  title: Road511 Traffic API
  description: |
    Aggregated North American 511 traffic data API. Normalizes data from 45+ US state
    and Canadian province 511 systems into a single REST API.

    ## Authentication
    - **API Key**: Pass via `X-API-Key` header or `api_key` query parameter
    - **Admin**: Bearer JWT token via `Authorization: Bearer <token>`
    - **Customer Portal**: Bearer JWT token via `Authorization: Bearer <token>`

    ## Plan Limits
    | Plan | RPM | Daily Requests | Jurisdictions | Results/Page | GeoJSON | Analytics | Truck | Data Delay | Trial |
    |------|-----|----------------|---------------|--------------|---------|-----------|-------|------------|-------|
    | Free | 60 | 1,000 | 2 | 100 | No | No | No | 15 min | 14 days |
    | Starter | 300 | 50,000 | 10 | 500 | Yes | No | No | Real-time | - |
    | Pro | 1,000 | 500,000 | Unlimited | 1,000 | Yes | Yes | Yes | Real-time | - |
    | Enterprise | 5,000 | Unlimited | Unlimited | Unlimited | Yes | Yes | Yes | Real-time | - |
  version: 1.0.0
  contact:
    email: support@road511.com
  license:
    name: Proprietary
servers:
  - url: https://api.road511.com/api/v1
    description: Production
  - url: http://localhost:8080/api/v1
    description: Local development
tags:
  - name: events
    description: Traffic events (incidents, construction, closures, etc.)
  - name: features
    description: Point-of-interest features (cameras, rest areas, signs, etc.)
  - name: map
    description: Map-optimized endpoints (compact mode, same data)
  - name: analytics
    description: Historical analytics and trends (Pro+ plans)
  - name: truck
    description: Truck corridor restriction queries (Pro+ plans with truck data access)
  - name: routing
    description: |
      Truck routing — compute a hazard-enriched route via HERE, refetch / persist
      / delete saved routes. Counts against monthly routing quota (subscription
      bucket first, then top-ups FIFO).
  - name: metadata
    description: Jurisdictions, stats, health
  - name: fuel-prices
    description: Regional fuel prices — weekly US state-level (EIA), monthly Canadian province-level (StatCan)
security:
  - ApiKeyHeader: []
  - ApiKeyQuery: []
components:
  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
    ApiKeyQuery:
      type: apiKey
      in: query
      name: api_key
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
      required: [error]
    HealthResponse:
      type: object
      properties:
        status:
          type: string
          enum: [ok, degraded]
        version:
          type: string
        uptime:
          type: string
        db:
          type: string
          description: |
            Combined Postgres and Redis liveness, formatted as
            `<postgres>/<redis>` where each segment is `ok` or `down`
            (e.g. `ok/ok`, `ok/down`). Probe timeout is 2 seconds; the
            underlying error is logged server-side, not returned.
          example: ok/ok
      required: [status, version, uptime, db]
    TrafficEvent:
      type: object
      properties:
        id:
          type: string
        source_id:
          type: string
        source:
          type: string
          description: Jurisdiction code
        jurisdiction:
          type: string
        type:
          type: string
          enum: [incident, construction, closure, special_event, weather, road_condition, planned, hazard]
        severity:
          type: string
          enum: [minor, moderate, major, critical]
        status:
          type: string
          enum: [active, archived]
        title:
          type: string
        description:
          type: string
        location:
          type: object
          description: GeoJSON geometry (Point, LineString, or Polygon) describing where the event applies.
        affected_roads:
          type: array
          items:
            type: string
        direction:
          type: [string, "null"]
        lanes_affected:
          type: [string, "null"]
        start_time:
          type: string
          format: date-time
        end_time:
          type: [string, "null"]
          format: date-time
          description: |
            Upstream-reported end time. NULL for events the upstream feed
            never assigned an explicit end (the common case — most 511 feeds
            simply drop resolved incidents rather than emitting a final
            timestamp).
        effective_end_time:
          type: [string, "null"]
          format: date-time
          description: |
            Best available end time for the event. Equals `end_time` when
            the upstream feed reported one; otherwise falls back to
            `archived_at` (the moment our sweep first observed the event
            had disappeared from the feed). NULL only for active events
            with no upstream-reported end — i.e., events that are still
            ongoing. Use this field for "incident duration" analytics.
        estimated_start_time:
          type: [string, "null"]
          format: date-time
        estimated_end_time:
          type: [string, "null"]
          format: date-time
        latitude:
          type: number
        longitude:
          type: number
        road_class:
          type: string
          enum: [interstate, us_highway, state_highway, local]
        last_updated:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        archived_at:
          type: [string, "null"]
          format: date-time
          description: Set the moment our sweep first observed the event missing from the upstream feed.
        archive_reason:
          type: [string, "null"]
          enum: [observed, stale_sweep, null]
          description: |
            How the event was archived. NULL while `status='active'`; set
            alongside `archived_at` at archival time.

            - `observed` — upstream returned a clean response and the event
              was not in it, so the per-poll archival path retired it. This
              is the normal case.
            - `stale_sweep` — the per-poll path could not retire the event
              because upstream responses were empty or errored (so we cannot
              prove the event is gone vs. a transient outage). After roughly
              `poll_interval × sweep_factor` with no fresh observation
              (default factor = 15), a safety-net sweep forces the event to
              archived. A persistently high `stale_sweep` ratio on a source
              signals that the upstream feed often fails to emit clearance
              records or is unreliable in general — treat the
              `archived_at` for such events as "presumed gone by", not a
              precise end timestamp.
        source_created_at:
          type: [string, "null"]
          format: date-time
          description: Upstream-reported creation timestamp, when available.
        source_updated_at:
          type: [string, "null"]
          format: date-time
          description: Upstream-reported last-modified timestamp, when available.
        metadata:
          type: object
          description: Raw upstream attributes preserved as opaque JSON. Shape varies by source.
    Jurisdiction:
      type: object
      properties:
        code:
          type: string
          description: |
            Identifier used everywhere else in the API (`source`, `jurisdiction`,
            webhook filters, etc.). Two-letter postal codes for US states and
            Canadian provinces; `WZDX_<STATE>` for the parallel WZDx feed; ad-hoc
            codes for sub-state and federal sources (`511SF`, `EIA`, `FHWA`,
            `WZDX_NPS`, ...).
        name:
          type: string
        country:
          type: string
          description: ISO 3166-1 alpha-2 (`US`, `CA`, `DE`, ...).
        scope:
          type: string
          enum: [state, federal, regional]
          description: |
            `state` is the default — real geographic jurisdictions (US states,
            Canadian provinces). `federal` covers cross-cutting national sources
            (`EIA`, `FHWA`, `WZDX_NPS`). `regional` covers sub-state feeds
            (`511SF`, `WZDX_AUSTIN`, `WZDX_MCDOT`, `WZDX_STC_MO`). The default
            response from `GET /jurisdictions` is `scope=state` only.
        group_id:
          type: [string, "null"]
          description: |
            When non-null, points at a record in `GET /jurisdictions/groups` —
            the canonical bundle that includes this row's primary code plus its
            WZDx / CWZ / regional siblings. Querying `?jurisdiction=<primary>`
            on `/events` or `/features` auto-expands to all members.
        is_active:
          type: boolean
        last_sync_at:
          type: [string, "null"]
          format: date-time
        official_name:
          type: [string, "null"]
      required: [code, name, country, scope, is_active]
    JurisdictionGroup:
      type: object
      properties:
        id:
          type: string
          description: Stable slug, e.g. `us-ca`, `us-co`, `us-tx`.
        name:
          type: string
        description:
          type: string
        primary_jurisdiction_code:
          type: string
          description: |
            The canonical state/province code (`CA`, `CO`, `TX`). Querying this
            code on `/events` or `/features` auto-expands to all `members`.
        sort_order:
          type: integer
        members:
          type: array
          items:
            type: string
          description: |
            Jurisdiction codes in this group, primary first. Includes the state
            feed plus its `WZDX_<STATE>`, optional `WZDX_CWZ_<STATE>`, and any
            sub-state regional siblings.
      required: [id, name, primary_jurisdiction_code, members]
    Feature:
      type: object
      properties:
        id:
          type: string
        source_id:
          type: string
          description: Upstream-provided identifier, before our synthetic `id` prefix.
        source:
          type: string
        jurisdiction:
          type: string
        feature_type:
          type: string
        name:
          type: string
        description:
          type: string
        latitude:
          type: number
        longitude:
          type: number
        road_name:
          type: [string, "null"]
        direction:
          type: [string, "null"]
        is_active:
          type: boolean
        start_time:
          type: [string, "null"]
          format: date-time
          description: Populated for time-bounded feature types (future_construction, alerts, special_events, truck_restrictions, etc.). NULL for permanent features.
        end_time:
          type: [string, "null"]
          format: date-time
        estimated_start_time:
          type: [string, "null"]
          format: date-time
        estimated_end_time:
          type: [string, "null"]
          format: date-time
        properties:
          type: object
          description: Type-specific fields (JSONB)
        geometry:
          type: [object, "null"]
          description: Full GeoJSON geometry when the feature is a line or polygon; NULL for point-only features (read latitude/longitude instead).
        last_updated:
          type: string
          format: date-time
        has_details:
          type: boolean
          description: |
            `true` when this feature's `(source, feature_type)` pair has a
            registered lazy-load detail function — calling
            `GET /features/{id}/details` or batching via
            `POST /features/details/batch` will return strictly richer data
            than what's in the list row (multi-view camera URLs, EV connector
            availability, sign message text, etc.). `false` means the list
            row already carries everything the source publishes, and the
            detail call would be a no-op.
            Not set on the `/features/geojson` path (that JSON is built in
            Postgres for performance — call `/features` for the hint).
    Plan:
      type: object
      properties:
        id:
          type: integer
        code:
          type: string
        name:
          type: string
        description:
          type: string
        price_monthly:
          type: number
          description: Monthly price in the currency the Paddle account is configured for.
        rate_limit_rpm:
          type: integer
        max_keys:
          type: integer
        max_requests_day:
          type: integer
          description: 0 = unlimited
        max_jurisdictions:
          type: integer
          description: 0 = unlimited
        max_results_per_page:
          type: integer
          description: 0 = unlimited
        max_batch_size:
          type: integer
          description: |
            Maximum number of ids accepted by batch endpoints
            (`GET /events?ids=…`, `POST /features/details/batch`). 0 = unlimited.
        allow_geojson:
          type: boolean
        allow_analytics:
          type: boolean
        allow_truck_data:
          type: boolean
        allow_webhooks:
          type: boolean
        max_webhooks:
          type: integer
          description: 0 = unlimited (only meaningful when `allow_webhooks` is true).
        data_delay_sec:
          type: integer
          description: 0 = real-time
        trial_days:
          type: integer
          description: 0 = no trial limit
        paddle_price_id:
          type: [string, "null"]
          description: Paddle price ID this plan is wired to for checkout. NULL for free/trial plans.
        is_popular:
          type: boolean
          description: Marketing flag — highlights this plan on the pricing page.
        is_contact_sales:
          type: boolean
          description: When true, the pricing page renders a "Contact sales" CTA instead of Paddle checkout.
        is_active:
          type: boolean
        features:
          type: array
          description: |
            Marketing bullets shown on pricing pages. Admin-managed via
            `/admin/plans/{id}/features`. Resolution order on the frontend:
            `i18n_key` (if the active locale has it) → raw `label` → the key
            string itself.
          items:
            $ref: '#/components/schemas/PlanFeature'
    PlanFeature:
      type: object
      description: |
        One marketing bullet on a plan's pricing card. Either `i18n_key` or
        `label` is always populated (DB CHECK constraint). Stable bullets use
        `i18n_key` so translations live in frontend locale files; short-lived
        marketing copy uses `label` only and ships unchanged in every locale.
      properties:
        id:
          type: integer
        plan_id:
          type: integer
        sort_order:
          type: integer
        i18n_key:
          type: [string, "null"]
          description: Translation key, e.g. `plan_feature.events_cameras`.
        label:
          type: [string, "null"]
          description: Raw fallback shown when no `i18n_key` is set or the locale lacks it.
        icon:
          type: [string, "null"]
          description: Short icon hint (`check`, `zap`, `shield`, ...) the frontend maps to an SVG.
        tooltip_i18n_key:
          type: [string, "null"]
        tooltip:
          type: [string, "null"]
      required: [id, plan_id, sort_order]
    Customer:
      type: object
      properties:
        id:
          type: integer
        email:
          type: string
        name:
          type: string
        company:
          type: string
        plan_id:
          type: integer
        subscription_status:
          type: string
          enum: [none, active, canceled, past_due, paused]
        subscription_ends_at:
          type: string
          format: date-time
        trial_ends_at:
          type: string
          format: date-time
        is_active:
          type: boolean
        email_verified:
          type: boolean
        created_at:
          type: string
          format: date-time
    APIKey:
      type: object
      properties:
        id:
          type: integer
        key_prefix:
          type: string
        name:
          type: string
        owner_email:
          type: string
        is_active:
          type: boolean
        rate_limit_rpm:
          type: integer
        allowed_origins:
          type: array
          items:
            type: string
        created_at:
          type: string
          format: date-time
        last_used_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
    EventWeatherSnapshot:
      type: object
      properties:
        id:
          type: integer
        event_id:
          type: string
        station_feature_id:
          type: string
        distance_km:
          type: number
          description: Distance from event to weather station in km
        temperature:
          type: number
          description: Temperature in °C
        wind_speed:
          type: number
          description: Wind speed in km/h
        wind_direction:
          type: string
        visibility:
          type: number
          description: Visibility in km
        precipitation:
          type: string
          description: Precipitation type (none, rain, snow, ice, etc.)
        road_surface:
          type: string
          description: Road surface condition (dry, wet, icy, etc.)
        humidity:
          type: number
          description: Relative humidity percentage
        reading_recorded_at:
          type: string
          format: date-time
          description: When the weather reading was taken
        created_at:
          type: string
          format: date-time
    WeatherCorrelationStats:
      type: object
      properties:
        precipitation:
          type: string
        road_surface:
          type: string
        event_count:
          type: integer
        avg_severity:
          type: number
        avg_temp_c:
          type: number
        avg_wind_speed:
          type: number
        avg_visibility:
          type: number
    PagedEvents:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/TrafficEvent'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        has_more:
          type: boolean
    PagedFeatures:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Feature'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        has_more:
          type: boolean
  parameters:
    jurisdiction:
      name: jurisdiction
      in: query
      schema:
        type: string
      description: |
        Filter by jurisdiction code (e.g., `GA`, `ON`, `CA`). Required on Free plan.

        **Auto-expansion:** querying a state's primary code that has WZDx/CWZ
        or sub-state regional siblings (see `GET /jurisdictions/groups`) is
        expanded transparently to include all members. For example,
        `?jurisdiction=CA` returns rows from `CA`, `WZDX_CA`, and `511SF`.

        To opt out, query a non-primary code directly (`?jurisdiction=WZDX_CA`
        returns WZDx-only rows) or pass a comma-separated list explicitly
        (`?jurisdiction=CA,WZDX_CA`), which bypasses expansion and matches
        each code literally.
    limit:
      name: limit
      in: query
      schema:
        type: integer
        default: 100
      description: Results per page (capped by plan)
    offset:
      name: offset
      in: query
      schema:
        type: integer
        default: 0
      description: Pagination offset
    bbox:
      name: bbox
      in: query
      schema:
        type: string
      description: "Bounding box: minLng,minLat,maxLng,maxLat"
    lat:
      name: lat
      in: query
      schema:
        type: number
      description: Latitude for radius search
    lng:
      name: lng
      in: query
      schema:
        type: number
      description: Longitude for radius search
    radius_km:
      name: radius_km
      in: query
      schema:
        type: number
        default: 50
      description: Radius in km (used with lat/lng)
paths:
  # ---- Health ----
  /health:
    get:
      tags: [metadata]
      summary: Health check
      description: |
        Returns server status, version, uptime, and the combined live status
        of the Postgres and Redis dependencies in `db` (formatted as
        `<postgres>/<redis>`, each segment `ok` or `down`). Returns 200 when
        both respond within 2 seconds; otherwise 503 with the same body shape.
      security: []
      responses:
        '200':
          description: All dependencies healthy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
              example:
                status: ok
                version: 0.1.0
                uptime: 2h30m
                db: ok/ok
        '503':
          description: One or more dependencies unhealthy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
              example:
                status: degraded
                version: 0.1.0
                uptime: 2h30m
                db: ok/down
  /status:
    get:
      tags: [metadata]
      summary: Public status snapshot
      description: |
        Aggregate health snapshot suitable for an unauthenticated status page or external uptime monitor.
        Returns the worker heartbeat freshness, total/healthy/warning/critical resource counts, and a
        per-jurisdiction breakdown with 7-day uptime. 60-second cache. No API key required.
      security: []
      responses:
        '200':
          description: Aggregate status snapshot
          content:
            application/json:
              example:
                overall: operational
                generated_at: "2026-05-08T14:00:00Z"
                worker:
                  last_seen: "2026-05-08T13:59:50Z"
                  staleness_seconds: 10
                  healthy: true
                total_resources: 312
                healthy: 295
                warning: 12
                critical: 5
                jurisdictions:
                  - code: ON
                    name: Ontario
                    country: CA
                    status: operational
                    total_resources: 8
                    open_circuits: 0
                    uptime_7day_percent: 99.8
                  - code: WA
                    name: Washington
                    country: US
                    status: degraded
                    total_resources: 6
                    open_circuits: 1
                    uptime_7day_percent: 94.5
  # ---- Plans (public) ----
  /plans:
    get:
      tags: [metadata]
      summary: List subscription plans
      description: |
        Returns active subscription plans with their gating columns (RPM,
        request quotas, allow_* flags) **and** their admin-curated marketing
        bullets (`features`). The response is wrapped in a `plans` array;
        when Paddle is configured the response also carries
        `paddle_client_token` + `paddle_env` for the checkout overlay.

        Resolution order for each feature on the frontend:
        `i18n_key` (when the current locale has it) → raw `label` → key.
      security: []
      responses:
        '200':
          description: Active plans + Paddle config
          content:
            application/json:
              example:
                plans:
                  - id: 1
                    code: free
                    name: Free
                    description: 14-day trial with basic access
                    rate_limit_rpm: 60
                    max_keys: 1
                    max_requests_day: 1000
                    max_jurisdictions: 2
                    max_results_per_page: 100
                    allow_geojson: false
                    allow_analytics: false
                    allow_truck_data: false
                    data_delay_sec: 900
                    trial_days: 14
                    is_active: true
                    features:
                      - {id: 11, plan_id: 1, sort_order: 10, label: "60 RPM · 1k req/day", icon: "check"}
                      - {id: 12, plan_id: 1, sort_order: 20, label: "2 jurisdictions", icon: "check"}
                      - {id: 13, plan_id: 1, sort_order: 30, i18n_key: "plan_feature.events_cameras", icon: "check"}
                      - {id: 14, plan_id: 1, sort_order: 40, i18n_key: "plan_feature.email_support", icon: "check"}
                  - id: 2
                    code: starter
                    name: Starter
                    description: For small apps and prototypes
                    rate_limit_rpm: 300
                    max_keys: 3
                    max_requests_day: 50000
                    max_jurisdictions: 10
                    max_results_per_page: 500
                    allow_geojson: true
                    allow_analytics: false
                    allow_truck_data: false
                    data_delay_sec: 0
                    trial_days: 0
                    is_active: true
                    features:
                      - {id: 21, plan_id: 2, sort_order: 10, label: "300 RPM · 50k req/day", icon: "check"}
                      - {id: 22, plan_id: 2, sort_order: 20, label: "All 65 jurisdictions", icon: "check"}
                      - {id: 23, plan_id: 2, sort_order: 30, i18n_key: "plan_feature.geojson_export", icon: "check"}
                      - {id: 24, plan_id: 2, sort_order: 40, label: "500 results per page", icon: "check"}
                paddle_client_token: "live_abc123"
                paddle_env: "live"
  # ---- Events ----
  /events:
    get:
      tags: [events]
      summary: List traffic events
      description: |
        Paginated list of traffic events with filters. Free plan requires
        jurisdiction parameter.

        **Batch lookup**: pass `?ids=id1,id2,...` to fetch a known set of
        events by id. When `ids` is set, all other filters except plan
        data delay are ignored, status is not enforced (archived events
        are returned alongside active ones), and pagination is disabled —
        the response always returns the full set in one page. The batch
        size is capped by the plan's `max_batch_size`.
      parameters:
        - name: ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated event IDs for batch lookup. Mutually exclusive
            with the filter parameters below (when present, other filters
            are ignored). Bounded by the plan's `max_batch_size`.
        - $ref: '#/components/parameters/jurisdiction'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/offset'
        - $ref: '#/components/parameters/bbox'
        - $ref: '#/components/parameters/lat'
        - $ref: '#/components/parameters/lng'
        - $ref: '#/components/parameters/radius_km'
        - name: type
          in: query
          schema:
            type: string
          description: Filter by event type
        - name: severity
          in: query
          schema:
            type: string
            enum: [minor, moderate, major, critical]
        - name: status
          in: query
          schema:
            type: string
            enum: [active, archived, all]
            default: active
          description: |
            `active` (default) returns active events, `archived` returns
            archived events only, `all` disables the filter and returns both.
        - name: road
          in: query
          schema:
            type: string
          description: Filter by road name (partial match)
      responses:
        '200':
          description: Paginated events
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PagedEvents'
              example:
                data:
                  - id: "on-evt-401-001"
                    source_id: "EVT-2026-04521"
                    source: "on"
                    jurisdiction: "ON"
                    type: incident
                    severity: major
                    status: active
                    title: "Multi-vehicle collision on Highway 401 WB near Yonge St"
                    description: "Two right lanes blocked. Emergency services on scene. Expect delays of 30+ minutes."
                    location:
                      type: Point
                      coordinates: [-79.38, 43.65]
                    affected_roads: ["Highway 401"]
                    direction: westbound
                    lanes_affected: "2 of 4 lanes blocked"
                    start_time: "2026-03-29T14:30:00Z"
                    end_time: null
                    estimated_end_time: "2026-03-29T17:00:00Z"
                    latitude: 43.65
                    longitude: -79.38
                    road_class: interstate
                    last_updated: "2026-03-29T14:45:00Z"
                    created_at: "2026-03-29T14:30:00Z"
                  - id: "ga-evt-i75-002"
                    source_id: "GA-CONST-8832"
                    source: "ga"
                    jurisdiction: "GA"
                    type: construction
                    severity: moderate
                    status: active
                    title: "Lane closure on I-75 NB near Exit 249"
                    description: "Road resurfacing. Right lane closed from mile marker 248 to 251."
                    location:
                      type: Point
                      coordinates: [-84.39, 33.75]
                    affected_roads: ["I-75"]
                    direction: northbound
                    lanes_affected: "1 of 3 lanes closed"
                    start_time: "2026-03-25T06:00:00Z"
                    end_time: null
                    estimated_end_time: "2026-04-15T18:00:00Z"
                    latitude: 33.75
                    longitude: -84.39
                    road_class: interstate
                    last_updated: "2026-03-29T08:00:00Z"
                    created_at: "2026-03-25T06:00:00Z"
                total: 2
                limit: 100
                offset: 0
                has_more: false
        '403':
          description: Plan limit exceeded (jurisdiction required, trial expired)
          content:
            application/json:
              example:
                error: "jurisdiction parameter required on Free plan"
  /events/geojson:
    get:
      tags: [events]
      summary: Events as GeoJSON
      description: Returns events as a GeoJSON FeatureCollection. Requires Starter+ plan.
      parameters:
        - $ref: '#/components/parameters/jurisdiction'
        - $ref: '#/components/parameters/bbox'
        - $ref: '#/components/parameters/lat'
        - $ref: '#/components/parameters/lng'
        - $ref: '#/components/parameters/radius_km'
        - name: type
          in: query
          schema:
            type: string
        - name: severity
          in: query
          schema:
            type: string
        - name: status
          in: query
          schema:
            type: string
            enum: [active, archived, all]
            default: active
          description: |
            `active` (default) returns active events, `archived` returns
            archived events only, `all` disables the filter and returns both.
      responses:
        '200':
          description: GeoJSON FeatureCollection
          content:
            application/geo+json:
              schema:
                type: object
              example:
                type: FeatureCollection
                features:
                  - type: Feature
                    geometry:
                      type: Point
                      coordinates: [-79.38, 43.65]
                    properties:
                      id: "on-evt-401-001"
                      source: "on"
                      jurisdiction: "ON"
                      type: incident
                      severity: major
                      status: active
                      title: "Multi-vehicle collision on Highway 401 WB near Yonge St"
                      affected_roads: ["Highway 401"]
                      direction: westbound
                      start_time: "2026-03-29T14:30:00Z"
        '403':
          description: GeoJSON not available on Free plan
          content:
            application/json:
              example:
                error: "GeoJSON access requires Starter plan or higher"
  /events/{id}:
    get:
      tags: [events]
      summary: Get event by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Event detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TrafficEvent'
              example:
                id: "on-evt-401-001"
                source_id: "EVT-2026-04521"
                source: "on"
                jurisdiction: "ON"
                type: incident
                severity: major
                status: active
                title: "Multi-vehicle collision on Highway 401 WB near Yonge St"
                description: "Two right lanes blocked. Emergency services on scene. Expect delays of 30+ minutes."
                location:
                  type: Point
                  coordinates: [-79.38, 43.65]
                affected_roads: ["Highway 401"]
                direction: westbound
                lanes_affected: "2 of 4 lanes blocked"
                start_time: "2026-03-29T14:30:00Z"
                end_time: null
                estimated_end_time: "2026-03-29T17:00:00Z"
                latitude: 43.65
                longitude: -79.38
                road_class: interstate
                last_updated: "2026-03-29T14:45:00Z"
                created_at: "2026-03-29T14:30:00Z"
        '404':
          description: Event not found
          content:
            application/json:
              example:
                error: "event not found"
  # ---- Features ----
  /features:
    get:
      tags: [features]
      summary: List features
      description: Paginated features filtered by type or group. Either `type` or `group` is required. Free plan requires jurisdiction parameter.
      parameters:
        - name: type
          in: query
          required: false
          schema:
            type: string
          description: "Feature type: cameras, rest_areas, weigh_stations, weather_stations, ev_charging, signs, road_conditions, etc. Required unless `group` is provided."
        - name: group
          in: query
          required: false
          schema:
            type: string
            enum: [imagery, weather, road_conditions, traffic_performance, planned_events, alerts_advisories, wildfires, trucking, traveler_services, fuel_charging, borders, ferries, transit, tolls, static_infrastructure, operations]
          description: "Feature group id. Convenience expansion to all feature_types in the group. See GET /features/groups."
        - $ref: '#/components/parameters/jurisdiction'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/offset'
        - $ref: '#/components/parameters/bbox'
        - $ref: '#/components/parameters/lat'
        - $ref: '#/components/parameters/lng'
        - $ref: '#/components/parameters/radius_km'
        - name: active
          in: query
          schema:
            type: string
            enum: ['true', 'false']
          description: |
            Tri-state filter on `is_active`. `true` returns active only,
            `false` returns inactive (archived) only, omitted returns both.
      responses:
        '200':
          description: Paginated features
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PagedFeatures'
              example:
                data:
                  - id: "on-cam-401-yonge"
                    source: "on"
                    jurisdiction: "ON"
                    feature_type: cameras
                    name: "Hwy 401 at Yonge St"
                    description: "Traffic camera - Highway 401 westbound at Yonge Street"
                    latitude: 43.6532
                    longitude: -79.3832
                    road_name: "Highway 401"
                    direction: westbound
                    is_active: true
                    properties:
                      url: "https://511on.ca/cameras/cam_401_yonge.jpg"
                      video_url: null
                    last_updated: "2026-03-29T14:50:00Z"
                    has_details: true
                  - id: "ga-wx-i75-atl"
                    source: "ga"
                    jurisdiction: "GA"
                    feature_type: weather_stations
                    name: "I-75 Atlanta Weather Station"
                    description: "RWIS weather station on I-75 near downtown Atlanta"
                    latitude: 33.7490
                    longitude: -84.3880
                    road_name: "I-75"
                    direction: null
                    is_active: true
                    properties:
                      temperature: 18.5
                      wind_speed: 12.3
                      wind_direction: "NW"
                      precipitation: "none"
                      road_surface: "dry"
                      humidity: 62
                      visibility: 16.0
                    last_updated: "2026-03-29T14:45:00Z"
                    has_details: false
                total: 2
                limit: 100
                offset: 0
                has_more: false
        '400':
          description: Missing type parameter
          content:
            application/json:
              example:
                error: "type parameter is required"
        '403':
          description: Plan limit exceeded
          content:
            application/json:
              example:
                error: "jurisdiction parameter required on Free plan"
  /features/geojson:
    get:
      tags: [features]
      summary: Features as GeoJSON
      description: Returns features as a GeoJSON FeatureCollection. Requires Starter+ plan.
      parameters:
        - name: type
          in: query
          required: true
          schema:
            type: string
        - $ref: '#/components/parameters/jurisdiction'
        - $ref: '#/components/parameters/bbox'
        - name: active
          in: query
          schema:
            type: string
            enum: ['true', 'false']
          description: |
            Defaults to active-only for map rendering. `active=false` drops the
            filter and returns both active and inactive features (asymmetric vs
            `/features` for back-compat with map clients — use `/features?active=false`
            to fetch inactive-only).
      responses:
        '200':
          description: GeoJSON FeatureCollection
          content:
            application/geo+json:
              schema:
                type: object
              example:
                type: FeatureCollection
                features:
                  - type: Feature
                    geometry:
                      type: Point
                      coordinates: [-79.3832, 43.6532]
                    properties:
                      id: "on-cam-401-yonge"
                      source: "on"
                      jurisdiction: "ON"
                      feature_type: cameras
                      name: "Hwy 401 at Yonge St"
                      is_active: true
        '403':
          description: GeoJSON not available on Free plan
          content:
            application/json:
              example:
                error: "GeoJSON access requires Starter plan or higher"
  /features/types:
    get:
      tags: [features]
      summary: List feature types
      description: Returns all feature types with total and active counts
      responses:
        '200':
          description: Feature type counts
          content:
            application/json:
              example:
                - type: cameras
                  count: 12450
                  active_count: 11893
                - type: weather_stations
                  count: 3200
                  active_count: 3142
                - type: signs
                  count: 4870
                  active_count: 4651
                - type: rest_areas
                  count: 1580
                  active_count: 1580
                - type: ev_charging
                  count: 98500
                  active_count: 98500
                - type: bridge_clearances
                  count: 45200
                  active_count: 45200
                - type: truck_restrictions
                  count: 8900
                  active_count: 7200
                - type: road_conditions
                  count: 2100
                  active_count: 1850
  /features/groups:
    get:
      tags: [features]
      summary: List feature groups (taxonomy)
      description: Returns the 16 feature-type groups with their member feature_types. Use the group `id` with `GET /features?group=<id>` to fetch all features in that group.
      responses:
        '200':
          description: Feature group taxonomy
          content:
            application/json:
              example:
                - id: imagery
                  name: Imagery
                  description: Live traffic camera feeds
                  sort_order: 1
                  feature_types: [cameras]
                - id: weather
                  name: Weather & Environment
                  description: Weather stations, forecasts, alerts and warnings
                  sort_order: 2
                  feature_types: [weather_stations, regional_weather, weather_forecasts, weather_alerts, wind_warnings]
                - id: trucking
                  name: Trucking & Commercial Vehicles
                  description: Truck restrictions, routes, parking, inspections
                  sort_order: 8
                  feature_types: [truck_restrictions, weight_restrictions, bridge_clearances, truck_routes, freight_corridors, truck_parking, truck_rest_areas, weigh_stations, inspection_stations]
  /features/{id}/details:
    get:
      tags: [features]
      summary: Feature detail (lazy-loaded)
      description: Fetches detailed properties for a feature on demand
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Feature with full properties
          content:
            application/json:
              example:
                data:
                  id: "on-cam-401-yonge"
                  source: "on"
                  jurisdiction: "ON"
                  feature_type: cameras
                  name: "Hwy 401 at Yonge St"
                  description: "Traffic camera - Highway 401 westbound at Yonge Street"
                  latitude: 43.6532
                  longitude: -79.3832
                  road_name: "Highway 401"
                  direction: westbound
                  is_active: true
                  properties:
                    url: "https://511on.ca/cameras/cam_401_yonge.jpg"
                    video_url: "https://511on.ca/cameras/cam_401_yonge.m3u8"
                    views:
                      - name: "Westbound"
                        url: "https://511on.ca/cameras/cam_401_yonge_wb.jpg"
                      - name: "Eastbound"
                        url: "https://511on.ca/cameras/cam_401_yonge_eb.jpg"
                  last_updated: "2026-03-29T14:50:00Z"
                detail_available: true
                cache: miss
        '404':
          description: Feature not found
          content:
            application/json:
              example:
                error: "feature not found"
  /features/details/batch:
    post:
      tags: [features]
      summary: Feature detail batch (lazy-loaded)
      description: |
        Fan-out variant of `/features/{id}/details` for callers that need to
        resolve many features at once. POST is used so the id list isn't
        constrained by URL length.

        Per-id caching is identical to the single endpoint (Redis-backed
        keyed on the id), so a batch that's a superset of previously
        requested ids costs nothing extra for the overlap. Partial success
        is preserved: a single failed upstream fetch sets `cache: "error"`
        with an `error` string on that entry without failing the whole
        request. Missing ids land in the response with `not_found: true`.

        Bounded by the plan's `max_batch_size`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items: {type: string}
                  minItems: 1
            example:
              ids: ["on-cam-401-yonge", "ga-cam-i75-249", "missing-id"]
      responses:
        '200':
          description: Per-id detail results (one entry per requested id, in order)
          content:
            application/json:
              example:
                count: 3
                data:
                  - id: "on-cam-401-yonge"
                    data:
                      id: "on-cam-401-yonge"
                      source: "on"
                      jurisdiction: "ON"
                      feature_type: cameras
                      name: "Hwy 401 at Yonge St"
                      properties:
                        url: "https://511on.ca/cameras/cam_401_yonge.jpg"
                    detail_available: true
                    cache: hit
                  - id: "ga-cam-i75-249"
                    data:
                      id: "ga-cam-i75-249"
                      source: "ga"
                      jurisdiction: "GA"
                      feature_type: cameras
                      name: "I-75 at Exit 249"
                    detail_available: true
                    cache: miss
                  - id: "missing-id"
                    detail_available: false
                    cache: ""
                    not_found: true
        '400':
          description: Invalid JSON body or empty ids array
          content:
            application/json:
              example:
                error: "ids is required and must contain at least one non-empty id"
        '403':
          description: Batch exceeds plan's max_batch_size
          content:
            application/json:
              example:
                error: "batch too large: 150 ids requested, max 100 on the Pro plan — upgrade for more"
  # ---- Map ----
  /map/config:
    get:
      tags: [map]
      summary: Map configuration
      description: Returns map detail level and other config
      responses:
        '200':
          description: Map config
          content:
            application/json:
              example:
                detail_level: compact
                max_results: 100
                rpm: 60
  /map/events:
    get:
      tags: [map]
      summary: Map events
      description: Public, capped to MAP_MAX_RESULTS, no pagination (offset always 0). No API key required.
      security: []
      parameters:
        - $ref: '#/components/parameters/jurisdiction'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/bbox'
      responses:
        '200':
          description: Paginated events
          content:
            application/json:
              example:
                data:
                  - id: "on-evt-401-001"
                    source: "on"
                    jurisdiction: "ON"
                    type: incident
                    severity: major
                    status: active
                    title: "Multi-vehicle collision on Highway 401 WB"
                    latitude: 43.65
                    longitude: -79.38
                    road_class: interstate
                    start_time: "2026-03-29T14:30:00Z"
                    last_updated: "2026-03-29T14:45:00Z"
                total: 1
                limit: 100
                offset: 0
                has_more: false
  /map/events/geojson:
    get:
      tags: [map]
      summary: Map events GeoJSON
      description: Public, same restrictions as /map/events (capped, no pagination)
      responses:
        '200':
          description: GeoJSON FeatureCollection
          content:
            application/geo+json:
              example:
                type: FeatureCollection
                features:
                  - type: Feature
                    geometry:
                      type: Point
                      coordinates: [-79.38, 43.65]
                    properties:
                      id: "on-evt-401-001"
                      source: "on"
                      jurisdiction: "ON"
                      type: incident
                      severity: major
                      title: "Multi-vehicle collision on Highway 401 WB"
  /map/features:
    get:
      tags: [map]
      summary: Map features
      description: Public, capped to MAP_MAX_RESULTS, no pagination (offset always 0)
      parameters:
        - name: type
          in: query
          required: true
          schema:
            type: string
        - $ref: '#/components/parameters/jurisdiction'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/bbox'
      responses:
        '200':
          description: Paginated features (capped)
          content:
            application/json:
              example:
                data:
                  - id: "on-cam-401-yonge"
                    source: "on"
                    jurisdiction: "ON"
                    feature_type: cameras
                    name: "Hwy 401 at Yonge St"
                    latitude: 43.6532
                    longitude: -79.3832
                    is_active: true
                    last_updated: "2026-03-29T14:50:00Z"
                total: 1
                limit: 100
                offset: 0
                has_more: false
  /map/features/geojson:
    get:
      tags: [map]
      summary: Map features GeoJSON
      description: Public, same restrictions as /map/features (capped, no pagination)
      responses:
        '200':
          description: GeoJSON FeatureCollection
          content:
            application/geo+json:
              example:
                type: FeatureCollection
                features:
                  - type: Feature
                    geometry:
                      type: Point
                      coordinates: [-79.3832, 43.6532]
                    properties:
                      id: "on-cam-401-yonge"
                      source: "on"
                      jurisdiction: "ON"
                      feature_type: cameras
                      name: "Hwy 401 at Yonge St"
                      is_active: true
  /map/features/types:
    get:
      tags: [map]
      summary: Map feature types
      responses:
        '200':
          description: Feature type counts
          content:
            application/json:
              example:
                - type: cameras
                  count: 12450
                  active_count: 11893
                - type: weather_stations
                  count: 3200
                  active_count: 3142
                - type: signs
                  count: 4870
                  active_count: 4651
  # ---- Metadata ----
  /jurisdictions:
    get:
      tags: [metadata]
      summary: List jurisdictions
      description: |
        Returns all active jurisdictions. By default, only real geographic
        jurisdictions (US states + Canadian provinces, `scope=state`) are
        returned — cross-cutting federal sources and sub-state regional
        feeds are filtered out so customer pickers stay clean.

        Use `?scope=federal` to list cross-cutting federal sources (EIA,
        FHWA, WZDX_NPS), `?scope=regional` for sub-state feeds (511SF,
        WZDX_AUSTIN, ...), or `?scope=all` to return everything.

        Each row's `group_id` (when present) points at a record in
        `GET /jurisdictions/groups`: a state's primary feed plus its WZDx
        and sub-state siblings collapsed under one umbrella id.
      parameters:
        - name: scope
          in: query
          schema:
            type: string
            enum: [state, federal, regional, all]
            default: state
          description: Filter by jurisdiction scope; default `state`
      responses:
        '200':
          description: Array of jurisdictions
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Jurisdiction'
              example:
                - code: "ON"
                  name: Ontario
                  country: CA
                  scope: state
                  group_id: null
                  is_active: true
                - code: "GA"
                  name: Georgia
                  country: US
                  scope: state
                  group_id: null
                  is_active: true
                - code: "CA"
                  name: California
                  country: US
                  scope: state
                  group_id: us-ca
                  is_active: true
                - code: "NY"
                  name: New York
                  country: US
                  scope: state
                  group_id: us-ny
                  is_active: true
  /jurisdictions/groups:
    get:
      tags: [metadata]
      summary: List jurisdiction groups
      description: |
        Returns the canonical bundles for states whose feed is split across
        multiple codes (state + WZDx + optional CWZ + optional sub-state
        regional). Querying `?jurisdiction=<primary_jurisdiction_code>` on
        `/events` or `/features` is transparently expanded to all members.
      responses:
        '200':
          description: Array of jurisdiction groups
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/JurisdictionGroup'
              example:
                - id: us-ca
                  name: California
                  description: California feeds (state + WZDx + 511SF Bay Area)
                  primary_jurisdiction_code: CA
                  sort_order: 2
                  members: [CA, WZDX_CA, 511SF]
                - id: us-co
                  name: Colorado
                  description: Colorado feeds (state + WZDx + CWZ)
                  primary_jurisdiction_code: CO
                  sort_order: 3
                  members: [CO, WZDX_CO, WZDX_CWZ_CO]
                - id: us-tx
                  name: Texas
                  description: Texas feeds (state + WZDx + sub-state regional)
                  primary_jurisdiction_code: TX
                  sort_order: 24
                  members: [TX, WZDX_TX, WZDX_AUSTIN]
  /stats:
    get:
      tags: [metadata]
      summary: Basic stats
      responses:
        '200':
          description: Event and feature counts
          content:
            application/json:
              example:
                active_events: 4523
                jurisdictions: 47
                features_by_type:
                  cameras: 12450
                  weather_stations: 3200
                  signs: 4870
                  rest_areas: 1580
                  ev_charging: 98500
                  bridge_clearances: 45200
                  truck_restrictions: 8900
                  road_conditions: 2100
                total_features: 176800
                uptime: 72h15m
  /stats/summary:
    get:
      tags: [metadata]
      summary: Rich summary stats
      description: Jurisdictions breakdown, events by type/severity, feature counts, last sync times
      responses:
        '200':
          description: Summary stats object
          content:
            application/json:
              example:
                jurisdictions:
                  total: 47
                  countries:
                    US: 35
                    CA: 12
                  list:
                    - code: CA
                      name: California
                      country: US
                    - code: ON
                      name: Ontario
                      country: CA
                events:
                  active: 4523
                  total: 158420
                  by_type:
                    incident: 1230
                    construction: 2150
                    closure: 380
                    weather: 210
                    road_condition: 553
                  by_severity:
                    minor: 1520
                    moderate: 2100
                    major: 780
                    critical: 123
                features:
                  total: 176800
                  by_type:
                    cameras: 12450
                    weather_stations: 3200
                    signs: 4870
                    rest_areas: 1580
                    ev_charging: 98500
                sources:
                  servers: 38
                  resources: 162
                last_sync: "2026-03-29T14:55:00Z"
                uptime: 72h15m
  # ---- Fuel prices ----
  /fuel-prices:
    get:
      tags: [fuel-prices]
      summary: Regional fuel prices
      description: |
        Returns regional fuel prices. Weekly US state-level (EIA) and monthly Canadian
        province-level (StatCan). Use `latest=true` to get only the most recent observation
        per jurisdiction + fuel_type. 24-hour cache (data refreshes daily at the source).
      parameters:
        - $ref: '#/components/parameters/jurisdiction'
        - name: country
          in: query
          schema: {type: string, enum: [US, CA]}
          description: Filter by country
        - name: fuel_type
          in: query
          schema: {type: string, enum: [regular, midgrade, premium, diesel]}
          description: Filter by fuel grade
        - name: from
          in: query
          schema: {type: string, format: date}
          description: Start date (YYYY-MM-DD)
        - name: to
          in: query
          schema: {type: string, format: date}
          description: End date (YYYY-MM-DD)
        - name: latest
          in: query
          schema: {type: boolean, default: false}
          description: Return only the most recent observation per jurisdiction + fuel_type
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: Paginated fuel prices
          content:
            application/json:
              example:
                data:
                  - id: 12048
                    jurisdiction: CA
                    country: US
                    fuel_type: regular
                    price: 4.85
                    currency: USD
                    unit: gallon
                    period_date: "2026-05-05"
                    frequency: weekly
                    source: EIA
                  - id: 12122
                    jurisdiction: ON
                    country: CA
                    fuel_type: regular
                    price: 1.62
                    currency: CAD
                    unit: litre
                    period_date: "2026-04-01"
                    frequency: monthly
                    source: StatCan
                total: 312
                limit: 100
                offset: 0
                has_more: true
  # ---- Analytics (Pro+ plans) ----
  /analytics/history/{id}:
    get:
      tags: [analytics]
      summary: Event history timeline
      description: |
        Lifecycle changes for a specific event. Requires Pro+ plan.

        `change_type` values include `created`, `severity_change`,
        `lanes_change`, `end_time_change`, `estimated_end_time_change`,
        `archived`, and `stale_sweep`. The last two both mean the event
        is no longer active — see `archive_reason` on the `TrafficEvent`
        schema for the distinction.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Array of history entries
          content:
            application/json:
              example:
                - id: 0
                  event_id: "on-evt-401-001"
                  source: "ON"
                  event_type: incident
                  severity: moderate
                  change_type: created
                  road_name: "Highway 401"
                  recorded_at: "2026-03-29T14:30:00Z"
                  jurisdiction: "ON"
                - id: 2
                  event_id: "on-evt-401-001"
                  source: "ON"
                  event_type: incident
                  severity: major
                  change_type: severity_change
                  road_name: "Highway 401"
                  recorded_at: "2026-03-29T14:35:00Z"
                  jurisdiction: "ON"
                  old_value: "moderate"
                  new_value: "major"
                - id: 3
                  event_id: "on-evt-401-001"
                  source: "ON"
                  event_type: incident
                  severity: major
                  change_type: lanes_change
                  road_name: "Highway 401"
                  recorded_at: "2026-03-29T14:40:00Z"
                  jurisdiction: "ON"
                  old_value: "1 of 4 lanes blocked"
                  new_value: "2 of 4 lanes blocked"
        '403':
          description: Analytics not available on current plan
          content:
            application/json:
              example:
                error: "analytics access requires Pro plan or higher"
  /analytics/changes:
    get:
      tags: [analytics]
      summary: Recent changes feed
      description: |
        Latest event lifecycle changes across all jurisdictions. Requires
        Pro+ plan. Archival entries carry `new_value=archived` or
        `new_value=stale_sweep` — see `archive_reason` on the
        `TrafficEvent` schema for what each means.
      responses:
        '200':
          description: Paginated recent changes
          content:
            application/json:
              example:
                data:
                  - id: 45
                    event_id: "on-evt-401-001"
                    source: "ON"
                    event_type: incident
                    severity: major
                    change_type: severity_change
                    road_name: "Highway 401"
                    recorded_at: "2026-03-29T14:35:00Z"
                    jurisdiction: "ON"
                    old_value: "moderate"
                    new_value: "major"
                  - id: 44
                    event_id: "ga-evt-i75-002"
                    source: "ga"
                    event_type: construction
                    severity: moderate
                    change_type: end_time_change
                    road_name: "I-75"
                    recorded_at: "2026-03-29T10:00:00Z"
                    jurisdiction: "GA"
                    old_value: "2026-04-10T18:00:00Z"
                    new_value: "2026-04-15T18:00:00Z"
                total: 2
                limit: 100
                offset: 0
                has_more: false
        '403':
          description: Analytics not available on current plan
          content:
            application/json:
              example:
                error: "analytics access requires Pro plan or higher"
  /analytics/clearance:
    get:
      tags: [analytics]
      summary: Clearance times (P50/P95)
      description: Event clearance time statistics. Requires Pro+ plan.
      responses:
        '200':
          description: Clearance statistics
          content:
            application/json:
              example:
                - jurisdiction: "ON"
                  event_type: incident
                  severity: major
                  count: 320
                  avg_minutes: 62.8
                  min_minutes: 8.0
                  max_minutes: 540.0
                  p50_minutes: 45.2
                  p95_minutes: 180.5
                - jurisdiction: "GA"
                  event_type: construction
                  severity: moderate
                  count: 150
                  avg_minutes: 7200.0
                  min_minutes: 360.0
                  max_minutes: 43200.0
                  p50_minutes: 4320.0
                  p95_minutes: 21600.0
  /analytics/corridors:
    get:
      tags: [analytics]
      summary: Corridor reliability
      description: Event frequency and clearance by road corridor. Requires Pro+ plan.
      responses:
        '200':
          description: Corridor stats
          content:
            application/json:
              example:
                - road_name: "Highway 401"
                  jurisdiction: "ON"
                  event_count: 245
                  avg_severity: 2.6
                  last_event: "2026-03-29T14:35:00Z"
                - road_name: "I-75"
                  jurisdiction: "GA"
                  event_count: 112
                  avg_severity: 2.1
                  last_event: "2026-03-29T11:08:00Z"
  /analytics/trends:
    get:
      tags: [analytics]
      summary: Event trends
      description: Time-series event counts. Requires Pro+ plan.
      responses:
        '200':
          description: Trend data points
          content:
            application/json:
              example:
                - timestamp: "2026-03-29T00:00:00Z"
                  count: 58
                  type: incident
                - timestamp: "2026-03-29T00:00:00Z"
                  count: 62
                  type: construction
                - timestamp: "2026-03-28T00:00:00Z"
                  count: 45
                  type: incident
                - timestamp: "2026-03-28T00:00:00Z"
                  count: 60
                  type: construction
  /analytics/hotspots:
    get:
      tags: [analytics]
      summary: Hotspot clustering
      description: Geographic clustering of events. Requires Pro+ plan.
      responses:
        '200':
          description: Hotspot clusters
          content:
            application/json:
              example:
                - latitude: 43.6510
                  longitude: -79.3470
                  event_count: 28
                  avg_severity: 2.8
                  top_road: "Highway 401"
                - latitude: 33.7580
                  longitude: -84.3920
                  event_count: 15
                  avg_severity: 2.2
                  top_road: "I-75"
  /analytics/weather:
    get:
      tags: [analytics]
      summary: Weather readings history
      description: Historical weather station data. Requires Pro+ plan.
      responses:
        '200':
          description: Weather readings
          content:
            application/json:
              example:
                - id: 88412
                  feature_id: "ga-wx-i75-atl"
                  source: "ga"
                  temperature: 18.5
                  wind_speed: 12.3
                  wind_direction: "NW"
                  visibility: 16.0
                  precipitation: "none"
                  road_surface: "dry"
                  humidity: 62
                  recorded_at: "2026-03-29T14:45:00Z"
  /analytics/feature-history:
    get:
      tags: [analytics]
      summary: Feature history
      description: Lifecycle changes for features. Requires Pro+ plan.
      responses:
        '200':
          description: Feature history entries
          content:
            application/json:
              example:
                - id: 4011
                  feature_id: "on-cam-401-yonge"
                  source: "ON"
                  feature_type: cameras
                  change_type: deactivated
                  changed_field: "is_active"
                  old_value: "true"
                  new_value: "false"
                  recorded_at: "2026-03-15T08:00:00Z"
                - id: 4012
                  feature_id: "on-cam-401-yonge"
                  source: "ON"
                  feature_type: cameras
                  change_type: reactivated
                  changed_field: "is_active"
                  old_value: "false"
                  new_value: "true"
                  recorded_at: "2026-03-16T06:00:00Z"
  /analytics/source-health:
    get:
      tags: [analytics]
      summary: Source health stats
      description: Per-source fetch success/failure rates. Requires Pro+ plan.
      responses:
        '200':
          description: Source health data
          content:
            application/json:
              example:
                - source_code: "on"
                  resource: "events"
                  total_fetches: 2880
                  success_count: 2875
                  error_count: 5
                  success_rate: 99.83
                  avg_duration_ms: 450
                  p95_duration_ms: 980
                  avg_items_count: 124.5
                  avg_response_bytes: 184320
                - source_code: "ga"
                  resource: "cameras"
                  total_fetches: 2880
                  success_count: 2862
                  error_count: 18
                  success_rate: 99.38
                  avg_duration_ms: 620
                  p95_duration_ms: 1340
                  avg_items_count: 450.0
                  avg_response_bytes: 612800
  /analytics/fetch-log:
    get:
      tags: [analytics]
      summary: Fetch log analytics
      description: Fetch operation statistics. Requires Pro+ plan.
      responses:
        '200':
          description: Fetch log data
          content:
            application/json:
              example:
                - id: 9821445
                  source_resource_id: 12
                  source_code: "on"
                  resource: "events"
                  status: "success"
                  items_count: 125
                  duration_ms: 380
                  error_message: null
                  created_at: "2026-03-29T14:55:00Z"
                  jurisdiction_name: "Ontario"
                  country: "CA"
                - id: 9821444
                  source_resource_id: 8
                  source_code: "ga"
                  resource: "cameras"
                  status: "success"
                  items_count: 450
                  duration_ms: 520
                  error_message: null
                  created_at: "2026-03-29T14:54:00Z"
                  jurisdiction_name: "Georgia"
                  country: "US"
  /analytics/event-weather/{id}:
    get:
      tags: [analytics]
      summary: Event weather snapshot
      description: |
        Returns the weather conditions near an event at the time it was created.
        Captures nearest weather station data (temperature, wind, visibility,
        precipitation, road surface, humidity) via PostGIS spatial query.
        Requires Pro+ plan.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Event ID
      responses:
        '200':
          description: Weather snapshot for the event
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventWeatherSnapshot'
              example:
                id: 42
                event_id: "on-evt-401-001"
                station_feature_id: "on-wx-401-north-york"
                distance_km: 3.2
                temperature: -2.5
                wind_speed: 35.0
                wind_direction: "NW"
                visibility: 1.5
                precipitation: "snow"
                road_surface: "icy"
                humidity: 88
                reading_recorded_at: "2026-03-29T14:28:00Z"
                created_at: "2026-03-29T14:30:00Z"
        '404':
          description: No weather snapshot for this event
          content:
            application/json:
              example:
                error: "no weather snapshot for this event"
  /analytics/weather-correlation:
    get:
      tags: [analytics]
      summary: Weather-incident correlation
      description: |
        Returns aggregated weather conditions across events, grouped by
        precipitation type and road surface. Shows how weather conditions
        correlate with incident frequency and severity. Requires Pro+ plan.
      parameters:
        - $ref: '#/components/parameters/jurisdiction'
        - name: type
          in: query
          schema:
            type: string
          description: Filter by event type
        - name: severity
          in: query
          schema:
            type: string
            enum: [minor, moderate, major, critical]
        - name: from
          in: query
          schema:
            type: string
            format: date
        - name: to
          in: query
          schema:
            type: string
            format: date
        - name: max_dist_km
          in: query
          schema:
            type: number
            default: 50
          description: Max station distance in km (default 50)
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
      responses:
        '200':
          description: Correlation statistics
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/WeatherCorrelationStats'
              example:
                - precipitation: "snow"
                  road_surface: "icy"
                  event_count: 84
                  avg_severity: 3.2
                  avg_temp_c: -5.1
                  avg_wind_speed: 28.4
                  avg_visibility: 2.3
                - precipitation: "rain"
                  road_surface: "wet"
                  event_count: 156
                  avg_severity: 2.1
                  avg_temp_c: 8.3
                  avg_wind_speed: 18.7
                  avg_visibility: 8.5
                - precipitation: "none"
                  road_surface: "dry"
                  event_count: 320
                  avg_severity: 1.8
                  avg_temp_c: 15.2
                  avg_wind_speed: 10.1
                  avg_visibility: 16.0
  # ---- Truck Corridor ----
  /truck/corridor:
    get:
      tags: [truck]
      summary: Query truck restrictions along a corridor
      description: |
        Returns all truck-related restrictions within a buffer around the straight-line path
        between two points. Uses PostGIS spatial intersection (ST_Buffer + ST_Intersects).

        Searches across feature types: `bridge_clearances`, `bridges`, `weight_restrictions`,
        `truck_restrictions`, `truck_routes`, `freight_corridors`, `truck_parking`.

        Results are ordered by distance from the corridor centerline. Each feature includes
        `_distance_km` in its properties.

        Requires Pro+ plan with truck data access.
      parameters:
        - name: from_lat
          in: query
          required: true
          schema: {type: number}
          description: Origin latitude
        - name: from_lng
          in: query
          required: true
          schema: {type: number}
          description: Origin longitude
        - name: to_lat
          in: query
          required: true
          schema: {type: number}
          description: Destination latitude
        - name: to_lng
          in: query
          required: true
          schema: {type: number}
          description: Destination longitude
        - name: buffer_km
          in: query
          schema: {type: number, default: 5, maximum: 50}
          description: Buffer distance around corridor in km (default 5, max 50)
        - name: height
          in: query
          schema: {type: number}
          description: Vehicle height in meters — filters bridge clearances below this value
        - name: weight
          in: query
          schema: {type: number}
          description: Vehicle weight in metric tons — filters weight limits below this value
        - name: jurisdiction
          in: query
          schema: {type: string}
          description: Filter by jurisdiction code (e.g., WA, NY, ON)
        - name: limit
          in: query
          schema: {type: integer, default: 500, maximum: 2000}
          description: Maximum results (default 500, max 2000)
      responses:
        '200':
          description: Corridor restrictions with metadata
          content:
            application/json:
              schema:
                type: object
                properties:
                  corridor:
                    type: object
                    properties:
                      from: {type: array, items: {type: number}}
                      to: {type: array, items: {type: number}}
                      buffer_km: {type: number}
                      distance_km: {type: number}
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Feature'
                  total: {type: integer}
                  limit: {type: integer}
              example:
                corridor:
                  from: [43.65, -79.38]
                  to: [43.25, -79.87]
                  buffer_km: 5
                  distance_km: 58.3
                data:
                  - id: "on-br-qew-001"
                    source: "on_arcgis"
                    jurisdiction: "ON"
                    feature_type: bridge_clearances
                    name: "QEW Overpass at Trafalgar Rd"
                    latitude: 43.4521
                    longitude: -79.7032
                    is_active: true
                    properties:
                      clearance_m: 4.35
                      road_name: "QEW"
                      over_road: "Trafalgar Rd"
                      _distance_km: 1.2
                  - id: "on-wr-hwy6-003"
                    source: "on_arcgis"
                    jurisdiction: "ON"
                    feature_type: weight_restrictions
                    name: "Hwy 6 Spring Load Restriction"
                    latitude: 43.3812
                    longitude: -79.7654
                    is_active: true
                    properties:
                      max_weight_tonnes: 5.0
                      restriction_type: "spring_load"
                      _distance_km: 3.8
                total: 2
                limit: 500
        '400':
          description: Missing required coordinates
          content:
            application/json:
              example:
                error: "from_lat, from_lng, to_lat, to_lng are required"
        '401':
          description: API key required
          content:
            application/json:
              example:
                error: "API key required"
        '403':
          description: Plan does not include truck data access
          content:
            application/json:
              example:
                error: "truck data access requires Pro plan or higher"
  /truck/corridor/geojson:
    get:
      tags: [truck]
      summary: Query truck restrictions along a corridor (GeoJSON)
      description: |
        Same query as `/truck/corridor` but returns results as a GeoJSON FeatureCollection
        for direct map rendering. Requires Pro+ plan with truck data + GeoJSON access.
      parameters:
        - name: from_lat
          in: query
          required: true
          schema: {type: number}
        - name: from_lng
          in: query
          required: true
          schema: {type: number}
        - name: to_lat
          in: query
          required: true
          schema: {type: number}
        - name: to_lng
          in: query
          required: true
          schema: {type: number}
        - name: buffer_km
          in: query
          schema: {type: number, default: 5, maximum: 50}
        - name: jurisdiction
          in: query
          schema: {type: string}
        - name: limit
          in: query
          schema: {type: integer, default: 500, maximum: 2000}
      responses:
        '200':
          description: GeoJSON FeatureCollection
          content:
            application/geo+json:
              schema:
                type: object
                properties:
                  type: {type: string, enum: [FeatureCollection]}
                  features:
                    type: array
                    items:
                      type: object
              example:
                type: FeatureCollection
                features:
                  - type: Feature
                    geometry:
                      type: Point
                      coordinates: [-79.7032, 43.4521]
                    properties:
                      id: "on-br-qew-001"
                      feature_type: bridge_clearances
                      name: "QEW Overpass at Trafalgar Rd"
                      clearance_m: 4.35
                      _distance_km: 1.2
                  - type: Feature
                    geometry:
                      type: LineString
                      coordinates: [[-79.76, 43.38], [-79.77, 43.39]]
                    properties:
                      id: "on-wr-hwy6-003"
                      feature_type: weight_restrictions
                      name: "Hwy 6 Spring Load Restriction"
                      max_weight_tonnes: 5.0
                      _distance_km: 3.8
        '403':
          description: Plan does not include truck data or GeoJSON access
          content:
            application/json:
              example:
                error: "truck data access requires Pro plan or higher"
  # ---- Truck Routing (HERE-backed) ----
  /routing/truck:
    post:
      tags: [routing]
      summary: Compute a truck route with hazard enrichment
      description: |
        Returns a routed polyline annotated with `warnings[]` — every nearby
        hazard, restriction, and operational note our database knows about
        (active incidents, planned construction, bridge clearance/weight
        problems for the requested truck, public at-grade rail crossings
        with 49 CFR 392.10 hazmat-stop awareness, truck/weight restrictions,
        weigh stations, weather, alerts, special events).

        Every successful call:
          * Counts against the customer's monthly routing quota (subscription
            bucket first, then top-ups FIFO by expiry). Cache hits still
            charge — the route is the API call regardless of whether we
            re-used a cached payload.
          * Auto-saves the result to `saved_routes` with a 30-minute TTL.
            The returned `route_id` can be refetched (with re-evaluated
            warnings) via `GET /routing/truck/saved/{id}` or persisted via
            `POST /routing/truck/saved/{id}/persist`.

        The `truck` block is required on every request. See the request
        schema for the full set of vehicle parameters.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [origin, destination, truck]
              properties:
                origin:
                  type: object
                  required: [lat, lng]
                  properties:
                    lat: {type: number}
                    lng: {type: number}
                destination:
                  type: object
                  required: [lat, lng]
                  properties:
                    lat: {type: number}
                    lng: {type: number}
                waypoints:
                  type: array
                  items:
                    type: object
                    properties:
                      lat: {type: number}
                      lng: {type: number}
                      stop_over: {type: boolean}
                optimize: {type: string, enum: [time, distance], default: time}
                departure_time: {type: string, description: "ISO 8601 or 'now'; defaults to 'now'"}
                arrival_time: {type: string, description: "Mutually exclusive with departure_time"}
                alternatives: {type: integer, minimum: 0, maximum: 3}
                language: {type: string, description: "BCP-47 (e.g. en-US)"}
                units: {type: string, enum: [metric, imperial], default: metric}
                currency: {type: string, description: "ISO 4217; only honored when tolls is requested"}
                traffic: {type: string, enum: [live, historical, off], default: live}
                truck:
                  type: object
                  required: []
                  description: "Required block (the endpoint is truck-only)."
                  properties:
                    profile: {type: string, enum: [tractor, straight, box], default: tractor}
                    commercial: {type: boolean}
                    weight_t: {type: number, description: "Gross combined weight in tonnes"}
                    axle_weight_t: {type: number}
                    height_m: {type: number, maximum: 5}
                    width_m: {type: number, maximum: 4.5}
                    length_m: {type: number, maximum: 30}
                    axle_count: {type: integer}
                    trailer_count: {type: integer}
                    trailer_axle_count: {type: integer}
                    occupancy: {type: integer}
                    max_speed_kph: {type: integer}
                    fuel: {type: string, enum: [diesel, gasoline, lpg, cng]}
                    hazardous:
                      type: array
                      items: {type: string, enum: [explosive, gas, flammable, combustible, organic, poison, radioactive, corrosive, poison_inhalation, harmful_to_water, other]}
                    tunnel_category: {type: string, enum: [B, C, D, E]}
                    permit_number:
                      type: string
                      description: |
                        Oversize/overweight permit identifier carried by this truck.
                        Not proxied to HERE — stored on the saved route and echoed in
                        the response for client-side correlation.
                avoid:
                  type: array
                  items: {type: string, enum: [highway, toll_road, ferry, tunnel, dirt_road, car_shuttle_train, seasonal_closure]}
                enrichment:
                  type: object
                  description: |
                    Per-request tuning for the post-HERE truck-aware filter pipeline.
                    None of these fields are sent to HERE — they only shape what
                    `warnings[]` contains. Omit the block to keep defaults.
                  properties:
                    exclude_types:
                      type: array
                      description: |
                        Drop these feature_types from the spatial-join entirely. Useful
                        for trimming the response when a UI doesn't display, e.g., weigh
                        stations.
                      items:
                        type: string
                        enum: [future_construction, bridge_clearances, rail_crossings, truck_restrictions, weight_restrictions, weigh_stations, inspection_stations, alerts, special_events, weather_stations]
                    min_severity:
                      type: string
                      enum: [info, warning, critical]
                      description: |
                        Filter out warnings below this threshold. Ranking is
                        info < warning < critical. Critical warnings (e.g., bridge
                        clearance violations) always pass.
                    buffer_m:
                      type: number
                      minimum: 10
                      maximum: 1000
                      default: 100
                      description: Spatial-join buffer radius around the polyline, in metres.
                    clearance_pad_m:
                      type: number
                      minimum: 0
                      maximum: 1
                      default: 0.15
                      description: |
                        Safety pad added to `truck.height_m` when evaluating
                        bridge_clearances. A warning fires when
                        `clearance_m < truck.height_m + clearance_pad_m`.
                    skip_temporal_filter:
                      type: boolean
                      default: false
                      description: |
                        When true, keeps traffic events whose end_time is already past
                        projected arrival. Useful for planning-ahead runs.
                    max_distance_m:
                      type: number
                      minimum: 0
                      description: |
                        Only emit warnings within this distance from the route origin.
                        0 (default) = unlimited.
                cargo:
                  type: object
                  description: |
                    Cargo metadata. Not proxied to HERE; stored on the saved route
                    and available to future enrichment rules.
                  properties:
                    hazmat_class:
                      type: string
                      description: |
                        DOT placard class (e.g., "1.3D", "3", "8"). Finer-grained than
                        the truck.hazardous bucket; future enrichment rules can use the
                        class digit for stricter 49 CFR 392.10 rail-crossing logic.
                customer_route_id:
                  type: string
                  maxLength: 128
                  description: |
                    Opaque client-side correlation key. Echoed verbatim in the response.
                    Not used by enrichment.
                tags:
                  type: array
                  maxItems: 16
                  items: {type: string, maxLength: 64}
                  description: |
                    Free-form labels for fleet/lane analytics rollup. Echoed and stored
                    on the saved route. Max 16 tags, each up to 64 chars.
      responses:
        '200':
          description: Route computed with warnings enriched
          content:
            application/json:
              example:
                route_id: "01HKZ9P5XJVQ6NB8E2RTH4FMW7"
                expires_at: "2026-05-18T13:30:00Z"
                is_persisted: false
                cached: false
                routes:
                  - summary:
                      distance_m: 350000
                      duration_s: 12600
                    geometry: {type: LineString, coordinates: [[-74.006, 40.7128]]}
                    warnings:
                      - type: bridge_clearance
                        severity: critical
                        title: "Bridge clearance below truck height"
                        description: "I-95 NB overpass at MP 23: 4.0m clearance; your truck is 4.2m."
                        distance_along_route_m: 12450
                        projected_arrival_time: "2026-05-18T11:12:00Z"
                        source: "FHWA NBI"
                        source_id: "feat_bridge_clearances_nbi_12345"
                        geometry: {type: Point, coordinates: [-74.006, 40.7128]}
                        properties: {clearance_m: 4.0, truck_height_m: 4.2}
                quota:
                  subscription: {plan: "pro_7500", tier: "pro", limit: 7500, used: 1}
                  topups: []
                  total: {remaining: 7499}
                  reset_at: "2026-06-01T00:00:00Z"
                customer_route_id: "my-route-42"
                tags: ["fleet-east", "lane-LA-NYC"]
        '400':
          description: Validation failure — see `code` for the specific reason
          content:
            application/json:
              examples:
                truck_block_missing:
                  value: {code: "truck_block_required", message: "every routing request must include a truck block"}
                conflicting_times:
                  value: {code: "conflicting_times", message: "departure_time and arrival_time are mutually exclusive"}
                unrouteable:
                  value: {code: "unrouteable", message: "no route found between the given coordinates"}
                invalid_enrichment_type:
                  value: {code: "invalid_enrichment_type", message: "enrichment.exclude_types: unknown feature_type \"made_up\""}
                invalid_enrichment_severity:
                  value: {code: "invalid_enrichment_severity", message: "enrichment.min_severity must be info, warning, or critical (got \"loud\")"}
                out_of_range_enrichment:
                  value: {code: "out_of_range", message: "enrichment.buffer_m must be in [10, 1000]"}
        '403':
          description: Plan does not include routing
          content:
            application/json:
              example: {code: "no_subscription", message: "routing is not enabled on this plan"}
        '429':
          description: Quota or rate limit hit
          content:
            application/json:
              examples:
                quota_exhausted:
                  value: {code: "quota_exhausted", message: "Routing quota exhausted. Upgrade or buy a top-up pack to continue."}
                daily_cap:
                  value: {code: "trial_daily_cap", message: "Free plan daily limit reached (5/day). Upgrade to remove the cap."}
        '502':
          description: Upstream routing provider unavailable
          content:
            application/json:
              example: {code: "routing_unavailable", message: "routing service unavailable"}
        '504':
          description: Upstream routing provider timed out
          content:
            application/json:
              example: {code: "routing_timeout", message: "routing service timed out"}
  /routing/truck/saved/{id}:
    get:
      tags: [routing]
      summary: Refetch a saved route with re-evaluated warnings
      description: |
        Returns the previously-computed route with `warnings[]` re-run against
        current data. Does NOT count against routing quota, but is subject to
        a separate refetch rate limit (1 / 30 s per route, 60 / minute per
        customer; both configurable). A 15-second refetch cache absorbs
        the dispatcher's tweak-and-re-evaluate flow.

        Auto-saved routes (`is_persisted=false`) expire 30 minutes after the
        original routing call. After that, this endpoint returns `route_expired`.
      parameters:
        - {name: id, in: path, required: true, schema: {type: string}}
      responses:
        '200':
          description: Route returned (warnings re-evaluated)
        '404':
          description: Route not found or expired
          content:
            application/json:
              examples:
                not_found: {value: {code: "route_not_found", message: "route not found"}}
                expired: {value: {code: "route_expired", message: "route auto-save window has expired"}}
        '429':
          description: Refetch rate limit exceeded
          content:
            application/json:
              example: {code: "refetch_rate_limit_exceeded", message: "refetch rate limit exceeded"}
    delete:
      tags: [routing]
      summary: Delete a saved route
      description: Removes the row early. Works for both auto-saved and persisted rows.
      parameters:
        - {name: id, in: path, required: true, schema: {type: string}}
      responses:
        '204': {description: Deleted}
        '404': {description: Route not found (or owned by another customer)}
  /routing/truck/saved/{id}/persist:
    post:
      tags: [routing]
      summary: Mark a saved route as persisted (keeps it past the 30-min TTL)
      description: |
        Counts against the customer's per-plan persisted-route cap
        (`max_saved_routes`, or the per-customer override when set). Records
        the Phase-2 monitoring config (label, buffer, validity window,
        notification preferences) which the future matching engine will
        consume on every ingested traffic_events / features row.
      parameters:
        - {name: id, in: path, required: true, schema: {type: string}}
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label: {type: string}
                buffer_m: {type: integer, default: 100}
                valid_from: {type: string, format: date-time}
                valid_to: {type: string, format: date-time}
                notify:
                  type: object
                  properties:
                    webhook_id: {type: integer}
                    min_severity: {type: string, enum: [info, warning, critical], default: warning}
                    types:
                      type: array
                      items: {type: string}
      responses:
        '200': {description: Persisted}
        '403':
          description: Persisted-routes cap reached
          content:
            application/json:
              example: {code: "saved_routes_quota_exhausted", message: "persisted-routes cap reached (5/5)"}
        '404': {description: Route not found or already persisted}
  /routing/truck/saved/recent:
    get:
      tags: [routing]
      summary: List the customer's recent auto-saved + persisted routes
      description: |
        Returns the slim row shape (`id`, `label`, `is_persisted`, `created_at`,
        `expires_at`) — fetch the full payload via the by-id refetch
        endpoint. Used by the portal's "Recent routes (last 30 min)" section.
      responses:
        '200':
          description: List returned
          content:
            application/json:
              example:
                routes:
                  - id: "01HKZ9P5XJVQ6NB8E2RTH4FMW7"
                    label: ""
                    is_persisted: false
                    created_at: "2026-05-18T10:00:00Z"
                    expires_at: "2026-05-18T10:30:00Z"
  /routing/quota:
    get:
      tags: [routing]
      summary: Probe the customer's current routing quota envelope
      description: |
        Returns the same `quota` block emitted alongside `/routing/truck`
        responses, without performing a routing call (zero quota charge).
        Used by the portal's subscription card to render the usage bar +
        active top-ups list.
      responses:
        '200':
          description: Quota envelope
          content:
            application/json:
              example:
                subscription: {plan: "pro_7500", tier: "pro", limit: 7500, used: 234}
                topups:
                  - id: "01HK..."
                    pack_sku: "topup_routing_truck_1000"
                    title: "Standard Pack"
                    calls_added: 1000
                    calls_remaining: 750
                    purchased_at: "2026-05-04T09:12:00Z"
                    expires_at: "2026-05-31T23:59:59Z"
                total: {remaining: 8016}
                reset_at: "2026-06-01T00:00:00Z"
