Documentation

Stream APIBeta

One WebSocket, every bookmaker and sport your plan allows: each board once, then only what changed, once a second. This page is the complete reference — protocol version 2. The product overview is at /stream-api.

Overview

Delta streams are the low-bandwidth alternative to the WebSocket feed: one socket, as many bookmakers and sports as your plan allows, prematch, live and racing. Each stream sends its full board once (in ~1 MB pages) and then, once per second, only what changed — keyed by market and selection, so a client keeps an exact copy of every board with a few KB/s instead of hundreds. Available on PRO, MAX and ULTRA.

Connect

Declare the whole subscription in the URL; the server checks it against your plan before the upgrade and subscribes every bookmaker × sport × kind that exists:

text
wss://api.pulsescore.net/api/stream/ws?key=YOUR_API_KEY&bookmakers=bet365,onexbet&sports=soccer,tennis&kinds=live,prematch
# bookmakers=all for every bookmaker; kinds defaults to live,prematch — add racing for races
# optional, prematch only: &markets=MATCH_RESULT,TOTALS &from=2026-09-12T00:00:00Z&to=2026-09-13T00:00:00Z

For per-stream league filters or to resume streams after a drop, connect with only key and send one JSON message within 5 seconds:

json
{"streams":[
  {"bookmaker":"bet365","kind":"prematch","sport":"soccer","league":"United Kingdom||England Premier League"},
  {"bookmaker":"onexbet","kind":"live","sport":"soccer","since":7684229893122425111}
]}

Capabilities

GET /api/stream/capabilities (no key needed) is the source of truth for what you can declare: the socket path, the protocol version, the limits of every plan, and each bookmaker with the sports it streams live and prematch. Sport lists follow what each bookmaker publishes and are refreshed every 10 minutes.

json
{ "protocol": 2, "endpoint": "/api/stream/ws", "discoveredAt": "…",
  "plans": { "pro":   { "connections": 1, "sports": 1,    "bookmakers": 1000 },
             "max":   { "connections": 2, "sports": 3,    "bookmakers": 1000 },
             "ultra": { "connections": 3, "sports": 1000, "bookmakers": 1000 } },        // 1000 = all
  "bookmakers": [ { "id": "tab", "label": "TAB", "prematchSports": ["soccer", …], "liveSports": ["tennis", …],
                    "liveSportsOffered": ["soccer", "tennis", …], "racingSports": ["greyhounds", "harness", "horse-racing"] }, … ] }

Plan Limits

Counted per account across all your connections; live and prematch of the same sport count once.

PlanConnectionsDistinct sportsBookmakers
PRO11all
MAX23all
ULTRA3allall

58 bookmakers and 39 sports are streamable today; the live list is at /api/stream/capabilities. A declaration over your limits is refused before the upgrade with HTTP 402 and a message; a connection beyond your cap replaces your oldest one (close code 4012) — unless it names the socket it replaces with replaces=<conn> (see Reconnect rules), in which case only that socket is closed.

Sockets are budgeted per bookmaker across both WebSocket products: PRO 1, MAX 3, ULTRA 6 per bookmaker, shared between /api/<bookmaker>/ws/live and delta streams. A delta stream connection uses one socket on every bookmaker it declares (all of them with bookmakers=all), so the oldest socket beyond that budget — legacy or stream — is closed with 4012, and a replacement inside the 5-second cooldown is refused with 429 (or 4429 after the upgrade) and retryAfterSec.

Refusals before the upgrade

Anything that can never stream is refused during the HTTP handshake, before a socket exists, with a JSON body {"error", "message"}. Browsers surface these as a 1006 close; every other client sees the status.

  • 400not a WebSocket handshake

    WebSocket upgrade required (Connection: Upgrade, Upgrade: websocket)

  • 400bad URL declaration

    unknown bookmaker … / unknown sport … (see /api/stream/capabilities) / kinds must be any of live, prematch, racing / from/to must be RFC 3339 timestamps / no stream matches the declaration / declaration too large / at most 256 markets

  • 401no key

    Missing API key

  • 401unknown, malformed or denylisted key

    Invalid API key

  • 402plan without WebSocket access

    WebSocket streaming requires a PRO, MAX, or ULTRA plan. Upgrade at https://pulsescore.net/dashboard

  • 402subscription not active or trialing

    Subscription is not active. Update payment at https://pulsescore.net/dashboard

  • 402declaration over the sports cap

    Your plan allows N sport(s); this connection together with your other connections would use M (live and prematch of one sport count once)

  • 402declaration over the bookmakers cap

    Your plan allows N bookmakers; this connection together with your other connections would use M

  • 403account blocked

    Account suspended

  • 429at a socket cap inside the 5-s cooldown

    At your plan's N-socket limit on <bookmaker> (shared between /ws/live and /api/stream/ws): wait 5s to replace a connection, or close one first — retryAfterSec: 5

  • 429too many invalid keys from your address

    Too many rejected API keys from this address; retry in Ns — retryAfterSec, Retry-After

  • 401 / 402 / 403key refused 10× within 10 s

    This API key was rejected repeatedly and is temporarily blocked from the WebSocket. Check your plan and subscription at https://pulsescore.net/dashboard (60 s, same status as the refusal)

  • 503authentication backend unreachable

    Authentication temporarily unavailable, retry shortly

