> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://statpad.dev/docs/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://statpad.dev/docs/_mcp/server.

# Quickstart

> Make your first Statpad request, then stream a live game with resume and reconnect.

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](https://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.

> **Tip**
>
> No game on right now? Every step works on a finished game too. [Sample responses](/docs/replay) has a full recorded game you can replay through the same code without a key.

### 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:

```bash
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.

### List your leagues

```bash
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/...`.

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

### 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`**

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

**`TypeScript`**

```ts TypeScript
const res = await fetch("https://api.statpad.dev/v1/nfl/games?date=2026-11-09", {
  headers: { Authorization: `Bearer ${process.env.STATPAD_API_KEY}` },
});
const { data: games, teams } = await res.json();
```

**`Python`**

```python Python
import os, requests

res = requests.get(
    "https://api.statpad.dev/v1/nfl/games",
    params={"date": "2026-11-09"},
    headers={"Authorization": f"Bearer {os.environ['STATPAD_API_KEY']}"},
    timeout=10,
)
res.raise_for_status()
games, teams = res.json()["data"], res.json()["teams"]
```

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.

### Read the game

```bash
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:

```json
{
  "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](/docs/replay).

### 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:

```json
{ "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):

```json
{
  "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`.

### 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 code                                     | Meaning                                                                       | What to do                                                 |
| ---------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `1008`                                         | The key is invalid or revoked, or the account has no active streaming access. | Stop. Retrying won't help until the key or plan changes.   |
| `4008`                                         | The 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 dropped | The 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.

```bash
npm install ws
node follow.mts <game-id>
```

**`follow.mts`**

```ts title="follow.mts" maxLines=0
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`](https://websockets.readthedocs.io) takes the key in `additional_headers`.

## Next

#### [Sample responses](/docs/replay)

A full recorded NFL game and a Premier League match, readable without a key and replayable through this client.

#### [Coverage by league](/docs/coverage)

Which fields and endpoints each league fills today, and how far back the data goes.

#### [Rate limits](/docs/get-started/rate-limits)

How the request rate and the monthly allowance work, and the headers that report them.

#### [Plans and limits](https://statpad.dev/pricing)

Developer $19, Pro $49 and Business \$299 a month, side by side.