docs / syndication api v1
Syndication API
- Status
-
Live - For
-
Listing Platforms and Availability Databases - Replaces
-
Integrations with individual providers
Quickstart
The Syndication API is for listing platforms and data partners. Data API access is switched on by arrangement: contact us if your account doesn't have it yet.
- Create an access tokenIn the app, under Settings → Access tokens, with the Data API (read only) scope. It's shown once.
- Shape your exportOptional. Under Integrations → Data API, save an export profile with your columns, headers and format.
- Pull the feedCall
GET /v1/spaceswith your token. You get every space currently syndicated to you. - Poll with an ETagSend back the ETag on each pull. Unchanged snapshots answer
304, so polling costs almost nothing.
curl "https://api.greatspace.ai/v1/spaces?format=json" \
-H "Authorization: Bearer $GREATSPACE_TOKEN" 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()
console.log(feed.count, 'spaces in generation', feed.generation) 200 OK
Content-Type: application/json
ETag: "d3a26cdd5f0e41b2"
Last-Modified: Thu, 15 Oct 2026 09:00:12 GMT
Cache-Control: private, must-revalidate
X-Feed-Schema-Version: 2
{
"schemaVersion": 2,
"generation": "2026-10-15T09-00-04-512Z",
"generatedAt": "2026-10-15T09:00:04.512Z",
"profileId": "k2Lw9qXc",
"profileName": "Nightly import",
"count": 1284,
"data": [
{
"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-11-01",
"Desks": 24,
"Price (pcm)": 14400,
"Currency": "GBP",
"City": "London",
"Postcode": "EC1V 9NR",
"photos": ["https://storage.googleapis.com/…/foundry-402-a.jpg", "https://storage.googleapis.com/…/foundry-402-b.jpg"],
…
},
…
]
} 401 Unauthorized
{
"success": false,
"errorMessage": "This access token is not valid.",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
} Authentication
Every pull authenticates with an access token. The token identifies your organisation, so there's no account id in the URL: what you get back is whatever is syndicated to you at that moment.
Create a token
- Sign in to Great Space as an admin of your organisation.
- Go to Settings → Access tokens and create a token with the Data API (read only) scope.
- Give it a name that says where it's used, such as Production importer.
- Copy it. It's shown only once: we keep just a hash, so we can't show it to you again.
Send it with each request
Put it in the Authorization header as Bearer followed by the token. Clients
that can't set headers, such as spreadsheet import functions and some platform pollers, can pass it as
a token query parameter instead. A URL ends up in logs and shared documents, so prefer the
header wherever you can.
curl "https://api.greatspace.ai/v1/spaces" \
-H "Authorization: Bearer $GREATSPACE_TOKEN" # For importers that cannot set headers, e.g. a spreadsheet
=IMPORTDATA("https://api.greatspace.ai/v1/spaces?format=csv&token=YOUR_TOKEN") 403 Forbidden
{
"success": false,
"errorMessage": "Data Feed access is not enabled for this organisation.",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
} What a token can do
- Read only. A Data API token can pull the feed and nothing else. It can't change anything.
- Your organisation only. It reaches the spaces syndicated to the organisation that created it.
- Revoke at any time. Revoking a token in Settings → Access tokens stops it on the next request. Use one token per importer, so you can revoke one without touching the others.
- Checked on every pull. If your organisation's Data API access is switched off, every token stops with
403, without anyone having to revoke them.
Pull the feed
One endpoint, GET /v1/spaces. With no parameters it returns every space syndicated to you,
shaped by your default export profile. Every parameter is optional.
| Parameter | Default | What it does |
|---|---|---|
profileId | your default | The id of a saved export profile to shape the response with. Leave it out for your default profile, or the built-in one if you have none. Can't be combined with columns. |
columns | none | A one-off, comma-separated list of column keys, in the order you want them. Headers are the standard ones from the table below. Must include spaceId and providerId. Can't be combined with profileId. |
format | the profile's | csv, xls or json. Overrides the profile's own format for one pull. |
provider | all providers | Narrows the pull to one provider, by org id or Portal slug. Repeat it for several. An unknown value answers 400, never an empty feed. |
limit | none | Returns only the first n rows, for sampling the shape. Never cached and never answers 304, so don't use it for production pulls. |
token | none | Your access token, for clients that can't send an Authorization header. |
curl -G "https://api.greatspace.ai/v1/spaces" \
-H "Authorization: Bearer $GREATSPACE_TOKEN" \
--data-urlencode "format=csv" \
--data-urlencode "columns=spaceId,providerId,building,unit,desks,listPrice,city" \
-o spaces.csv import { writeFile } from 'node:fs/promises'
const url = new URL('https://api.greatspace.ai/v1/spaces')
url.searchParams.set('format', 'csv')
url.searchParams.set('columns', 'spaceId,providerId,building,unit,desks,listPrice,city')
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.GREATSPACE_TOKEN}` },
})
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).errorMessage}`)
await writeFile('spaces.csv', Buffer.from(await res.arrayBuffer())) 200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="spaces-2026-10-15T09-00-04-512Z.csv"
ETag: "9b41e07a3c2d8f15"
X-Feed-Schema-Version: 2
GS Space ID,GS Provider ID,Building name,Unit name,Desks,Price (pcm),City
sp_8Qm2xR4kLp,org_3Fv9Tn1c,The Foundry,Suite 4.02,24,14400,London
sp_1Zt7Nc0vWd,org_3Fv9Tn1c,The Foundry,Suite 5.01,40,22800,London
… 400 Bad Request
{
"success": false,
"errorMessage": "Use either `columns` or `profileId`, not both — a profile already names its columns.",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
} -G with --data-urlencode puts each parameter on its own line and encodes it,
which is safer than pasting one long URL.
Export profiles
An export profile is the shape of your feed, saved in your account so your importer's URL can stay simple. Create and edit them in the app under Integrations → Data API. A profile sets:
- Columns and their order, from the columns below.
spaceIdandproviderIdare always included. - Headers: rename any column to the name your system expects.
- Value maps: rewrite enum values, such as
under_offertoLet agreed. - Format (
csv,xlsorjson) and date format (ISO, UK or US). - CSV details: the delimiter, quoting, and how an empty value is written.
Mark one profile as your default and it applies whenever a pull doesn't name one. With no profiles
at all you get the built-in profile: every column, standard headers, ISO dates, CSV. Editing a profile
changes its ETag, so an importer polling with If-None-Match picks up the new shape on its
next pull.
For a quick look at a particular slice without saving a profile, use the columns parameter.
Renaming, reordering beyond the list order, and value maps are what a profile is for.
Response formats
Every format carries the same rows. CSV and XLS have one header row followed by one row per space, and
come with a Content-Disposition filename that names the snapshot. JSON wraps the rows in an
envelope that says which snapshot and which profile produced them:
{
"schemaVersion": 2, // matches X-Feed-Schema-Version
"generation": "2026-10-15T09-00-04-512Z", // the snapshot these rows come from
"generatedAt": "2026-10-15T09:00:04.512Z",
"profileId": "k2Lw9qXc", // null for the built-in profile or a columns pull
"profileName": "Nightly import",
"count": 1284,
"data": [ { … }, … ] // one object per space, keyed by your headers
}
Each object in data is keyed by your profile's headers, not by column key, so the JSON and
the CSV from the same profile always agree. The one difference is photos: CSV and XLS have a column per
photo slot, so the header row never changes, while JSON gathers them into a single photos array, primary photo first, with empty slots left out ([] when a
space has none). Here are the same two spaces in each:
curl -G "https://api.greatspace.ai/v1/spaces" \
-H "Authorization: Bearer $GREATSPACE_TOKEN" \
--data-urlencode "format=json" \
--data-urlencode "columns=spaceId,providerId,building,unit,desks,listPrice,currency,city,photo_1,photo_2,photo_3" 200 OK
Content-Type: application/json; charset=utf-8
Content-Disposition: attachment; filename="spaces-2026-10-15T09-00-04-512Z.json"
ETag: "5c1e9a7b20d4f3e8"
X-Feed-Schema-Version: 2
{
"schemaVersion": 2,
"generation": "2026-10-15T09-00-04-512Z",
"generatedAt": "2026-10-15T09:00:04.512Z",
"profileId": null,
"profileName": null,
"count": 2,
"data": [
{
"GS Space ID": "sp_8Qm2xR4kLp",
"GS Provider ID": "org_3Fv9Tn1c",
"Building name": "The Foundry",
"Unit name": "Suite 4.02",
"Desks": 24,
"Price (pcm)": 14400,
"Currency": "GBP",
"City": "London",
"photos": [
"https://storage.googleapis.com/…/foundry-402-a.jpg",
"https://storage.googleapis.com/…/foundry-402-b.jpg"
]
},
{
"GS Space ID": "sp_1Zt7Nc0vWd",
"GS Provider ID": "org_3Fv9Tn1c",
"Building name": "The Foundry",
"Unit name": "Suite 5.01",
"Desks": 40,
"Price (pcm)": 22800,
"Currency": "GBP",
"City": "London",
"photos": ["https://storage.googleapis.com/…/foundry-501-a.jpg"]
}
]
} curl -G "https://api.greatspace.ai/v1/spaces" \
-H "Authorization: Bearer $GREATSPACE_TOKEN" \
--data-urlencode "format=csv" \
--data-urlencode "columns=spaceId,providerId,building,unit,desks,listPrice,currency,city,photo_1,photo_2,photo_3" 200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="spaces-2026-10-15T09-00-04-512Z.csv"
ETag: "9b41e07a3c2d8f15"
X-Feed-Schema-Version: 2
GS Space ID,GS Provider ID,Building name,Unit name,Desks,Price (pcm),Currency,City,Photo 1,Photo 2,Photo 3
sp_8Qm2xR4kLp,org_3Fv9Tn1c,The Foundry,Suite 4.02,24,14400,GBP,London,https://storage.googleapis.com/…/foundry-402-a.jpg,https://storage.googleapis.com/…/foundry-402-b.jpg,
sp_1Zt7Nc0vWd,org_3Fv9Tn1c,The Foundry,Suite 5.01,40,22800,GBP,London,https://storage.googleapis.com/…/foundry-501-a.jpg,, Polling and the contract
The feed is rebuilt from providers' live inventory every hour. Polling more often than that won't find anything new, but with conditional requests it costs nearly nothing, so poll on whatever schedule suits your importer.
Conditional GET
Every full response carries an ETag and a Last-Modified. Send the ETag back as If-None-Match (or the date as If-Modified-Since) and an unchanged snapshot
answers 304 Not Modified with no body. The ETag's double quotes are part of its value: send
it without them and nothing matches, which looks like a normal 200 rather than an error.
Prefer the ETag; it also changes when you edit your profile, which a timestamp can't capture.
curl -i "https://api.greatspace.ai/v1/spaces?format=json" \
-H "Authorization: Bearer $GREATSPACE_TOKEN" \
-H 'If-None-Match: "d3a26cdd5f0e41b2"' // Keep the ETag from your last successful pull, quotes and all.
const res = await fetch('https://api.greatspace.ai/v1/spaces?format=json', {
headers: {
Authorization: `Bearer ${process.env.GREATSPACE_TOKEN}`,
...(lastETag ? { 'If-None-Match': lastETag } : {}),
},
})
if (res.status === 304) return // nothing changed: keep what you have
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).errorMessage}`)
const feed = await res.json()
lastETag = res.headers.get('ETag')
await replaceAll(feed.data) // a full snapshot: anything not in it has gone 304 Not Modified
ETag: "d3a26cdd5f0e41b2"
X-Feed-Schema-Version: 2
(no body) 503 Service Unavailable
{
"success": false,
"errorMessage": "The listing feed is temporarily unavailable.",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
} The contract
- Every pull is a full snapshot. It's the complete current availability, not a list of changes. A space that has gone, been let or been withdrawn is simply absent, so delete anything missing from the latest pull.
- Upsert on
spaceId+providerId. Both are always present and can't be removed from a profile, so an import keyed on them is idempotent. - Only published spaces. You get spaces a provider has published and syndicated to you, including those under offer. Unpublishing or stopping syndication removes them from the next snapshot.
- 304 and 503 are never empty feeds. Keep what you have. Only a
200replaces your data. - One permanent URL. As providers opt in and out, the same request returns more or fewer spaces. You never need to change your importer's URL.
Columns
Every column the feed can return: the key to name it by in columns and in a profile, and
the header it gets unless your profile renames it.
| Key | Header | Type | Notes |
|---|---|---|---|
spaceId required | GS Space ID | string | Stable id of the space. Half of the upsert key. |
providerId required | GS Provider ID | string | The provider who listed it. The other half of the upsert key. |
building | Building name | string | |
floor | Floor name | string | |
unit | Unit name | string | |
status | Publication status | enum | published or under_offer: the feed never includes unpublished spaces. A profile can map these to your own values. |
availableFrom | Available from | date | |
sizeSqft | Size (sq ft) | number | |
sizeSqm | Size (sq m) | number | |
desks | Desks | number | |
meetingRooms | Meeting rooms | number | |
density | Desk density (desks/sqft) | number | |
listPrice | Price (pcm) | number | |
currency | Currency | string | Currency code, e.g. GBP. |
pricePerSqft | Price per sq ft per year | number | |
pricePerDesk | Price per desk (pcm) | number | |
addressLine1 | Address line 1 | string | |
addressLine2 | Address line 2 | string | |
city | City | string | |
region | Region | string | |
postcode | Postcode | string | |
country | Country | string | |
latitude | Latitude | number | |
longitude | Longitude | number | |
description | Description | string | |
photo_1 | Photo 1 | string | Public image URLs, primary photo first. In JSON, the photo columns you choose come back as one photos array. |
photo_2 | Photo 2 | string | |
photo_3 | Photo 3 | string | |
photo_4 | Photo 4 | string | |
photo_5 | Photo 5 | string | |
photo_6 | Photo 6 | string | |
photo_7 | Photo 7 | string | |
photo_8 | Photo 8 | string | |
brochureUrl | Brochure URL | string | |
listingUrl | Listing URL | string | |
providerName | Provider name | string | |
providerUrl | Provider website | string | |
roomUrl | Broker Portal URL | string | The space on the provider's Broker Portal. |
createdAt | Created At | datetime | |
updatedAt | Updated At | datetime |
Status codes and errors
Every response has a standard HTTP status code. 200 and 304 mean the
pull worked. Anything in the 4xx range is a problem with the request, which retrying
unchanged won't fix; 5xx is a problem on our side, so try again later.
An error's body is JSON whatever format you asked for: an errorMessage that says what
went wrong, and a correlationId, also sent as the x-correlation-id header.
Quote it if you ask us about a request. Branch on the status code, not the message, which may change.
GET /v1/spaces?provider=acme-offices
400 Bad Request
{
"success": false,
"errorMessage": "Unknown provider \"acme-offices\". Use a provider's organisation id or Portal slug, and only providers who have syndicated to you.",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
} GET /v1/spaces
401 Unauthorized
{
"success": false,
"errorMessage": "This access token is not valid.",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
} GET /v1/spaces?profileId=k2Lw9qXc
404 Not Found
{
"success": false,
"errorMessage": "Export profile \"k2Lw9qXc\" not found.",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
} GET /v1/spaces
503 Service Unavailable
{
"success": false,
"errorMessage": "The listing feed is temporarily unavailable.",
"correlationId": "8f0c2e1a-5d7b-4c39-9a51-2b6e0f4d7c18"
} | Status code | Description | Meaning |
|---|---|---|
200 | OK | The full current snapshot, in your profile's format, with ETag, Last-Modified and X-Feed-Schema-Version headers. |
304 | Not Modified | Nothing has changed since the ETag or Last-Modified you sent. No body: keep what you already have, and never read it as an empty feed. |
400 | Bad Request | A parameter is wrong: an unknown provider, column or format, a non-numeric limit, or columns and profileId together. The message names it. |
401 | Unauthorized | No token, or one that is invalid, revoked or missing the Data API scope. |
403 | Forbidden | The token is valid, but your organisation no longer has Data API access. Access is checked on every pull. |
404 | Not Found | The profile named by profileId doesn't exist. |
503 | Service Unavailable | No snapshot is available right now. Try again on your normal schedule. This is never an instruction to delete listings. |
Versions
Every response carries X-Feed-Schema-Version: 2, and JSON responses repeat
it as schemaVersion. Within a schema version we only add things: new columns you can opt
into, and new fields in the JSON envelope. The shape of a row your profile already produces won't
change without the schema version changing, so check it and ignore fields you don't recognise.