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

This 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.events

It is published before the React app renders, so a consumer running on DOMContentLoaded can attach listeners before any event fires.

MethodSignatureDescription
onon(event, fn) => unsubscribeSubscribe to an event. Returns a function that removes the listener.
onceonce(event, fn) => unsubscribeSubscribe for a single delivery, then automatically remove the listener.
offoff(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:

  • ready
  • metrics.units.change
  • metrics.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.click
  • metrics.plan.details.click
  • metrics.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

EventWhen It FiresStickyPayload Data
readyOnce, when the app has mounted and is ready to emit.{ message }
metrics.units.changeThe list of units on the index changes — initial load, filtering, sorting, or pagination — while the Units tab is active.{ units: MetricsUnit[] }
metrics.plans.changeThe list of plans on the index changes — initial load, filtering, sorting, or pagination — while the Plans tab is active.{ plans: MetricsPlan[] }
metrics.unit.details.clickThe unit detail view loads.{ unit: MetricsUnit }
metrics.plan.details.clickThe plan detail view loads.{ plan: MetricsPlan }
metrics.unit.apply.clickThe primary Apply CTA on the unit detail view is clicked.{ unit: MetricsUnit }

Note on *.change scope:
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

interface 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 footage
  • areaDisplay — Human-readable value, e.g. "1,145 Sq. Ft."
  • pricenull when the unit has no price
  • priceDisplay — Human-readable value, e.g. "$2,050" or "Call for pricing"
  • availableOn — ISO date, or null when unknown
  • availableOnDisplay — Human-readable date, or "" when unknown

MetricsPlan

interface MetricsPlan {
  id: string | number
  name: string
  bedroomCount: number
  bathroomCount: number

  availableOn: string | null
  price: number | null
}

Field Notes

  • availableOn — Currently always null on list payloads
  • price — Minimum price, or null when no price is available

MetricsPlan.availableOn is emitted as null on metrics.plans.change payloads because the plan list data does not carry a soonest-available date.


Example Payloads

ready

{
  "name": "ready",
  "data": {
    "message": "SPACES is ready"
  }
}

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

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

{
  "name": "metrics.unit.apply.click",
  "data": {
    "unit": {
      "id": 1201,
      "unitNumber": "1201-A",
      "...": "see MetricsUnit"
    }
  }
}

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

GTM 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.price

3. 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:

SettingValue
Event Nameunit_apply
TriggerCustom 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

If 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.
    Because ready and the *.change events replay their last payload, a *.change listener 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.

  • *.change reflects the active tab only.
    You will not receive metrics.plans.change while 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.
    Use window.SPACES?.events?.on(...) in case a page loads without the Spaces app present.


Did this page help you?