Skip to navigation

Quickstart

Read a game over REST, then follow it live over the stream and pick up where you left off after a disconnect.

By the end of this page you’ll have a script that prints a live NFL game as it happens and survives a dropped connection without missing a play. It takes about ten minutes.

You need an API key from the Keys page at app.statpad.dev. Steps 1 to 4 work on every plan. Step 5 uses the WebSocket stream, which comes with Pro, Business and the Pro trial; Developer is REST only.

No game on right now? Every step works on a finished game too. Sample responses has a full recorded game you can replay through the same code without a key.

1

Keep your key on the server

Every request authenticates with your key as a bearer token. Put it in an environment variable rather than in code:

export STATPAD_API_KEY="sporty_..."

A key is a secret that draws on your whole account’s allowance. Call Statpad from your backend, never from a browser or a mobile app.

2

List your leagues

curl https://api.statpad.dev/v1/leagues \
-H "Authorization: Bearer $STATPAD_API_KEY"

You get the leagues your plan includes. The alias is what goes in every other path: /v1/nfl/..., /v1/ncaaf/..., /v1/epl/....

{
"data": [
{
"id": "01a1b986-8c40-7d11-9a3e-5b1f2c7d8e01",
"alias": "nfl",
"name": "National Football League",
"sport": "football",
"level": "pro",
"period_kind": "quarter",
"regulation_periods": 4
}
]
}
3

Find a game

Ask for a day’s schedule. date is a calendar day in tz, which defaults to America/New_York, so a Monday night game is on Monday’s date:

curl "https://api.statpad.dev/v1/nfl/games?date=2026-11-09" \
-H "Authorization: Bearer $STATPAD_API_KEY"

Each game in data references its teams by id, and teams holds those teams, so one request is enough to render a scoreboard. Copy the id of the game you want.

4

Read the game

curl https://api.statpad.dev/v1/nfl/games/$GAME_ID \
-H "Authorization: Bearer $STATPAD_API_KEY"

The response has the game, its live_state (score, period, clock and, for football, down, distance and yard line), both teams, the venue and one more field that matters for the next step:

{
"game": { "id": "01a1d60f-67be-7bdd-83f4-53286521d9bb", "status": "inprogress", "away_score": 13, "home_score": 6, "version": 11 },
"live_state": {
"period": 4,
"clock_seconds": 422,
"possession_team_id": "01a1d60f-67be-7bdf-ac2f-bcdeeac1da68",
"away_score": 13,
"home_score": 6,
"football": { "down": 1, "yards_to_go": 5, "yardline": 95, "away_timeouts": 3, "home_timeouts": 2 },
"version": 264
},
"home_team": { "alias": "MIN", "market": "Minnesota", "name": "Vikings" },
"away_team": { "alias": "BUF", "market": "Buffalo", "name": "Bills" },
"seq": 1101
}

That’s Monday night’s game with 7:02 left: Bills ball, 1st and goal at the Minnesota 5. yardline counts from the possessing team’s own goal line, so 95 means five yards from scoring.

seq is the position in the game’s stream that this response reflects. Hand it to the stream and you get exactly the changes after this response: nothing missed, nothing repeated.

The full response, untrimmed, is in Sample responses.

5

Follow it live

The stream is one WebSocket at wss://stream.statpad.dev/v1/stream. After the server’s welcome, send subscribe with the game’s stream name and the seq from step 4 as the resume point:

{ "type": "subscribe", "streams": ["game:01a1d60f-67be-7bdd-83f4-53286521d9bb"], "resume": { "game:01a1d60f-67be-7bdd-83f4-53286521d9bb": 1101 } }

From then on, changes arrive in order. Each one carries an entity’s full current state, so you don’t merge fields; you replace what you hold under entity_type and entity_id. Here’s the touchdown, five changes after the resume point, trimmed to the fields this page uses (the real change also has at and payload_version, and the full play):

{
"type": "changes",
"stream": "game:01a1d60f-67be-7bdd-83f4-53286521d9bb",
"changes": [
{
"seq": 1106,
"op": "upsert",
"entity_type": "play",
"entity_id": "01a1d60f-a829-759c-a632-7e6eca90b7f0",
"version": 1,
"payload": {
"type": "rush",
"description": "J.Cook up the middle for 5 yards, TOUCHDOWN.",
"period": 4,
"football": { "down": 1, "yards_to_go": 5, "start_yardline": 95, "end_yardline": 100, "yards_gained": 5, "is_touchdown": true }
}
}
]
}

A game stream carries the game, its live state, plays, drives, game roster and player stats. Changes to teams and players, which many games share, go to league:nfl.

6

Reconnect without missing anything

Connections drop: deploys, mobile networks, laptops going to sleep. Because every change has a gapless seq, recovering is one rule: remember the last seq you applied, and resume from it.

  • Reconnect with backoff. Wait about 1 second, then double up to 30 seconds, with some jitter. Resubscribe with resume set to your last seq and the server replays everything you missed, then carries on live.
  • Watch the heartbeat. The server sends heartbeat every 5 seconds. If you hear nothing for 15 seconds, the connection is dead even if the socket hasn’t noticed. Close it and reconnect.
  • resync_required means your seq is older than the stream still holds (a game’s stream is kept for at least 7 days after it ends). Read the game over REST again and resume from the seq it returns.
  • slow_consumer means you fell behind and were dropped from that stream. Resubscribe from your last seq.

Most close codes mean “reconnect and resume”. The exceptions:

