A typed fetch helper
- Create an API key and set it as
OHLC_API_KEYin your server's environment. - Use the helper below for every request: it adds the header, retries rate limits and throws the API's problem document on failure.
- Pass lists as one comma-separated value, such as
symbols: "XNAS:AAPL,XNYS:JNJ", and dates asYYYY-MM-DD.
// ohlc.ts — server-side only
const API = "https://api.ohlc.io/v1";
export type DailyBar = {
date: string;
instrument_id: string;
symbol: string;
exchange_mic: string;
currency_code: string;
open: string | null;
high: string | null;
low: string | null;
close: string | null;
volume: string | null;
trade_count: number | null;
adjustment?: "split" | "total_return";
factor_set_version?: number;
};
export type Page<T> = {
data: T[];
meta: {
timezone: "UTC";
as_of: string;
next_cursor: string | null;
errors?: { symbol: string | null; instrument_id: string | null; code: string; status: number; detail: string }[];
};
};
export async function ohlc<T>(path: string, params: Record<string, string | number> = {}): Promise<Page<T>> {
const url = new URL(API + path);
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, String(value));
for (let attempt = 0; ; attempt++) {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.OHLC_API_KEY}` },
});
if (response.ok) return response.json();
const wait = Number(response.headers.get("retry-after") ?? 1);
if (response.status === 429 && wait <= 60 && attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
continue;
}
const problem = await response.json().catch(() => ({}));
throw Object.assign(new Error(`${response.status} ${problem.code}: ${problem.detail}`), { status: response.status, problem });
}
}Errors are RFC 9457 problem documents with a stable code, such as symbol-not-resolved or daily-credit-limit. A Retry-After longer than a minute on a 429 usually means the daily credit allowance is spent until 00:00 UTC, so the helper stops instead of waiting.
Pagination with an async generator
export async function* paginate<T>(path: string, params: Record<string, string | number>) {
let cursor: string | null = null;
do {
const page: Page<T> = await ohlc<T>(path, cursor ? { ...params, cursor } : params);
yield* page.data;
cursor = page.meta.next_cursor;
} while (cursor);
}
for await (const bar of paginate<DailyBar>("/v1/eod", {
symbols: "XNAS:AAPL,XNYS:JNJ",
from: "2026-01-02",
to: "2026-10-02",
adjustment: "split",
limit: 500,
})) {
console.log(bar.symbol, bar.date, bar.close);
}Cursors expire after an hour and are bound to the query, so keep the other parameters unchanged between pages. Prices are decimal strings; use a decimal library if you need exact arithmetic, or Number() for charts.
Keep the API key on the server
A key in browser code can be copied by anyone who opens the page. Call OHLC from your backend and return only what the page needs. Browser access is possible only with a key that is explicitly browser-enabled and restricted to allowed origins, and even then a proxy is simpler to reason about.
import express from "express";
import { ohlc, type DailyBar } from "./ohlc";
const app = express();
app.get("/api/closes/:symbol", async (req, res) => {
try {
const page = await ohlc<DailyBar>("/v1/eod", {
symbols: req.params.symbol,
from: String(req.query.from ?? "2026-01-02"),
limit: 500,
});
res.json(page.data.map(({ date, close }) => ({ date, close })));
} catch (error: any) {
res.status(error.status ?? 500).json({ error: error.problem?.code ?? "unexpected" });
}
});
app.listen(3000);Stream delayed quotes over WebSocket
On Pro and above, wss://api.ohlc.io/v1/stream pushes a quote frame whenever a new snapshot changes a subscribed quote: exchange listings are delayed at least 15 minutes, and crypto and currency pairs are snapshotted every few minutes. It is not real-time. Authenticate within 10 seconds, wait for authenticated, subscribe, and send ping well inside each 120-second lease. Node.js 22 and later include a global WebSocket.
const socket = new WebSocket("wss://api.ohlc.io/v1/stream");
let heartbeat;
socket.addEventListener("open", () => {
socket.send(JSON.stringify({ type: "authenticate", key: process.env.OHLC_API_KEY }));
});
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "authenticated") {
socket.send(JSON.stringify({ type: "subscribe", symbols: ["XNAS:AAPL", "XNYS:JNJ"] }));
heartbeat = setInterval(() => socket.send(JSON.stringify({ type: "ping" })), 60_000);
} else if (message.type === "quotes") {
for (const quote of message.data) console.log(quote.symbol, quote.last_price, quote.last_at);
} else if (message.type === "error") {
console.error(message.detail);
}
});
socket.addEventListener("close", () => clearInterval(heartbeat));There is no replay: on disconnect, reconnect with backoff, authenticate and resubscribe. A subscribe message takes at most 100 symbols, and your plan's symbol limit counts the union across all of the account's connections. Pro streams up to 250 symbols.
Generate a client from the OpenAPI spec
The full contract is published at /specs/ohlc-openapi.json, with every parameter, enum and response schema. Generators such as openapi-typescript turn it into types for every endpoint, so a typo in adjustment or a missing required interval fails at compile time rather than as a 422. The same spec feeds the API reference and the Postman collection, so the three stay in step.
Practical tips for production
- Cache daily bars. They change only when a new session is published or, for adjusted series, when
factor_set_versionmoves. The dataset endpoints also return anETagand acceptIf-None-Match; a304costs no credits, so revalidating an unchanged result is free. - Log the
X-Request-IDresponse header with every error; it is the fastest way to trace a request with support. - Watch
X-Credits-Remaining. The daily credit allowance is a hard cap with no overage charges, so a runaway loop stops at 429 until 00:00 UTC rather than producing a bill. - Tolerate new response fields. The API adds fields without a version change; read the ones you need and ignore the rest.