{
  "openapi": "3.1.0",
  "info": {
    "title": "MISO LMP API",
    "version": "1.0.0",
    "summary": "MISO wholesale electricity pricing (LMP) as JSON, built for bots and scripts, not trading terminals.",
    "description": "Real-time, hourly, and day-ahead Locational Marginal Prices (LMP) for the ~325 pricing nodes of the MISO grid. Built for autonomous agents, BESS dispatch bots, and home automation, not human trading desks.\n\n- Price records are flat with short keys, so a full day of one node's prices fits comfortably in an LLM context window. Each response wraps them in a `{ data, metadata }` envelope.\n- Pass a lat/lon to `/api/v1/lmp/nearest` and get the closest real grid node by great-circle distance, plus its live price. No need to know a node name first. Coordinates outside the MISO footprint return a structured `OUT_OF_TERRITORY` error rather than a meaningless far-away node.\n- Moving averages (`/api/v1/analytics/moving-avg`) and congestion ranking (`/api/v1/analytics/congestion`) run as PostgreSQL window functions server-side, not client-side.\n- Free key at `/api/v1/signup`, no payment info required. Paid tiers up to $49/mo via Stripe Checkout (`/api/stripe/checkout`), which upgrades your key's tier and does not consume your daily quota.\n\nEvery response carries a `metadata` block (execution time, data source, staleness). Every error is structured as `{ error_code, message, agent_action }`, where `agent_action` is one of a fixed enum (`retry_in_300s`, `check_config`, etc.) telling your agent what to do next; see the `Error` schema. Upstream MISO outages never return a 500: stale data is served with `is_stale: true` instead.\n\nDaily request limits by tier: free 150, hobbyist 2,000, developer 10,000, professional 500,000. Quota resets at 00:00 UTC. History depth is also tier-gated; see `/api/v1/lmp`.\n\nLMP = System Energy + Marginal Congestion Cost (`mcc`) + Marginal Loss Cost (`mlc`). All prices in $/MWh.\n\nNote: node lookups use exact node names (e.g. `NIPS.CC.WHITN`), not MISO zone names. If you only know a zone or a location, use `/api/v1/lmp/nearest` (lat/lon) or browse `/api/v1/nodes`, which lists each node's `zone_name`.",
    "contact": {
      "name": "MISO LMP API",
      "url": "https://miso-lmp-api.vercel.app"
    }
  },
  "servers": [
    {
      "url": "https://miso-lmp-api.vercel.app",
      "description": "Production"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "Prices",
      "description": "Nodal LMP data: real-time, hourly, day-ahead."
    },
    {
      "name": "Geolocation",
      "description": "Physical location → nearest grid node + price."
    },
    {
      "name": "Analytics",
      "description": "Server-side signals: moving averages, congestion ranking."
    },
    {
      "name": "Reference",
      "description": "Node/zone directory."
    },
    {
      "name": "Account",
      "description": "Usage, quota, and billing."
    },
    {
      "name": "Status",
      "description": "Public health & data freshness (no key required)."
    }
  ],
  "paths": {
    "/api/v1/health": {
      "get": {
        "tags": [
          "Status"
        ],
        "summary": "Ingestion health & data freshness",
        "description": "Public, no API key required. Returns per-data-type freshness (`five_min`, `hourly`, `day_ahead`) so an agent can check feed staleness before making a priced request.",
        "security": [],
        "responses": {
          "200": {
            "description": "Per-data-type freshness.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HealthResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/lmp": {
      "get": {
        "tags": [
          "Prices"
        ],
        "summary": "Nodal LMP time series",
        "description": "LMP records for one node, newest first. History depth is tier-gated (free: 2d, hobbyist: 7d, developer: 30d, professional: 2y) for `hourly`/`day_ahead` data; older `start` values are clamped to your tier's floor. `five_min` (raw real-time) is capped at 7 days of history on every tier; for longer lookback, use `hourly` or `day_ahead`. Cursor-paginated.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Node"
          },
          {
            "$ref": "#/components/parameters/DataType"
          },
          {
            "name": "start",
            "in": "query",
            "description": "ISO 8601 start timestamp. Clamped to your tier's history floor.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of LMP records.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/LmpRecord"
                      }
                    },
                    "metadata": {
                      "$ref": "#/components/schemas/PriceMetadata"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NodeNotFound"
          },
          "429": {
            "$ref": "#/components/responses/QuotaExceeded"
          },
          "500": {
            "description": "Unexpected server error. error_code: INTERNAL_ERROR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/CapacityLimit"
          }
        }
      }
    },
    "/api/v1/lmp/nearest": {
      "get": {
        "tags": [
          "Geolocation"
        ],
        "summary": "Nearest node to a coordinate + live price",
        "description": "Give a lat/lon; get the closest real grid node (great-circle distance) and its latest 5-minute LMP. Use this when you don't know a node name; no need to call /api/v1/nodes first. Searches generator (`GEN`) and load-zone (`LZN`) nodes by default; interface ties and aggregate hubs are excluded since they aren't tied to a physical location. Narrow the search with `node_type`.",
        "parameters": [
          {
            "name": "lat",
            "in": "query",
            "required": true,
            "description": "Latitude, -90 to 90.",
            "schema": {
              "type": "number",
              "minimum": -90,
              "maximum": 90
            },
            "example": 41.85
          },
          {
            "name": "lon",
            "in": "query",
            "required": true,
            "description": "Longitude, -180 to 180.",
            "schema": {
              "type": "number",
              "minimum": -180,
              "maximum": 180
            },
            "example": -87.65
          },
          {
            "name": "node_type",
            "in": "query",
            "description": "Restrict the search. `gen` = generator nodes, `zone` = load zones. Default: both.",
            "schema": {
              "type": "string",
              "enum": [
                "gen",
                "zone"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Nearest node with its latest 5-minute price (`data` is null if no recent price exists).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/LmpRecord"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "metadata": {
                      "$ref": "#/components/schemas/NearestMetadata"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/OutOfTerritory"
          },
          "429": {
            "$ref": "#/components/responses/QuotaExceeded"
          },
          "500": {
            "description": "Unexpected server error. error_code: INTERNAL_ERROR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/CapacityLimit"
          }
        }
      }
    },
    "/api/v1/analytics/moving-avg": {
      "get": {
        "tags": [
          "Analytics"
        ],
        "summary": "Rolling LMP moving average",
        "description": "Server-side rolling average of LMP over a window of preceding intervals, newest first. Returns the raw `lmp` alongside `lmp_ma` so an agent can compare current price to trend in one call.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Node"
          },
          {
            "$ref": "#/components/parameters/DataType"
          },
          {
            "name": "window",
            "in": "query",
            "description": "Number of preceding intervals in the average (1–100).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 12
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Max rows returned (max 500).",
            "schema": {
              "type": "integer",
              "default": 100,
              "maximum": 500
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Moving-average series.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/MovingAvgRow"
                      }
                    },
                    "metadata": {
                      "$ref": "#/components/schemas/AnalyticsMetadata"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NodeNotFound"
          },
          "429": {
            "$ref": "#/components/responses/QuotaExceeded"
          },
          "500": {
            "description": "Unexpected server error. error_code: INTERNAL_ERROR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/CapacityLimit"
          }
        }
      }
    },
    "/api/v1/analytics/congestion": {
      "get": {
        "tags": [
          "Analytics"
        ],
        "summary": "Node congestion ranking",
        "description": "Ranks all nodes by average Marginal Congestion Cost (`avg_mcc`, the same `mcc` field returned per-record by `/api/v1/lmp`) over a time window, most congested first. Defaults to the last 3 hours. Use this to find which nodes are congested right now; use `/api/v1/lmp`'s `mcc` field for a single node's live value.",
        "parameters": [
          {
            "$ref": "#/components/parameters/DataType"
          },
          {
            "name": "start",
            "in": "query",
            "description": "ISO 8601 window start. Default: 3h before `end`.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "end",
            "in": "query",
            "description": "ISO 8601 window end. Default: now.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Top-N nodes. Must be a positive integer (max 325).",
            "schema": {
              "type": "integer",
              "default": 100,
              "minimum": 1,
              "maximum": 325
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Congestion ranking, highest avg_mcc first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CongestionRow"
                      }
                    },
                    "metadata": {
                      "$ref": "#/components/schemas/CongestionMetadata"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/QuotaExceeded"
          },
          "500": {
            "description": "Unexpected server error. error_code: INTERNAL_ERROR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/CapacityLimit"
          }
        }
      }
    },
    "/api/v1/nodes": {
      "get": {
        "tags": [
          "Reference"
        ],
        "summary": "List grid nodes",
        "description": "The full node directory with zone names. Cursor-paginated by node id. Use it to discover valid `node` values for the price endpoints. Node coordinates are not returned here — pass a lat/lon to /api/v1/lmp/nearest instead.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of nodes.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/NodeRecord"
                      }
                    },
                    "metadata": {
                      "$ref": "#/components/schemas/PageMetadata"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/QuotaExceeded"
          },
          "500": {
            "description": "Unexpected server error. error_code: INTERNAL_ERROR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/CapacityLimit"
          }
        }
      }
    },
    "/api/v1/signup": {
      "post": {
        "tags": [
          "Account"
        ],
        "summary": "Create a free-tier API key",
        "description": "Public, no API key required. Takes an email, returns a new free-tier key instantly (150 req/day, 2 days history). The plaintext key is returned exactly once in this response and is never recoverable afterward, so store it immediately. To move to a paid tier later, use `/api/stripe/checkout` with this key.\n\nRate-limited to 3 signups per IP per day. One key per email; signing up again with an email already on file returns 409.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Key created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "api_key": {
                      "type": "string",
                      "description": "Plaintext key. Shown once, so save it now.",
                      "example": "miso_9f2a..."
                    },
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "tier": {
                      "type": "string",
                      "example": "free"
                    },
                    "requests_limit": {
                      "type": "integer",
                      "example": 150
                    },
                    "created_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "409": {
            "description": "An API key already exists for this email.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "More than 3 signups from this IP today. `agent_action: retry_tomorrow`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/SignupCapacity"
          }
        }
      }
    },
    "/api/v1/account/usage": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "Today's usage & quota",
        "description": "Your tier, daily request limit, requests used today, and remaining. Note: this call itself counts toward your quota.",
        "responses": {
          "200": {
            "description": "Usage snapshot.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UsageResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/QuotaExceeded"
          },
          "500": {
            "description": "Unexpected server error. error_code: INTERNAL_ERROR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/CapacityLimit"
          }
        }
      }
    },
    "/api/stripe/checkout": {
      "post": {
        "tags": [
          "Account"
        ],
        "summary": "Upgrade tier (Stripe Checkout)",
        "description": "Authenticate with your existing key and receive a hosted Stripe Checkout URL for a paid tier. Does not consume quota, so you can upgrade even when your daily limit is exhausted. On payment, your key's tier and limit are raised automatically.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "tier"
                ],
                "properties": {
                  "tier": {
                    "type": "string",
                    "enum": [
                      "hobbyist",
                      "developer",
                      "professional"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checkout session created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "checkout_url": {
                      "type": "string",
                      "format": "uri",
                      "description": "Redirect the user here to complete payment."
                    },
                    "tier": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "description": "Unexpected server error. error_code: INTERNAL_ERROR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/stripe/portal": {
      "post": {
        "tags": [
          "Account"
        ],
        "summary": "Self-serve billing management (Stripe Customer Portal)",
        "description": "Authenticate with your existing key and receive a hosted portal URL to cancel, upgrade/downgrade tier, update payment method, or view invoices. Does not consume quota. Requires an existing Stripe customer (i.e. you've been through `/api/stripe/checkout` at least once).",
        "responses": {
          "200": {
            "description": "Portal session created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "portal_url": {
                      "type": "string",
                      "format": "uri",
                      "description": "Redirect the user here."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "No billing account exists yet for this key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "description": "Unexpected server error. error_code: INTERNAL_ERROR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key",
        "description": "Your API key, sent as `x-api-key` on every request except `/api/v1/health` and `/api/v1/signup`. Get a free-tier key at `/api/v1/signup`. Note: `/api/stripe/checkout` upgrades an existing key's tier; it does not issue a new key."
      }
    },
    "parameters": {
      "Node": {
        "name": "node",
        "in": "query",
        "required": true,
        "description": "Exact node name, not a zone name (see /api/v1/nodes for the directory, or /api/v1/lmp/nearest if you only have a lat/lon).",
        "schema": {
          "type": "string"
        },
        "example": "NIPS.CC.WHITN"
      },
      "DataType": {
        "name": "type",
        "in": "query",
        "description": "Market feed.",
        "schema": {
          "$ref": "#/components/schemas/DataType"
        }
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "description": "Page size.",
        "schema": {
          "type": "integer",
          "default": 100
        }
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "description": "Opaque pagination cursor from a previous response's `metadata.next_cursor`. Omit to start from the beginning.",
        "schema": {
          "type": "string"
        }
      }
    },
    "schemas": {
      "DataType": {
        "type": "string",
        "enum": [
          "five_min",
          "hourly",
          "day_ahead"
        ],
        "default": "five_min",
        "description": "`five_min` real-time (most volatile), `hourly` integrated, `day_ahead` market-cleared price (a financially binding settlement from the prior day's auction, not a prediction)."
      },
      "Tier": {
        "type": "string",
        "enum": [
          "free",
          "hobbyist",
          "developer",
          "professional"
        ],
        "description": "Daily request limit and history depth per tier: free = 150 req/day, 2 days history. hobbyist = 2,000 req/day, 7 days. developer = 10,000 req/day, 30 days. professional = 500,000 req/day, 730 days. History depth applies to `hourly`/`day_ahead` data; raw `five_min` history is capped at 7 days on every tier. See `/api/v1/account/usage` for your current usage."
      },
      "LmpRecord": {
        "type": "object",
        "description": "One price observation at one node. All prices in $/MWh.",
        "properties": {
          "interval_ts": {
            "type": "string",
            "format": "date-time"
          },
          "node_id": {
            "type": "integer"
          },
          "lmp": {
            "type": "number",
            "description": "Total locational marginal price."
          },
          "mlc": {
            "type": "number",
            "description": "Marginal loss component: cost of line losses to the reference bus, usually smaller and steadier than mcc."
          },
          "mcc": {
            "type": "number",
            "description": "Marginal congestion component, the volatile tradeable signal."
          },
          "data_type": {
            "$ref": "#/components/schemas/DataType"
          }
        }
      },
      "NodeRecord": {
        "type": "object",
        "description": "One entry in the node directory. `node_name` is the exact string to pass as the `node` query parameter on /api/v1/lmp and /api/v1/analytics/moving-avg. Node coordinates are not returned here — use /api/v1/lmp/nearest to resolve a lat/lon to its nearest node.",
        "properties": {
          "node_id": {
            "type": "integer"
          },
          "node_name": {
            "type": "string",
            "example": "NIPS.CC.WHITN"
          },
          "zone_id": {
            "type": "integer"
          },
          "miso_zones": {
            "type": [
              "object",
              "null"
            ],
            "description": "The MISO pricing zone this node belongs to. Filter this endpoint's results by zone_name client-side to find all nodes in your zone.",
            "properties": {
              "zone_name": {
                "type": "string"
              }
            }
          }
        }
      },
      "MovingAvgRow": {
        "type": "object",
        "properties": {
          "interval_ts": {
            "type": "string",
            "format": "date-time"
          },
          "lmp": {
            "type": "number"
          },
          "lmp_ma": {
            "type": "number",
            "description": "Rolling average of lmp over the window (4dp)."
          },
          "mlc": {
            "type": "number"
          },
          "mcc": {
            "type": "number"
          }
        }
      },
      "CongestionRow": {
        "type": "object",
        "properties": {
          "node_id": {
            "type": "integer"
          },
          "node_name": {
            "type": "string"
          },
          "zone_name": {
            "type": "string"
          },
          "avg_mcc": {
            "type": "number",
            "description": "Average congestion cost over the window (4dp)."
          },
          "avg_lmp": {
            "type": "number",
            "description": "Average total LMP over the window (4dp)."
          },
          "sample_count": {
            "type": "integer"
          },
          "rank": {
            "type": "integer",
            "description": "1 = most congested."
          }
        }
      },
      "Staleness": {
        "type": "object",
        "description": "Freshness flags present on data responses. On an upstream MISO outage, this endpoint serves the last-known values with is_stale: true rather than returning an error.",
        "properties": {
          "is_stale": {
            "type": "boolean"
          },
          "stale_since": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "last_ingested_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "PageMetadata": {
        "type": "object",
        "properties": {
          "count": {
            "type": "integer"
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ]
          },
          "has_next_page": {
            "type": "boolean"
          },
          "query_execution_time_ms": {
            "type": "integer"
          },
          "data_source": {
            "type": "string"
          }
        }
      },
      "PriceMetadata": {
        "allOf": [
          {
            "$ref": "#/components/schemas/PageMetadata"
          },
          {
            "$ref": "#/components/schemas/Staleness"
          },
          {
            "type": "object",
            "properties": {
              "node": {
                "type": "string"
              },
              "node_type": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          }
        ]
      },
      "NearestMetadata": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Staleness"
          },
          {
            "type": "object",
            "properties": {
              "node": {
                "type": "string"
              },
              "node_type": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "node_lat": {
                "type": "number"
              },
              "node_lon": {
                "type": "number"
              },
              "distance_km": {
                "type": "number"
              },
              "data_type": {
                "type": "string"
              },
              "query_execution_time_ms": {
                "type": "integer"
              },
              "data_source": {
                "type": "string"
              }
            }
          }
        ]
      },
      "AnalyticsMetadata": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Staleness"
          },
          {
            "type": "object",
            "properties": {
              "node": {
                "type": "string"
              },
              "data_type": {
                "type": "string"
              },
              "window": {
                "type": "integer"
              },
              "count": {
                "type": "integer"
              },
              "total_available": {
                "type": "integer"
              },
              "query_execution_time_ms": {
                "type": "integer"
              }
            }
          }
        ]
      },
      "CongestionMetadata": {
        "type": "object",
        "properties": {
          "data_type": {
            "type": "string"
          },
          "start": {
            "type": "string",
            "format": "date-time"
          },
          "end": {
            "type": "string",
            "format": "date-time"
          },
          "count": {
            "type": "integer"
          },
          "query_execution_time_ms": {
            "type": "integer"
          }
        }
      },
      "HealthResponse": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok",
              "degraded"
            ]
          },
          "data": {
            "type": "object",
            "additionalProperties": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Staleness"
                },
                {
                  "type": "object",
                  "properties": {
                    "data_type": {
                      "type": "string"
                    },
                    "latest_interval": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "date-time"
                    }
                  }
                }
              ]
            }
          },
          "query_execution_time_ms": {
            "type": "integer"
          }
        }
      },
      "UsageResponse": {
        "type": "object",
        "properties": {
          "tier": {
            "$ref": "#/components/schemas/Tier"
          },
          "requests_limit": {
            "type": "integer"
          },
          "used_today": {
            "type": "integer"
          },
          "remaining": {
            "type": "integer"
          },
          "date": {
            "type": "string",
            "format": "date"
          },
          "metadata": {
            "type": "object",
            "properties": {
              "timestamp": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "description": "Every error response has this shape, so an agent can branch on `error_code`/`agent_action` without parsing `message` text. Fixed pairings: MISSING_API_KEY -> provide_api_key (401), INVALID_API_KEY -> check_api_key (401), QUOTA_EXCEEDED -> retry_tomorrow_or_upgrade (429), NODE_NOT_FOUND -> check_config (404), INVALID_CURSOR -> omit_cursor_to_restart (400), INVALID_BODY -> check_config (400), INTERNAL_ERROR -> contact_support (500), SIGNUP_RATE_LIMITED -> retry_tomorrow (429, /api/v1/signup only), EMAIL_ALREADY_REGISTERED -> contact_support (409, /api/v1/signup only), CAPACITY_LIMIT -> retry_tomorrow_or_upgrade (503, key-authed endpoints), CAPACITY_LIMIT means free-tier capacity for the day is exhausted across all users — it is NOT your personal quota (that is QUOTA_EXCEEDED) and it is not a fault in your request. It carries a Retry-After header pointing at the 00:00 UTC reset. Paid tiers are never capacity-shed, so upgrading resolves it immediately. OUT_OF_TERRITORY -> check_config (404, /api/v1/lmp/nearest only): the supplied lat/lon is outside the MISO footprint (further than 300 km from any node); this API covers the MISO grid only. SIGNUP_CAPACITY_REACHED -> retry_tomorrow (503, /api/v1/signup only): we have hit our own daily ceiling on new free keys; not a limit on the caller.",
        "required": [
          "error_code",
          "message",
          "agent_action"
        ],
        "properties": {
          "error_code": {
            "type": "string",
            "enum": [
              "MISSING_API_KEY",
              "INVALID_API_KEY",
              "QUOTA_EXCEEDED",
              "CAPACITY_LIMIT",
              "INVALID_CURSOR",
              "INVALID_BODY",
              "NODE_NOT_FOUND",
              "INTERNAL_ERROR",
              "SIGNUP_RATE_LIMITED",
              "EMAIL_ALREADY_REGISTERED",
              "OUT_OF_TERRITORY",
              "SIGNUP_CAPACITY_REACHED"
            ]
          },
          "message": {
            "type": "string"
          },
          "agent_action": {
            "type": "string",
            "enum": [
              "provide_api_key",
              "check_api_key",
              "retry_tomorrow_or_upgrade",
              "retry_tomorrow",
              "retry_in_300s",
              "check_config",
              "contact_support",
              "omit_cursor_to_restart"
            ],
            "description": "Machine-readable hint for what to do next."
          }
        },
        "examples": [
          {
            "error_code": "QUOTA_EXCEEDED",
            "message": "Daily limit of 150 requests reached",
            "agent_action": "retry_tomorrow_or_upgrade"
          },
          {
            "error_code": "NODE_NOT_FOUND",
            "message": "Node \"BADNODE\" not found",
            "agent_action": "check_config"
          }
        ]
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid parameters.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid API key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NodeNotFound": {
        "description": "Unknown node name.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "QuotaExceeded": {
        "description": "Daily quota reached. Resets at 00:00 UTC, or upgrade immediately via `/api/stripe/checkout` (does not consume quota). `agent_action: retry_tomorrow_or_upgrade`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "OutOfTerritory": {
        "description": "Coordinates are outside the MISO footprint (further than 300 km from any node). error_code: OUT_OF_TERRITORY.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "CapacityLimit": {
        "description": "Free-tier capacity for today is exhausted across all users. Not your personal quota, and not a fault in the request. Carries Retry-After pointing at the 00:00 UTC reset; paid tiers are never capacity-shed. error_code: CAPACITY_LIMIT.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "SignupCapacity": {
        "description": "Our daily ceiling on new free keys has been reached. A limit on our side, not the caller. error_code: SIGNUP_CAPACITY_REACHED.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  }
}
