TransportServer-Sent Events
ResumeLast-Event-ID
Delay on ProductionInstant
DependenciesNone
Endpoint: GET /api/v1/stream. Both files were run against the stream's exact wire format: resume after reconnect, a 429 with Retry-After, a server error frame, and a restart from a saved cursor.

Python

Standard library only. Save as line_alerts.py.

line_alerts.py
#!/usr/bin/env python3
"""Line-move alerts from the prizepicksapi.com change stream (SSE).

Standard library only. Resumes from the last applied event with Last-Event-ID,
so a restart or a dropped connection never skips or repeats an alert.
Run:  PRIZEPICKS_API_KEY=pp_... python3 line_alerts.py
"""
import json
import os
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

BASE = os.environ.get("PRIZEPICKS_BASE", "https://prizepicksapi.com")
KEY = os.environ["PRIZEPICKS_API_KEY"]
STATE_FILE = os.environ.get("STREAM_STATE", "last_event_id.txt")
FILTERS = {"league": "NBA", "type": "offer.changed"}
MIN_MOVE = 0.5  # alert only when the line moves at least this much


def load_cursor():
    try:
        with open(STATE_FILE) as f:
            return f.read().strip() or None
    except FileNotFoundError:
        return None


def save_cursor(cursor):
    tmp = STATE_FILE + ".tmp"
    with open(tmp, "w") as f:
        f.write(cursor)
    os.replace(tmp, STATE_FILE)  # atomic: never a half-written cursor


def on_event(kind, event):
    prev, line = event.get("previous_line"), event.get("line")
    if kind != "offer.changed" or not isinstance(prev, (int, float)) or not isinstance(line, (int, float)):
        return
    if abs(line - prev) >= MIN_MOVE:
        print(f"{event['observed_at']}  {event['league']}  {event['player_name']}  "
              f"{event['stat']}: {prev} -> {line}", flush=True)


def stream_once(cursor):
    """Read one connection until the server asks for a reconnect. Returns (cursor, retry_seconds)."""
    headers = {"Authorization": "Bearer " + KEY, "Accept": "text/event-stream"}
    if cursor:
        headers["Last-Event-ID"] = cursor
    request = Request(BASE + "/api/v1/stream?" + urlencode(FILTERS), headers=headers)
    retry = 2.0
    # Heartbeats arrive every 15 s, so a 60 s read timeout only fires on a dead connection.
    with urlopen(request, timeout=60) as response:
        event_id, kind, data = None, None, []
        for raw in response:
            text = raw.decode("utf-8").rstrip("\r\n")
            if text == "":  # blank line: dispatch the event
                if data:
                    payload = "\n".join(data)
                    if kind == "reconnect":
                        return cursor, retry
                    if kind == "error":
                        raise ConnectionError(payload)
                    on_event(kind, json.loads(payload))
                    if event_id:
                        cursor = event_id
                        save_cursor(cursor)  # after handling: at-least-once delivery
                event_id, kind, data = None, None, []
                continue
            if text.startswith(":"):  # comment: connected banner or heartbeat
                continue
            field, _, value = text.partition(":")
            value = value[1:] if value.startswith(" ") else value
            if field == "id":
                event_id = value
            elif field == "event":
                kind = value
            elif field == "data":
                data.append(value)
            elif field == "retry" and value.isdigit():
                retry = int(value) / 1000
    return cursor, retry


def main():
    cursor, backoff = load_cursor(), 2
    print("Resuming after", cursor or "the newest event", file=sys.stderr)
    while True:
        try:
            cursor, retry = stream_once(cursor)
            backoff = 2
            time.sleep(retry)
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code in (400, 401, 403):
                sys.exit(f"HTTP {error.code}: {body}")  # bad key or query: retrying will not help
            wait = int(error.headers.get("Retry-After") or backoff)
            print(f"HTTP {error.code}, retrying in {wait}s", file=sys.stderr)
            time.sleep(wait)
            backoff = min(backoff * 2, 60)
        except (URLError, ConnectionError, TimeoutError, OSError) as error:
            print(f"Connection lost ({error}), retrying in {backoff}s", file=sys.stderr)
            time.sleep(backoff)
            backoff = min(backoff * 2, 60)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        pass

Node.js

Node 18 or later, no dependencies. Save as line-alerts.mjs.

line-alerts.mjs
// Line-move alerts from the prizepicksapi.com change stream (SSE). Node 18+, no dependencies.
// Resumes with Last-Event-ID, so a restart or dropped connection never skips or repeats an alert.
// Run:  PRIZEPICKS_API_KEY=pp_... node line-alerts.mjs
import { readFile, rename, writeFile } from "node:fs/promises";

const BASE = process.env.PRIZEPICKS_BASE ?? "https://prizepicksapi.com";
const KEY = process.env.PRIZEPICKS_API_KEY;
const STATE_FILE = process.env.STREAM_STATE ?? "last_event_id.txt";
const FILTERS = new URLSearchParams({ league: "NBA", type: "offer.changed" });
const MIN_MOVE = 0.5;
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));

async function loadCursor() {
  try { return (await readFile(STATE_FILE, "utf8")).trim() || null; } catch { return null; }
}
async function saveCursor(cursor) {
  await writeFile(STATE_FILE + ".tmp", cursor);
  await rename(STATE_FILE + ".tmp", STATE_FILE); // atomic
}

