A sports odds API gives your program the prices that sportsbooks, exchanges and pick'em apps are offering, in one format, so you don't have to read each venue yourself. You ask for a league or a game, and you get back each book's moneyline, spread, total and often player props, with the price, the line and the time it was read.
That last field decides whether the data is any use. A price is a quote at a moment, and a quote that's ten minutes old can describe a market that has already moved. This guide covers what a good odds API returns, the checks to run before you trust one, and how ZippyOddsAPI is put together.
What it is, and what it isn't
It's a read-only data service. It reports prices and doesn't take bets, hold money or place wagers. Searches for a sports betting API often mean the same thing, though some people want to place bets through code, which a read-only service can't do.
It also isn't a statistics feed. Box scores, play-by-play and player season totals come from a different kind of sports API. Odds and stats meet in a model: the stats feed tells you what happened, the prices tell you what the market expected and what it costs to act on your view.
A betting odds API usually covers three groups of data:
- Catalog: leagues, books, market types, teams and players.
- Prices: each book's lines for each game, props, futures and price history.
- Derived boards: numbers worked out from those prices, such as +EV, arbitrage, middles and best lines.
What a good response contains
Every price needs enough context that two prices from two books can be compared without guessing. These are the fields that matter, with the names ZippyOddsAPI uses:
| Field | Why you need it |
|---|---|
price | The odds, in American, decimal or probability format as you ask. |
point and line | The handicap or total. A -110 price means nothing without it. |
side | home, away, draw, over or under, so a spread pairs by side rather than by label. |
key and period | The market (spread, total, player points) and the part of the game. A first-half line must say it's a first-half line. |
updated_at and observed_at | When the price was read. |
suspended | Whether the market was open when it was read. |
liquidity | At an exchange, the dollars available at that price. |
Then there's identity. The same game listed by ten books has to become one event, and the same side at the same line has to get the same id at every book. In ZippyOddsAPI an outcome id starts with sel_, and the same side and point in the same market has the same id at every book, so finding the best price on a side is a group-by rather than a string-matching job.
Five checks before you build on one
Run these against any sports betting odds API before your code depends on it.
- Is every price dated? Ask for a quiet market and look at the read times. If every price looks seconds old, the service is probably stamping its own response time, not the time it read the book.
- Does a part-game line say so? A first-half total mixed into full-game totals produces fake edges. Each line should carry its period.
- How are games matched? A false merge of two different games invents a price gap that isn't there. Ask how close names are handled, and check a slate of college games, where similar names are common.
- Does a short answer say it's short? A page of 200 rows should tell you whether more exist. Silent truncation looks exactly like a complete answer.
- What does a request cost? If a service charges by the size of the answer, a busy Saturday costs more than a quiet Tuesday, and your bill is hard to predict. Check whether a refused request is charged too.
How ZippyOddsAPI is built
Prices are read from operators' own public data; where a book is only seen through a public publisher's snapshot, that copy is labelled raw-only and never scored. Every price keeps the time it was observed, and a copy a collector sends again from its own cache keeps the clock of the response it came in, so it never looks fresher than it is. Prices are polled, which means they are minutes old rather than milliseconds, and the age is there for you to judge.
A request costs a fixed 0 to 3 credits, whatever the size of the answer, and a refused request costs nothing. The catalog endpoints are free, events and scores cost 1, odds cost 2, and props, history and the computed boards cost 3. Plans differ by credits, rate limit and streaming, and the pricing page has them.
The key goes in an Authorization: Bearer header. A key sent in the URL is refused with 400 api_key_in_url, because URLs end up in logs.
A book appearing in the registry isn't a promise of prices. The coverage page shows which books are feeding now, and /public/coverage returns the same report without a key.
The endpoint families
| Family | Endpoints | Credits |
|---|---|---|
| Account and catalog | status, usage, leagues, sportsbooks, markets, coverage | 0 |
| Teams and players | teams, players | 1 |
| Events | events, events/{id}, scores | 1 |
| Prices | odds, events/{id}/odds, futures, prediction-contracts | 2 |
| Prices with more work | props, best-lines, history | 3 |
| Computed | ev, arbitrage, middles, holds, hedges, smart-money, dfs | 3 |
All of them sit under https://zippyoddsapi.com/v1. There's also a WebSocket stream at /v1/stream that sends a snapshot and then deltas as prices change. Our guide to the live odds API covers when the stream beats polling, and the one on the sportsbook data feed covers keeping a local copy of the board in step.
A first request, with the age of each price
This reads NHL moneylines and prints each book's prices with their age, measured on the server's clock rather than your machine's:
import os
from datetime import datetime
import requests
def when(stamp):
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
r = requests.get(
"https://zippyoddsapi.com/v1/odds",
params={"league": "nhl", "markets": "moneyline"},
headers={"Authorization": f"Bearer {os.environ['ZIPPYODDS_KEY']}"},
timeout=10,
)
r.raise_for_status()
body = r.json()
now = when(body["meta"]["generated_at"])
for event in body["data"]:
game = f'{event["away"]["name"]} @ {event["home"]["name"]}'
for book in event["bookmakers"]:
age = (now - when(book["updated_at"])).total_seconds()
for market in book["markets"]:
prices = {o["side"]: o["price"] for o in market["outcomes"]}
print(game, book["name"], prices, f"{age:.0f}s old")
Two things to add before this goes anywhere near production. Check meta.has_more and follow meta.next_cursor until it's gone, because /v1/odds returns at most 200 events a page. And branch on error.code in a failed response, not on the message: rate_limited comes with a Retry-After header that you should honor.
When the age matters most
An odds API is most useful for the work where a few minutes don't change the answer: building a model's training set from opening and closing prices, comparing a book's margins, or showing a user which book has the best number right now with its age next to it.
It's least forgiving in the work where they do. A +EV or arbitrage row is only as good as its oldest leg. ZippyOddsAPI ages every leg from when it was really read and leaves a stale row off the default boards, but no polled service can promise that a price is still there when a person clicks. Treat every edge as an estimate that can be gone by the time you act.
The narrower guides
Each of these takes one part of the subject further:
- A soccer odds API has three-way markets, many competitions and kickoffs in every time zone.
- The NFL API and NBA API guides sort out what developers want for each league: scores, stats or odds.
- The DraftKings API and Pinnacle API guides explain how a single book's prices reach a program, and why Pinnacle is used as a reference.
The full reference, with every parameter and field, is in the ZippyOddsAPI docs, along with how to get a key.