Get started
Integrations

Stock market data in Python,
straight into pandas.

The OHLC API is plain HTTPS and JSON, so Python needs nothing beyond requests or the standard library. This guide covers a first request, a pagination helper, a pandas DataFrame and errors handled the way the API reports them.

No SDK required

Bearer auth, JSON envelopes and cursor pagination work with requests or urllib.

Cursor pagination

Copy meta.next_cursor into the next request until it is null; cursors last an hour.

Exact decimals

Prices and volumes are strings. Parse them with Decimal or pd.to_numeric as your analysis needs.

Problem documents

Errors are RFC 9457 JSON with a stable code; 429 responses carry Retry-After.

Your first request

  1. Create a free account and an API key, then export it: export OHLC_API_KEY=....
  2. Install requests and pandas: pip install requests pandas.
  3. Request daily bars from /v1/eod with an exchange-qualified symbol such as XNAS:AAPL.
requestsPython
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:

Standard library onlyPython
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.

ohlc_client.pyPython
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.

Generator over every rowPython
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"] = cursor

Daily bars in a pandas DataFrame

Total-return closes and daily returnsPython
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.

How far back each symbol goesPython
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-Cost and X-Credits-Remaining show what each request spent and what is left until 00:00 UTC. GET /v1/usage reports 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.

Questions, answered.

Is there an official OHLC Python SDK?

Not today. The API is designed to be used directly with requests or the standard library, and the OpenAPI spec at /specs/ohlc-openapi.json can generate a typed client if you want one.

How do I get more than 500 rows?

Follow meta.next_cursor. Each page is one request; pass the cursor back with the original parameters until it is null.

Why are prices strings instead of floats?

To keep exact decimal values. Convert them with Decimal for exact arithmetic or pd.to_numeric for analysis.

How should I handle 429 Too Many Requests?

Wait the number of seconds in Retry-After and retry. If it points hours ahead, the daily credit allowance is spent until 00:00 UTC.

Can I get intraday bars in pandas?

Yes, on Pro and above: call /v1/intraday with interval=5m or 1h and session=regular, and parse datetime as UTC.

A little JSON.
A lot of possibility.

Explore the API