function onEvent(kind, e) {
  if (kind !== "offer.changed" || typeof e.previous_line !== "number" || typeof e.line !== "number") return;
  if (Math.abs(e.line - e.previous_line) >= MIN_MOVE) {
    console.log(`${e.observed_at}  ${e.league}  ${e.player_name}  ${e.stat}: ${e.previous_line} -> ${e.line}`);
  }
}

class HttpError extends Error {
  constructor(status, body, retryAfter) { super(`HTTP ${status}: ${body}`); Object.assign(this, { status, retryAfter }); }
}

// Reads one connection until the server sends "reconnect". Returns { cursor, retry }.
async function streamOnce(cursor) {
  const headers = { Authorization: `Bearer ${KEY}`, Accept: "text/event-stream" };
  if (cursor) headers["Last-Event-ID"] = cursor;
  const res = await fetch(`${BASE}/api/v1/stream?${FILTERS}`, { headers });
  if (!res.ok) throw new HttpError(res.status, await res.text(), Number(res.headers.get("retry-after")) || null);

  const decoder = new TextDecoder();
  let buffer = "", retry = 2, id = null, kind = null, data = [];
  for await (const chunk of res.body) {
    buffer += decoder.decode(chunk, { stream: true });
    let newline;
    while ((newline = buffer.indexOf("\n")) >= 0) {
      const line = buffer.slice(0, newline).replace(/\r$/, "");
      buffer = buffer.slice(newline + 1);
      if (line === "") { // blank line: dispatch
        if (data.length) {
          const payload = data.join("\n");
          if (kind === "reconnect") return { cursor, retry };
          if (kind === "error") throw new Error(`stream error: ${payload}`);
          onEvent(kind, JSON.parse(payload));
          if (id) { cursor = id; await saveCursor(cursor); } // after handling: at-least-once
        }
        id = null; kind = null; data = [];
        continue;
      }
      if (line.startsWith(":")) continue; // connected banner or heartbeat
      const colon = line.indexOf(":");
      const field = colon < 0 ? line : line.slice(0, colon);
      let value = colon < 0 ? "" : line.slice(colon + 1);
      if (value.startsWith(" ")) value = value.slice(1);
      if (field === "id") id = value;
      else if (field === "event") kind = value;
      else if (field === "data") data.push(value);
      else if (field === "retry" && /^\d+$/.test(value)) retry = Number(value) / 1000;
    }
  }
  return { cursor, retry }; // connection closed without a reconnect event
}

if (!KEY) throw new Error("Set PRIZEPICKS_API_KEY");
let cursor = await loadCursor(), backoff = 2;
console.error("Resuming after", cursor ?? "the newest event");
for (;;) {
  try {
    const next = await streamOnce(cursor);
    cursor = next.cursor; backoff = 2;
    await sleep(next.retry);
  } catch (error) {
    if (error instanceof HttpError && [400, 401, 403].includes(error.status)) throw error; // bad key or query
    const wait = error.retryAfter ?? backoff;
    console.error(`${error.message}, retrying in ${wait}s`);
    await sleep(wait);
    backoff = Math.min(backoff * 2, 60);
  }
}

How resume works

  1. Every event has an id. It is the change cursor, the same value as id in /api/v1/changes.
  2. Save it after you act. The consumers write the id to a file only after handling the event, so a crash replays at most one event and never skips one.
  3. Send it back. On reconnect, Last-Event-ID tells the server where to continue. Without it, the stream starts at the newest event.
  4. Reconnect on cue. Each connection ends after about 285 seconds with event: reconnect. Comment heartbeats every 15 seconds keep proxies from closing an idle stream.
  5. Back off on errors. 400 and 401 stop the consumer, since retrying cannot fix a bad key. 429 waits for Retry-After; anything else retries with exponential backoff up to a minute.
On the wire
retry: 2000
: connected plan=developer delay=60s cursor=48211

id: 48212
event: offer.changed
data: {"id":"48212","type":"offer.changed","projection_id":"pp_…","league":"NBA","player_name":"…","stat":"Points","previous_line":24.5,"line":25.5,"observed_at":"…","cursor":"48212"}

: heartbeat 2026-10-28T20:15:00.000Z

event: reconnect
data: {"cursor":"48212"}

Delay and connections by plan

PlanStream delayConcurrent streamsRequests a month
Free300s11,000
Developer60s3100,000
ProductionInstant101,000,000

A connection counts as one request. Prefer push to your server instead of holding a connection open? Signed webhooks deliver the same events on Production.

Questions

Does the PrizePicks API have a websocket?

It streams over Server-Sent Events instead: a plain HTTPS response that stays open and pushes each change as it is recorded. SSE works through proxies, resumes natively with Last-Event-ID, and needs no client library in Python or Node.

What happens if my consumer disconnects?

Nothing is lost. Both consumers save the id of the last event they handled and send it as Last-Event-ID when they reconnect; the stream continues right after it. A cursor saved before a restart works the same way.

How real-time is the stream on each plan?

Free runs 5 minutes behind, Developer 60 seconds, Production instantly. Each league's board is re-read about every nine minutes, so that sets how quickly a move is recorded.

How many requests does a stream use?

One per connection. A connection lasts about 285 seconds, so a consumer left running uses about 9,031 requests a month. That fits the Developer plan; on Free, run it for a slate rather than around the clock.

Keep going

Get free API key

References