Guide
Odds API in JavaScript and TypeScript
A typed Node.js client built on the built-in fetch: list upcoming football events page by page, read their odds and retry correctly on 429 and 503. No dependencies.
Before you start
You need Node.js 18 or newer and an API key; see Authentication. Call the API from a server or script, never from browser code: the key would be visible to every visitor.
Which bookmakers your key can read depends on your plan and bookmaker entitlements; see Bookmakers for the list your key returns. No bookmaker is in verified customer coverage right now. The examples use bookmaker=tipico and football. Examples use Tipico for illustration only. Tipico ingestion is offline for a source review and is not part of current customer coverage.
export BETSPREAD_API_KEY="YOUR_API_KEY"
# Node.js 18+ ships fetch. Run the TypeScript file with tsx:
npx tsx odds.tsTypes from the OpenAPI contract
These interfaces follow the schemas in the OpenAPI contract (Event, EventOdds, MarketOdds, BookmakerOdds, OutcomeOdds, PaginationMeta, Error). To generate them instead, point an OpenAPI generator at /api/v1/spec.
type Transport = "rest" | "live";
interface PaginationMeta {
total: number;
limit: number;
offset: number;
transport: Transport;
sport?: string;
}
interface Page<T> {
data: T[];
meta: PaginationMeta;
}
interface OddsEvent {
id: string;
sport: string;
leagueKey: string;
leagueName: string;
homeTeam: string;
awayTeam: string;
kickoffAt: string;
marketCount: number;
}
interface OutcomeOdds {
outcome: string;
transport: Transport;
odds: number;
status: "open" | "suspended" | "closed";
lastUpdatedAt: string;
}
interface BookmakerOdds {
slug: string;
displayName: string;
outcomes: OutcomeOdds[];
}
interface MarketOdds {
type: string;
line?: number | null;
bookmakers: BookmakerOdds[];
}
interface EventOdds {
event: OddsEvent;
markets: MarketOdds[];
}
interface ErrorBody {
error: { code: string; message: string };
}A client that handles errors and rate limits
Error responses share the shape {"error":{"code","message"}}; branch on error.code. The get helper retries 429 rate_limit_exceeded after Retry-After seconds (or at X-RateLimit-Reset, Unix seconds) and 503 service_unavailable with exponential backoff. 400, 401, 403 and 404 throw a BetspreadError right away.
A used-up monthly REST quota also answers 429, with X-RateLimit-Reset at the next month start. Waits longer than MAX_WAIT_MS end the loop instead of blocking the process. Details: Errors, Rate limits.
const API_BASE = "https://api.betspread.io/api/v1";
const API_KEY = process.env.BETSPREAD_API_KEY;
const MAX_RETRIES = 5;
const MAX_WAIT_MS = 120_000;
class BetspreadError extends Error {
status: number;
code: string;
constructor(status: number, code: string, message: string) {
super(`${status} ${code}: ${message}`);
this.status = status;
this.code = code;
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function errorOf(response: Response) {
try {
const body = (await response.json()) as ErrorBody;
return body.error;
} catch {
return { code: "unknown", message: response.statusText };
}
}
function waitMs(response: Response, attempt: number) {
const retryAfter = response.headers.get("Retry-After");
if (retryAfter !== null) return Number(retryAfter) * 1000;
const reset = response.headers.get("X-RateLimit-Reset");
if (reset !== null) return Math.max(0, Number(reset) * 1000 - Date.now());
return 2 ** attempt * 1000;
}
async function get<T>(
path: string,
params: Record<string, string | number> = {},
): Promise<T> {
if (!API_KEY) throw new Error("Set BETSPREAD_API_KEY first");
const url = new URL(API_BASE + path);
for (const [name, value] of Object.entries(params)) {
url.searchParams.set(name, String(value));
}
for (let attempt = 1; ; attempt++) {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
if (response.ok) return (await response.json()) as T;
const error = await errorOf(response);
const retryable = response.status === 429 || response.status === 503;
const delay = response.status === 429
? waitMs(response, attempt)
: 2 ** attempt * 1000;
// 400, 401, 403 and 404 do not get better by retrying. A monthly quota
// resets at the next month start, so a long wait also ends the loop.
if (!retryable || attempt >= MAX_RETRIES || delay > MAX_WAIT_MS) {
throw new BetspreadError(response.status, error.code, error.message);
}
await sleep(delay);
}
}Paginate events
GET /api/v1/events takes limit (1–500, default 50) and offset and returns meta.total. The async generator below yields events until the offset passes the total. Pass dateFrom: default ordering does not guarantee the next upcoming event first.
async function* listEvents(sport: string, dateFrom: string, pageSize = 100) {
const params = { sport, dateFrom, limit: pageSize, offset: 0 };
while (true) {
const page = await get<Page<OddsEvent>>("/events", params);
yield* page.data;
params.offset += page.meta.limit;
if (page.data.length === 0 || params.offset >= page.meta.total) return;
}
}Read the odds of an event
GET /api/v1/events/{eventId}/odds groups decimal odds by market and bookmaker. REST serves only open quotes; lastUpdatedAt tells you how old each quote is, so apply your own cutoff before acting on a price (Quote freshness).
async function eventOdds(eventId: string, bookmaker = "tipico") {
const params = { bookmaker };
const body = await get<{ data: EventOdds }>(`/events/${eventId}/odds`, params);
return body.data;
}
function printOdds({ event, markets }: EventOdds) {
for (const market of markets) {
for (const bookmaker of market.bookmakers) {
for (const outcome of bookmaker.outcomes) {
console.log(
event.kickoffAt,
`${event.homeTeam} vs ${event.awayTeam}`,
market.type,
market.line ?? "",
bookmaker.slug,
outcome.outcome,
outcome.odds,
outcome.lastUpdatedAt,
);
}
}
}
}Run it
Each odds request counts against your monthly REST quota, so the example reads the first five events only.
async function main() {
const events: OddsEvent[] = [];
for await (const event of listEvents("football", new Date().toISOString())) {
events.push(event);
}
console.log(`${events.length} upcoming events`);
// One odds request per event: start small, every call counts.
for (const event of events.slice(0, 5)) {
printOdds(await eventOdds(event.id));
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});Complete file
type Transport = "rest" | "live";
interface PaginationMeta {
total: number;
limit: number;
offset: number;
transport: Transport;
sport?: string;
}
interface Page<T> {
data: T[];
meta: PaginationMeta;
}
interface OddsEvent {
id: string;
sport: string;
leagueKey: string;
leagueName: string;
homeTeam: string;
awayTeam: string;
kickoffAt: string;
marketCount: number;
}
interface OutcomeOdds {
outcome: string;
transport: Transport;
odds: number;
status: "open" | "suspended" | "closed";
lastUpdatedAt: string;
}
interface BookmakerOdds {
slug: string;
displayName: string;
outcomes: OutcomeOdds[];
}
interface MarketOdds {
type: string;
line?: number | null;
bookmakers: BookmakerOdds[];
}
interface EventOdds {
event: OddsEvent;
markets: MarketOdds[];
}
interface ErrorBody {
error: { code: string; message: string };
}
const API_BASE = "https://api.betspread.io/api/v1";
const API_KEY = process.env.BETSPREAD_API_KEY;
const MAX_RETRIES = 5;
const MAX_WAIT_MS = 120_000;
class BetspreadError extends Error {
status: number;
code: string;
constructor(status: number, code: string, message: string) {
super(`${status} ${code}: ${message}`);
this.status = status;
this.code = code;
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function errorOf(response: Response) {
try {
const body = (await response.json()) as ErrorBody;
return body.error;
} catch {
return { code: "unknown", message: response.statusText };
}
}
function waitMs(response: Response, attempt: number) {
const retryAfter = response.headers.get("Retry-After");
if (retryAfter !== null) return Number(retryAfter) * 1000;
const reset = response.headers.get("X-RateLimit-Reset");
if (reset !== null) return Math.max(0, Number(reset) * 1000 - Date.now());
return 2 ** attempt * 1000;
}
async function get<T>(
path: string,
params: Record<string, string | number> = {},
): Promise<T> {
if (!API_KEY) throw new Error("Set BETSPREAD_API_KEY first");
const url = new URL(API_BASE + path);
for (const [name, value] of Object.entries(params)) {
url.searchParams.set(name, String(value));
}
for (let attempt = 1; ; attempt++) {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
if (response.ok) return (await response.json()) as T;
const error = await errorOf(response);
const retryable = response.status === 429 || response.status === 503;
const delay = response.status === 429
? waitMs(response, attempt)
: 2 ** attempt * 1000;
// 400, 401, 403 and 404 do not get better by retrying. A monthly quota
// resets at the next month start, so a long wait also ends the loop.
if (!retryable || attempt >= MAX_RETRIES || delay > MAX_WAIT_MS) {
throw new BetspreadError(response.status, error.code, error.message);
}
await sleep(delay);
}
}
async function* listEvents(sport: string, dateFrom: string, pageSize = 100) {
const params = { sport, dateFrom, limit: pageSize, offset: 0 };
while (true) {
const page = await get<Page<OddsEvent>>("/events", params);
yield* page.data;
params.offset += page.meta.limit;
if (page.data.length === 0 || params.offset >= page.meta.total) return;
}
}
async function eventOdds(eventId: string, bookmaker = "tipico") {
const params = { bookmaker };
const body = await get<{ data: EventOdds }>(`/events/${eventId}/odds`, params);
return body.data;
}
function printOdds({ event, markets }: EventOdds) {
for (const market of markets) {
for (const bookmaker of market.bookmakers) {
for (const outcome of bookmaker.outcomes) {
console.log(
event.kickoffAt,
`${event.homeTeam} vs ${event.awayTeam}`,
market.type,
market.line ?? "",
bookmaker.slug,
outcome.outcome,
outcome.odds,
outcome.lastUpdatedAt,
);
}
}
}
}
async function main() {
const events: OddsEvent[] = [];
for await (const event of listEvents("football", new Date().toISOString())) {
events.push(event);
}
console.log(`${events.length} upcoming events`);
// One odds request per event: start small, every call counts.
for (const event of events.slice(0, 5)) {
printOdds(await eventOdds(event.id));
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});Next steps
- Stream live odds over WebSocket with replay and reconnect.
- The same client in Python.
- API reference for every parameter and response code.