ERCOT engineering guide · API

Getting started with the ERCOT Public API in Python

From registration and a first request to what a collector needs to run unattended: token handling, pagination, raw storage and completeness checks.

Published by SteadyState Data · Updated

ERCOT publishes a large amount of market data through its Public API, which means you can start working with real market datasets without first adopting a commercial data platform.

Getting one successful response is straightforward. Building a collector you would trust to run unattended every day is a different problem.

This guide starts with registration and a single request, then adds the pieces a real pipeline needs. The examples use Python 3.10 or later and the requests library. They show structure, not a finished client.

Where this guide sits in the pipeline

  1. Source
  2. Fetch
  3. Raw copy
  4. Validate
  5. Verify completeness
  6. Normalize
  7. Promote
  8. Client system

1. Register for Public API access

You need an account on ERCOT's API Explorer and a subscription key. ERCOT's registration and authentication guide walks through it: sign up, subscribe to the Public API product, then copy the primary key from your profile. Follow ERCOT's current instructions rather than a copy of them — the steps change.

You end up with two kinds of credential: your account's username and password, which are exchanged for a short-lived token, and the subscription key, which is sent with every API request.

2. Keep credentials out of code

import os

ERCOT_USERNAME = os.environ["ERCOT_USERNAME"]
ERCOT_PASSWORD = os.environ["ERCOT_PASSWORD"]
ERCOT_SUBSCRIPTION_KEY = os.environ["ERCOT_SUBSCRIPTION_KEY"]

Read credentials from the environment or a secrets manager. ERCOT's own starter sample in Discussion #34 puts placeholder strings at the top of the file, which is fine for a first test. The habit to build is that real credentials never appear in code, in the repository or in logs.

3. Authenticate

The Public API signs you in through Azure AD B2C: you POST your username and password to ERCOT's token endpoint and receive a bearer token. The endpoint and client values below come from ERCOT's authentication guide:

import requests

TOKEN_URL = (
    "https://ercotb2c.b2clogin.com/ercotb2c.onmicrosoft.com/"
    "B2C_1_PUBAPI-ROPC-FLOW/oauth2/v2.0/token"
)
CLIENT_ID = "fec253ea-0d06-4272-a5e6-b478baeecd70"  # ERCOT's published client ID


def get_token(session: requests.Session) -> str:
    response = session.post(
        TOKEN_URL,
        data={
            "username": ERCOT_USERNAME,
            "password": ERCOT_PASSWORD,
            "grant_type": "password",
            "scope": f"openid {CLIENT_ID} offline_access",
            "client_id": CLIENT_ID,
            "response_type": "id_token",
        },
        timeout=30,
    )
    response.raise_for_status()
    return response.json()["access_token"]

Three details are worth knowing:

  • Send credentials in the request body. ERCOT's documentation lists these values as query-string parameters. OAuth 2.0 token requests normally carry them in a form-encoded body, which keeps the password out of URLs — and out of any proxy or log that records URLs. A February 2026 community comment in Discussion #34 makes the same recommendation.
  • Let the library encode the scope. Write the scope with spaces, as above. Copying the already-encoded form with + signs into a parameter dictionary gets the plus signs encoded a second time.
  • Tokens last one hour and can't be refreshed. ERCOT's guide says to request a new one instead. Cache the token and replace it shortly before it expires, rather than signing in for every request.
import time


class TokenCache:
    """Reuse one token until shortly before ERCOT's one-hour expiry."""

    def __init__(self, session: requests.Session, max_age_seconds: float = 50 * 60):
        self.session = session
        self.max_age = max_age_seconds
        self.token: str | None = None
        self.fetched_at = 0.0

    def get(self) -> str:
        if self.token is None or time.monotonic() - self.fetched_at > self.max_age:
            self.token = get_token(self.session)
            self.fetched_at = time.monotonic()
        return self.token

4. Make one API request

Report endpoints live under https://api.ercot.com/api/public-reports and are named by EMIL product ID. This asks for one hour of LMPs by resource node, load zone and trading hub (NP6-788-CD):

API_BASE = "https://api.ercot.com/api/public-reports"


