Skip to content

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.


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.


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.


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.


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.


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.


  • One script per page. A second copy anywhere on the page is a no-op. It initialises on DOMContentLoaded and again on astro: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-height computed 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 fetch to the brokerage domain; images from the platform photo host.
  • A labelled <section> with a heading at data-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-size and color set as inline style so 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" and decoding="async".
  • Street addresses appear in link text and alt only when the listing allows it.

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

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.

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.

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.

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.


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 columns
rows = ceil(limit / columns)
cardWidth = (width - gap * (columns - 1)) / columns
chrome = 55 + 12 * ceil(2200 / width)
height = rows * (cardWidth * 0.75 + 132) + gap * (rows - 1) + chrome

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


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.


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.


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.