Frames

Every frame that belongs to a stream carries stream (bookmaker/kind/sport). Events inside snapshots and upserts are exactly the documents the REST API serves.

json
{"t":"connected","plan":"ultra","protocol":2,"conn":"9f2c…d1","limits":{"connections":3,"sports":1000,"bookmakers":1000},"streams":[…]}   // 1000 = all; conn → replaces= on reconnect
{"t":"snapshot","stream":"bet365/prematch/soccer","seq":…,"part":1,"of":224,"events":[…]}   // pages of ~1 MB
{"t":"snapshot_end","stream":"bet365/prematch/soccer","seq":…,"count":1781}
{"t":"resumed","stream":"onexbet/live/soccer","seq":<your since>}          // instead of a snapshot when since was honoured
{"t":"delta","stream":"onexbet/live/soccer","seq":…,"ts":"…","changes":[
  {"eventId":"E1","op":"upsert","event":{…}},
  {"eventId":"E2","op":"remove","reason":"expired"},                         // expired | removed | filtered
  {"eventId":"E3","op":"patch","set":{"score":{…}},"mSet":[{"k":"<marketId>","isActive":false}],
   "sel":[{"m":"<marketId>","k":"<selectionId>","odds":2.1,…}],"selDel":[{"m":"<marketId>","k":"<selectionId>"}]}]}
{"t":"hb"}                                                                   // every 10 s when idle
{"t":"error","stream":"bet365/live/curling","code":4004,"message":"…","validSports":[…]}   // per stream, socket stays open
{"t":"error","stream":"onexbet/live/soccer","code":4031,"message":"…"}      // per stream: a fresh snapshot follows
{"t":"error","code":1012,"message":"Service restarting, reconnect with since="}          // socket-level: a close frame follows

Apply a patch in the order set → unset → mDel → mAdd → mSet → selDel → sel; markets are keyed by marketId and selections by selectionId. Every value is absolute, so re-applying a frame is harmless. Changes that happen while your snapshot pages are still arriving are sent as one catch-up delta afterwards — a slow reader never misses anything. Keep the last seq per stream and pass it as since on reconnect to resume without a new snapshot (120 s window).

Applying deltas

Keep one board per stream: a map of events by eventId; inside an event keep markets as an ordered map by market key and each market's selections by selection key.

  • Market key = marketId when present and non-empty, else canonicalMarket|period|line|rawName (missing parts are empty strings). Selection key = selectionId, else canonicalOutcome|rawName|line. Numbers in keys have no exponent and no trailing .0, booleans are true/false, ObjectIDs are 24-hex; a numeric eventId is rendered the same way.
  • Apply a patch in the order set → unset → mDel → mAdd → mSet → selDel → sel. set assigns whole top-level values (a value may be null); unset deletes; mDel deletes markets by key; mAdd inserts full markets (an existing key is replaced in place); mSet merges scalars into the market without touching selections, a null value removes that scalar; selDel deletes selections; sel replaces the whole selection object (m and k are addressing only).
  • upsert replaces the event, remove deletes it, a snapshot with part: 1 resets the board. Every value is absolute, so re-applying a frame is idempotent.
  • Never sent, never diffed: _id, createdAt, updatedAt, markets[].updatedAt, markets[].oddsSig. Market and selection order on the wire is not significant. Numeric ids above 2^53 cannot be parsed exactly by JavaScript; the bookmakers streamed today send such ids as strings.

The reference clients below implement exactly these rules and are tested against the server's own diff engine.

Racing streams

Horse racing, greyhounds, harness and trotting stream as kind=racing. Racing is opt-in: kinds defaults to live,prematch, so a socket that never declares racing never receives a race. The bookmakers that have races list them as racingSports in the capabilities (Bet365, TAB, Betfair, Sportsbet AU and others); for the rest the list is empty.

