Realtime Events (SSE)
Live change notifications over Server-Sent Events: what to subscribe to, what an event looks like, and how to keep a site up to date without polling.
The events stream tells your code when something in Soccerverse has changed — a new block, a club, a player, a transfer bid — so you can reload just that data instead of polling on a timer. It is standard Server-Sent Events (SSE): one long-lived GET request, no API key.
An event is a doorbell, not a delivery. It says what changed and never carries the new values. When one arrives, fetch the fresh data from the /v1 API, for example https://play.soccerverse.com/v1/club/50.
This page covers the hosted service on play.soccerverse.com. If you run your own node, see Running your own node.
Quick start
Run this in a terminal:
curl -N 'https://play.soccerverse.com/events?topics=chain:block'
-N turns off curl's buffering so each line shows up as it arrives. Press Ctrl-C to stop. Every second or two you get one view.ready event, one per new game block. This is the start of a real capture:
retry: 2000
id: 7838391252ce922b:1790657443067-0
event: cursor
data: {"epoch":"7838391252ce922b","id":"1790657443067-0","schema":"sv.events.v1"}
id: 7838391252ce922b:1790657445164-0
event: view.ready
data: {"data":{"current":{"blockHash":"121ce40e43955a92892fc9525328fab55ec357522c4f4f46004b1d282d1c19e3","height":94634870,"name":"gsp","ready":true},"polygonHeight":94634869,"previous":{"blockHash":"b1659cf5cbbdaaf45719b5bf265faaf44811704e2a2e057f3fa5f2f780c626fc","height":94634869,"name":"gsp","ready":true},"transition":"forward"},"id":"1790657445164-0","observedAt":"2026-09-29T04:50:45.164362Z","schema":"sv.events.v1","source":{"blockHash":"121ce40e43955a92892fc9525328fab55ec357522c4f4f46004b1d282d1c19e3","height":94634870,"name":"gsp","ready":true},"topics":["chain:block","source:gsp"],"type":"view.ready"}
Each event is three lines: id, event (the event's name) and data (JSON). The first two blocks are set-up: retry: 2000 tells the client to wait 2 seconds before reconnecting, and cursor is a bookmark of your place in the stream. That is the whole protocol. The rest of this page is what to subscribe to and how to keep the connection healthy.
Base URL and access
- Endpoint:
https://play.soccerverse.com/events GETonly, with no authentication and no API key.- The response is
200withContent-Type: text/event-stream, and it stays open until you close it. - Browsers on other websites are currently blocked, see Use it in a browser. Servers and scripts are not affected.
| Method | Path | Purpose | Main query/path parameters |
|---|---|---|---|
| GET | /events | The live stream. | topics optional comma-separated list. Leave it out to receive everything. |
Pitfalls:
- Some clients, for example Python's default
urllib, get a Cloudflare403unless they send aUser-Agentheader. Set your own, such asmy-app/1.0. HEADandPOSTreturn405. OnlyGETworks.
Use it from Node.js
Browsers on other websites cannot open the stream (see below), so the dependable way to add live updates to your own site is to keep one stream open on your server. When something you care about changes, your server reloads the data and updates your visitors however you like: its own SSE endpoint, a WebSocket, or a short poll of your server.
This example watches club 50. It needs Node.js 22.12 or newer and one small package, which gives Node the same EventSource that browsers have:
npm install eventsource
Save this as watch.mjs:
import { EventSource } from 'eventsource';
async function refresh() {
try {
const res = await fetch('https://play.soccerverse.com/v1/club/50');
const club = await res.json();
// Do something with `club` here, such as updating your page or database.
console.log('club 50 reloaded');
} catch (err) {
console.error('refresh failed:', err.message);
}
}
function connect() {
const url = 'https://play.soccerverse.com/events?topics=club:50,clubs,cache:reset';
const es = new EventSource(url);
// Load when the stream opens. This also reloads after every reconnect.
es.addEventListener('open', refresh);
// Reload if club 50 is named. Events that name no clubs (a reset or a full
// rebuild) or whose list was cut short also mean "reload".
const onChange = (e) => {
const { data } = JSON.parse(e.data);
if (!data.clubIds || data.clubIds.includes(50) || data.truncated) refresh();
};
for (const type of ['resource.changed', 'view.ready', 'chain.reorg']) {
es.addEventListener(type, onChange);
}
// The server cannot replay what you missed: start again from scratch.
es.addEventListener('resync-required', () => {
es.close();
connect();
});
// Network blips reconnect by themselves. If the server refused the
// connection (429 or 503), the stream stays closed: try again later.
es.addEventListener('error', () => {
if (es.readyState === EventSource.CLOSED) setTimeout(connect, 30_000);
});
}
connect();
setInterval(refresh, 60_000); // safety net, and keeps Node alive while the stream reconnects
Run it with node watch.mjs. What it does:
- It loads club 50 when the stream opens (at the start and after every reconnect), again whenever
club:50orclubssays club 50 may have changed, and once a minute as a safety net. Thecache:resettopic means "reload everything you show", see Topics. - Watching more than one club or player? Add their topics to the URL (plus
clubsorplayers) and readdata.clubIdsordata.playerIdsto see which ones changed, so you reload only those. If the list is missing ortruncatedistrue, reload all of them. - To see it react while testing, add
chain:blockto the topics. You will get a reload every second or two.
Pitfalls:
es.onmessagenever fires. Every Soccerverse event has a name, andonmessageonly receives events without one. Register each name withaddEventListener, as above.- The
eventsourcepackage does not keep Node running while it waits to reconnect. A script that only holds the stream open would quietly exit after the first dropped connection. ThesetIntervalline prevents that, and a web server would too.
Use it in a browser
Currently this only works from Soccerverse's own game apps, such as https://play.soccerverse.com. The stream sends the CORS headers browsers need to those addresses and to no other website. On any other website, including soccerverse.com, the browser refuses the connection: open never fires, readyState goes straight to 2 (closed), and the console shows a CORS error. Servers, scripts and tools like curl are not affected.
For a page on your own website you have two options:
- Poll
/v1from the page. Unlike the stream,/v1and/datacentreallow requests from any website. - Hold the stream on your server. Run the Node.js example there and pass changes on to your pages.
Topics
Pick what you want to hear about with ?topics=a,b,c. Names are exact. A trailing :* matches a whole family, for example share:club:50:*. Leave topics out (or use *) to receive every event. That is handy for a minute of exploring, but in real code subscribe only to what you show.
| Topic | Fires when |
|---|---|
chain:block | A new game block was processed, every second or two. Arrives as view.ready; the height is in data.current.height. |
club:{id}, for example club:50 | That club changed. Reload /v1/club/{id}. Bids and some other changes arrive only on clubs, so listen to both. |
clubs | One or more clubs changed. Some changes, such as bids, arrive only here; the ids are in data.clubIds. No clubIds means any club may have changed. |
player:{id} and players | One player, or a batch of players, changed. The ids are in data.playerIds. Reload /v1/player/{id}. |
user:{name}:profile and users | A user's profile changed. Reload /v1/user/{name}. |
share:club:{id}:orders and share:club:{id}:trades | Influence orders or trades for that club. share:player:{id}:orders and :trades work the same way for a player. Also subscribe to market:shares, see below. |
auctions | A transfer auction changed: a bid, a new listing or an ending. The player ids are in data.playerIds. |
messages | New in-game news, such as transfers and results. |
leaderboard | The rich list was recalculated. This one is chatty. |
proposals | A governance proposal or vote changed. |
graph:xaya-stats, graph:sv-subgraph, graph:democrit-sv | That public subgraph indexed newer data. |
cache:reset | The chain reorganised, or the game node recovered from catching up. Reload everything you show. |
More topics exist, for example matchday, jobs, transfers and params. Exploring with no topics shows what is being published.
Good to know:
- No single club topic covers everything. Some changes, such as a transfer bid that ties up a club's money, are announced only on
clubs, never onclub:{id}. Others, such as a club's wages, player values and ratings moving after one of its players changes, can arrive only onclub:{id}. To catch every change to club 50, listen toclub:50andclubs, and reload whendata.clubIdsis missing, contains 50, ordata.truncatedistrue, as the example above does. clubIdsis missing about once a day, when every club's wages, player values and ratings are recalculated. That arrives as aclubsevent whosedatais just{"full":true}.- When a lot changes at once, such as a matchday, you get one
players(orusers) event instead ofplayer:{id}(oruser:{name}:profile) events. It lists what changed:data.playerIdsfor players anddata.namesfor users. - In a busy block, the per-share topics (such as
share:club:{id}:orders) can be replaced by a singlemarket:sharesevent. If you watch order books or trades, also subscribe tomarket:sharesand reload them when it arrives. Itsdatahas no ids on an ordinary trade, so do not rely on it. - User names with anything other than ASCII letters, digits,
.,_,-and~(but not a leading~) must be encoded:~followed by the unpadded base64url of the UTF-8 name. For exampleJosébecomesuser:~Sm9zw6k:profile.
Pitfalls:
- A well-formed topic that nobody publishes is accepted and simply never fires. If nothing arrives, check the spelling against the table.
- Topic names start with a lowercase letter, so
Club:50is rejected with400. Invalid topics fail the whole connection, not just that one topic.
Event format
A real event, captured the moment club 20808 changed:
id: 7dee42ad5f405260:1790657569212-0
event: resource.changed
data: {"data":{"clubIds":[20808],"count":1},"id":"1790657569212-0","observedAt":"2026-09-29T04:52:49.212312Z","schema":"sv.events.v1","source":{"blockHash":"c2bf2b962ee243f10d335d18adfb3a4f33f0f5a1e70b5839f467053fa02fd151","checkpoint":94634952,"checkpointKind":"gspHeight","height":94634952,"name":"datacentre.clubs","ready":true},"topics":["club:20808"],"type":"resource.changed"}
The data line is JSON. Formatted for reading:
{
"data": { "clubIds": [20808], "count": 1 },
"id": "1790657569212-0",
"observedAt": "2026-09-29T04:52:49.212312Z",
"schema": "sv.events.v1",
"source": {
"blockHash": "c2bf2b962ee243f10d335d18adfb3a4f33f0f5a1e70b5839f467053fa02fd151",
"checkpoint": 94634952,
"checkpointKind": "gspHeight",
"height": 94634952,
"name": "datacentre.clubs",
"ready": true
},
"topics": ["club:20808"],
"type": "resource.changed"
}
| Field | Meaning |
|---|---|
type | The event name. Same as the event: line. |
topics | The topics this event was published on. |
data | A small JSON object, usually the ids that changed (clubIds, playerIds) or a count. Never new values. Its shape depends on the topic. If the id list is missing, or truncated is true because the list was cut at 500 ids, reload everything you show for that topic. |
source | Which part of the system reported the change and the block height it has reached. You can ignore it. |
observedAt | When the server noticed the change, in UTC. This is not the block time. |
id | The event's place in the stream. Treat it as opaque. When you reconnect, send the full id: line (<epoch>:<id>) instead of this shorter one, see Staying connected. |
schema | Always sv.events.v1. |
The events you will see:
| Event name | Meaning |
|---|---|
resource.changed | Something you can fetch has changed. The topic says what. |
view.ready | A new block was processed (chain:block), or the game node recovered (cache:reset). |
chain.reorg | The newest blocks were replaced. Anything you show may have changed: reload everything. |
source.degraded | The game node behind the API is catching up, so data can lag until it recovers. |
cursor | Keep-alive and bookmark. Nothing to do. |
resync-required | The server cannot replay what you missed. See Staying connected. |
draining | This server is being restarted. The connection closes and your client reconnects by itself. Nothing to do. |
Events are delivered at least once, so the same event can occasionally arrive twice. Reloading twice is harmless.
Staying connected
Browsers and the eventsource package do most of this for you:
- Reconnecting. If the connection drops, the client waits 2 seconds (the
retry: 2000line) and reconnects. It sends the value of the lastid:line it saw in aLast-Event-IDheader, and the server replays everything after it. If you write your own client, send that header yourself, with the full value of the lastid:line, for example7838391252ce922b:1790657443067-0, not the shorteridinside the JSON. The server rejects that one with400. - Keep-alive. On a quiet stream the server sends a
cursorevent every 15 seconds. If a whole minute passes with nothing at all, treat the connection as dead: close it, open a new one and reload. - Replay window. The server can catch you up on roughly the last 15 minutes. Come back later than that and it cannot.
When the server cannot catch you up it sends resync-required and closes the stream. This is a real one, captured by reconnecting with a made-up Last-Event-ID:
event: resync-required
data: {"epoch":"7838391252ce922b","latest":"1790657581008-0","oldestAvailable":"1790617290105-0","reason":"epoch_mismatch","requested":"1790657443067-0","schema":"sv.events.v1","snapshotRequired":true}
The reason field says why; the fix is always the same:
- Close the stream yourself. The event has no
idon purpose, so a browser that reconnects on its own would ask for the same impossible position again and again. - Open a new stream. Without a
Last-Event-IDit starts from now. - Once it is open, reload everything you show.
The example above does exactly this. You will see resync-required now and then because play.soccerverse.com is served by several machines, and a reconnect can land on one that cannot continue your stream. Clients that keep cookies (browsers do) usually stay on the same machine, so it is rare in a browser. A plain Node.js script keeps no cookies and will see it more often.
Fair use and limits
- One stream per app, not one per visitor. Limits are per IP address. Open the stream from your server and share it.
- Subscribe only to what you show. At most 64 topics per connection.
- Limits per IP address. At most 50 open streams and 5 new connections per second (bursts of up to 10). Beyond that the server answers
429. When it is at capacity it answers503withRetry-After: 30. - Back off. After a
429or503, wait at least 30 seconds before you try again, and never retry in a tight loop. The example above waits 30 seconds. - Reload politely. Events can arrive in bursts. If several arrive together, wait a second and reload once. Reloads are ordinary API calls, and
/v1and/datacentreshare a limit of 20 requests per second (bursts of 40) per IP address. - Topics are public. Anyone can listen to any topic. Events are hints: they carry ids, counts and block numbers, not game data.
Events or polling?
| Compared on | Poll /v1 | Use events |
|---|---|---|
| Works from a browser on your own site | Yes | Not yet, see above |
| Time to see a change | Up to one polling interval | Usually a few seconds |
| Requests when nothing has changed | One per interval | Almost none |
| Effort | A timer and a fetch | A stream plus reconnect handling |
Start with polling if a page only needs to refresh now and then. Use events when you want changes within seconds, or want to stop polling. Either way the data comes from /v1: events only tell you when to ask. Even with events, keep a slow reload (about once a minute) as a safety net.
Running your own node
Everything above is the hosted service on play.soccerverse.com. A bare GSP node, which is what you get when you run your own, has none of the hosted extras: no /events, no /v1 and no Datacentre REST API. It has one helpful method instead: the JSON-RPC call waitforchange, a long poll that returns when the newest block changes. The hosted /v1/gsp proxy does not allow it. On the hosted service use /events.
The loop:
- Call
getnullstate. Itsblockhashis your starting hash. - Call
waitforchangewith that hash. It waits until the newest block is a different one and returns its hash. If nothing changes it returns the same hash after the node's own timeout (5 seconds by default), so compare the answer with what you sent and call again. - When the hash is new, read what you need with the
get_*methods (see GSP JSON-RPC), then go back to step 2 with the new hash.
# Use your own node's JSON-RPC address.
curl -sS http://localhost:8600/ \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"waitforchange","params":["<last block hash>"]}'
The result is the block hash as a plain string, not the usual state envelope:
{
"id": 1,
"jsonrpc": "2.0",
"result": "20bb58a2bfde318045c44525048db7bf8daac4703af789c09b618d6da380b9db"
}