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.
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
- Source
- Fetch
- Raw copy
- Validate
- Verify completeness
- Normalize
- Promote
- 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
- Source
- Raw copy
- Parse
- 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.
Related ERCOT guides
Reliability
How to make an ERCOT data pipeline survive 400s, 429s, 500s and timeouts
An error taxonomy for ERCOT collectors: backoff with jitter, retry budgets, persisted state and quarantine.
Read the guideAPI
Building a reliable ERCOT API pipeline under rate limits
Why ERCOT API 429s appeared in March 2026, and how to collect in small, resumable windows without hardcoding limits.
Read the guideData Quality
How to detect missing ERCOT market data before it reaches your models
Validate ERCOT datasets for missing intervals and entities, repair only the gaps, and promote only verified periods.
Read the guideSources & 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.
- ERCOT Public API: registration and authentication Official ERCOT developer documentation.
- ERCOT Public API specification (OpenAPI), ercot/api-specs Official endpoint and parameter definitions published by ERCOT.
- Discussion #34: Python starter sample code for downloading API data ERCOT staff sample code, June 19, 2024, with community comments through February 2026.
- Discussion #45: SUBSCRIPTION_KEY ERCOT staff reply, August 12, 2024, pointing to the registration guide and Discussion #34.
- Discussion #124: Public API requests timing out Community reports from January 26, 2026; ERCOT staff reply, January 27, 2026. Not marked answered.