docs / real-time syndication (draft)
Real-time Syndication
- Status
-
Draft - For
-
Listing Platforms and Availability Databases - Replaces
-
Hourly polling
Status
This stream isn't available yet. This page describes what we're planning, so you can see where it's heading and tell us what you need. The URL, messages and behaviour may change before release. If you'd like to help shape it or be an early user, get in touch.
Why a stream
The Syndication API gives you a full snapshot of everything syndicated to you, rebuilt every hour. That's simple and robust, but a suite let at 10:05 stays on your site until the 11:00 snapshot, and you download every space to find the handful that changed.
Real-time Syndication sends you just the changes, as they happen:
- Seconds, not hoursA change a provider makes in the app, through the Inventory API or in a spreadsheet import reaches you within seconds.
- Only what changedOne event per space that joins, changes or leaves your feed, with the columns that changed, instead of a full download.
- In your shapeEach space comes as a row of your own export profile, identical to a row of the JSON feed, so your importer's mapping doesn't change.
- Nothing missedEvery event has a cursor. Reconnect with the last one you applied and we replay what you missed, up to 72 hours back.
The stream sits alongside the snapshot rather than replacing it: you start from a snapshot, stream from there, and can always reconcile against a fresh pull.
Quickstart
- Use your Data API tokenThe same access token as the Syndication API, with the Data API (read only) scope.
- Take a snapshotPull
GET /v1/spacesonce and keep itsX-Stream-Cursorheader. More on this. - Connect and subscribeOpen
wss://api.greatspace.ai/v1/streamand sendsubscribewith that cursor. - Apply events in orderUpsert published and updated spaces, delete removed ones, and save each event's cursor once it's applied.
Every message is a JSON text frame with a type. In the examples, → is a message
you send and ← is one we send.
websocat -H "Authorization: Bearer $GREATSPACE_TOKEN" wss://api.greatspace.ai/v1/stream
# then send:
{"type":"subscribe"} import WebSocket from 'ws'
let cursor = await loadCursor() // where you got to last time; see "Start from a snapshot"
let queue = Promise.resolve() // apply events one at a time, in order
let attempt = 0
function connect() {
const ws = new WebSocket('wss://api.greatspace.ai/v1/stream', {
headers: { Authorization: `Bearer ${process.env.GREATSPACE_TOKEN}` },
})
ws.on('open', () => ws.send(JSON.stringify({ type: 'subscribe', cursor })))
ws.on('message', (raw) => {
const msg = JSON.parse(raw)
queue = queue.then(() => handle(msg))
})
ws.on('close', (code) => {
if ([4001, 4003, 4004].includes(code)) return alertSomeone(code) // retrying won't help
if (code === 4010) return queue.then(resync).then(connect) // pull a fresh snapshot first
const wait = Math.min(60_000, 1000 * 2 ** attempt++) * (0.5 + Math.random() / 2)
setTimeout(connect, wait)
})
}
async function handle(msg) {
if (msg.type === 'subscribed') attempt = 0
if (msg.type === 'space.published' || msg.type === 'space.updated') await upsert(msg.space)
if (msg.type === 'space.removed') await remove(msg.spaceId)
if (msg.cursor) await saveCursor((cursor = msg.cursor)) // only after the change is applied
}
connect() {
"type": "space.updated",
"eventId": "evt_2Kq8Vn4mXr7Tz1",
"cursor": "c_0001844671",
"occurredAt": "2026-10-16T10:42:07.318Z",
"spaceId": "sp_8Qm2xR4kLp",
"providerId": "org_3Fv9Tn1c",
"profileId": "k2Lw9qXc",
"changed": ["Available from", "Price (pcm)"],
"space": {
"GS Space ID": "sp_8Qm2xR4kLp",
"GS Provider ID": "org_3Fv9Tn1c",
"Building name": "The Foundry",
"Unit name": "Suite 4.02",
"Publication status": "published",
"Available from": "2026-12-01",
"Desks": 24,
"Price (pcm)": 13900,
"Currency": "GBP",
"City": "London",
"Postcode": "EC1V 9NR",
"photos": ["https://storage.googleapis.com/…/foundry-402-a.jpg"]
}
} Connect
WSS /v1/stream Draft
One URL, wss://api.greatspace.ai/v1/stream, over TLS only. Authenticate the connection in either of two ways:
- A header, for server-side clients: send
Authorization: Bearer …on the upgrade request, as you would to the Syndication API. Most WebSocket libraries outside the browser can. - A ticket, for clients that can't set headers on the upgrade, such as the browser's
WebSocket: exchange your token for a short-lived ticket withPOST /v1/stream/ticketsand put it in the URL. Never put the access token itself in a WebSocket URL, which ends up in logs.
curl -X POST "https://api.greatspace.ai/v1/stream/tickets" \
-H "Authorization: Bearer $GREATSPACE_TOKEN" 200 OK
{
"success": true,
"data": { "ticket": "tkt_9fXc2LmQ7pWv0sRb", "expiresAt": "2026-10-16T10:43:07.000Z" }
} // Single use, valid for 60 seconds: fetch a new one for every connection.
const ws = new WebSocket(`wss://api.greatspace.ai/v1/stream?ticket=${ticket}`) Subscribe
Once connected, send one subscribe message. It says which spaces you want, in which shape,
and where to start. We answer subscribed, replay anything since your cursor, then send live: from there on, events arrive as they happen.
A connection carries one subscription. To follow two profiles, open two connections; every event carries its profileId, so one handler can serve both. A subscription
sees exactly what your snapshot would: published spaces from providers who syndicate to you, narrowed
by providers if you send it.
{
"type": "subscribe",
"profileId": "k2Lw9qXc",
"providers": ["org_3Fv9Tn1c", "the-workspace-co"],
"cursor": "c_0001844502"
} {
"type": "subscribed",
"connectionId": "conn_6Rb1Hq3Zt8",
"schemaVersion": 2,
"profileId": "k2Lw9qXc",
"profileName": "Nightly import",
"heartbeatSeconds": 30,
"replaying": 169
} {
"type": "live",
"cursor": "c_0001844671"
} {
"type": "error",
"errorCode": "unknown_provider",
"errorMessage": "No provider syndicates to you as the-workspace-co",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
} Fields
| Field | Type | What it does |
|---|---|---|
type required | subscribe | The first message on every connection, within 10 seconds of connecting. |
profileId | string | The id of the export profile to shape each space with, as in the Syndication API. Leave it out for your default profile. |
providers | string[] | Only these providers, by org id or Portal slug. Leave it out for every provider who syndicates to you, including ones who start later. |
events | string[] | Only these event types. Leave it out for all three, which is what a mirror of the feed needs. |
cursor | string | null | Resume after this event. Leave it out to start from now, with no replay. |
Events
Three event types cover everything that can happen to a space in your feed. Applying them in order keeps your copy identical to what the next snapshot would contain.
| Type | reason | What to do |
|---|---|---|
space.published | published, syndication_started, access_granted | A space has joined your feed: a provider published it, started syndicating to you, or you gained access. Insert it. |
space.updated | none | A column in your profile changed on a space already in your feed, including a status change such as under offer. Upsert it. |
space.removed | unpublished, withdrawn, syndication_stopped, access_ended | A space has left your feed and won't be in the next snapshot. Delete it. |
Published and updated events carry the whole space, not just the changed fields, so you can
upsert it without reading your own copy first, and applying an event twice does no harm. You only get space.updated when a column in your profile changes: if your profile leaves out photos,
a new photo sends you nothing.
When a provider edits the same space several times in a couple of seconds, such as a bulk edit in the app, we may send one event with every changed column rather than one per edit.
{
"type": "space.published",
"eventId": "evt_7Wd3Lp9kQm2Ns5",
"cursor": "c_0001844672",
"occurredAt": "2026-10-16T10:44:51.902Z",
"spaceId": "sp_3Jx5Pb7rYc",
"providerId": "org_3Fv9Tn1c",
"profileId": "k2Lw9qXc",
"reason": "published",
"space": {
"GS Space ID": "sp_3Jx5Pb7rYc",
"GS Provider ID": "org_3Fv9Tn1c",
"Building name": "The Foundry",
"Unit name": "Suite 6.01",
"Publication status": "published",
"Available from": null,
"Desks": 12,
"Price (pcm)": 7200,
"Currency": "GBP",
"City": "London",
"Postcode": "EC1V 9NR",
"photos": []
}
} {
"type": "space.updated",
"eventId": "evt_2Kq8Vn4mXr7Tz1",
"cursor": "c_0001844671",
"occurredAt": "2026-10-16T10:42:07.318Z",
"spaceId": "sp_8Qm2xR4kLp",
"providerId": "org_3Fv9Tn1c",
"profileId": "k2Lw9qXc",
"changed": ["Available from", "Price (pcm)"],
"space": {
"GS Space ID": "sp_8Qm2xR4kLp",
"GS Provider ID": "org_3Fv9Tn1c",
"Building name": "The Foundry",
"Unit name": "Suite 4.02",
"Publication status": "published",
"Available from": "2026-12-01",
"Desks": 24,
"Price (pcm)": 13900,
"Currency": "GBP",
"City": "London",
"Postcode": "EC1V 9NR",
"photos": ["https://storage.googleapis.com/…/foundry-402-a.jpg"]
}
} {
"type": "space.removed",
"eventId": "evt_5Tg1Cz8hBn4Ke6",
"cursor": "c_0001844673",
"occurredAt": "2026-10-16T10:45:30.044Z",
"spaceId": "sp_1Zt7Nc0vWd",
"providerId": "org_3Fv9Tn1c",
"profileId": "k2Lw9qXc",
"reason": "unpublished"
} {
"type": "heartbeat",
"at": "2026-10-16T10:46:00.000Z",
"cursor": "c_0001844673"
} Event fields
| Field | Type | What it does |
|---|---|---|
type required | space.publishedspace.updatedspace.removed | What happened. See the table below. |
eventId required | string | Unique to this event. The same event delivered twice has the same eventId. |
cursor required | string | This event's position in your stream. Save it once you've applied the event, and resume from it. |
occurredAt required | datetime | When the change happened in Great Space, ISO 8601 in UTC. |
spaceId required | string | Our id for the space: the GS Space ID column, whatever your profile calls it. |
providerId required | string | The provider's org id: the GS Provider ID column. |
profileId required | string | null | The export profile this event is shaped by, as in subscribe: null for the built-in profile. Lets one handler serve connections for several profiles. |
space | object | The whole space as one row of your export profile, keyed by your headers, exactly like a row of the JSON feed. On published and updated events. |
changed | string[] | On updated events: the headers whose values changed, so you can skip work when nothing you care about moved. |
reason | string | On published and removed events: why the space arrived or left. See the table below. |
While a connection is quiet, a heartbeat arrives every 30 seconds with your current cursor.
It isn't an event: there's nothing to apply, and you don't need to save its cursor.
Cursors and resuming
Every event has a cursor: an opaque string marking its place in your stream. Store the
cursor of the last event you've finished applying, in the same transaction as the change if you
can. When you reconnect, send it in subscribe and we replay every event after it, then
carry on live.
- 72-hour replay window. We keep events for 72 hours, so a deploy, a network blip or a weekend outage on your side loses nothing.
- Treat cursors as opaque. Compare them for equality only. Their format may change.
- Older than the window? We send
resync_requiredand close with4010. Pull a fresh snapshot and resume from there. - A shape change also means a resync. Editing the export profile you subscribed with, or a new feed schema version, sends
resync_requiredtoo, so your rows never mix two shapes.
{
"type": "resync_required",
"reason": "cursor_expired",
"errorMessage": "Cursor c_0001022310 is older than the 72-hour replay window. Pull a fresh snapshot and resume from its X-Stream-Cursor."
} 4010 resync_required Start from a snapshot
A stream only tells you about changes, so you need a starting point. With Real-time Syndication, every GET /v1/spaces response from the Syndication API will
also carry an X-Stream-Cursor header: the point in the stream that snapshot reflects.
- Pull the snapshot as JSON, with the same profile you'll subscribe with, and replace your data with it.
- Save its
X-Stream-Cursor. - Subscribe with that cursor. We replay everything since the snapshot was built, up to an hour's worth, then go live.
Some replayed events may already be reflected in your snapshot. That's fine: events carry whole rows,
so applying them again leaves your data as it should be. Do the same after a 4010.
// First run, or after a 4010: take a full snapshot, then stream from where it ends.
async function resync() {
const res = await fetch('https://api.greatspace.ai/v1/spaces?format=json', {
headers: { Authorization: `Bearer ${process.env.GREATSPACE_TOKEN}` },
})
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).errorMessage}`)
const feed = await res.json()
await replaceAll(feed.data)
await saveCursor((cursor = res.headers.get('X-Stream-Cursor')))
} 200 OK
Content-Type: application/json
ETag: "d3a26cdd5f0e41b2"
X-Feed-Schema-Version: 2
X-Stream-Cursor: c_0001844502
{
"schemaVersion": 2,
"generation": "2026-10-16T10-00-04-512Z",
"count": 1284,
"data": [ … ]
} Staying connected
- Expect to reconnect. We close every connection after 24 hours, and sometimes sooner when we deploy, with
1001. Reconnect straight away with your cursor and you won't miss anything. - Back off on failure. After an error or a dropped connection, wait about a second before reconnecting, doubling each time up to a minute, with some randomness so your instances don't reconnect in lockstep. Reset once you're subscribed again.
- Watch the heartbeat. If nothing arrives for 90 seconds, three missed heartbeats, assume the connection is dead, close it and reconnect.
- Up to 3 connections per organisation at once. For failover, run a second instance that connects only when the first goes quiet.
- Access is checked continuously. Revoking the token, or your organisation losing streaming access, closes open connections within a minute.
The contract
- At least once. Every event is delivered, but after a reconnect you may see some again. Ignore an
eventIdyou've already applied, or rely on events being safe to repeat. - In order for each space. Events for one space always arrive in the order they happened. Events for different spaces are roughly in time order, but don't depend on it.
- The same data as the snapshot. Applying every event from a snapshot's cursor gives you what a fresh snapshot would. If they ever disagree, the snapshot wins: reconcile with a pull now and then, daily is plenty.
- Remove means remove. A
space.removedis the stream's version of a space missing from the snapshot. Delete it, whatever the reason. - Additive within a version. We may add event types, reasons and fields. Ignore any you don't recognise, rather than failing.
Errors and close codes
When we close a connection because of a problem, we first send an error message with an errorCode to branch on and a correlationId to quote if you ask us about it,
then close with a matching code.
{
"type": "error",
"errorCode": "too_many_connections",
"errorMessage": "This organisation already has 3 open stream connections.",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
} | Close code | errorCode | Meaning and what to do |
|---|---|---|
1001 | going_away | We're restarting or moving your connection, or it's been open 24 hours. Reconnect straight away with your cursor. |
1011 | internal_error | Something went wrong on our side. Reconnect with backoff. |
4000 | bad_request | The first message wasn't a valid subscribe within 10 seconds, or named an unknown provider or event type. The error message says which. Fix it before retrying. |
4001 | invalid_token | No token, or one that is invalid, revoked or missing the Data API scope. Don't retry with the same token. |
4003 | forbidden | The token is valid, but your organisation doesn't have streaming access. Checked when you connect and while you're connected. |
4004 | unknown_profile | The profile named in subscribe doesn't exist. |
4009 | too_many_connections | Your organisation already has 3 open connections. Close one, or reconnect with backoff. |
4010 | resync_required | Your cursor is too old, or your profile or the schema changed. Pull a fresh snapshot, then resume from its X-Stream-Cursor. |
The ticket call returns ordinary HTTP errors, as described in the Syndication API.
Until then
The Syndication API is live today. Poll it with conditional requests: an unchanged snapshot answers 304 with no body, so checking every few minutes costs almost nothing, and you'll pick up
each hourly rebuild as soon as it lands. An importer built on the snapshot now will move to the stream
without changing its mapping, since both deliver the same rows.