Close codeMeaningWhat to do
1008The key is invalid or revoked, or the account has no active streaming access.Stop. Retrying won’t help until the key or plan changes.
4008The account already has as many connections as its plan allows.Close a connection you don’t need, or back off for longer.
1001, 1011, 4009, or the network droppedThe server restarted, couldn’t verify access, or missed your pong.Reconnect with backoff and resume.

The whole client

This is everything above in one file: it reads the game, subscribes from its seq, applies changes, prints a line per update and reconnects with resume. It runs on Node 22.18 or newer, which runs TypeScript directly.

npm install ws
node follow.mts <game-id>
follow.mts
import WebSocket from "ws";
const KEY = process.env.STATPAD_API_KEY;
const API = "https://api.statpad.dev/v1";
const STREAM = "wss://stream.statpad.dev/v1/stream";
const league = "nfl";
const gameId = process.argv[2];
const stream = `game:${gameId}`;
type Change = { seq: number; op: "upsert" | "delete"; entity_type: string; entity_id: string; version: number; payload: any };
// Everything known about the game, keyed by "entity_type:entity_id". A deleted entity keeps its
// version with a null payload, so an older copy can't bring it back.
const state = new Map<string, { version: number; payload: any }>();
let lastSeq = 0;
function put(type: string, id: string, version: number, payload: any) {
const key = `${type}:${id}`;
if ((state.get(key)?.version ?? 0) >= version) return;
state.set(key, { version, payload });
}
const get = (type: string, id: string) => state.get(`${type}:${id}`)?.payload;
// Step 4: read the game and remember the stream position it reflects.
async function readGame() {
const res = await fetch(`${API}/${league}/games/${gameId}`, { headers: { Authorization: `Bearer ${KEY}` } });
if (!res.ok) throw new Error(`GET game: ${res.status} ${await res.text()}`);
const body = await res.json();
state.clear();
put("game", body.game.id, body.game.version, body.game);
if (body.live_state) put("live_state", gameId, body.live_state.version, body.live_state);
for (const team of [body.home_team, body.away_team]) put("team", team.id, team.version, team);
lastSeq = body.seq;
}
function render() {
const game = get("game", gameId);
const live = get("live_state", gameId);
const away = get("team", game.away_team_id)?.alias;
const home = get("team", game.home_team_id)?.alias;
const score = `${away} ${live?.away_score ?? game.away_score ?? 0}, ${home} ${live?.home_score ?? game.home_score ?? 0}`;
if (!live) return console.log(`${score} (${game.status})`);
const clock = live.clock_seconds == null ? "" : ` ${Math.floor(live.clock_seconds / 60)}:${String(live.clock_seconds % 60).padStart(2, "0")}`;
const f = live.football;
const down = f?.down ? `, ${f.down} & ${f.yards_to_go} at the ${f.yardline}` : "";
console.log(`${score} Q${live.period}${clock}${down} (seq ${lastSeq})`);
}
let attempt = 0;
const fatal = new Set(["unauthorized", "forbidden", "unknown_stream", "too_many_subscriptions", "quota_exhausted", "bad_request", "invalid_resume"]);
// Steps 5 and 6: subscribe from lastSeq, apply changes, and reconnect with resume.
function connect() {
const ws = new WebSocket(STREAM, { headers: { Authorization: `Bearer ${KEY}` } });
let idle: ReturnType<typeof setTimeout> | undefined;
const alive = () => {
clearTimeout(idle);
idle = setTimeout(() => ws.terminate(), 15_000);
};
const subscribe = () => ws.send(JSON.stringify({ type: "subscribe", streams: [stream], resume: { [stream]: lastSeq } }));
ws.on("open", alive);
ws.on("message", async (raw) => {
alive();
const msg = JSON.parse(raw.toString());
switch (msg.type) {
case "welcome":
subscribe();
break;
case "changes":
attempt = 0;
for (const c of msg.changes as Change[]) {
if (c.seq <= lastSeq) continue;
if (c.seq !== lastSeq + 1) return ws.close();
if (c.op === "delete") state.set(`${c.entity_type}:${c.entity_id}`, { version: c.version, payload: null });
else put(c.entity_type, c.entity_id, c.version, c.payload);
lastSeq = c.seq;
}
render();
break;
case "error":
console.error(`${msg.code}: ${msg.message}`);
if (msg.code === "resync_required") {
try {
await readGame();
subscribe();
} catch (err) {
console.error((err as Error).message);
ws.close();
}
} else if (msg.code === "slow_consumer") {
subscribe();
} else if (fatal.has(msg.code)) {
process.exit(1);
} else {
ws.close();
}
break;
}
});
ws.on("error", (err) => console.error(err.message));
ws.on("close", (code) => {
clearTimeout(idle);
if (code === 1008) {
console.error("The key was rejected or the plan has no streaming access.");
process.exit(1);
}
const delay = Math.min(30_000, 1000 * 2 ** attempt++) * (0.5 + Math.random() / 2);
console.error(`Disconnected (${code}), resuming from seq ${lastSeq} in ${Math.round(delay / 1000)}s`);
setTimeout(connect, delay);
});
}
await readGame();
render();
connect();

To see the reconnect work, turn your Wi-Fi off for a few seconds mid-game. The script prints the disconnect, then catches up from its last seq: every change you missed is applied once, in order, and the score line picks up where the game is. An internal or unavailable error closes the socket so the same backoff resumes it; errors that retrying can’t fix, such as forbidden, stop the script.

The stream is plain JSON over a WebSocket, so any language’s WebSocket library works the same way. Python’s websockets takes the key in additional_headers.

Next