greatspace

docs / real-time syndication (draft)

Real-time Syndication

A WebSocket that tells you the moment availability changes across every provider who syndicates to you: new spaces, price and date changes, and spaces that have gone. Keep your listings in step with providers' own systems in seconds, not on the next hourly snapshot.
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

  1. Use your Data API tokenThe same access token as the Syndication API, with the Data API (read only) scope.
  2. Take a snapshotPull GET /v1/spaces once and keep its X-Stream-Cursor header. More on this.
  3. Connect and subscribeOpen wss://api.greatspace.ai/v1/stream and send subscribe with that cursor.
  4. 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"}

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 with POST /v1/stream/tickets and 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"

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"
}

Fields

FieldTypeWhat 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.

TypereasonWhat to do
space.publishedpublished, syndication_started, access_grantedA space has joined your feed: a provider published it, started syndicating to you, or you gained access. Insert it.
space.updatednoneA column in your profile changed on a space already in your feed, including a status change such as under offer. Upsert it.
space.removedunpublished, withdrawn, syndication_stopped, access_endedA 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": []
  }
}

Event fields

FieldTypeWhat 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_required and close with 4010. 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_required too, 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."
}

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.

  1. Pull the snapshot as JSON, with the same profile you'll subscribe with, and replace your data with it.
  2. Save its X-Stream-Cursor.
  3. 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')))
}

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 eventId you'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.removed is 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 codeerrorCodeMeaning and what to do
1001going_awayWe're restarting or moving your connection, or it's been open 24 hours. Reconnect straight away with your cursor.
1011internal_errorSomething went wrong on our side. Reconnect with backoff.
4000bad_requestThe 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.
4001invalid_tokenNo token, or one that is invalid, revoked or missing the Data API scope. Don't retry with the same token.
4003forbiddenThe token is valid, but your organisation doesn't have streaming access. Checked when you connect and while you're connected.
4004unknown_profileThe profile named in subscribe doesn't exist.
4009too_many_connectionsYour organisation already has 3 open connections. Close one, or reconnect with backoff.
4010resync_requiredYour 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.