Your first request
- Create a free account and an API key, then export it:
export OHLC_API_KEY=.... - Install requests and pandas:
pip install requests pandas. - Request daily bars from
/v1/eodwith an exchange-qualified symbol such asXNAS:AAPL.
import os
import requests
response = requests.get(
"https://api.ohlc.io/v1/eod",
params={"symbols": "XNAS:AAPL", "from": "2026-09-28", "to": "2026-10-02"},
headers={"Authorization": f"Bearer {os.environ['OHLC_API_KEY']}"},
timeout=30,
)
response.raise_for_status()
for bar in response.json()["data"]:
print(bar["date"], bar["open"], bar["high"], bar["low"], bar["close"], bar["volume"])Lists are comma-separated in one parameter: symbols=XNAS:AAPL,XNYS:JNJ. Repeating a parameter or using symbols[]= returns 422. Dates are always YYYY-MM-DD, and from and to are inclusive. Without third-party packages, the standard library works the same way:
import json
import os
import urllib.parse
import urllib.request
query = urllib.parse.urlencode({"symbols": "XNAS:AAPL,XNYS:JNJ", "from": "2026-09-28"})
request = urllib.request.Request(
f"https://api.ohlc.io/v1/eod?{query}",
headers={"Authorization": f"Bearer {os.environ['OHLC_API_KEY']}"},
)
with urllib.request.urlopen(request, timeout=30) as response:
page = json.load(response)
print(len(page["data"]), page["meta"]["next_cursor"])A client with retries and problem documents
Failed requests return application/problem+json with type (https://ohlc.io/problems/<code>), title, status, detail, a stable code and invalid_params. Retry 429 after the Retry-After seconds. When the daily credit allowance is spent, Retry-After points at 00:00 UTC, so give up rather than sleep for hours. Fix 4xx errors instead of retrying them; failed requests are refunded.
import os
import time
import requests
API = "https://api.ohlc.io/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['OHLC_API_KEY']}"
class OhlcError(Exception):
def __init__(self, status, problem):
self.status = status
self.code = problem.get("code")
self.problem = problem
super().__init__(f"{status} {self.code}: {problem.get('detail')}")
def get(path, params, max_wait=60):
for attempt in range(5):
response = session.get(f"{API}{path}", params=params, timeout=30)
if response.status_code == 429:
wait = int(response.headers.get("Retry-After", "1"))
if wait > max_wait or attempt == 4: # daily credits reset at 00:00 UTC
break
time.sleep(wait)
continue
if response.status_code in (500, 502, 504) and attempt < 4:
time.sleep(2 ** attempt)
continue
break
if not response.ok:
try:
problem = response.json()
except ValueError:
problem = {"detail": response.text}
raise OhlcError(response.status_code, problem)
return response.json()Pagination with meta.next_cursor
Pages hold up to limit rows (1 to 500, default 100). Keep every other parameter unchanged and add the cursor. Symbols that returned nothing are listed in meta.errors on the page that reaches them.
def paginate(path, params):
params = dict(params)
while True:
page = get(path, params)
yield from page["data"]
for error in page["meta"].get("errors", []):
print("no rows:", error["symbol"], error["code"])
cursor = page["meta"]["next_cursor"]
if not cursor:
return
params["cursor"] = cursorDaily bars in a pandas DataFrame
import pandas as pd
rows = paginate("/v1/eod", {
"symbols": "XNAS:AAPL,XNAS:MSFT,XNYS:JNJ",
"from": "2026-01-02",
"to": "2026-10-02",
"adjustment": "total_return",
"limit": 500,
})
df = pd.DataFrame(rows)
df["date"] = pd.to_datetime(df["date"])
for column in ["open", "high", "low", "close", "volume"]:
df[column] = pd.to_numeric(df[column])
closes = df.pivot(index="date", columns="symbol", values="close")
returns = closes.pct_change().dropna()
print(returns.describe())pd.to_numeric converts to floats, which is fine for returns and charts. If you reconcile amounts or compare prices for equality, keep the strings or convert with decimal.Decimal. The date column is the exchange-local trading date; intraday bars from /v1/intraday carry a UTC datetime instead.
Check coverage before a large download
Before pulling years of history for hundreds of symbols, ask how much exists. /v1/datasets/eod/coverage returns history_from, history_to and any known gaps for each symbol at 5 credits per request. Your plan then bounds what you can read: five years on Free and Developer, fifteen on Pro, full history on Commercial and Scale.
coverage = get("/v1/datasets/eod/coverage", {"symbols": "XNAS:AAPL,XNYS:JNJ"})
for row in coverage["data"]:
print(row["exchange_mic"], row["history_from"], row["history_to"], row["status"])Intraday bars and point-in-time reads
On Pro and above, /v1/intraday takes the same pattern with two required parameters: interval (5m or 1h) and session (regular). Bars are kept for about 30 days and cost 25 credits per symbol. Parse datetime with pd.to_datetime(df["datetime"], utc=True) and convert to America/New_York if you want session times.
For backtests, as_of=YYYY-MM-DD limits a response to what was known by the end of that UTC day, and symbol_at resolves a ticker to the listing that held it on a past date. Both work on most datasets, so a research script can see the data it would have seen at the time rather than later restatements.
Credits and rate limits from Python
- Per-symbol endpoints charge for every distinct symbol, and each page is a separate request: three symbols cost 15 credits per page on
/v1/eod. - The response headers
X-Credits-CostandX-Credits-Remainingshow what each request spent and what is left until 00:00 UTC.GET /v1/usagereports the balance without spending credits. - Per-minute and concurrency limits count requests, not credits. A thread pool larger than your plan's concurrent-request limit only produces 429 responses.
- Batch symbols into one request (up to 100) rather than looping one symbol at a time; the credit cost is the same and you make far fewer requests.