Quickstart
From nothing to your first odds response in about a minute.
API base URL: https://api.pingodds.com
Every path in these docs goes after it, e.g. https://api.pingodds.com/v2/odds, with your
key in an Authorization: Bearer header.
1. Create an account
Sign up with email and password, or with Google. Every new account starts on the free plan (100 requests / month, 5 requests / minute) — no card required.
2. Create an API key
On /app/keys, click "Create key". The plaintext key is shown once — copy it somewhere safe. Only its last four characters are kept in clear on our side, so it can't be recovered or shown again.
3. Make your first call
Call the API at
https://api.pingodds.com, sending the key as a bearer token on every request:curl -H "Authorization: Bearer $PINGODDS_API_KEY" \ "https://api.pingodds.com/v2/odds?league=americanfootball_nfl&markets=spreads"4. Read the response
A trimmed real response (one event, one book, one market) —
eventsis an array like this, followed bymeta:{ "events": [ { "event": { "id": 10, "league": "americanfootball_nfl", "commence_time": "2026-09-27T17:00:00Z", "home": { "id": 16, "name": "Cleveland Browns" }, "away": { "id": 17, "name": "Carolina Panthers" }, "status": "scheduled" }, "markets": [ { "key": "spreads", "books": [ { "key": "pinnacle", "last_update": "2026-09-24T16:27:24.078490886Z", "last_checked": "2026-09-25T14:23:38.627896Z", "outcomes": [ { "name": "home", "line": 2.5, "price_dec": 2.01, "price_am": 101, "implied": 0.497512, "no_vig": 0.482918, "suspended": false }, { "name": "away", "line": -2.5, "price_dec": 1.8772, "price_am": -114, "implied": 0.532708, "no_vig": 0.517082, "suspended": false } ] } ] } ] } ], "meta": { "staleness_ms": 37634, "requests_used": 1, "requests_limit": 100, "cycle_resets_at": "2026-10-01T00:00:00Z" } }last_updateis when a book's price last changed;last_checkedis when PingOdds last polled it successfully.meta.staleness_msis the age, in milliseconds, of the stalest book included in the response — so you always know exactly how current what you got is, never just "live" asserted with nothing to back it.Outcomes come home first:
home,awayfor spreads and two-way moneylines;home,draw,awayfor a three-way (soccer) moneyline; thenover,underfor totals.5. Limits and headers
Every authenticated response carries three headers:
Header Meaning X-RateLimit-LimitYour plan's monthly request allowance, shared by all your API keys. X-RateLimit-RemainingRequests left in the current monthly cycle, across all your API keys. X-RateLimit-ResetUnix seconds when the monthly allowance resets. The two odds endpoints repeat the monthly figures in the body, as
meta.requests_used,meta.requests_limitandmeta.cycle_resets_atabove. Each plan also caps requests per minute across all your keys (5 on free); see pricing for every plan's limits.6. Errors
Every error body has the same shape:
{ "error": { "code": "...", "message": "..." } }A missing key gets 401
missing_api_key; an invalid or revoked key gets 401invalid_api_key. An AI agent key (po_mcp_…) gets 403wrong_key_kind: it works only with the MCP server. Go over your per-minute burst and you get 429rate_limitedwith aRetry-Afterheader. Go over your monthly allowance and you get 429monthly_limit_reached, with extra fields to act on directly:{ "error": { "code": "monthly_limit_reached", "message": "You have used all 100 requests in your free plan for this cycle. The allowance resets at 2026-10-01T00:00:00Z; upgrade for a higher limit.", "plan": "free", "requests_used": 100, "requests_limit": 100, "cycle_resets_at": "2026-10-01T00:00:00Z", "upgrade_url": "https://pingodds.com/pricing" } }7. Streaming (Pro)
On the Pro plan you can skip polling: open a WebSocket to
wss://api.pingodds.com/v2/stream, send{"action":"auth","key":"po_live_…"}, then subscribe to a league and receive every price change as it happens. Each subscribe counts as one request; the updates are free. See Streaming for the message contract, close codes and a live example.8. Signals
PingOdds' signal engine watches every tick and derives a consensus line plus four signal kinds: steam, positive EV, outlier books and arbitrage. Every signal carries the ticks that produced it (
ticks) andstaleness_ms, the age of the oldest of them.Endpoint Plans What GET /v2/consensus/{event_id}All plans Current no-vig consensus per market. GET /v2/events/{id}/closingAll plans Closing lines, once the event has gone live. GET /v2/signals/steam?league=&since=&limit=Starter, Pro ≥ 3 independent price sources moving the same side ≥ 0.5 within 120 s. GET /v2/signals/ev?league=&open=true&limit=Starter, Pro Book prices ≥ 2 % better than consensus. GET /v2/signals/outlier?league=&open=true&limit=Starter, Pro A book ≥ 2σ from consensus for over 30 s. GET /v2/signals/arb?league=&open=true&limit=Pro Best prices across books implying < 100 %. By default the signal endpoints return the last 24 hours, newest first; pass the response's
next_sinceback assinceto poll for newer ones. Withopen=true(EV, outlier and arb,leaguerequired) you get the states that are open right now instead:curl -H "Authorization: Bearer $PINGODDS_API_KEY" \ "https://api.pingodds.com/v2/signals/arb?league=baseball_mlb&open=true"A plan that does not include a kind gets 403
plan_requiredwith anupgrade_url. On Pro, signals also arrive over the stream: subscribe with a signal filter{"action":"subscribe","id":"sig","filters":[ {"kind":"signal","league":"baseball_mlb","signals":["steam","ev","arb","outlier","consensus"]} ]}and each one comes as
{"type":"signal","kind":"arb",…payload}. Any subscription also receives{"type":"event_status","event_id","league","status","at"}when one of its league's events goesliveorfinal.9. Webhooks (Pro)
Have PingOdds POST signals to your own endpoint instead of holding a socket open. Create one on /app/webhooks or with the API (at most 10 active per account; the
secretin the response is shown once):curl -X POST -H "Authorization: Bearer $PINGODDS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com/hooks/pingodds","kinds":["arb","steam"],"leagues":["baseball_mlb"]}' \ "https://api.pingodds.com/v2/webhooks"Each delivery is a JSON body
{id, kind, league, event_id, ts, payload}with these headers:X-PingOdds-Signaturesha256=+ lower-case hex HMAC-SHA256 of the raw body, keyed with your secret.X-PingOdds-TimestampUnix seconds when the attempt was sent. Informational only: the signature covers the body, not this header. X-PingOdds-DeliveryThe delivery id, the same on every retry: de-duplicate on it. Verify the signature over the exact bytes you received, before parsing. The signature covers only the body, so de-duplicate on
X-PingOdds-Deliveryand treatX-PingOdds-Timestampas informational (Node):import { createHmac, timingSafeEqual } from 'node:crypto'; import express from 'express'; const app = express(); const seen = new Set(); // use a durable store in production app.post('/hooks/pingodds', express.raw({ type: 'application/json' }), (req, res) => { const expected = 'sha256=' + createHmac('sha256', process.env.PINGODDS_WEBHOOK_SECRET) .update(req.body) // the raw Buffer .digest('hex'); const got = req.get('X-PingOdds-Signature') ?? ''; if (got.length !== expected.length || !timingSafeEqual(Buffer.from(got), Buffer.from(expected))) { return res.sendStatus(401); } // Retries carry the same X-PingOdds-Delivery: process each delivery id once. if (seen.has(req.get('X-PingOdds-Delivery'))) return res.sendStatus(204); seen.add(req.get('X-PingOdds-Delivery')); const signal = JSON.parse(req.body.toString('utf8')); console.log(signal.kind, signal.event_id, signal.payload); res.sendStatus(204); }); app.listen(3001);Answer with any 2xx within 5 seconds. A failed attempt is retried 3 times, after 5, 25 and 125 seconds. After 50 consecutive failed deliveries (each delivery is up to 4 attempts) the webhook is disabled; delete it and create a new one once your endpoint is fixed. The last deliveries of each webhook, with status codes and errors, are on /app/webhooks and at
GET /v2/webhooks/{id}/deliveries.
That covers everything you need for a first integration. For every endpoint, parameter and response schema, see the full API reference.