text
wss://api.pulsescore.net/api/stream/ws?key=YOUR_API_KEY&bookmakers=tab,bet365&sports=horse-racing,greyhounds&kinds=racing
  • A race is not an event: it has no markets. It is the race document as the REST racing endpoints serve it, with its runners under horses or greyhounds — the bookmaker's spelling, kept as is (several file greyhound races under horses).
  • Frames, seq, since and close codes are the same as for events. The change envelope's eventId carries the race id.
  • Keep one board per stream: races by id, and runners inside a race by runner key — id when present, else number|trap|name. When several runners of one race share an id, every one of them is keyed id|number|trap|name, and runners nothing tells apart get ##2, ##3, … in document order. The reference clients do this for you.
  • Apply a patch in the order set → unset → rDel → rSet. rDel deletes runners by key; rSet carries whole runner objects with their key in k (addressing only) — an existing runner is replaced in place, a new one is appended. A runner whose price moved arrives complete.
  • Prematch filters (league, from, to, markets) do not apply: they are ignored in the URL form and refused (4032) on a racing stream in the JSON form.
json
{ "t":"delta", "stream":"tab/racing/horse-racing", "seq":…, "ts":"…", "changes":[
    { "eventId":"SANDOWN_R9", "op":"patch",
      "set":  { "isInPlay":"true" },
      "rSet": [ { "k":"1", "id":"1", "number":"1", "name":"INSINUATING", "active":true, … } ],
      "rDel": [ "7" ] } ] }

Both reference clients keep racing boards with RaceBoard; StreamConnection picks it for every …/racing/… stream on its own.

Resuming with since

seq is the Mongo cluster time of the newest change on that stream, so it is identical across our servers and only ever grows on a socket. After a drop, reconnect with the payload form and since = the last seq you applied, per stream. Within the 120-second ring buffer you get resumed plus only the deltas you missed; otherwise a full snapshot whose part 1 resets that board. Three cases always start with a snapshot: filtered prematch streams, a stream that reports seq: 0 (no change since it was activated), and a since older than the ring. Redeclare exactly the streams connected listed (bookmaker, kind, sport, filters) so your plan counters do not change.

What happens when we deploy

Our deploys never cost you your board. The new process loads the live state, catches up and starts accepting before the old one lets go, then every socket receives one error frame and close with code 1012. Reconnect immediately with since per stream: the server answers resumed and the handful of deltas you missed. Measured end to end: reconnected in about 0.35 s, no snapshot, no interval between two deltas longer than the stream's normal one second. A client that does not reconnect on 1012 stays disconnected.

Reconnect rules

  • Reconnect with exponential backoff (0.5 s doubling to 30 s, jittered) on 1012, 1006, 4029, 4429, 4030 and 1009, and on a 502 / 503 / 429 handshake refusal. Honour retryAfterSec when the error frame carries one.
  • Put replaces=<conn> in the reconnect URL, with the conn of your last connected frame, and keep it until a new connected arrives. A dropped socket is not always reported to us at once; naming it makes us close that one, instead of your oldest connection when you are at your plan's cap. The reference clients do this for you.
  • Stop on 4001, 4003, 4010, 4011, 4012, 4013, 4032, 4034 and 4035 — a retry cannot fix those. 4012 in particular means another of your own clients took the slot.
  • Keep your boards across the reconnect; only a snapshot with part: 1 resets one. Treat 4031 and 1013 as per-stream notices the server handles by itself.
  • Before the upgrade, refusals are plain HTTP with a JSON body: 401 key, 402 plan / subscription / declaration over your caps, 403 blocked, 429 cooldown, 400 bad declaration, 503 auth temporarily unavailable.

