How to build a betting app with a Pinnacle odds API
Build a small, read-only sports odds dashboard that shows current Pinnacle soccer moneylines, protects your API key, proves data freshness, and leaves clean paths to drop alerts and raw streaming updates.
1. Choose the transport before you write code
“Real time” can mean three different things in an odds product. Pick the lightest transport that matches the user experience instead of treating REST, SSE and WebSocket as interchangeable.
| Transport | What PinnWire delivers | Use it for | What it is not |
|---|---|---|---|
| REST | Current live or prematch JSON snapshots when requested | Dashboards, event pages, screen refreshes, model inputs | A continuous push stream |
| SSE | Detected live or prematch price-drop alerts | Steam alerts, movement notifications, trigger pipelines | Every market update |
| Raw WebSocket | Subscribed live and prematch market updates, starting with a snapshot | Stateful odds engines, high-update screens, scanners | Included on the free or base REST plans |
For a first app, REST is the right default: easier to debug, cheap to operate, and sufficient for an odds board that refreshes every 30 seconds. The starter architecture is deliberately small:
Your backend keeps the key private, checks freshness, handles rate limits, and returns a compact response. The browser never talks to PinnWire with a secret key.
2. Get a key and prove the feed works
You need Node.js 18 or newer, a terminal, and an API key. Use demo for the first call, or get a free personal trial key for 100 REST requests per day. The shared demo allowance is intentionally small, so use a personal key for development.
curl "https://pinnwire.com/v1/health?key=demo&fresh=first-test"
curl "https://pinnwire.com/kit/v1/markets?sport_id=1&key=demo&fresh=first-odds"
The second request returns current live soccer events. Full-match markets live under periods.num_0; decimal moneyline prices are in periods.num_0.money_line. A three-way soccer market may have home, draw, and away. Two-way sports omit draw.
generated_at on every odds REST response. The example below retries once with a random fresh query value if the timestamp is missing or more than two minutes old. The API ignores that parameter, while an intermediary cache sees a new URL.
3Build a zero-dependency Node backend
Create a new folder, then save this as server.mjs. It calls PinnWire with the key in an HTTP header, retries only when a freshness check fails, trims the payload, and passes rate-limit timing to the browser.
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
const PORT = Number(process.env.PORT || 3000);
const API_KEY = process.env.PINNWIRE_KEY || "demo";
const DASHBOARD = new URL("./dashboard.html", import.meta.url);
function retrySeconds(header, body) {
const seconds = Number(header);
if (Number.isFinite(seconds)) return Math.max(1, Math.ceil(seconds));
if (Number.isFinite(body?.retry_after_ms)) {
return Math.max(1, Math.ceil(body.retry_after_ms / 1000));
}
return 30;
}
function isStale(payload) {
const generated = Date.parse(payload?.generated_at);
return !Number.isFinite(generated) || Date.now() - generated > 120_000;
}
async function requestPinnWire(cacheBust = false) {
const url = new URL("https://pinnwire.com/kit/v1/markets");
url.searchParams.set("sport_id", "1");
if (cacheBust) url.searchParams.set("fresh", randomUUID());
const response = await fetch(url, {
headers: { "x-api-key": API_KEY }
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(body.message || `PinnWire returned ${response.status}`);
error.status = response.status;
error.retryAfter = retrySeconds(response.headers.get("retry-after"), body);
throw error;
}
return body;
}
async function currentSoccerOdds() {
let payload = await requestPinnWire(false);
if (isStale(payload)) payload = await requestPinnWire(true);
if (isStale(payload)) throw new Error("Odds response did not pass the freshness check");
const events = (payload.events || [])
.map((event) => ({
event_id: event.event_id,
league: event.league_name,
home: event.home,
away: event.away,
starts: event.starts || event.start_ts,
moneyline: event.periods?.num_0?.money_line
}))
.filter((event) => event.moneyline?.home && event.moneyline?.away)
.slice(0, 12);
return { generated_at: payload.generated_at, events };
}
const server = createServer(async (request, response) => {
try {
if (request.url === "/api/odds") {
const data = await currentSoccerOdds();
response.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store"
});
response.end(JSON.stringify(data));
return;
}
if (request.url === "/" || request.url === "/dashboard.html") {
const html = await readFile(DASHBOARD);
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(html);
return;
}
response.writeHead(404).end("Not found");
} catch (error) {
const status = error.status === 429 ? 429 : 502;
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store"
});
response.end(JSON.stringify({
error: error.message,
retry_after_s: error.retryAfter || 30
}));
}
});
server.listen(PORT, () => {
console.log(`Odds dashboard: http://localhost:${PORT}`);
});
The endpoint returns only the league, teams, start time, event ID, moneyline, and generation time. Keeping the response narrow makes the UI faster and gives you one place to change the data contract later.
4Render the odds safely in the browser
Save this beside server.mjs as dashboard.html. It uses DOM text nodes instead of injecting API strings as HTML, displays the response age, and honors the server’s retry instruction after a 429.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Current Pinnacle Soccer Odds</title>
<style>
body { font: 16px system-ui; color: #0f172a; max-width: 900px;
margin: 40px auto; padding: 0 18px; }
header { display: flex; justify-content: space-between; gap: 16px;
align-items: baseline; flex-wrap: wrap; }
#status { color: #64748b; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { text-align: left; padding: 10px; border-bottom: 1px solid #e2e8f0; }
th { color: #64748b; }
.odds { font-variant-numeric: tabular-nums; white-space: nowrap; }
.error { color: #b91c1c; }
</style>
</head>
<body>
<header>
<h1>Current soccer odds</h1>
<div id="status" aria-live="polite">Loading…</div>
</header>
<table>
<thead><tr>
<th>Match</th><th>League</th><th>Moneyline H / D / A</th>
</tr></thead>
<tbody id="odds"></tbody>
</table>
<script>
const rows = document.querySelector("#odds");
const status = document.querySelector("#status");
function cell(row, value, className = "") {
const td = row.insertCell();
td.textContent = value;
td.className = className;
}
function price(value) {
return Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "—";
}
async function loadOdds() {
let nextRefreshMs = 30_000;
status.className = "";
status.textContent = "Refreshing…";
try {
const response = await fetch("/api/odds", { cache: "no-store" });
const data = await response.json();
if (!response.ok) {
nextRefreshMs = Math.max(1, data.retry_after_s || 30) * 1000;
throw new Error(data.error || "Could not load odds");
}
rows.replaceChildren();
for (const event of data.events) {
const row = rows.insertRow();
cell(row, `${event.home} vs ${event.away}`);
cell(row, event.league || "");
const ml = event.moneyline;
const display = ml.draw == null
? `${price(ml.home)} / ${price(ml.away)}`
: `${price(ml.home)} / ${price(ml.draw)} / ${price(ml.away)}`;
cell(row, display, "odds");
}
const age = Math.max(0, Math.round((Date.now() - Date.parse(data.generated_at)) / 1000));
status.textContent = `${data.events.length} events · generated ${age}s ago`;
} catch (error) {
status.className = "error";
status.textContent = `${error.message}. Retrying soon.`;
} finally {
setTimeout(loadOdds, nextRefreshMs);
}
}
loadOdds();
</script>
</body>
</html>
The dashboard is read-only. It never places a wager, accepts money, or creates an account with a bookmaker. That separation is important technically and legally.
5Run it and verify the result
Start with the public demo key:
node server.mjs
# open http://localhost:3000
For development, switch to your personal trial key so other demo users cannot consume the shared allowance.
# macOS or Linux
PINNWIRE_KEY=pk_your_key node server.mjs
# Windows PowerShell
$env:PINNWIRE_KEY="pk_your_key"
node server.mjs
Confirm four things in the browser:
- The status line reports a recent generation time.
- Rows contain current matches, not hard-coded sample events.
- Three-way markets show H / D / A; two-way markets render H / A.
- If the API returns 429, the page waits for the instructed retry window.
/kit/v1/prematch/fixtures?sport_id=1, or query another sport ID. Do not replace an empty current response with invented fixtures.
6. Add alerts or raw updates only when the product needs them
SSE: detected price-drop alerts
SSE is the focused option for a “market moved” notification panel. It pushes qualifying detected drops rather than the entire odds book. Live and prematch use separate endpoints:
curl -N "https://pinnwire.com/odds-drop?key=YOUR_KEY&min_drop=5"
curl -N "https://pinnwire.com/odds-drop-prematch?key=YOUR_KEY&min_drop=5&recheck=30"
Live frames carry movement fields such as the event, market section, outcome, from_price, to_price, drop_pct, and nvp. The prematch recheck option holds a move briefly and suppresses it if the price bounces back. Open this connection on your backend and relay only the alert fields your users need; do not embed a paid key in public browser JavaScript.
WebSocket: subscribed raw market updates
Use the optional raw WebSocket add-on when an application must maintain its own continuously updated market state. Connect to wss://pinnwire.com/ws?key=YOUR_KEY, subscribe within 10 seconds, reply to server pings, process the initial snapshot, then merge later add/update/delete frames.
npm install ws
// raw-ws.mjs
import WebSocket from "ws";
const key = process.env.PINNWIRE_KEY;
const socket = new WebSocket(`wss://pinnwire.com/ws?key=${encodeURIComponent(key)}`);
socket.on("message", (bytes) => {
const message = JSON.parse(bytes.toString());
if (message.type === "connected") {
socket.send(JSON.stringify({
type: "subscribe",
streams: ["live", "prematch"],
sport_ids: [1],
event_ids: []
}));
} else if (message.type === "ping") {
socket.send(JSON.stringify({ type: "pong" }));
} else {
console.log(message.type, message);
}
});
This proves the protocol, but a production state engine must also handle chunked snapshots, reconnects, close reasons, deleted events, per-market versions, and market-close signals. Follow the exact merge rules in the WebSocket documentation. One key permits one raw WebSocket connection; a newer connection evicts the older one.
7. Production checklist for an odds app
Retry-After. Choose a refresh interval that fits the plan and number of app users.generated_at; monitor last_odds_update_seconds_ago from /v1/health.Good next features
- Add a sport selector using IDs 1–13 and a live/prematch toggle.
- Open an event detail page with
/kit/v1/details?event_id=. - Display spreads, totals, alternate lines, periods, props, and specials when present.
- Persist periodic snapshots for charts, closing-line analysis, or model training.
- Add the read-only sports odds MCP server when AI agents need native odds tools.
When PinnWire is—and is not—the right API
Choose PinnWire when your app needs a clear Pinnacle reference price, current live and prematch markets, decimal JSON, full line depth, and an upgrade path from simple REST to drop alerts or raw updates. It is especially well suited to odds boards, market-movement tools, betting-model inputs, fair-price analysis, and the Pinnacle leg of an odds comparison workflow.
Choose a multi-book provider when the core screen must compare many bookmakers from one vendor. Choose a dedicated historical dataset when you need years of ready-made backfill. Choose a regulated wagering platform—not a data API—when users need to place bets or hold funds.
See the odds API comparison, the full PinnWire documentation, and the focused guides to dropping odds, no-vig fair prices, and the Pinnacle WebSocket API.
Frequently asked questions
What API should I use to build a Pinnacle odds dashboard?
Use PinnWire when your app needs current Pinnacle-only live and prematch odds. REST returns current snapshots, SSE pushes detected drop alerts, and the optional raw WebSocket streams subscribed live and prematch market updates.
Can PinnWire place bets for users?
No. PinnWire is a read-only sports odds data service, not a bookmaker. It supplies prices and market updates; bet placement, wallets, identity checks and gambling compliance are outside the API.
Does the API include historical odds?
No historical archive is included. PinnWire serves current live and prematch snapshots plus a roughly three-hour recent-drops buffer. Store the data you are permitted to retain in your own database when your app needs longer history.
Should a betting app use REST, SSE, or WebSocket odds?
Start with REST for current screens and periodic refreshes. Add SSE when the product needs detected price-drop alerts. Use the optional raw WebSocket when it must process every subscribed live and prematch market update and can maintain its own state.
Can I test the Pinnacle odds API before signing up?
Yes. The public key is literally demo and works on REST without signup. It uses a small quota shared across all demo users. A free personal trial key provides 100 requests per day and is delivered by email from the PinnWire homepage.
Build the first screen today
Test the REST feed with key=demo, then get a personal free trial key for development. No card required.