Spaces 3 Metrics API
SPACES 3 — Metrics Events
Spaces 3 emits front-end metrics events that analytics consumers such as Google Analytics, Google Tag Manager, and custom scripts can subscribe to.
Events are delivered through a small typed emitter published at:
window.SPACES.eventsThis is the Spaces 3 replacement for the legacy Spaces 2 emitter. Event names and payload shapes are preserved so existing consumers can migrate with minimal changes, but payloads are now built from typed data objects rather than scraped from the DOM.
Quick Start
// Wait for the Spaces app to publish its emitter, then subscribe.
window.SPACES?.events?.on('ready', () => {
console.log('Spaces is ready and will emit metrics events.')
})
window.SPACES?.events?.on('metrics.units.change', (payload) => {
console.log('Units on screen changed:', payload.data.units)
})Every listener receives a single payload argument with the following shape:
{
name,
data
}See Payload Reference for details.
The Emitter API
The emitter is available at:
window.SPACES.eventsIt is published before the React app renders, so a consumer running on DOMContentLoaded can attach listeners before any event fires.
| Method | Signature | Description |
|---|---|---|
on | on(event, fn) => unsubscribe | Subscribe to an event. Returns a function that removes the listener. |
once | once(event, fn) => unsubscribe | Subscribe for a single delivery, then automatically remove the listener. |
off | off(event, fn) | Remove a previously registered listener. |
Example
const unsubscribe = window.SPACES.events.on(
'metrics.unit.apply.click',
(payload) => {
trackApply(payload.data.unit)
}
)
// Later, to stop listening:
unsubscribe()Sticky Replay
The following events are sticky:
readymetrics.units.changemetrics.plans.change
The emitter remembers the most recently emitted payload for each sticky event and immediately replays it to new subscribers.
This means a consumer that attaches after the app has already mounted and rendered its first list still receives the current state. It does not have to wait for the next change.
The click events are not sticky:
metrics.unit.details.clickmetrics.plan.details.clickmetrics.unit.apply.click
These fire only at the moment of interaction.
Defensive Subscription
window.SPACES and window.SPACES.events may not exist yet if your script runs before the Spaces bundle loads.
Guard with optional chaining or poll briefly:
function onSpacesReady(cb) {
if (window.SPACES?.events) {
return cb(window.SPACES.events)
}
const timer = setInterval(() => {
if (window.SPACES?.events) {
clearInterval(timer)
cb(window.SPACES.events)
}
}, 50)
}
onSpacesReady((events) => {
events.on('ready', () => console.log('ready'))
})Events
| Event | When It Fires | Sticky | Payload Data |
|---|---|---|---|
ready | Once, when the app has mounted and is ready to emit. | ✅ | { message } |
metrics.units.change | The list of units on the index changes — initial load, filtering, sorting, or pagination — while the Units tab is active. | ✅ | { units: MetricsUnit[] } |
metrics.plans.change | The list of plans on the index changes — initial load, filtering, sorting, or pagination — while the Plans tab is active. | ✅ | { plans: MetricsPlan[] } |
metrics.unit.details.click | The unit detail view loads. | ❌ | { unit: MetricsUnit } |
metrics.plan.details.click | The plan detail view loads. | ❌ | { plan: MetricsPlan } |
metrics.unit.apply.click | The primary Apply CTA on the unit detail view is clicked. | ❌ | { unit: MetricsUnit } |
Note on
*.changescope:
The array contains only the items on the current page of the active tab — Units or Plans — matching what the user is currently looking at.Switching tabs, changing a filter, sorting, or paging all re-emit the event with the new page of items.
Payload Reference
All events share the following envelope:
interface MetricsEvent<T> {
name: string // Event name, e.g. "metrics.units.change"
data: T // Event-specific payload
}MetricsUnit
MetricsUnitinterface MetricsUnit {
id: string | number
unitNumber: string
area: number
areaDisplay: string
price: number | null
priceDisplay: string
availableOn: string | null
availableOnDisplay: string
floorPlan: {
id: string | number
name: string
bedroomCount: number
bathroomCount: number
}
}Field Notes
area— Numeric square footageareaDisplay— Human-readable value, e.g."1,145 Sq. Ft."price—nullwhen the unit has no pricepriceDisplay— Human-readable value, e.g."$2,050"or"Call for pricing"availableOn— ISO date, ornullwhen unknownavailableOnDisplay— Human-readable date, or""when unknown
MetricsPlan
MetricsPlaninterface MetricsPlan {
id: string | number
name: string
bedroomCount: number
bathroomCount: number
availableOn: string | null
price: number | null
}Field Notes
availableOn— Currently alwaysnullon list payloadsprice— Minimum price, ornullwhen no price is available
MetricsPlan.availableOnis emitted asnullonmetrics.plans.changepayloads because the plan list data does not carry a soonest-available date.
Example Payloads
ready
ready{
"name": "ready",
"data": {
"message": "SPACES is ready"
}
}metrics.units.change
metrics.units.change{
"name": "metrics.units.change",
"data": {
"units": [
{
"id": 1201,
"unitNumber": "1201-A",
"area": 1145,
"areaDisplay": "1,145 Sq. Ft.",
"price": 2050,
"priceDisplay": "$2,050",
"availableOn": "2027-10-16",
"availableOnDisplay": "October 16, 2027",
"floorPlan": {
"id": 12,
"name": "The Alpine",
"bedroomCount": 2,
"bathroomCount": 2
}
}
]
}
}metrics.plans.change
metrics.plans.change{
"name": "metrics.plans.change",
"data": {
"plans": [
{
"id": 12,
"name": "The Alpine",
"bedroomCount": 2,
"bathroomCount": 2,
"availableOn": null,
"price": 2050
}
]
}
}metrics.unit.details.click / metrics.unit.apply.click
metrics.unit.details.click / metrics.unit.apply.click{
"name": "metrics.unit.apply.click",
"data": {
"unit": {
"id": 1201,
"unitNumber": "1201-A",
"...": "see MetricsUnit"
}
}
}metrics.plan.details.click
metrics.plan.details.click{
"name": "metrics.plan.details.click",
"data": {
"plan": {
"id": 12,
"name": "The Alpine",
"...": "see MetricsPlan"
}
}
}Google Tag Manager / Google Analytics
Most properties consume Spaces metrics through Google Tag Manager by pushing the event payload onto the dataLayer.
Add the following snippet to the page, for example:
- Through a GTM Custom HTML tag configured to fire on DOM Ready
- Inline in the site's theme
This bridges every Spaces event into dataLayer so GTM triggers and GA4 tags can act on them.
<script>
window.dataLayer = window.dataLayer || [];
(function bridgeSpacesMetrics() {
function attach(events) {
var names = [
'ready',
'metrics.units.change',
'metrics.plans.change',
'metrics.unit.details.click',
'metrics.plan.details.click',
'metrics.unit.apply.click'
];
names.forEach(function(name) {
events.on(name, function(payload) {
window.dataLayer.push({
// GTM trigger key, e.g.
// "spaces.metrics.unit.apply.click"
event: 'spaces.' + name,
// Event-specific data object
spaces: payload.data
});
});
});
}
// window.SPACES.events is published before the app renders,
// but this script may load first — poll briefly until available.
if (window.SPACES && window.SPACES.events) {
attach(window.SPACES.events);
} else {
var poll = setInterval(function() {
if (window.SPACES && window.SPACES.events) {
clearInterval(poll);
attach(window.SPACES.events);
}
}, 50);
}
})();
</script>Configuring Google Tag Manager
1. Create a Trigger
Create a Custom Event trigger with the event name you want.
For example:
spaces.metrics.unit.apply.clickGTM matches the event key from the dataLayer.push shown above.
2. Create Variables
Create Data Layer Variables to read the pushed data.
Examples:
spaces.unit.unitNumber
spaces.unit.floorPlan.name
spaces.unit.price3. Create the GA4 Tag
Create a GA4 Event tag, select the trigger from Step 1, and map the Data Layer Variables to GA4 event parameters.
GA4 Event Tag Example
For an Apply conversion:
| Setting | Value |
|---|---|
| Event Name | unit_apply |
| Trigger | Custom Event spaces.metrics.unit.apply.click |
Parameter unit_number | {{spaces.unit.unitNumber}} |
Parameter floor_plan | {{spaces.unit.floorPlan.name}} |
Parameter price | {{spaces.unit.price}} |
Direct gtag() Alternative
gtag() AlternativeIf a property uses gtag.js directly instead of GTM:
window.SPACES?.events?.on(
'metrics.unit.apply.click',
function(payload) {
var unit = payload.data.unit;
gtag('event', 'unit_apply', {
unit_number: unit.unitNumber,
floor_plan: unit.floorPlan.name,
price: unit.price
});
}
);Notes & Gotchas
-
Sticky events replay on subscribe.
Becausereadyand the*.changeevents replay their last payload, a*.changelistener may receive the current page immediately on attach — before any user interaction. If you only want user-driven changes, ignore the first sticky delivery in your handler. -
*.changereflects the active tab only.
You will not receivemetrics.plans.changewhile the Units tab is active, and vice versa. -
Click events are not sticky.
They fire once per interaction. Attach your listener before the interaction occurs. The GTM bridge example above attaches on DOM Ready. -
Optional chaining is recommended.
Usewindow.SPACES?.events?.on(...)in case a page loads without the Spaces app present.
Updated 20 days ago