Architecture guide · Real-time data

Live odds WebSocket architecture: build a dependable real-time stream

A deep engineering guide to consuming live and prematch Pinnacle odds with PinnWire: subscription design, snapshot reconciliation, market-version dedupe, reconnects, heartbeats and backpressure.

The short answer The best live odds WebSocket architecture has a small, stateful consumer between PinnWire and your product. Keep one authenticated connection per key, subscribe only to the sports or events you need, load each snapshot before applying updates, merge by rec.id plus a stable market key, and dedupe on each market's version. On any close, reconnect with backoff, resubscribe and rebuild from the fresh snapshot. PinnWire is the strongest fit when your application needs a Pinnacle-only real-time odds stream rather than repeated polling.

1. The reference architecture for a live odds stream

A WebSocket is a transport, not an odds database. The connection delivers a baseline and changes; your application must turn those messages into a coherent current state. Put that responsibility in one backend consumer and let product surfaces read from your local book.

PinnWire
WebSocket
Stream
consumer
Versioned
odds book
Odds book
Rules / models
UI + alerts

The consumer owns connection lifecycle, subscriptions, snapshot loading, update ordering and reconnects. The odds book owns the current event and market state. A separate fan-out layer can serve dashboards, line-movement calculations, storage or internal events without making every browser open its own paid socket.

Why PinnWire fits this shape: PinnWire's optional raw WebSocket exposes current live and prematch Pinnacle updates, starts each subscription with a snapshot, filters by sport or event IDs, and gives you the protocol signals needed to operate a real consumer. REST and SSE remain available for simpler surfaces.

Design boundaries that keep the system understandable

  • Transport boundary: parse frames, respond to ping, track close codes and never put business rules in the socket callback.
  • State boundary: merge records by event ID and market identity; expose immutable or copied views to downstream code.
  • Decision boundary: run alerts, no-vig calculations and model triggers from committed state, not from a half-applied frame.
  • Presentation boundary: browsers read your compact API or server-pushed UI stream; they do not receive your PinnWire key.

2. Choose REST, SSE or the raw WebSocket intentionally

All three PinnWire surfaces are useful, but they answer different questions. A reliable architecture chooses the smallest surface that satisfies the feature.

SurfaceDelivery modelBest fitState responsibility
RESTRequest a current JSON snapshotEvent pages, occasional lookups, initial screen loads, model jobsYour request decides when to refresh
SSEPush detected odds-drop alertsSteam/drop notifications and trigger workflowsPinnWire detects the drop; your app handles the alert
Raw WebSocketPush subscribed live and prematch market updatesContinuously maintained books, fast screens, scanners and model inputsYour consumer merges snapshots and updates

Use REST to render the first page or recover a single event. Use SSE when the requirement is “tell me when a price falls.” Choose the raw WebSocket when the requirement is “keep this set of markets current and react to every subscribed update.” PinnWire lets one product use all three without forcing a WebSocket-sized architecture onto a simple lookup.

Do not confuse a drop stream with a state stream. SSE is intentionally selective: it reports detected drops. The raw WebSocket is the high-volume full update path, including opens, closes and reprices for the subscriptions you choose.

3. Understand the PinnWire Pinnacle WebSocket API lifecycle

The protocol is deliberately small. A client should be able to explain every phase: authenticate, subscribe, load a baseline, apply updates, maintain the heartbeat, and reconnect when the server closes the connection.

Connect

Open wss://pinnwire.com/ws?key=YOUR_KEY. The compatible alias wss://pinnwire.com/ws/feed?key=YOUR_KEY is also accepted. A Node client may put the key in the x-api-key handshake header; browsers normally use the query parameter because they cannot set arbitrary WebSocket headers.

// Header form for a server-side client
new WebSocket("wss://pinnwire.com/ws", {
  headers: { "x-api-key": process.env.PINNWIRE_KEY }
});

// Query form, useful for browsers and simple tools
wss://pinnwire.com/ws?key=YOUR_KEY
Authenticate

The server sends a connected frame such as {"type":"connected","events_in_store":N}. An invalid key or a plan without the raw WebSocket add-on produces an error frame and close code 1008. The public demo key is for REST testing; it does not unlock the raw WebSocket.

Subscribe

Send a subscription within 10 seconds. Select live, prematch, or both. Filter by one or more numeric sports, or by specific event IDs. You can keep up to 200 active event IDs per stream.