def get_page(session, tokens, endpoint: str, params: dict, page: int = 1) -> dict:
    response = session.get(
        f"{API_BASE}/{endpoint}",
        headers={
            "Authorization": f"Bearer {tokens.get()}",
            "Ocp-Apim-Subscription-Key": ERCOT_SUBSCRIPTION_KEY,
        },
        params={**params, "page": page},
        timeout=60,
    )
    response.raise_for_status()
    return response.json()


session = requests.Session()
tokens = TokenCache(session)

payload = get_page(
    session,
    tokens,
    "np6-788-cd/lmp_node_zone_hub",
    {
        "SCEDTimestampFrom": "2026-09-01T00:00:00",
        "SCEDTimestampTo": "2026-09-01T01:00:00",
        "size": 1000,  # tune per dataset
    },
)

Parameter names come from ERCOT's OpenAPI specification in the ercot/api-specs repository, and each endpoint has its own filters. The Day-Ahead Market settlement point price endpoint, for example, filters on deliveryDateFrom and deliveryDateTo rather than SCED timestamps. Look them up per endpoint instead of assuming.

5. Check the response status

raise_for_status() turns 4xx and 5xx responses into exceptions, so a failed request can't be mistaken for an empty result. Always pass a timeout, too: requests waits indefinitely without one, and ERCOT's API has had periods where data requests hung for minutes before failing (Discussion #124).

6. Parse the JSON

Report responses share an envelope: _meta (paging information such as totalPages and currentPage), report (which product this is), fields (the column definitions) and data (the rows). Columns differ from report to report, so read the names from fields instead of hardcoding positions:

def rows(payload: dict):
    names = [field["name"] for field in payload["fields"]]
    for row in payload["data"]:
        # Rows usually arrive as lists aligned with `fields`; accept dicts too.
        yield dict(zip(names, row)) if isinstance(row, list) else row

7. Paginate

Large reports span many pages. Read _meta.totalPages from the first response and request the rest:

def fetch_all(session, tokens, endpoint: str, params: dict):
    first = get_page(session, tokens, endpoint, params, page=1)
    yield first
    for page in range(2, first["_meta"]["totalPages"] + 1):
        yield get_page(session, tokens, endpoint, params, page=page)

Keep each query small. Paging through a full day of nodal prices in one query is the pattern that ran into ERCOT's bandwidth-based limits in March 2026. Ask for an hour or a few hours at a time and let a scheduler repeat the request — building under rate limits explains why.

8. Add retries

So far the collector stops at the first error. A production collector has to tell a throttled request (429) from an expired token (401), a rejected query (400), an upstream failure (5xx) and a timeout, because each needs a different response. Surviving ERCOT API failures covers each case.

9. Save raw data before transforming it

  1. Source
  2. Raw copy
  3. Parse
  4. Normalize
import json
from pathlib import Path


def save_raw(payload: dict, dataset: str, window_start: str, page: int,
             root: Path = Path("raw")) -> Path:
    path = root / dataset / window_start / f"page-{page:04d}.json"
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload))
    return path

Writing each page to storage exactly as received, before parsing it, gives you something to replay when a parsing bug turns up, evidence when numbers are questioned, and a way to rebuild normalized tables without asking ERCOT for the data again — which matters when API usage is limited by volume.

10. Validate completeness

Receiving every page of a query doesn't mean the dataset is complete: the upstream report may not have been fully processed yet. Compare what arrived against what should exist — see detecting missing ERCOT market data.

The first API call proves connectivity. The rest of the architecture determines whether you have a reliable data pipeline.

Practical takeaway

  • Follow ERCOT's current registration guide; keep credentials in the environment or a secrets manager.
  • Send token credentials in the POST body, and cache the one-hour token.
  • Look up each endpoint's parameters in ERCOT's OpenAPI specification.
  • Always set timeouts, and check status before parsing.
  • Read column names from fields; paginate with _meta.totalPages.
  • Save raw responses first, then validate completeness.

Sources & further reading

ERCOT APIs, publication behavior and access policies can change. These sources were reviewed on September 21, 2026; verify current requirements against ERCOT's official documentation before implementing production systems. Discussion threads mix ERCOT staff replies with community reports, and the notes below say which is which.

SteadyState Data

Need this running reliably?

SteadyState Data builds and operates focused power-market data pipelines for trading, analytics and energy teams. Start with the market, dataset and destination. We can help determine the smallest reliable system required to collect, validate and deliver it.

Discuss an Energy Data Project