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 and live. 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:
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 both
# optional, prematch only: &markets=MATCH_RESULT,TOTALS &from=2026-09-12T00:00:00Z&to=2026-09-13T00:00:00ZFor per-stream league filters or to resume streams after a drop, connect with only key and send one JSON message within 5 seconds:
{"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.
{ "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": "bet365", "label": "Bet365", "prematchSports": ["soccer", …], "liveSports": ["soccer", …] }, … ] }Plan Limits
Counted per account across all your connections; live and prematch of the same sport count once.
| Plan | Connections | Distinct sports | Bookmakers |
|---|---|---|---|
| PRO | 1 | 1 | all |
| MAX | 2 | 3 | all |
| ULTRA | 3 | all | all |
53 bookmakers and 46 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).
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 live and/or prematch / 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
| Status | When | Message |
|---|---|---|
| 400 | not a WebSocket handshake | WebSocket upgrade required (Connection: Upgrade, Upgrade: websocket) |
| 400 | bad URL declaration | unknown bookmaker … / unknown sport … (see /api/stream/capabilities) / kinds must be live and/or prematch / from/to must be RFC 3339 timestamps / no stream matches the declaration / declaration too large / at most 256 markets |
| 401 | no key | Missing API key |
| 401 | unknown, malformed or denylisted key | Invalid API key |
| 402 | plan without WebSocket access | WebSocket streaming requires a PRO, MAX, or ULTRA plan. Upgrade at https://pulsescore.net/dashboard |
| 402 | subscription not active or trialing | Subscription is not active. Update payment at https://pulsescore.net/dashboard |
| 402 | declaration 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) |
| 402 | declaration over the bookmakers cap | Your plan allows N bookmakers; this connection together with your other connections would use M |
| 403 | account blocked | Account suspended |
| 429 | at 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 |
| 429 | too many invalid keys from your address | Too many rejected API keys from this address; retry in Ns — retryAfterSec, Retry-After |
| 401 / 402 / 403 | key 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) |
| 503 | authentication 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.
{"t":"connected","plan":"ultra","protocol":2,"limits":{"connections":3,"sports":1000,"bookmakers":1000},"streams":[…]} // 1000 = all
{"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 followsApply 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 =
marketIdwhen present and non-empty, elsecanonicalMarket|period|line|rawName(missing parts are empty strings). Selection key =selectionId, elsecanonicalOutcome|rawName|line. Numbers in keys have no exponent and no trailing .0, booleans are true/false, ObjectIDs are 24-hex; a numericeventIdis rendered the same way. - Apply a patch in the order
set → unset → mDel → mAdd → mSet → selDel → sel.setassigns whole top-level values (a value may be null);unsetdeletes;mDeldeletes markets by key;mAddinserts full markets (an existing key is replaced in place);mSetmerges scalars into the market without touching selections, a null value removes that scalar;selDeldeletes selections;selreplaces the whole selection object (m and k are addressing only). upsertreplaces the event,removedeletes it, a snapshot withpart: 1resets 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.
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
retryAfterSecwhen the error frame carries one. - 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: 1resets 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)
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 the snapshot phase took over 5 minutes — the message says which
What to do: reconnect with since; read faster or declare fewer streams
- 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
- 4429socket
at the socket cap inside the 5-s reconnect cooldown (HTTP 429 before the upgrade)
What to do: wait retryAfterSec
| Code | Level | Meaning | What to do |
|---|---|---|---|
| 1012 | socket | service deploy or restart | reconnect at once with since per stream → resumed |
| 1006 / 1001 | socket | network drop, or an HTTP refusal seen by a browser | reconnect with backoff |
| 1009 | socket | you sent a message over 64 KB | fix the clientno retry |
| 1013 | per stream | stream could not be activated (database scan failed); retried automatically | nothing |
| 4001 | socket | invalid or missing API key | fix the keyno retry |
| 4003 | socket | plan without WebSocket access | upgradeno retry |
| 4004 | per stream | sport unknown for this bookmaker (validSports attached); other streams proceed | check capabilities |
| 4010 / 4013 | socket | key revoked, account blocked, plan lost stream access / subscription inactive | fix the accountno retry |
| 4011 | socket | plan downgraded; this connection no longer fits the new limits | reconnect with fewer streamsno retry |
| 4012 | socket | replaced by a newer connection of your account (connection cap, or the per-bookmaker socket budget shared with /ws/live) | stop — do not reconnect in a loopno retry |
| 4029 | socket | server-wide connection limit | retry later with backoff |
| 4030 | socket | 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 the snapshot phase took over 5 minutes — the message says which | reconnect with since; read faster or declare fewer streams |
| 4031 | per stream | stream state rebuilt on the server; a fresh snapshot follows automatically | nothing — part 1 resets the board |
| 4032 | socket | bad declaration or filter (payload form) | fix the declarationno retry |
| 4034 / 4035 | socket | declaration over your plan's sports / bookmakers cap (payload form) | declare less or close another connectionno retry |
| 4429 | socket | at the socket cap inside the 5-s reconnect cooldown (HTTP 429 before the upgrade) | 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 onstartTime; events without a start time are excluded when a bound is set.markets: keeps only markets whosecanonicalMarketormarketIdis 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 asremovewithreason: "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; a client that does not answer pings for about 65 seconds is dropped (browsers and every mainstream library answer them for you). 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.
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
- must finish within 5 minutes → 4030
- 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
| 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 | must finish within 5 minutes → 4030 |
| 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). Every frame's stream id uses the bookmaker's own 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. Each stream's snapshot pages interleave fairly with the other streams' deltas, so a big board never holds the others back.
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).
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 doneimport 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 yoursincewas 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.