{
  "type": "subscribe",
  "streams": ["live", "prematch"],
  "sport_ids": [1, 2],
  "event_ids": [1631005165]
}

Sport IDs include Soccer 1, Tennis 2, Basketball 3, Hockey 4, Football 5, Baseball 6 and Esports 11. See the complete sport list.

Baseline

Each accepted subscription is acknowledged with subscribed and followed by a snapshot containing matching events. A large snapshot is split into chunks with seq and final; a small one is a single frame. Treat the baseline as the starting point for the local book before relying on incremental updates.

{
  "type": "snapshot",
  "stream": "live",
  "sport_id": 1,
  "seq": 0,
  "final": true,
  "ts": 1766788800000,
  "events": [ /* current event records */ ]
}
Updates

After the snapshot, matching frames are forwarded for the selected stream. Live records use a live envelope with topic, op and rec; operations include add, upd and del. Prematch updates can arrive as prematch_ws, prematch_matchups or prematch_markets. Match by subscribed sport or event ID, not by a display name.

Heartbeat

Every 30 seconds the server sends {"type":"ping","ts":...,"buffered_max_bytes":N}. Reply with {"type":"pong"}. The server closes a connection after roughly 75 seconds without a pong, using close code 1001 and reason stale.

Unsubscribe

Remove a sport or event filter without opening another connection. The server acknowledges with unsubscribed; keep the local subscription registry in sync with that acknowledgement.

{ "type": "unsubscribe", "streams": ["live"], "sport_ids": [2] }
{ "type": "unsubscribe", "streams": ["prematch"], "event_ids": [1631005165] }
CloseReasonWhat your client should do
1001evicted by newer connectionStop the old consumer. One key supports one connection; coordinate workers so they do not race.
1001staleReconnect after backoff; check that the heartbeat loop is active and the process is not blocked.
1008no subscribeFix the startup path and send a valid subscription within 10 seconds.
1008invalid key or plan lacks wsFix credentials or enable the raw WebSocket add-on; do not hot-loop reconnects.
1011deregistered: slow_consumerDrain or discard the old queue, reconnect and rebuild from the new snapshot.

4. Build a version-aware odds book

The most common WebSocket bug is treating a stream as a sequence of complete, perfectly ordered objects. Build state explicitly. A good reducer has three rules:

  1. Identify the event by rec.id. Never use team names as identity; names can be normalized, abbreviated or changed.
  2. Identify a market by its stable key. Prefer m.key. If a market key is absent, use a deterministic fallback such as type|period|side|points.
  3. Dedupe per market version. Compare markets[i].version, not a record-level version. Ignore an update that is not newer than the copy already committed.
const events = new Map();

function marketIdentity(m) {
  return m.key ?? [m.type, m.period, m.side, m.points].join("|");
}

function mergeRecord(rec) {
  if (!rec || rec.id == null) return;

  const prior = events.get(rec.id) ?? { id: rec.id, markets: [] };
  const byMarket = new Map((prior.markets ?? []).map((m) => [marketIdentity(m), m]));

  for (const market of rec.markets ?? []) {
    const key = marketIdentity(market);
    const old = byMarket.get(key);
    // The market version is the ordering signal, not rec.version.
    if (old?.version != null && market.version != null
        && market.version <= old.version) continue;

    if (market.status !== "open") byMarket.delete(key);
    else byMarket.set(key, market);
  }

  events.set(rec.id, {
    ...prior,
    ...rec,
    id: rec.id,
    markets: [...byMarket.values()]
  });
}

function deleteRecord(id) {
  events.delete(id);
}

The exact record shape can contain more fields than this reducer needs. Preserve those fields when you pass an event to downstream code, but keep the merge key small and deterministic.

Close markets deliberately. When m.status !== "open", remove that market from the active book. For live frames, the ld price channel is the truth; dz is a volatility signal and should not be treated as a second price.

Make snapshot application atomic

For a chunked snapshot, collect chunks by subscription and sequence, then commit the complete baseline as one generation. Do not let a dashboard see half of a large baseline while it is being assembled. A simple generation counter works:

const snapshotBuffer = new Map();