Close codes

  • 1012socket

    service deploy or restart

    What to do: reconnect at once with since per stream → resumed

  • 1006 / 1001socket

    network drop, or an HTTP refusal seen by a browser

    What to do: reconnect with backoff

  • 1009socketno retry

    you sent a message over 64 KB

    What to do: fix the client

  • 1013per stream

    stream could not be activated (database scan failed); retried automatically

    What to do: nothing

  • 4001socketno retry

    invalid or missing API key

    What to do: fix the key

  • 4003socketno retry

    plan without WebSocket access

    What to do: upgrade

  • 4004per stream

    sport unknown for this bookmaker (validSports attached); other streams proceed

    What to do: check capabilities

  • 4010 / 4013socketno retry

    key revoked, account blocked, plan lost stream access / subscription inactive

    What to do: fix the account

  • 4011socketno retry

    plan downgraded; this connection no longer fits the new limits

    What to do: reconnect with fewer streams

  • 4012socketno retry

    replaced by a newer connection of your account (connection cap, or the per-bookmaker socket budget shared with /ws/live), or by your own reconnect that named this socket in replaces=

    What to do: stop — do not reconnect in a loop

  • 4029socket

    server-wide connection limit

    What to do: retry later with backoff

  • 4030socket

    slow consumer: more than 120 s behind the live feed, or over 256 MB of changes not yet received (both counted only after all your snapshots are out), or one stream's snapshot took over 5 minutes from its turn — the message says which stream

    What to do: reconnect with since: every finished stream resumes, only the unfinished ones snapshot again; read faster or declare fewer streams per socket

  • 4031per stream

    stream state rebuilt on the server; a fresh snapshot follows automatically

    What to do: nothing — part 1 resets the board

  • 4032socketno retry

    bad declaration or filter (payload form)

    What to do: fix the declaration

  • 4034 / 4035socketno retry

    declaration over your plan's sports / bookmakers cap (payload form)

    What to do: declare less or close another connection

  • 4036socket

    your socket was idle and answered none of the last ten keepalive pings (5 minutes) — nothing was queued in front of them, so the connection is gone

    What to do: let your library answer pings (a blocked event loop is the usual cause), then reconnect with since

  • 4429socket

    at the socket cap inside the 5-s reconnect cooldown (HTTP 429 before the upgrade)

    What to do: wait retryAfterSec

A server-initiated close is always preceded by an error frame with the same code and a message; per-stream errors keep the socket open.

Filters (prematch streams)

  • league: exact, case-insensitive match on the event's league, spelled the way that bookmaker spells it (bet365: United Kingdom||England Premier League); payload form only.
  • from / to: inclusive RFC 3339 bounds on startTime; events without a start time are excluded when a bound is set.
  • markets: keeps only markets whose canonicalMarket or marketId is listed (case-sensitive, at most 256); events keep streaming even when none of their markets match.
  • An event entering your view arrives as upsert, one leaving it as remove with reason: "filtered"; patches are cut to the allowed markets. Live streams accept no filters, and filtered streams always start with a snapshot.

Keeping the socket alive

The server sends hb when nothing else went out for 10 seconds and pings every 30 seconds; browsers and every mainstream library answer those for you. Ten unanswered pings (5 minutes) close the socket with 4036 only while it is idle — a pong that is queued behind the snapshot or the deltas you asked for is never held against you, and a client that is merely reading slowly is judged by the 4030 limits instead. Send nothing after the declaration. Your key is re-checked every 5 minutes: a revoked key, a blocked account or a lapsed subscription closes the socket with 4010 or 4013, a plan change applies the new limits and closes the connections that no longer fit with 4011, oldest kept first.

Your library's own keepalive. Most client libraries also ping the server and close the socket themselves when the pong is late. Our pong is immediate, but your library can only see it if it is reading the socket: when your consumer is slower than the stream and the receive queue is full, the reader pauses, the pong waits behind the data, and the library gives up. Python websockets does this after 20 s and closes with 1011 keepalive ping timeout; you then reconnect into a full snapshot and the cycle repeats. If you see 1011 (or 1006 right after a big snapshot) on your side, that is your client, not the server. Connect with ping_timeout=None (or 120 s and more), max_size=None, a larger max_queue, process frames off the receive loop, and always reconnect with since so the snapshot is not repeated. Node ws and PHP amphp have the same knobs (their ping and pong timeouts). The reference clients below are configured this way.

Limits

Payload declaration
one JSON message within 5 s of connecting, at most 64 KB after decompression → 4032
Declared streams
at most 16384 per connection (bookmakers × sports × kinds) → 400 / 4032
Market filters
at most 256 ids per stream → 400 / 4032
Messages after the declaration
ignored; one over 64 KB closes the socket with 1009
Per-stream queue
64 frames; when it overflows the stream switches to coalesced catch-up deltas (the net change per event) — nothing is lost
Falling behind
more than 120 s behind the live feed, or over 256 MB of changes not yet received, once all your snapshots are out → 4030
Snapshot phase
streams take their snapshot 4 at a time (live first, then racing, then prematch); each must finish within 5 minutes of its turn → 4030
Keepalive
a ping every 30 s; ten unanswered pings (5 minutes) close an idle socket with 4036 (never one that is still receiving data). Your own library's ping timeout is the usual cause of a 1011: raise it (Python websockets: ping_timeout=None or ≥ 120 s, max_queue up, max_size=None)
Resume window
the last 120 s of deltas per stream (survives our deploys)
Reconnect at the cap
one replacement per 5 s per bookmaker; a second attempt → 429 / 4429 with retryAfterSec
Repeated refusals
a key refused 10× within 10 s is quarantined for 60 s; 30 invalid keys from one address within a minute → 429 for the rest of it
Server capacity
5000 sockets per process → 4029

