Listing Embeds
A listing embed puts a small grid of a brokerage’s listings on a page that is not the HomeStar site: the brokerage’s marketing site, a community page, a partner’s blog. The server renders the cards as an HTML fragment and a small loader inserts it into the host page. There is no framework runtime, no cookie, no web font, and no request to anything but the brokerage’s own HomeStar domain. A second widget, the search widget, puts a search form above the same grid.
For the walkthrough, see Embed Listings on Your Site.
The snippet
Section titled “The snippet”One <script> per page, any number of mounts. search.example.com stands for the brokerage’s verified HomeStar domain throughout.
<div class="hs-embed" data-hs-embed="listings" data-hs-key="hsx_live_example_7f3a9c2e" data-hs-scope="hayden" data-hs-limit="9" data-hs-columns="3" data-hs-heading-level="3" data-hs-target="_self" data-hs-fallback-href="https://search.example.com/search?city=Hayden" style="--hs-accent: #4a5d3a"> <noscript><a href="https://search.example.com/search?city=Hayden">Browse Hayden listings</a></noscript></div><script src="https://search.example.com/embed/v1/loader.js" async></script>The <noscript> link is what a visitor without JavaScript sees. The loader also reads it: if data-hs-fallback-href is absent, the anchor’s href and text become the fallback link on failure.
Attributes
Section titled “Attributes”| Attribute | Values | Default | Notes |
|---|---|---|---|
data-hs-embed |
listings, search |
required | Marks the element as a mount. search adds a form above the grid; see Search widget |
data-hs-key |
hsx_live_<slug>_<8 hex> |
required | Public, non-secret. Identifies the brokerage |
data-hs-scope |
scope slug | required | Names a server-side filter. See Scopes |
data-hs-limit |
1 to 12 | 6 | Server clamps out-of-range values |
data-hs-columns |
1 to 4 | 3 | Collapses to at most 2 at viewports of 768px and under, and to 1 at 480px and under |
data-hs-heading-level |
2 to 4 | 3 | The fragment’s title heading. Card titles are one level deeper |
data-hs-target |
_self, _blank |
_self |
Applied to card links and the fallback link |
data-hs-theme |
light, dark, auto |
auto |
auto follows your page: the loader reads the background colour behind the mount and picks light or dark from it, so a visitor’s dark-mode OS never darkens cards on a light page. Without the loader (the preview page) auto follows prefers-color-scheme. |
data-hs-fallback-href |
URL | <noscript> anchor, then the scope’s fallback_href |
Rendered on any failure |
data-hs-fields |
comma list | all six | Search widget only. See Search widget |
data-hs-initial |
scope, none |
scope |
Search widget only |
The loader writes one attribute of its own, data-hs-state, and moves it through idle, loading, ready and failed. Read it if you need to react to the widget; never set it.
Nothing else on the element is read. What the widget shows (city, price range, status, sort) is decided by the scope on the server, not by the snippet. The search widget lets the visitor narrow that further, and only narrow it.
Custom properties
Section titled “Custom properties”Set these on the mount (inline style or a stylesheet rule). All are optional and every one has a fallback, so an unstyled mount renders sensibly.
| Property | Controls | Fallback |
|---|---|---|
--hs-accent |
Price, link hover, focus ring | a neutral blue |
--hs-fg |
Body text | near-black, or near-white under dark |
--hs-muted |
Meta text (beds, baths, area), status | grey |
--hs-surface |
Card background | white, or a dark grey under dark |
--hs-border |
Card border | light grey |
--hs-radius |
Card corner radius | 8px |
--hs-gap |
Grid gap between cards | 20px |
--hs-font |
Font family | inherit |
--hs-gap also feeds the height reservation below, so set it on the mount rather than on a descendant.
Scopes
Section titled “Scopes”A scope is a named, server-stored filter: hayden might mean city: Hayden, showcase might mean brokerage-wide, newest first. Scopes belong to a brokerage. Brokers and owners create and edit them under Widgets in the admin; the platform team can also keep them in the brokerage’s onboarding YAML under embeds.scopes, applied with make onboard-embeds BROKERAGE=<slug>. The script matches scopes by slug, so a slug present in both places takes the YAML’s definition whenever the file is applied.
Allowed filter keys: city, county, status, property_type, min_price, max_price, min_beds, min_baths, min_sqft, min_lot_size, waterfront, min_lat, min_lng, max_lat, max_lng, featured, featured_first (default true), sort_by (list_date, list_price, on_market_date), sort_order. Anything else is rejected, from the admin form and from the YAML alike. The admin form covers every key except the map area (min_lat and friends) and featured; a scope that has those keeps them through an edit.
county is the feed’s CountyOrParish, spelled as the feed spells it: Kootenai, Bonner, Benewah, Shoshone, Boundary, Spokane. A county page filters on that one field, so nobody has to maintain a list of its towns.
min_lot_size is in acres, not square feet. min_lot_size: 5 reads as five acres and up, and the admin form labels the box in acres for the same reason.
waterfront: true means the water is part of the property. It does not read the feed’s WaterfrontYN tickbox: agents fill that in by hand, it is null or false on most of the feed, and it reads false for houses whose own WaterBodyName is Coeur d’Alene Lake. The filter reads WaterfrontFeatures instead, the list an agent picks from rather than types, and counts Lakefront, River Front, Secondary Wtr Frnt, Comm Wtr Frnt, Creek and Pond. “Lake/River View” is deliberately excluded, because a view of the water is not frontage and a page that promises waterfront should not answer with one. A listing that does have the tickbox set still counts, on the grounds that an agent who ticked it meant it.
Every scope resolves rows through the same display policy as the search site, so the per-feed status allowlist, the sold-history window and address suppression apply to embeds unchanged.
Endpoints
Section titled “Endpoints”All on the brokerage’s verified domain. The key must belong to the brokerage that owns the host; a key used on another brokerage’s domain is a 404.
| Route | Returns | Cache |
|---|---|---|
GET /embed/v1/loader.js |
the loader, plain ES2019 | max-age=3600, ETag |
GET /embed/v1/listings?key=&scope=&limit=&columns=&heading=&target=&theme= |
text/html fragment |
max-age=300, stale-while-revalidate=900, ETag |
GET /embed/v1/preview?key=&scope= |
a full HTML page wrapping the fragment; &widget=search wraps the form and its initial results instead |
same |
GET /embed/v1/scopes?key= |
{"scopes": [{"slug", "label", "fallback_href"}]} |
same |
GET /embed/v1/search-form?key=&scope=&fields=&theme= |
text/html fragment: the search form |
same |
GET /embed/v1/search?key=&scope=&q=&city=&min_price=&max_price=&min_beds=&min_baths=&property_type=&limit=&columns=&heading=&target=&theme= |
text/html fragment: the listings grid for the visitor’s filters, with a results line and a link to the full search |
same |
Responses carry Access-Control-Allow-Origin: * with no credentials and set no cookies. OPTIONS answers 204. Unknown key, disabled key or unknown scope: 404 with a fragment containing only the fallback link.
The preview route is for hosts that forbid third-party JavaScript, and for checking a scope before pasting the snippet anywhere.
What the loader guarantees
Section titled “What the loader guarantees”- One script per page. A second copy anywhere on the page is a no-op. It initialises on
DOMContentLoadedand again onastro:page-load, so it survives Astro’s view transitions. State lives on the element, not in the script. - Deferred. No request leaves until the mount is within 800px of the viewport.
- Height reserved before the request. The mount gets a
min-heightcomputed from the snippet so the page does not jump when the fragment lands (formula below). - Attribution or nothing. A fragment without the MLS attribution block is refused; the loader never inserts it.
- Never an empty box. On a non-2xx response, an 8 second timeout, a network error, zero results, or a refused fragment, the mount gets
data-hs-state="failed"and a single link:<a class="hs-embed__fallback" href="...">pointing at the equivalent search on the brokerage site. - Inert insertion. The fragment is parsed through a
<template>, so a script inside it never runs. - No cookies, no storage, no third-party requests. One
fetchto the brokerage domain; images from the platform photo host.
What the fragment guarantees
Section titled “What the fragment guarantees”- A labelled
<section>with a heading atdata-hs-heading-level; cards are<article>with one link, on the card title. - The attribution block is in every fragment, names the MLS as source, carries its disclaimer verbatim, and has its
display,visibility,opacity,position,font-sizeandcolorset as inlinestyleso an accidental host rule cannot hide it. - Only the platform photo host appears as an image source. A listing without a derived image gets an inline placeholder, never the MLS original.
- Images carry
width,height,srcset,sizes,loading="lazy"anddecoding="async". - Street addresses appear in link text and
altonly when the listing allows it.
Search widget
Section titled “Search widget”data-hs-embed="search" mounts a search form with a grid of results under it, so a visitor can search on your site and see cards there, with the full search one click away. The form is rendered on the server (/embed/v1/search-form); every search fetches the ordinary listings fragment (/embed/v1/search) and swaps it into the results area. Everything the grid widget does, the search widget does: the same scope, the same height reservation, the same attribution, the same fallback.
<div class="hs-embed" data-hs-embed="search" data-hs-key="hsx_live_example_7f3a9c2e" data-hs-scope="search" data-hs-fields="q,city,price,beds,baths,type" data-hs-initial="scope" data-hs-limit="9" data-hs-columns="3" data-hs-heading-level="2" data-hs-fallback-href="https://search.example.com/search" style="--hs-accent: #4a5d3a"> <noscript><a href="https://search.example.com/search">Search listings</a></noscript></div><script src="https://search.example.com/embed/v1/loader.js" async></script>Attributes
Section titled “Attributes”| Attribute | Values | Default | Notes |
|---|---|---|---|
data-hs-fields |
comma list from q, city, price, beds, baths, type |
all six | Which controls the form carries. Order is fixed to the list here whatever order you write |
data-hs-initial |
scope, none |
scope |
scope loads the scope’s first data-hs-limit cards under the form before the visitor touches it. none loads the form alone; results appear on the first search |
data-hs-limit, data-hs-columns, data-hs-heading-level, data-hs-target, data-hs-theme and data-hs-fallback-href mean what they mean on the grid and apply to the results. The custom properties apply to the form too: the Search button takes --hs-accent.
Fields
Section titled “Fields”| Field | Control | Query parameter |
|---|---|---|
q |
A text box, “Describe what you are looking for”. Runs the same description search as your search site | q |
city |
A select: “Any city”, then the cities in the scope’s inventory with counts, most listings first, at most 40 | city |
price |
Two number boxes, Min and Max, in steps of 25,000 | min_price, max_price |
beds |
A select: Any, 1+ to 5+ | min_beds |
baths |
A select: Any, 1+ to 3+ | min_baths |
type |
A select: Any, then the property types in the scope’s inventory with counts | property_type |
A field the scope already fixes is left out of the form however data-hs-fields is written: a scope with city: Hayden renders no city select, a scope with property_type renders no type select. The city and type lists are counted inside the scope, so a visitor is never offered a choice that would return nothing.
Results are the listings fragment with one extra line above the cards (“12 listings in Hayden under $800,000”, or just “12 listings”) and a link below them, “See all N on search.example.com”, to the full search with the same filters. No matching listing gives the empty fragment with the same link. If the description search is unavailable, the results are filtered without q and a muted line above the cards says so.
Visitors narrow, never widen
Section titled “Visitors narrow, never widen”The scope stays the outer boundary. A visitor’s filters are merged under it: any key the scope sets wins, and the visitor fills in the rest. A scope with status: Active returns Active whatever status is sent; a scope with city: Hayden returns Hayden whatever city is sent. featured, featured_first, the sort and the map area are never readable from the form. Every results row still passes the display policy, and the attribution block is on every results fragment exactly as on the grid.
Visitor values are checked on the server: prices are non-negative and max is not below min, beds and baths are 0 to 10, city and type are at most 80 characters, q is at most 200. Anything outside that is dropped, not errored.
Without JavaScript
Section titled “Without JavaScript”The form is a real <form method="get"> whose action is your search site’s /search page, and its inputs use that page’s parameter names, so a visitor who submits before the loader is ready lands on the full search with their filters applied. With JavaScript, the loader intercepts submit (and changes to the selects, after a short pause) and fetches results into the widget instead; while a fetch is in flight the results area is marked aria-busy and the Search button is disabled.
If a results fetch fails, the results area shows one link to the full search carrying the visitor’s filters, so nothing they typed is lost. If the form itself cannot be fetched, the mount shows data-hs-fallback-href as the grid would.
Usage rows
Section titled “Usage rows”Each results fetch writes a usage row with widget set to search and whether a description was typed (q_present). The description text is never stored. Fetching the form writes nothing; it is the widget starting, not listings being shown.
Height reservation
Section titled “Height reservation”If you would rather set min-height in your own CSS (for example to render a skeleton), this is what the loader computes, in pixels. width is the mount’s width; the column collapse follows the viewport, matching the fragment’s own media queries:
columns = 1 if viewport <= 480px, else min(2, columns) if viewport <= 768px, else columnsrows = ceil(limit / columns)cardWidth = (width - gap * (columns - 1)) / columnschrome = 55 + 12 * ceil(2200 / width)height = rows * (cardWidth * 0.75 + 132) + gap * (rows - 1) + chromegap is --hs-gap (default 20). A card is a 4:3 image over a 132px body (the title is clamped to two lines so the body does not grow). chrome is the title plus the attribution block: 96px of fixed spacing, source line and margins, plus one 18px line for every 2200px of disclaimer text that has to wrap into the mount’s width, which is why it grows as the mount narrows. The smoke suite compares this reservation with the rendered fragment at 960, 768 and 390 pixels wide. The loader drops the reservation once the fragment is in, so a viewport change does not leave stale space behind.
Budget
Section titled “Budget”Six cards, three columns, in a 960px mount at 1x: under 250 KB on the wire in total, of which under 120 KB is imagery. A test in the smoke suite measures a rendered widget and fails above either number. At 2x the imagery roughly doubles and the total stays under 250 KB.
Usage data
Section titled “Usage data”Every fragment request writes one row, whether it was served from the in-process cache or rendered fresh. The row holds the brokerage, the key and scope (and the scope’s slug as text, so history survives a deleted scope), the tenant host that served it, the hostname of the page’s Origin or Referer header, the result (200 rendered cards, 204 rendered the empty fragment, 404 unknown key or scope), whether it was a cache hit, and the requested theme, columns and limit.
Nothing identifies a visitor. No IP address, no user agent, no cookie, no path or query string from the page: the origin is reduced to its hostname before it is stored. This is a record of which sites showed listings and how often, for the brokerage’s own analytics and as a compliance trail, not visitor tracking.
Rows are kept for 180 days. The lead sweep inside the API deletes older rows once a day. The Usage tab under Widgets reads the same table: request counts by day, by scope and by origin, and the most recent requests. An origin that is not in the key’s allowed_origins is marked there, since the list is advisory.
Compliance check
Section titled “Compliance check”A nightly job loads every page registered under a key’s registered_pages, waits for each mount to settle, and asserts that the attribution is present, visible, in the document flow, at least 12px, and no smaller than the card body text. See .gitea/workflows/embed-compliance.yml. A page that no longer carries a mount fails the run.