function acceptSnapshot(frame) {
  const key = `${frame.stream}:${frame.sport_id ?? frame.event_ids?.join(",")}`;
  const bucket = snapshotBuffer.get(key) ?? [];
  bucket[frame.seq ?? 0] = frame.events ?? [];
  snapshotBuffer.set(key, bucket);

  // A single-frame snapshot has no final/seq markers.
  if (frame.final === true || (frame.seq == null && frame.final == null)) {
    const allEvents = bucket.flat();
    for (const event of allEvents) mergeRecord(event);
    snapshotBuffer.delete(key);
  }
}

In a production reducer, namespace the buffer with a subscription generation so a reconnect cannot mix an old chunk with a new snapshot. When a reconnect starts, discard all incomplete snapshot buffers first.

5. A reconnecting Node.js consumer

The following skeleton uses the popular Node ws client. It keeps one connection, sends a subscription immediately, answers pings, applies snapshots and updates, and reconnects with exponential backoff plus jitter. Keep this process on your backend, where PINNWIRE_KEY is not exposed to a browser.

import WebSocket from "ws";

const KEY = process.env.PINNWIRE_KEY;
const ENDPOINT = `wss://pinnwire.com/ws?key=${encodeURIComponent(KEY)}`;
const subscription = {
  type: "subscribe",
  streams: ["live", "prematch"],
  sport_ids: [1, 2]
};

let socket;
let retry = 0;
let stopping = false;

function connect() {
  if (stopping) return;
  socket = new WebSocket(ENDPOINT, { perMessageDeflate: true });

  socket.on("open", () => {
    retry = 0;
    socket.send(JSON.stringify(subscription));
  });

  socket.on("message", (raw) => {
    let frame;
    try { frame = JSON.parse(raw.toString()); }
    catch { return; }

    if (frame.type === "ping") {
      socket.send(JSON.stringify({ type: "pong" }));
      observeBacklog(frame.buffered_max_bytes);
      return;
    }
    if (frame.type === "snapshot") return acceptSnapshot(frame);
    if (frame.type === "live") {
      if (frame.op === "del") deleteRecord(frame.rec?.id);
      else mergeRecord(frame.rec);
      return;
    }
    if (["prematch_ws", "prematch_matchups", "prematch_markets"].includes(frame.type)) {
      mergeRecord(frame.rec);
      return;
    }
    if (frame.type === "error") console.error("PinnWire WebSocket error", frame);
  });

  socket.on("close", (code, reasonBuffer) => {
    const reason = reasonBuffer.toString();
    if (code === 1008 && /invalid key|plan lacks ws/i.test(reason)) {
      console.error("Configuration error; will not reconnect", code, reason);
      return;
    }
    resetIncompleteSnapshots();
    scheduleReconnect();
  });

  socket.on("error", (error) => console.error("socket", error.message));
}

function scheduleReconnect() {
  const base = Math.min(30_000, 1_000 * 2 ** retry++);
  const jitter = Math.floor(Math.random() * 500);
  setTimeout(connect, base + jitter);
}

connect();

The handlers above call the reducer from the previous section. In your implementation, reset the local book or mark it “resyncing” before accepting the next snapshot. A reconnect is a new synchronization point, not a continuation of the old session.

Browser fan-out pattern

If a dashboard needs browser updates, keep the PinnWire socket in the backend and expose a small internal SSE or WebSocket endpoint from your application. Send only the compact event fields the UI needs. This keeps the API key private, prevents every user tab from consuming the one-connection allowance, and gives you a place to rate-limit UI clients.

browser  ←  your /ui-stream  ←  local odds book  ←  PinnWire /ws

// Example compact browser event
{
  "event_id": 1631005165,
  "market": "moneyline|num_0|home|",
  "price": 1.94,
  "version": 9281,
  "stream": "live"
}

6. Backpressure, compression and operational safety

Live sports data arrives in bursts. A consumer that parses correctly but cannot drain its socket is still unhealthy. PinnWire exposes a useful operational signal and a clear failure mode so you can handle that condition rather than silently falling behind.

Backpressure is part of the contract

Every server ping includes buffered_max_bytes, the high-water send backlog observed since the previous ping. Record it as a time series. If it trends upward, reduce the number of subscriptions, move parsing to a dedicated process, optimize downstream work, or scale the consumer horizontally by key. Do not open a second connection with the same key: a newer connection evicts the older one.

PinnWire closes a socket whose buffered amount exceeds 32 MB with code 1011 and reason deregistered: slow_consumer. That is a recoverable synchronization event. Drop stale queued work, reconnect, resubscribe and rebuild from the snapshot. A queue that keeps every old price is usually worse than a clean resync.