Sports and bookmaker names

Bookmaker ids and sport keys are exactly the strings /api/stream/capabilities lists per bookmaker and kind — kebab-case sports such as table-tennis or ice-hockey; esports and e-sports are the same sport on every bookmaker (the legacy live feed's validSports keeps each bookmaker's own spelling, bet365's being e-sports). A declaration may also use the REST spelling with underscores (table_tennis) — it resolves to the same stream. Every frame's stream id uses the bookmaker's own kebab-case spelling. The lists follow what each bookmaker publishes and are refreshed every 10 minutes; a sport a bookmaker no longer has answers 4004 on your next connect. Event, market and selection ids are per bookmaker.

Latency and bandwidth

Changes are batched once per second per stream, so a delta typically arrives 1–2 seconds after the bookmaker's write; this is not a sub-second feed. Frames are compressed with permessage-deflate when your client offers it (6–9× smaller on the wire): a live soccer stream of one bookmaker is roughly 50–150 KB/s uncompressed, and a full prematch soccer board of a large bookmaker is 20–450 MB for its snapshot. Streams take their snapshot four at a time — live first, then racing, then prematch, in the order you declared them — so your live boards are complete within seconds while the big prematch boards follow, and a stream that has its snapshot keeps delivering deltas while later ones ship theirs. If a socket drops half way, reconnect with since: every finished stream resumes and only the unfinished ones are sent again.

Reference clients

Two dependency-free files implement everything above — the board applier, the per-stream router and a connection keeper that reconnects, redeclares with since and stops on the fatal codes: apply.js (ES module, browser or Node ≥ 22) and apply.py (Python ≥ 3.8; the connection keeper uses the websockets package).

javascript
import { StreamConnection } from "./apply.js";

const conn = new StreamConnection({
  url: "wss://api.pulsescore.net/api/stream/ws",
  key: "YOUR_API_KEY",
  streams: [                                     // or put bookmakers=&sports= in the url instead
    { bookmaker: "bet365", kind: "live", sport: "soccer" },
    { bookmaker: "bet365", kind: "prematch", sport: "soccer", markets: ["MATCH_RESULT"] },
  ],
  onFrame: ({ stream, type }, client) => {
    if (type === "delta" || type === "snapshot_end") render(stream, client.board(stream).toArray());
  },
  onState: (state, info) => console.log(state, info),   // connecting | open | reconnecting | closed
});
conn.connect();
// conn.close() when done
python
import asyncio
from apply import StreamConnection                 # pip install websockets

conn = StreamConnection(
    "wss://api.pulsescore.net/api/stream/ws", key="YOUR_API_KEY",
    streams=[{"bookmaker": "bet365", "kind": "live", "sport": "soccer"}],
    on_frame=lambda stream, kind, client, raw: kind == "delta" and render(stream, client.board(stream).to_list()),
    on_state=lambda state, info: print(state, info),
)
asyncio.run(conn.run())                            # returns on a fatal close or conn.close()

Without the keeper, feed frames to StreamClient.apply() and reconnect yourself with the payload form and client.since().

FAQ

  • I reconnected with since and got a snapshot. The stream was filtered, had seq: 0, or your since was older than the 120-second ring. The snapshot resets that board.
  • I keep getting 4012. Another client of your own account took the slot: the stream connection cap, or the per-bookmaker socket budget shared with the legacy WebSocket. Close the other client instead of retrying.
  • 402 on sports although I declared few. Sports and bookmakers are counted across all your open connections; the message names the total the declaration would reach.
  • Can I add a stream without reconnecting? No — the declaration is fixed for the life of the socket; open another connection or reconnect with the new set.
  • Can the deploy lose changes? No. Every change is delivered at least once; re-applying a frame is harmless because every value is absolute.

The full wire contract — key derivation, filters, close codes, the resume rules and reference appliers for JavaScript and Python — is published by the stream service itself: /api/stream/doc.