Compression is a measured choice

permessage-deflate is negotiated per connection. It can reduce bandwidth on a distant or narrow link, but compression and decompression add CPU and can add a latency tail during bursts. Start with it enabled for a constrained link, measure parse-to-commit latency and memory, and disable it when CPU—not bandwidth—is the bottleneck.

Subscription scope is a performance control

  • Use event IDs for an event page, bet-slip validator or focused model.
  • Use sport IDs for a broad scanner, then fan out decisions from one backend book.
  • Split different workloads across separate API keys when they need independent lifecycle or throughput; never split one key across competing workers.
  • Unsubscribe events after they close or leave the product's active scope.

Reconnect without creating a storm

Back off from roughly one second toward 30 seconds with jitter. Reset the backoff after a stable open. Treat authentication and entitlement errors as configuration failures, not transient failures. For stale, eviction and slow-consumer closes, reconnect. Add logs for connection generation, subscription acknowledgement, snapshot completion, last pong, close code/reason and the latest buffered high-water mark.

Freshness metricTime from frame receipt to committed reducer state.
Completeness metricSnapshot chunks received and committed per generation.
Health metricLast pong, reconnect count and current socket age.
Pressure metricbuffered_max_bytes high-water trend and parse queue depth.

7. Production checklist

Before you call a real-time odds stream production-ready, verify the whole lifecycle—not just that a few frames appeared in a terminal.

  1. Keep the PinnWire key in a server-side secret store. Browser code receives only your compact API.
  2. Send a valid subscription immediately after open, and assert that every requested scope receives a subscribed acknowledgement.
  3. Do not publish state until the snapshot is complete; handle both one-frame and chunked snapshots.
  4. Merge by rec.id and market key, compare markets[i].version, and remove closed markets.
  5. Answer every 30-second ping with pong and alarm before the 75-second stale threshold.
  6. Record close codes and implement backoff; never reconnect indefinitely on invalid credentials or a missing add-on.
  7. Test a slow parser and a deliberately blocked downstream queue so slow_consumer recovery is real.
  8. Measure your own network and processing latency. PinnWire removes the polling interval; end-to-end latency still depends on region, link, parsing and your downstream work.
Recommended rollout: validate response shapes with the public REST demo, move to a free personal key for development, run one backend consumer with a narrow event-ID subscription, then widen to sport subscriptions after snapshot/reconnect metrics are visible.

Build the stream on PinnWire

PinnWire gives you current Pinnacle odds over REST, detected drops over SSE and an optional raw WebSocket for live and prematch updates. Test the data now, then add the stream when your architecture is ready.

See the PinnWire WebSocket API

Get a free trial key · Read the protocol reference · Check service status

Frequently asked questions

What is the best architecture for a live odds WebSocket consumer?

Use one long-lived PinnWire connection per API key, subscribe by sport or up to 200 event IDs, load the initial snapshot into a version-aware event book, apply updates by event and market key, and reconnect by rebuilding state from a new snapshot. Keep the key on your backend and enforce a bounded queue so slow consumers reconnect cleanly.

How does the PinnWire Pinnacle WebSocket API work?

Connect to wss://pinnwire.com/ws or /ws/feed, authenticate with an API key, subscribe to live and/or prematch streams, receive a subscription acknowledgement and snapshot, then process matching market updates. Reply to the server's 30-second ping with a pong.

How many event IDs can one PinnWire WebSocket subscription contain?

PinnWire accepts up to 200 active event IDs per stream. Sport subscriptions are also available when the application needs every event in a sport.

What is the difference between REST, SSE and the raw PinnWire WebSocket?

REST returns a current odds snapshot when requested, SSE sends detected odds-drop alerts, and the optional raw WebSocket sends subscribed live and prematch market updates. Choose REST for lookup screens, SSE for drop-triggered workflows, and WebSocket for a continuously maintained odds book.

What should a client do after a slow_consumer or stale close?

Treat both closes as reconnect conditions. Stop the old consumer, wait with exponential backoff and jitter, open one replacement connection, resubscribe, and rebuild state from the new snapshot. Do not assume the server retains session subscriptions.

Can I test PinnWire before paying for the WebSocket add-on?

Yes. PinnWire's public demo key works immediately for REST without signup, and a free personal trial key is available from the homepage. The raw WebSocket is an add-on on eligible paid plans, so use REST to validate the data shape before enabling streaming.