← Resources · johnder.com
Resources · Vibe coding

Vibe coding a real estate website: RESO and Repliers reference

Everything an AI builder needs to know to put MLS® listings on a site without leaking keys, breaking compliance or shipping the classic bugs. It covers how the RESO Web API (OData) and the Repliers API work, a field translation table, statuses, paging, photos, maps, caching, IDX vs VOW rules, and prompts you can paste into Lovable or Cursor.

Examples use PropTx AMPRE for RESO and generic boards for Repliers. Environment variables are shown by name only. Nothing here contains a real key.

18 sections100 cards33 copy-paste promptsPress / to filter
For informational purposes only. This is not legal, compliance or professional advice. Do your own research and due diligence, including checking your board/MLS® rules and your data licence terms, before you build anything from it. Read the full disclosure.
01

Start here

Two ways to get MLS® listing data into a site you build with AI. Pick the data source first: filter syntax, IDs, photos, sold-data rules and stats all depend on it.

Basics

RESO Web API vs Repliers at a glance

Also calledRESO vs Repliers, OData vs REST params

RESO Web API is the industry standard way to query MLS® data using OData ($filter, $select, $top…). In Ontario, PropTx serves it as AMPRE at https://query.ampre.ca/odata. Repliers is a listings API with plain query parameters, built-in statistics and an image CDN, at https://api.repliers.io/listings.

QuestionRESO patternRepliers pattern
Base URLhttps://query.ampre.ca/odata (PropTx AMPRE RESO endpoint)https://api.repliers.io/listings
Auth (server only)Authorization: Bearer from env AMPRE_BEARER_TOKENHeader REPLIERS-API-KEY from env REPLIERS_API_KEY (one key per board if you use several, e.g. REPLIERS_KEY_<BOARD>)
Query styleOData: $filter, $select, $top, $skip, $orderby, $count=truePlain query params: status, minPrice, pageNum, resultsPerPage…
Listing ID in URLsListingKey (consumer-facing MLS® # is ListingId)mlsNumber
PhotosSeparate /Media resourceimages[] paths on the listing, served from cdn.repliers.io
RoomsSeparate /PropertyRooms resourcerooms[] on the listing
Sold dataStandardStatus eq 'Closed' + MlsStatusstatus=U + lastStatus=Sld
Stats / aggregatesNo reliable live aggregates. Keep a private Postgres snapshotBuilt in: statistics=…, aggregates=…, listings=false
Security

The universal rule

The browser calls your server or edge function, never the MLS API. The browser gets only public values, such as VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_KEY (Vite + Supabase), NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY (Next.js), and NEXT_PUBLIC_MAPBOX_TOKEN.

RuleAMPRE_BEARER_TOKEN and REPLIERS_API_KEY (or per-board keys) are server or edge secrets only. Never give them a VITE_ or NEXT_PUBLIC_ prefix.
NoteThe AMPRE token is a static, long-lived bearer issued to the brokerage. There is no OAuth flow. Repliers uses the REPLIERS-API-KEY header on every call, with no OAuth and no bearer.
Basics

Words you will hear

TermMeaning
RESO Web APIStandard MLS® API built on OData; resources such as /Property, /Media, /PropertyRooms
ODataQuery language in the URL: $filter, $select, $orderby, $top, $skip, $count
AMPREPropTx's RESO Web API service (query.ampre.ca/odata)
RepliersListings API with /listings, statistics, aggregates and an image CDN
BoardThe MLS® organisation that owns the data (e.g. PROPTX, ITSO, Naples Area MLS)
IDXPublic listing display. On RESO the gate field is InternetEntireListingDisplayYN
VOWVirtual Office Website: sold/leased data for registered consumers who accepted terms
ListingKey / ListingIdRESO system key vs the consumer-facing MLS® number
mlsNumberRepliers' public MLS® number and id
Architecture

Implementation styles (and which to copy)

RESO / OData
Style 1 (recommended)Style 2
ShapeServer builds ODataThin proxy, client builds OData
Edge functionValidates params, builds OData, maps rows to an internal Listing modelPass-through of path + query
Internal modelNormalized Listing typeUI reads raw RESO names
VOWPattern A: server-enforced (401 without a valid user JWT)Often Pattern B: copy only, not enforced
MediaBatched (5 listing keys per /Media call)Often N+1 (one call per card)

Style 2 is acceptable for prototypes only if you add the hardening: allow-listed paths, a $top cap, no HTTP 200 on failure.

Repliers
Style 1 (recommended)Style 2Style 3
ShapeFull server API (Next.js App Router or any SSR framework)Single edge function with action = search, detail or aggregatesOpen pass-through proxy
KeysOne key per boardREPLIERS_API_KEYREPLIERS_API_KEY
Internal modelShared normalized Listing via normalize()Raw Repliers JSONRaw Repliers JSON
VOWPattern A: server-enforced (403 on status=U for non-members)NonePattern B: client blur only (anti-pattern)

Style 3 is useful only as a source of recipes (statistics, radius/CMA comps, address-lookup ladder), not as an architecture.

02

Architecture & server-side secrets

Where the key lives, what the browser is allowed to send, and how to avoid turning your proxy into an open relay on your credentials.

RESOArchitecture

Reference architecture: RESO

diagram
Browser (anon/publishable key; plus x-user-jwt when signed in)
   │  GET {VITE_SUPABASE_URL}/functions/v1/<listings-function>?...
   ▼
Supabase Edge Function (Deno)  ── holds AMPRE_BEARER_TOKEN (edge secret only)
   │  - builds / validates OData
   │  - enforces IDX display filter for anonymous callers
   │  - verifies x-user-jwt with supabase.auth.getUser() before any Closed data
   │  GET https://query.ampre.ca/odata/{Property|Media|PropertyRooms}?...
   │  Authorization: Bearer <AMPRE_BEARER_TOKEN>   Accept: application/json
   ▼
PropTx AMPRE OData
  • The browser sends the Supabase anon/publishable key as both Authorization: Bearer and apikey; the signed-in user's JWT travels separately in x-user-jwt.
  • Photos can be loaded straight from Media.MediaURL in <img>; a proxy is optional.
  • Supabase is the example backend; Next.js route handlers, Cloudflare Workers or Express work the same way.
RepliersArchitecture

Reference architecture: Repliers

diagram
Browser / mobile app
   │  (short-lived HMAC token from GET /api/token, or Supabase anon key for an edge function)
   ▼
Server you control (Next.js route handler or Supabase edge function)
   │  holds REPLIERS_* keys; picks boards; validates params; checks VOW for status=U
   │  GET https://api.repliers.io/listings?...      header REPLIERS-API-KEY: <board key>
   ▼
Repliers API  ──►  images served from https://cdn.repliers.io/{path}?class=small|medium|large
  • Send REPLIERS-API-KEY: <key> on every Repliers call, plus Content-Type: application/json (or Accept: application/json).
  • Keys are server-only. The browser never calls api.repliers.io.
Security

Environment variable names (never values)

NameWherePurpose
AMPRE_BEARER_TOKENEdge secretUpstream RESO bearer. If missing, return HTTP 500 { error: "AMPRE_BEARER_TOKEN not configured" }
REPLIERS_API_KEY / REPLIERS_KEY_<BOARD>Server secretRepliers key(s), one per board on multi-board sites
VITE_SUPABASE_URLBrowserBuilds /functions/v1/... URLs
VITE_SUPABASE_PUBLISHABLE_KEYBrowserAnon key sent as Bearer + apikey
SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEYEdge (injected)JWT verification, service-role reads
INTERNAL_API_SECRET, CRON_SECRETServerHMAC site token, server-to-server and cron auth
NEXT_PUBLIC_MAPBOX_TOKENBrowserMap tiles and geocoding
RESEND_API_KEY, TURNSTILE_SECRET_KEY, NEXT_PUBLIC_TURNSTILE_SITE_KEYServer / browserLead emails and spam protection
RecommendationRead these from the environment at every call site. Don't hardcode the backend URL/key into vite.config.ts define, and keep .env.example names identical to what the code reads. A common failure is .env.example using NEXT_PUBLIC_* names that a Vite app never reads, so changing .env silently does nothing.
RepliersSecurity

Protecting your own API (in front of Repliers)

OptionBrowser → server authNotes
HMAC tokenAuthorization: Bearer <hmac> from GET /api/token (HMAC-SHA256 of the unix timestamp with INTERNAL_API_SECRET, "{unixSeconds}.{hex}", TTL 30 min, cached in sessionStorage), or x-internal-secret server-to-serverIf /api/token is unauthenticated this is anti-scrape friction, not a security boundary. Protect every route (stats, neighbourhoods) consistently.
Supabase edge functionSupabase anon key as Authorization: Bearer + apikeySet verify_jwt explicitly in config.toml
Generic proxyapikey + Authorization: Bearer {session token or anon key}Anti-pattern if it has verify_jwt = false and forwards any subpath and method: an open proxy on your key
SecurityBug

Don't build an open relay

Anti-patternA pass-through with verify_jwt = false and no caller check is an open AMPRE relay: anyone holding the public anon key can query the feed on your credentials.
  • If the proxy is generic, allow only /Property, /Media, /PropertyRooms (or listings on Repliers).
  • Cap $top (e.g. 200) / pageSize; reject $expand and unexpected query keys; require a caller check.
  • Internal service secrets that bypass the VOW gate must stay server-to-server. A public anti-scrape token must not bypass it.
RESOBug

Transport rules for AMPRE

  1. Encode spaces as %20, never +. AMPRE rejects + inside $filter / $orderby. Build with encodeURIComponent; if you proxy, rewrite any + in the raw query to %20, because the WHATWG URL parser turns %20 into + when re-serializing search params.
  2. Escape OData strings by doubling single quotes (names like L'Amoreaux or O'Connor).
  3. Retries: up to 3 attempts; HTTP ≥ 500 and network errors wait 300 × (attempt + 1) ms; return 4xx immediately. Add a token-bucket limiter if you expect bursts.
  4. Failure shape: never return HTTP 200 { error, fallback: true } when every attempt throws; clients will treat it as data. Return 502 JSON.
03

RESO Web API / OData (PropTx AMPRE)

Resources, OData options, the PropTx-specific restrictions to design around, and copy-ready recipes. Encode spaces as %20 when sending.

RESO

Resources and what they are for

ResourceUsed forNotes
/PropertyLists, detail by key, neighbourhood counts, postal lookup, syncList queries use $filter, $select, $top, $skip, $orderby, $count=true
/Property('{ListingKey}')DetailSend $select={DETAIL_SELECT}; with no $select you get ~300 default fields
/MediaCard photos, detail gallery, CMA comp photosSeparate query; $expand is not needed
/PropertyRoomsDetail room table$top=100
/$metadataDebug / schema discovery (CSDL)
/Property?$top=1Debug: see the default field set
/Member, /Office, /OpenHouseAgent pages, office rosters, open-house calendarsOptional; not required for a listings site

$apply (groupby/aggregate) exists, but most sites aggregate from a snapshot table instead (see Market stats).

RESOBug

PropTx restrictions to design around

RestrictionWorkaround
CloseDate is blocked in $filter (error 1108)Sold windows filter on ModificationTimestamp ge {cutoff}T00:00:00Z
MLSAreaDistrictToronto cannot be used in $filter (1108)Scope list queries with CityRegion in (...); derive district for display only
DaysOnMarket is not allowed in $orderbyDOM sorts use ListingContractDate desc/asc
OData groupby on CityRegion ignores in (...) and returns province-wide regionsPage /Property ($top=1000, cap ~8 pages) and count in code
Large Media galleries starve later keys in one OData pageChunks of 5 listing keys per /Media call
RESOSearch

Filter vocabulary

IntentOData clause
Sale vs leaseTransactionType eq 'For Sale' / TransactionType eq 'For Lease'
Residential(PropertyType eq 'Residential Freehold' or PropertyType eq 'Residential Condo & Other')
Condo onlyPropertyType eq 'Residential Condo & Other'
CommercialPropertyType eq 'Commercial'
SubtypePropertySubType eq '…': Detached, Semi-Detached, Att/Row/Townhouse, Condo Townhouse, Condo Apartment, Common Element Condo, Link, Duplex, Cottage, Vacant Land
ActiveStandardStatus eq 'Active'
SoldStandardStatus eq 'Closed' and (MlsStatus eq 'Sold' or MlsStatus eq 'Sold Conditional')
LeasedStandardStatus eq 'Closed' and (MlsStatus eq 'Leased' or MlsStatus eq 'Leased Conditional')
IDX display (anonymous)(InternetEntireListingDisplayYN eq null or InternetEntireListingDisplayYN eq true)
GeographyCityRegion in (...), CityRegion eq '…', contains(City,'…')
Keyword(contains(City,'{q}') or contains(UnparsedAddress,'{q}') or contains(CityRegion,'{q}'))
PriceListPrice ge {n}, ListPrice le {n} (switch to ClosePrice for closed rows)
Beds / baths / parkingBedroomsTotal ge {n}, BathroomsTotalInteger ge {n}, ParkingTotal ge {n}
AgentListAgentKey eq '…' or ListAgentFullName eq '…'
Street (postal lookup)StreetNumber eq '…' and contains(tolower(StreetName), '…')
Sync windowModificationTimestamp ge {YYYY-MM-01}T00:00:00Z (not CloseDate)

Useful orderings: ListingContractDate desc (newest), ListPrice asc/desc, ModificationTimestamp desc (default), ListingKey (stable paging for sync and counts).

RESORecipe

Recipe: active list page

OData · all residential
GET {server}/Property
  ?$top=24
  &$skip=0
  &$count=true
  &$orderby=ModificationTimestamp%20desc
  &$filter=(PropertyType%20eq%20'Residential%20Freehold'%20or%20PropertyType%20eq%20'Residential%20Condo%20%26%20Other')
      %20and%20TransactionType%20eq%20'For%20Sale'
      %20and%20StandardStatus%20eq%20'Active'
  &$select=ListingKey,UnparsedAddress,StreetNumber,StreetName,StreetSuffix,City,CityRegion,StateOrProvince,PostalCode,ListPrice,BedroomsTotal,BathroomsTotalInteger,PropertyType,PropertySubType,TransactionType,StandardStatus,MlsStatus,ListAgentFullName,ListOfficeName,BuildingAreaTotal,LivingAreaRange,ParkingSpaces,OriginalEntryTimestamp,ListingContractDate,ModificationTimestamp,DaysOnMarket
GotchaPut every displayed field in $select. A field the card renders but the query omits is silently blank.
RESORecipe

Recipe: detail page (3 calls in parallel)

OData · detail
GET /odata/Property('{ListingKey}')?$select={DETAIL_SELECT}

GET /odata/Media?$filter=ResourceRecordKey eq '{key}' and MediaCategory eq 'Photo'
    &$select=MediaKey,MediaURL,MediaCategory,ImageSizeDescription,Order,PreferredPhotoYN,ResourceRecordKey
    &$top=200

GET /odata/PropertyRooms?$filter=ListingKey eq '{key}'&$top=100

Run the three with Promise.all. Sort rooms by Order.

RESOCompliance

ListingKey vs ListingId

  • ListingKey is the stable system key: use it for the id, URL key, favourites key and all /Media / /PropertyRooms joins.
  • ListingId is the board's consumer-facing MLS® number.
GotchaShowing ListingKey as the "MLS® #" is a common bug. Show ListingId to consumers and keep ListingKey as the route key.
RESOBug

Field names that look right but don't exist in AMPRE

These show up in some RESO client code and generic examples but are not fields in the AMPRE Property entity. Selecting or reading them always yields null.

NameClosest real AMPRE field(s)
Appliances(none)
GarageSpacesGarageParkingSpaces / ParkingSpaces
HeatingHeatType + HeatSource
LivingAreaLivingAreaRange (range string only)
OnMarketDate(none): use ListingContractDate / OriginalEntryTimestamp
StoriesLegalStories
StoriesTotal(none)

Field sets can change as PropTx updates the feed. Re-pull $metadata before relying on a rarely used field. Many values are arrays (e.g. Cooling, CommunityFeatures, ArchitecturalStyle): join them for display.

04

Repliers API

Endpoints, the /listings parameter reference, and the quirks that cause most Repliers bugs.

Repliers

Endpoints

CallShape
SearchGET https://api.repliers.io/listings?{params}
One listing by pathGET https://api.repliers.io/listings/{mlsNumber}?boardId={id} (validate mlsNumber against /^[A-Za-z0-9]+$/, else 400)
One listing by queryGET /listings?mlsNumber={mls}&status=A (or U) &fields=*,raw
StatisticsGET /listings?...&statistics=...&listings=false (or resultsPerPage=1)
AggregatesGET /listings?aggregates=address.area,address.city&listings=false&status=A
LocationsGET https://api.repliers.io/listings/locations (place tree; cache ~24 h)

Search response shape: { listings, count, numPages, page, pageSize }.

RepliersSearch

/listings parameter reference

ParamValues / notes
statusA active, U unavailable/off-market. Default A
lastStatusSld (sold), Lsd (leased), Ter, Exp, Sus, New, …
typesale, lease
classresidential, condo, commercial (repeatable); some code uses ResidentialProperty, CondoProperty: compare case-insensitively
propertyTypeBoard vocabulary, e.g. Detached, Semi-Detached, Att/Row/Twnhouse, Condo Apt / Condo Apartment, Condo Townhouse, Link, Duplex… (repeat = OR)
raw.{Field}Board raw fields must keep the raw. prefix, e.g. raw.PropertySubType. The bare key is silently ignored
minPrice, maxPrice
minBeds, maxBeds, minBaths, maxBathsminBeds filters the combined beds count (3+1 = 4). Avoid minBedrooms (excludes "+" rooms); minBathrooms is not recognized
minSqft, maxSqft
city, area, neighborhood, postalCodeExact board strings. Large municipal "areas" may be rejected as a neighbourhood
streetNumber, streetName, unitNumberAddress match
lat, long, radiusRepliers uses long, not lng; radius in km
mapGeoJSON polygon string. When set, do not also send city/neighborhood/postal
searchFree text. Usually send no searchFields (constraining them breaks multi-token addresses)
brokerageExact office/brokerage string, e.g. ACME REALTY
agentIdAn agent's listings
mlsNumber, boardIdDetail lookups
hasImagestrue
minListDate, maxListDate, minSoldDate, maxSoldDateYYYY-MM-DD
sortBye.g. updatedOnDesc (incremental sync, default browse), createdOnAsc (backfill/sweep)
pageNum, resultsPerPagepage is silently ignored. Treat 100 as the practical page-size cap for bulk walks
fields*, *,raw, or a list (mlsNumber,agents.agentId,agents.name). raw is omitted unless requested
statisticse.g. avg-soldPrice,med-soldPrice,avg-daysOnMarket,med-daysOnMarket,cnt-closed,pct-aboveBelowList,grp-mth,cnt-new
aggregatese.g. address.area,address.city,address.neighborhood
listingsfalse to return only statistics/aggregates
RepliersBug

Quirks to design around

  1. pageNum, not page. Sending page returns page 1 forever.
  2. Past-the-end pages return HTTP 400 with a body matching /pageNum/i; treat that as an empty page.
  3. Array params are OR (propertyType=Detached&propertyType=Semi-Detached).
  4. status=U ≠ sold. It includes expired, terminated, suspended and leased rows. Add lastStatus=Sld for true sales.
  5. Every statistics query needs type=sale. Otherwise lease rents collapse the averages.
  6. raw. prefix is mandatory for raw-field filters; mixing a raw filter with propertyType ANDs and can return zero unless widened.
  7. Rate limit is shared across board keys on one account: walk boards sequentially in bulk jobs, add ~220 ms between pages and retry 429 up to 5 times (1 s, 2 s, 4 s, 8 s, 16 s).
  8. Exact-string geography. city= must match the board's municipality string; a geocoder bbox sent as map bounds is often more reliable.
RepliersRecipe

Recipe: detail lookups and the status ladder

Repliers · detail
# multi-board: each board in order, first non-empty wins; status=U only for VOW members
GET /listings?mlsNumber={MLS}&status=A&fields=*,raw

# by path
GET /listings/{mlsNumber}?boardId={boardId}

# status ladder (e.g. hydrating saved/CMA cards), stop at the first non-empty
/listings?mlsNumber={mls}&status=A
/listings?mlsNumber={mls}&status=U
/listings?mlsNumber={mls}&status=U&lastStatus=Ter
/listings?mlsNumber={mls}&status=U&lastStatus=Exp
/listings?mlsNumber={mls}&status=U&lastStatus=Sus
/listings?mlsNumber={mls}
RepliersRecipe

Recipe: radius comparables (CMA)

Repliers · comps
GET https://api.repliers.io/listings
  ?resultsPerPage=30&pageNum=1&hasImages=true
  &lat={lat}&long={lng}&radius={1.5|2.5|3.0|5.0}
  &status=U&lastStatus=Sld&type=sale
  &minSoldDate={YYYY-MM-DD}
  &propertyType={mapped type}&class={residential|condo}
  &minBeds={n}&maxBeds={n}
  &minSqft={n}&maxSqft={n}       # omitted on the widest tier

Tiers (stop at the first with ≥ 6 rows): tight 1.5 km / 6 months / ±15% sqft / ±1 bed; broad 2.5 / 9 / ±20% / ±1; wider 3.0 / 12 / ±30% / ±2; widest 5.0 / 18 / none / ±2. Run a same-building tier first when there is a unit (streetNumber, streetName, city, 24 months, beds ±1); ≥ 3 rows replaces the radius search.

RepliersRecipe

Recipe: address match ladder

Repliers · address
1) /listings?resultsPerPage=10&pageNum=1&streetNumber={n}&streetName={normalized}&city={city}&unitNumber={unit}
2) same without unitNumber
3) /listings?resultsPerPage=10&pageNum=1&streetName={normalized}&city={city}&lat={lat}&long={lng}&radius=0.15

No status filter. Normalize suffixes first (street→st, avenue→ave, road→rd, drive→dr, boulevard→blvd, crescent→cres), in every call that uses street fields.

RepliersArchitecture

Multi-board fan-out

idMarketEnv var (example)Attribution rule (example)
board_a (e.g. PROPTX)ontarioREPLIERS_KEY_BOARD_Aagent (IDX/VOW agents[] is often empty)
board_b (e.g. ITSO)ontarioREPLIERS_KEY_BOARD_Bbrokerage-only: display the brokerage name, hide agent names
board_c (e.g. Naples Area MLS)floridaREPLIERS_KEY_BOARD_Cagent
  • Fan out with Promise.allSettled; a failed board goes into _meta.error. Concatenate in board order; dedupe by mlsNumber across boards (with several boards use board + mlsNumber as the key).
  • Page 2 = page 2 of each board; totalCount = sum; hasMore if any board has more. Sorting across boards is only correct on what has loaded.
  • Per-board property-type maps: a PROPTX-style board uses propertyType; an ITSO-style board uses raw.ITSO_PropertyAttached=Detached / Semi Detached (no hyphen); a Naples-style board uses lowercase raw.PropertySubType. Skip a board that can't satisfy a selected type.
RepliersSearch

Own-brokerage-first ordering

ApproachMechanismCaveat
Two-query mergeBrokerage-filtered and unfiltered searches in parallel, dedupe on ${mlsNumber}-${resource}, priority first, slice the pagePage 5 at 24/page pulls 120 + 144 rows every time; pager can open empty pages
In-page sortDedupe by mlsNumber (first wins), then stable-sort own-brokerage rows first inside the pageCross-board copies collapse
Featured panelFetch ~27 rows, own-brokerage first then listDate desc, drop near-duplicate addresses, show 9
RecommendationMatch the brokerage the same way the API filters it (exact string), or normalize both sides. Page the merge with a real cursor, or accept "own brokerage first" as a sort within each page.
05

Field translation table

The same idea under two names. Map raw fields into one normalized Listing type on the server; the browser never sees raw RESO or raw Repliers JSON.

Fields

RESO ↔ Repliers ↔ what you display

Also calledfield mapping, normalize(), transformListing
DisplayRESO field(s)Repliers path(s)
id / route keyListingKey${board.id}-${mlsNumber} (or mlsNumber on one board)
MLS® number shownListingIdmlsNumber
AddressStreetNumber + StreetName + StreetSuffix + UnitNumber/ApartmentNumber; else UnparsedAddressaddress.streetNumber streetName streetSuffix streetDirection (+ unitNumber)
Area lineCityRegion, Cityaddress.city + ", " + address.state; address.neighborhood, address.area
PriceListPrice; ClosePrice when closedlistPrice (strip non-numeric) else price; soldPrice ?? listPrice on sold cards
StatusStandardStatus, MlsStatusstatus, lastStatus
Sale vs leaseTransactionType contains "lease"type / listingType contains lease or rent
BedsBedroomsTotal; dens = BedroomsTotal − BedroomsAboveGrade ("2+1")details.numBedrooms + details.numBedroomsPlus
BathsBathroomsTotalInteger (no half-bath field)details.numBathrooms + details.numBathroomsPlus (Plus often empty)
SizeLivingAreaRange (e.g. "700-799"), BuildingAreaTotal. LivingArea is not an AMPRE fielddetails.sqft (may be a "min-max" string)
Days on marketDaysOnMarket, else days since ListingContractDate / OriginalEntryTimestampdaysOnMarket
Listed dateListingContractDatelistDate
RemarksPublicRemarksdetails.description
Fees / taxesAssociationFee (+ AssociationFeeFrequency), TaxAnnualAmount, TaxYeardetails.condoFee else details.HOAFee; taxes.annualAmount
Year builtYearBuilt, else ApproximateAgedetails.yearBuilt
Parking / garageParkingTotal / ParkingSpaces, GarageType, ParkingType1/2details.numParkingSpaces, details.garage, details.numGarageSpaces
Heating / coolingHeatType + HeatSource; Cooling (arrays; Heating is not an AMPRE field)details.heating + heatingFuel; details.airConditioning
BasementBasementdetails.basement1 + basement2
FeaturesInteriorFeatures, ExteriorFeatures, CommunityFeatures (cap chips, e.g. 16)details.extras, inclusions, exclusions
LotLotSizeDimensions, else LotWidth × LotDepth + LotSizeUnitslot.width, lot.depth
CoordinatesLatitude, Longitude (must be in $select)map.latitude, map.longitude (drop 0,0 and non-finite)
Photos/Media → MediaURLimages[] → https://cdn.repliers.io/{path}?class=medium
Rooms/PropertyRooms: RoomType/RoomDescription, RoomLevel, RoomLength, RoomWidth, RoomLengthWidthUnits, RoomFeature1/2/3, Orderrooms[]: description, level, length, width, features/features2/features3
AttributionListOfficeName, ListAgentFullNameoffice.brokerageName or agents[0].officeName; co-list from agents[].brokerage.name + raw.CoListOfficeName/3/4
Gotcha$select discipline. A mapper that reads Latitude, Longitude, YearBuilt, ParkingType1/2 without selecting them gets empty values every time. Generate $select from the same field list the mapper reads.
GotchaRanges. Number("700-799") is NaN. Parse sqft ranges explicitly (low end or midpoint). Rooms: RESO ft↔m uses × 0.3048 when RoomLengthWidthUnits starts with "f"; Repliers rooms are treated as metres, ft = m × 3.281.
Fields

Response envelopes

RESO / OData

List: read @odata.count with $count=true. Your function returns { listings, page, numPages, count }.

Repliers

Search: { listings, count, numPages, page, pageSize }. Aggregates: { aggregates: { address: { area: {name:count}, city: {…}, neighborhood: {…} } }, count }.

RepliersFields

raw.* fields on Repliers

raw holds board-native (often RESO-named) fields passed through from the source board. It is present only when fields includes raw, and filtering on a raw field requires the raw. prefix.

  • ITSO (Ontario): raw.ITSO_PropertyAttached with values Detached, Semi Detached.
  • PROPTX (Ontario): raw.PropertySubType, raw.CoListOfficeName, raw.CoListOfficeName3, raw.CoListOfficeName4. IDX agents[] is often empty, so co-list office names are the fallback.
  • Naples Area MLS (Florida): lowercase raw.PropertySubType values.
RecommendationTo see the full shape for your account, call GET /listings/{mlsNumber}?fields=*,raw server-side for one listing per board.
06

Statuses: active vs off-market vs sold

A frequent data bug: treating off-market ("not active") as sold.

Status

Status models side by side

MeaningRESO / AMPRERepliers
ActiveStandardStatus eq 'Active'status=A (default)
Off-market / unavailableStandardStatus eq 'Closed' plus MlsStatusstatus=U: includes expired (Exp), terminated (Ter), suspended (Sus), leased (Lsd) and sold
SoldStandardStatus eq 'Closed' and (MlsStatus eq 'Sold' or MlsStatus eq 'Sold Conditional')status=U&lastStatus=Sld
LeasedStandardStatus eq 'Closed' and (MlsStatus eq 'Leased' or MlsStatus eq 'Leased Conditional')lastStatus=Lsd
Price to showClosePrice on closed rowssoldPrice (with soldDate)
Anti-patternOn Repliers, status=U is not "sold". A public "Show Sold" that sends status=U without lastStatus=Sld shows expired and terminated listings too. Add lastStatus=Sld in stats, CMA comps, "recently sold" widgets, digests and toggles alike.
StatusUI

Badge rules

BadgeRESORepliers
For Sale / For LeaseTransactionType contains "lease"type contains lease or rent
Sold / LeasedStandardStatus Closed or MlsStatus Sold/LeasedlastStatus / status matches sld, sold, u, closed, leased
NewDaysOnMarket 0 (or ≤ 7), else "{dom}d"daysOnMarket <= 0 or listDate within 24 h, and not sold
StatusCompliance

Sold is VOW data

Sold and leased rows (prices, dates) are only for registered consumers who accepted the VOW terms, and must be withheld on the server. See Compliance: IDX vs VOW.

RuleCheck VOW access before calling the MLS API. Anonymous status=U / Closed requests get 401 or 403 with no soldPrice or ClosePrice in the body.
07

Paging & search

Filters both APIs understand, how to page, and the UI behaviours that make search feel solid.

Search

Paging

RESO / OData
  • $top = page size, $skip = (page − 1) × pageSize, $count=true → read @odata.count.
  • numPages = ceil(@odata.count / pageSize). If count is missing and the page was full, fall back to page + 1.
  • Bulk walks (sync, neighbourhood counts) use $top=1000 with $skip, ordered by ListingKey; cap pages (e.g. 30 for sync ≈ 30k rows, 8 for counts).
Repliers
  • pageNum + resultsPerPage. page is ignored.
  • A page past the end returns 400 mentioning pageNum: treat as empty.
  • Bulk walks: page size 100, ~220 ms between pages, 429 backoff 1, 2, 4, 8, 16 s.
GotchaPost-filtering breaks counts. Any filter applied after $top/$skip (e.g. sqft or "deal" post-filters) leaves count and numPages at the pre-filter value and makes pages look empty. Filter on the server.
Paging stylePage sizeNotes
Previous / Next12Simplest. Show the total count from the API.
Numbered pages24All pages if ≤ 7; otherwise first, last, current ±1, with ellipses. Scroll to top on change.
Load more24 (150 in map view)Appends pageNum+1. Show {loaded} of {total}.
Search

Filter params side by side

FilterRESO $filterRepliers param
Text searchcontains(City,'{q}') or contains(UnparsedAddress,'{q}') or contains(CityRegion,'{q}')search={q} (no searchFields)
Min / max priceListPrice ge / le (ClosePrice for sold)minPrice / maxPrice
BedsBedroomsTotal ge {n}minBeds / maxBeds (combined count, 3+1 = 4)
BathsBathroomsTotalInteger ge {n}minBaths / maxBaths
ParkingParkingTotal ge {n}not seen in the reference implementations
Sale vs leaseTransactionType eq 'For Sale' / 'For Lease'type=sale / type=lease
Property typePropertySubType eq '…'; category via PropertyTypepropertyType=…, class= (repeat = OR); raw. for board fields
AreaCityRegion in (…) / eq '…'city, neighborhood, area, postalCode
Radius / mapnot seen in the reference implementationslat + long + radius (km), or map= polygon
Sort$orderby=ListingContractDate desc, ListPrice asc/desc, default ModificationTimestamp descsortBy=updatedOnDesc, createdOnAsc

Server-side validation that worked well: page ≥ 1, pageSize clamped to 10–200, type ∈ sale/lease, status ∈ A/U, class ∈ residential/condo/commercial, city and neighborhood ≤ 120 characters with no <>;, postal code ≤ 20. Invalid bounds return 400.

SearchUI

Search UI behaviours

  • URL sync: write every filter and the page number to the query string and restore them on load (two-way, not inbound-only).
  • Debounce: 350 ms on text, 600 ms on price inputs, 500 ms on map moves. Reset to page 1 on any filter change.
  • Sold toggle: a separate button. When signed out it opens the login/register modal and does not send the request.
  • Rent mode sends type=lease and switches Sold off. Clear all resets every filter and the page.
  • Keep one neighbourhood whitelist shared by the server and the router so links never 404.
GotchaDead filters and mismatched sorts. One implementation sent a property-type param the server never read; another sent Repliers-style sort names to a RESO function, so every sort silently fell back to the default. Test that every control changes the result set.
Search

Autocomplete / typeahead options

OptionHow it worksUse when
Local catalog + Repliers search= fallbackGET /api/listings/search?q=&limit=8 (2–120 chars, limit 1–15) against a Postgres listing_catalog of active listings; fall back to live search= if the table is missing or empty. Rank exact MLS > MLS prefix > address prefix > contains.Listings in the dropdown
Mapbox placesGeocoder with types=place,postcode,neighborhood,locality&limit=4; skip when the input looks like an MLS number. Picking a place applies its bbox as map bounds.Place search
Snapshot street suggest (RESO)Query your private Postgres snapshot. No live AMPRE call per keystroke. Min 2 chars, 300 ms debounce.Address capture on RESO
Text contains (RESO)The search box becomes contains on City / UnparsedAddress / CityRegion, debounced 350 ms.A simple RESO search box
GotchaCatalogs are often active-only, so sold or leased addresses won't autocomplete.
08

Photos & media

Fast, correct photos on cards and detail pages, with no MLS® credentials in the browser.

Photos

Photo rules side by side

TopicRESO /MediaRepliers CDN
SourceGET /odata/Media?$filter=ResourceRecordKey in (…) and MediaCategory eq 'Photo'images[] paths on the listing
SizeImageSizeDescription eq 'Largest'?class=medium (cards), ?class=large (lightbox), ?class=small (mini cards), or ?w=800
Cover photoPreferredPhotoYN (true, "true", "Y", 1), then lowest Orderimages[0]
BatchingChunks of 5 keys per call, $orderby=ResourceRecordKey,Order, $top=800Not needed; images come with the row
CapsCards: 5. Detail: all Largest ($top=200)Up to 20
Absolute vs relativeMediaURL is a full https URLPrefix https://cdn.repliers.io/ unless the string already starts with http (one shared helper)
ProxyNot needed. An optional image proxy must allow-list https://*.ampre.ca/ only, send no bearer, and set Cache-Control: public, max-age=3600Not needed. With Next.js, add cdn.repliers.io to next/image remote patterns
StorageDon't store photos (licensing and freshness)Don't store photos. A saved-listing snapshot may keep one image URL
FallbackTry the next URL on error, then a placeholder. Drop broken URLs from the galleryPlaceholder image or camera icon. Don't ship stock-photo fallbacks
RESOPhotosRecipe

Recipe: batched card photos

OData · 5 keys per call
GET /odata/Media
  ?$filter=ResourceRecordKey in ('k1','k2',...) and MediaCategory eq 'Photo'
  &$select=MediaURL,Order,ImageSizeDescription,PreferredPhotoYN,ResourceRecordKey
  &$orderby=ResourceRecordKey,Order
  &$top=800
GotchaN+1 photos. One /Media call per card means 24 calls per page. A page of n cards should issue ceil(n/5) Media calls.
PhotosUI

Photo UI behaviour

  • Lazy-load card images (loading="lazy"). Card carousel of up to 5–8 photos with dots.
  • Detail: thumbnails plus a lightbox with arrow keys and Escape; make the "+N more" overflow clickable.
  • Photo URLs are public CDN URLs. Never attach the API token to image requests.
09

Maps & geography

Map views, bounds search and place lookups. Only render a map when you have real coordinates.

Maps

Coordinates

RESO / OData

Latitude, Longitude. They must be in $select or the map has nothing to plot. Map-bounds and radius search were not seen in the RESO reference implementations; scope by CityRegion instead.

Repliers

map.latitude, map.longitude. Drop pins that are non-finite or both 0. The query param is long, not lng.

RuleNo map until Latitude and Longitude are in $select (RESO), and no map without real coordinates in general.
RepliersMapsRecipe

Recipe: map bounds → Repliers polygon

bounds conversion
bounds=swLng,swLat,neLng,neLat   (website param)

→ map=[[[swLng,swLat],[neLng,swLat],[neLng,neLat],[swLng,neLat],[swLng,swLat]]]
  • When map is set, drop city, neighborhood and postalCode.
  • Invalid bounds → 400.
  • Map GL price-pill markers; popup with address/city/brokerage/photo (escape strings); viewport changes debounced 500 ms become bounds; leaving map view clears bounds.
  • Page size ~150 in map view vs 24 in list view.
Maps

Places and exact-string geography

city= must match the board's municipality string exactly, and some boards file many towns under one municipality name. Picking a place from a geocoder (e.g. Mapbox with NEXT_PUBLIC_MAPBOX_TOKEN) and sending its bbox as map bounds is often more reliable.

Repliers also offers GET /listings/locations (a place tree; cache ~24 h) and aggregates=address.area,address.city for dropdowns.

10

Caching & performance

Numbers that worked in the reference implementations. Cache on your server, and never cache sold data where an anonymous user could read it.

Caching

What to cache, and for how long

WhatSetting seen
Repliers /listings responses (server client)cache 300 s
Repliers /listings/locations place tree~24 h
Client query cache (TanStack Query)staleTime 5 minutes for listings, 15 for neighbourhoods
Typeahead responsesCache-Control: private, max-age=15, stale-while-revalidate=60
Optional RESO image proxyCache-Control: public, max-age=3600
RESO mediaCache media by ListingKey
CachingCompliance

Cache keys must include who is asking

  • Query keys include the auth identity: ["listing", key, userId] vs "anon". Invalidate ["listing", key] when auth state changes.
  • If you use SSR loaders, prefetch with anonymous headers only (never a JWT) and dehydrate. Sold HTML is never pre-rendered.
GotchaStale anonymous cache. If listing queries aren't invalidated on sign-in/out, users see the wrong gate state.
CachingArchitecture

Snapshots and catalogs (optional)

RESO / OData
  • A private Postgres snapshot (RLS on, no public policies) for aggregates and autocomplete only; live pages stay on AMPRE.
  • Sync job: POST { "months": 3 }, service role or agent only, pages of 1000 ordered by ListingKey (max 30), upsert in batches of 500, never photos.
  • Sold window uses ModificationTimestamp ge {cutoff} because CloseDate can't be filtered.
Repliers
  • listing_catalog of active listings for typeahead and sitemaps; public read where status = A; writes via service role.
  • Cron: sync every 10 min (backfill createdOnAsc, then incremental updatedOnDesc to a watermark), sweep every 6 h. Don't advance the watermark if the page cap is hit.
  • Photos and agent PII are not stored. Sold listings never enter the catalog, sitemap or typeahead.
RecommendationCommit the schedule in a migration (e.g. pg_cron + pg_net) or document the external scheduler. Don't leave it only in a README.
11

Compliance: IDX vs VOW

Active listings can be public (IDX). Sold and leased data is VOW data: registered users only, enforced on the server, with stored terms acceptance. Always confirm the rules with your own board and data licence.

Compliance

Access matrix

Also calledIDX display rules, VOW gating, sold data behind sign-in
DataAnonymousSigned-in VOW user (confirmed email + stored terms)
Active, InternetEntireListingDisplayYN null/true (RESO) / status=A (Repliers)ShownShown
InternetEntireListingDisplayYN === false (RESO)Dropped from lists; detail 401 vowRequiredShown
Closed Sold / Leased (RESO)List 401 before AMPRE is called; detail 401 { vowRequired: true }Shown
status=U (Repliers)403 { error: "Sold listing data is available to VOW members only. Please sign in." }Allowed
Market statsAggregates only (no rows)Same
Compliance

Four approaches seen, and which ones work

ApproachHow it worksVerdict
Server 401 on a user JWT (RESO)Edge function verifies a user JWT header; anonymous closed/hidden requests get 401 before the MLS API is called; SSR uses anonymous credentials✅ Strong. Gap seen: *any* signed-in user unlocked; email confirmation and terms weren't checked
Server 403 on session + terms flag (Repliers)Server-only isVowMember() requires a session and profiles.vow_terms_accepted_at; sold pages are noindex✅ Strong. Gap seen: status=U without lastStatus=Sld
CSS blur + localStorage flagSold prices are in the JSON and the DOM, only blurred; proxy is public❌ Not a gate. Data leaks
Copy + checkbox onlyTerms text exists, but the Sold toggle sends the closed filter for everyone through a public proxy❌ Unenforced
ComplianceSecurity

The server check

RESO / OData

hasVowAccess(req): read x-user-jwt, verify with supabase.auth.getUser(jwt); require a user, email_confirmed_at, and a stored profiles.tos_accepted_at (+ tos_version). Never trust the anon Authorization header as the user.

Repliers

isVowMember(): cookie session user via auth.getUser() and profiles.vow_terms_accepted_at non-null; any error → false. Enforce it in every listing route and again inside the shared getListing() helper. The site's HMAC token does not unlock sold data.

Compliance

Registration and stored terms acceptance

  • Auth modal with a required Terms/VOW checkbox. Expandable VOW text covers the consumer–broker relationship, bona fide interest, personal non-commercial use and board ownership of MLS® data.
  • Store the acceptance timestamp and version server-side (profiles.tos_accepted_at / tos_version, or set vow_terms_accepted_at from signup metadata terms: "true" in a new-user trigger).
  • Email confirmation redirects to a noindex confirmation page (e.g. /vow-confirmed) that strips the URL hash.
GotchaTOS lost on email confirmation. If confirmation delays the session, write the terms acceptance on the confirm landing page. 401 redirects home: on a detail 401, open the registration modal instead.
ComplianceRESO

IDX display flags

  • InternetEntireListingDisplayYN is the IDX gate: null/true is public; false is VOW-only. Anonymous queries add (InternetEntireListingDisplayYN eq null or InternetEntireListingDisplayYN eq true).
  • InternetAddressDisplayYN: a common gap is selecting it but never applying it. Decide explicitly whether to honour it when building the address, and enforce it or document it as intentionally unused.
  • Anonymous HTML must never contain a closed listing or a listing with InternetEntireListingDisplayYN=false.
Compliance

Brokerage attribution

  • Show the listing brokerage on detail: "Listed by {ListOfficeName}" (RESO) or "Listed By (Primary)" / "Co-Listed By" from office.brokerageName, agents[].brokerage.name and raw.CoListOfficeName (Repliers).
  • Some boards are brokerage-only attribution: display the brokerage name and hide agent names (set agent "" and presentedBy to your brokerage display name).
  • Never show agent email or cell unless the board allows it.
  • Site broker vs listing brokerage: if you show your own agent, label them as the presenting broker and still show the listing brokerage.
ComplianceSEO

SEO for gated pages

  • RESO: noindex whenever the server detail fetch fails, including a VOW 401.
  • Repliers: noindex unless status is A; sold detail pages are noindex.
  • Sitemaps list active listings only.
12

Listing detail page, leads & favourites

One page per listing with a stable, SEO-friendly URL, and leads that keep the listing context.

DetailSEO

Slug patterns

PatternExample shapeID parsing
RESO address slug + key/listing/{streetNumber}-{streetName}-{unit}-{beds}bed-{baths}bath-condo--{ListingKey}Text after the last -- → ListingKey. The double dash keeps keys safe from address hyphens
RESO bare key/listings/{encodeURIComponent(ListingKey)}Works, but no SEO address
Repliers address slug + MLS/listings/123-main-st-X1234567Last hyphen segment, uppercased. Permanently redirect bare or legacy slugs to the canonical slug
RecommendationAn address slug ending in the lookup ID, plus a permanent redirect to the canonical slug.
DetailUI

Detail page sections

  • Gallery: large photo + thumbnails, "+N" overflow, lightbox with arrows and Escape (RESO); mobile carousel, desktop mosaic 1 + 4, lightbox at class=large with scroll lock (Repliers).
  • Header: address H1, area, status badge, price (sold price + SOLD badge with sold date when sold and permitted).
  • Key stats, remarks, a facts grid that skips empty values, maintenance fee, taxes + year, rooms table with ft/m toggle.
  • Attribution, Book a Visit / showing request (active only), favourite, Report an Inaccuracy.
  • SEO: per-listing title, description, og:image = first photo, canonical URL, BreadcrumbList JSON-LD.
Anti-patternNever invent data. No placeholder schools, fake neighbourhood blurbs, or "similar listings" without a real query. Never let a chatbot claim listings are simulated or invent prices.
Leads

Leads and inquiries

  • Inquiry/showing form → POST /api/inquiry: honeypot field, reject submissions faster than 3 s or older than 6 h, ~15/hour/IP, optional Turnstile.
  • Store with the service role (RLS on, no public read). Carry the listing ID, address and absolute URL.
  • Resolve recipients on the server (roster by posted agent IDs, else office region, else a default lead manager from config). Never accept a recipient email from the client; keep addresses in env, not code.
  • Show success only after the request actually succeeded.
Leads

Favourites / saved listings

  • saved_listings(user_id, mls_number, address, price, image, beds, baths, slug) with a unique (user_id, mls_number) pair and owner-only RLS, keyed by the same ID as the detail slug.
  • Snapshot columns let saved cards render after the listing leaves the feed.
  • Load favourites by their IDs, including sold ones after sign-in, never by intersecting with "the first N active listings".
13

Market stats & aggregates

Repliers has built-in statistics. RESO effectively doesn't, so you aggregate from a private snapshot.

RepliersStats

Repliers statistics

Repliers · statistics
GET /listings?city={city}&propertyType={label}&type=sale&status=U&lastStatus=Sld
    &minSoldDate={12 months back}
    &statistics=avg-soldPrice,med-soldPrice,avg-daysOnMarket,med-daysOnMarket,cnt-closed,pct-aboveBelowList
    &listings=false

# monthly sold
...&statistics=grp-mth,avg-soldPrice,med-soldPrice,cnt-closed,avg-daysOnMarket&listings=false
# monthly new listings
GET /listings?city={city}&propertyType={label}&type=sale&minListDate={date}&statistics=grp-mth,cnt-new&listings=false

Read statistics.soldPrice.avg|med, statistics.daysOnMarket.avg|med, statistics.closed.count, statistics.aboveBelowList.below|above; monthly statistics.soldPrice.mth["YYYY-MM"].avg, closed.mth, new.mth (number or { count }).

GotchaEvery stats call includes type=sale (a big-city median in the low thousands means it was dropped). If a neighbourhood cohort has fewer than 24 sales over 12 months, fall back to city and say so. Label approximations such as sale-to-list derived from aboveBelowList.
RESOStats

RESO: snapshot stats

OData groupby on CityRegion ignored in (…) and CloseDate is blocked in $filter, so there are effectively no reliable live aggregates. Sync to a private snapshot, then serve a public market-stats function that reads only that table.

It returns avgSoldPrice, avgListPrice, avgNewListPrice, avgDOM, totalSold, totalNew, snlr = round(sold / new × 100), marketStatus (under 40 Buyer's, over 60 Seller's, else Balanced) and monthly buckets for 3/6/9/12/18/24 months.

RuleStats endpoints return aggregates only, never raw rows, so public stats don't leak VOW records.
14

Common bugs

The failures that showed up again and again in real implementations.

RESOBug

RESO bugs

  • + instead of %20 in $filter / $orderby (AMPRE rejects it; proxies re-serialize %20 to +).
  • Unescaped single quotes in OData strings.
  • CloseDate or MLSAreaDistrictToronto in $filter (error 1108); DaysOnMarket in $orderby.
  • Displayed fields missing from $select, or non-AMPRE names (LivingArea, OnMarketDate, Heating, Stories…) in the mapper.
  • One /Media call per card instead of batches of 5.
  • ListingKey shown as the MLS® number.
  • Token mismatch: the mapper wrote "Yes" while the page checked for "Y", so fee-includes chips never rendered.
  • Proxy returns HTTP 200 { error, fallback: true } on failure, and it gets rendered as a listing.
RepliersBug

Repliers bugs

  • Sending page instead of pageNum (page 1 forever).
  • Treating the past-the-end 400 as an error instead of an empty page.
  • status=U used as "sold" without lastStatus=Sld.
  • Stats without type=sale.
  • Raw filters without the raw. prefix; per-board vocabularies (hyphens, case) ignored.
  • Beds not computed as numBedrooms + numBedroomsPlus, so minBeds results look wrong.
  • Number() on a sqft range string.
  • lng instead of long.
  • Class compared case-sensitively (residential vs RESIDENTIALPROPERTY).
  • Currency not set per market (e.g. USD for Florida rows).
UIBug

UI and app bugs

  • Showing a count larger than what the user can reach (one implementation requested 24 rows, rendered 9, and had no next page).
  • Slicing rows to a multiple of the column count, which hides listings.
  • target="_blank" on every card.
  • Filters not written to the URL.
  • A form that toasts success without sending anything.
  • Sold data hidden with CSS blur while the JSON contains it.
  • README / agent docs describing a framework that isn't actually in the repo.
15

Before you ship

The shared checklist for any new listings site, RESO or Repliers.

Security

Security and data

  • MLS® credentials exist only as server or edge secrets. A search of the client bundle finds none.
  • The browser calls only your API or edge function, never query.ampre.ca or api.repliers.io.
  • The proxy allow-lists paths, caps $top / pageSize, rejects $expand and unexpected params.
  • A total upstream failure returns 5xx, never HTTP 200 with an error body.
  • RESO queries encode spaces as %20 and escape quotes.
Compliance

VOW / compliance

  • Sold and leased data is gated on the server (401/403) by user + confirmed email + terms timestamp. No CSS-only blur.
  • RESO anonymous queries apply the InternetEntireListingDisplayYN rule. Repliers sold queries use lastStatus=Sld.
  • Anonymous SSR HTML never contains a closed listing. Sold detail pages are noindex.
  • Footer: data source/board, "deemed reliable but not guaranteed", brokerage, "not intended to solicit buyers or sellers currently under contract". Terms, Privacy and VOW terms are linked.
  • The listing brokerage (and co-list brokerage when available) is shown on detail.
Leads

Leads and ops

  • Inquiries store the listing ID and address and email server-resolved recipients. The spam guard is on.
  • Favourites are keyed by the slug ID with owner-only RLS.
  • Sync and cron schedules are committed, if you rely on a snapshot or catalog.
  • Repliers bulk walkers: pageNum, page size 100, ~220 ms between pages, 429 backoff.
  • Sitemap strategy decided (e.g. active listings from the catalog, 10,000 URLs per chunk).
16

Copy-paste prompts: quick builds

One prompt per UI piece, for Lovable, Cursor or any AI builder. Each one keeps the key on the server.

Prompts

How to use these prompts

  • Replace {…} placeholders with your site's scope (board, brokerage, neighbourhood whitelist, subtypes).
  • Quick prompts build one UI piece. The build sequences are ordered: give them one at a time, each assumes the previous one is done.
  • Every prompt keeps the MLS® key server-side. If an AI tool suggests putting a key in a VITE_ or NEXT_PUBLIC_ variable, stop and correct it.
  • Run the acceptance-check prompt at the end, and read the generated code yourself.
PromptsRESO

Search box / filters (RESO)

Prompt · Lovable / CursorSearch box / filters (RESO)
Build a listings filter bar plus a server-side edge function for a RESO OData feed (PropTx AMPRE, base https://query.ampre.ca/odata, Authorization: Bearer from env AMPRE_BEARER_TOKEN, never in the browser).

Controls: text search, For Sale / For Lease, min and max price, min beds, min baths, parking, property subtype, sort (price-asc, price-desc, newest), Sold toggle.

Map them on the server to $filter:

- text: contains(City,'q') or contains(UnparsedAddress,'q') or contains(CityRegion,'q'), escaping single quotes by doubling them

- ListPrice ge/le; BedroomsTotal ge; BathroomsTotalInteger ge; ParkingTotal ge

- TransactionType eq 'For Sale' or 'For Lease'; PropertySubType eq '...'

- active: StandardStatus eq 'Active'; anonymous callers also need (InternetEntireListingDisplayYN eq null or InternetEntireListingDisplayYN eq true)

- sort: price uses ListPrice, newest uses ListingContractDate desc, default ModificationTimestamp desc. Never put DaysOnMarket in $orderby.

Paging: $top, $skip, $count=true. Encode spaces as %20, never +. Do not filter on MLSAreaDistrictToronto or CloseDate.

Write every filter and the page to the URL query string and restore them on load. Debounce text and price inputs by 350 ms. Reset to page 1 on filter change. Sort option values must exactly match what the server accepts.
PromptsRepliers

Search box / filters (Repliers)

Prompt · Lovable / CursorSearch box / filters (Repliers)
Build a listings filter bar plus a server route that proxies Repliers (GET https://api.repliers.io/listings, header REPLIERS-API-KEY from env REPLIERS_API_KEY, server only).

Controls: free-text search, Sale / Rent, min and max price, min and max beds, min and max baths, property type multi-select, Sold toggle.

Send: search, type=sale or lease, minPrice, maxPrice, minBeds, maxBeds, minBaths, maxBaths, propertyType (repeat the key for OR), class, status=A by default, pageNum and resultsPerPage (never "page").

Validate on the server: pageSize 10-200, type in sale|lease, status in A|U, city and neighborhood at most 120 characters with no <>; characters.

If map bounds "swLng,swLat,neLng,neLat" are given, send map as a closed GeoJSON ring and omit city, neighborhood, and postalCode.

Rent mode forces Sold off. Write all filters to the URL and restore them on load. Debounce price by 600 ms and map moves by 500 ms.
PromptsRESO

Full listing detail page (RESO)

Prompt · Lovable / CursorFull listing detail page (RESO)
Build /listing/:slug for a RESO OData feed. Slug = {street}-{unit}-{beds}bed-{baths}bath-{type}--{ListingKey}; parse the key after the last "--".

Server loader (anonymous credentials only): GET /Property('{key}') with a $select that includes every field rendered below (add Latitude, Longitude, YearBuilt if shown); GET /Media (ResourceRecordKey eq key, MediaCategory eq 'Photo', $top=200, keep Largest, PreferredPhotoYN first, then Order); GET /PropertyRooms (ListingKey eq key, $top=100, sort by Order).

If the listing is Closed or InternetEntireListingDisplayYN is false, the edge function returns 401 vowRequired unless a valid user JWT is sent. On 401, render noindex metadata and open the register modal.

Page: breadcrumb, gallery with thumbnails and a keyboard lightbox, status badge, H1 address, beds/baths/sqft/DOM/parking, PublicRemarks, a facts grid that skips empty values, AssociationFee, TaxAnnualAmount + TaxYear, a room table with an ft/m toggle (x 0.3048), "Listed by {ListOfficeName}", Book a Visit, favourite, Report an Inaccuracy.

Show ListingId as the MLS number; keep ListingKey as the route key. Title, description, og:image = first photo, canonical URL. No map unless Latitude/Longitude are selected. No similar-listings block unless backed by a real query.
PromptsRepliers

Full listing detail page (Repliers)

Prompt · Lovable / CursorFull listing detail page (Repliers)
Build /listings/[slug] as a server-rendered page. Slug = address-slug + "-" + mlsNumber; parse the last hyphen segment, uppercase it, and permanently redirect non-canonical slugs.

Fetch on the server: GET https://api.repliers.io/listings?mlsNumber={mls}&status=A&fields=*,raw (REPLIERS-API-KEY server-side). Retry with status=U only if the user is a VOW member; otherwise send the user to the grid with an "unavailable" banner.

Gallery: mobile carousel, desktop mosaic (1 large + 4, "+N"), lightbox that swaps class=medium for class=large, arrow keys, Escape, scroll lock.

Sections: price (soldPrice + SOLD badge with soldDate when sold), address, beds/baths/sqft/type, details.description, facts (style, yearBuilt, garage, parking, basement1+2, heating, airConditioning, swimmingPool, taxes.annualAmount, condoFee or HOAFee, lot.width x lot.depth), extras/inclusions/exclusions, rooms with an m/ft toggle (ft = m x 3.281), Listed By / Co-Listed By (office.brokerageName, agents[].brokerage.name, raw.CoListOfficeName), and an inquiry/showing form.

SEO: title "{address}, {city}, {brand}", noindex unless status is A, BreadcrumbList JSON-LD. No map or similar listings unless built on real data.
PromptsBoth

Photo / media handling

Prompt · Lovable / CursorPhoto / media handling
Implement listing photos.

RESO: in the edge function only (AMPRE_BEARER_TOKEN never reaches the browser), fetch /Media with ResourceRecordKey in (up to 5 keys per call) and MediaCategory eq 'Photo', $select MediaURL,Order,ImageSizeDescription,PreferredPhotoYN,ResourceRecordKey, $orderby ResourceRecordKey,Order. Keep ImageSizeDescription 'Largest'. Put PreferredPhotoYN (true, "true", "Y", 1) first, then Order. Dedupe and require https. Return 5 URLs per card and all URLs on detail.

Repliers: build https://cdn.repliers.io/{path}?class=medium for cards and class=large for the lightbox. If the string already starts with http, keep it and add the class param. Cap at 20. Add cdn.repliers.io to next/image remotePatterns if using Next.js.

Both: load photo URLs directly in <img> with no API key. On error, try the next URL, then show a placeholder. Lazy-load card images.
PromptsBoth

Sold / VOW gating (server-enforced VOW)

Prompt · Lovable / CursorSold / VOW gating (server-enforced VOW)
Implement server-enforced VOW gating for sold/leased listings.

1. Auth: email/password (e.g. Supabase). Registration requires a VOW Terms checkbox. Store profiles.tos_accepted_at and tos_version (or set vow_terms_accepted_at from signup metadata terms==="true" in a new-user trigger). Email confirmation redirects to a noindex confirmation page.

2. Server check isVowMember(): valid user (JWT header in an edge function, or cookie session in Next.js), confirmed email, and a non-null terms timestamp. Return false on any error.

3. Listing API: if the request asks for sold data (RESO: StandardStatus 'Closed' or MlsStatus Sold/Leased; Repliers: status=U), or a detail record is closed or has InternetEntireListingDisplayYN=false, return 401 { vowRequired: true } unless isVowMember() is true. Check BEFORE calling the MLS API.

4. Sold filter: RESO StandardStatus eq 'Closed' and (MlsStatus eq 'Sold' or MlsStatus eq 'Sold Conditional'); Repliers status=U&lastStatus=Sld.

5. SSR uses anonymous credentials only. Never render a sold listing into anonymous HTML. Sold detail pages are noindex.

6. Client: when signed out, the Sold toggle opens the register/login modal and sends no request. Include the user ID in query keys and invalidate on auth change. A blur is decoration only, never the gate.

7. Footer: data source/board, deemed reliable but not guaranteed, brokerage name, not intended to solicit buyers or sellers under contract.

Acceptance: an anonymous sold request returns 401/403 with no soldPrice or ClosePrice in the body; a confirmed user who accepted the terms gets the rows.
PromptsBoth

Listing autocomplete / address suggest

Prompt · Lovable / CursorListing autocomplete / address suggest
Build a listing/address typeahead.

UI: debounce 250-300 ms, minimum 2 characters, at most 8 results in two groups (Listings, Places), keyboard navigation. Address-capture forms require picking a suggestion.

Listings (Repliers): GET /api/listings/search?q=&limit=8 (limit clamped 1-15). Search a Postgres listing_catalog table (mls_number prefix plus trigram on address) first. If it is missing or returns nothing, call Repliers /listings?status=A&search={q}&pageNum=1 without searchFields, then retry with the street name only. Rank: exact MLS, MLS prefix, address prefix, address contains. Return { results, source } with Cache-Control private, max-age=15.

Listings (RESO): query our own Postgres snapshot (street_number eq, street_name ilike). Never call live AMPRE per keystroke.

Places: Mapbox geocoding (NEXT_PUBLIC_MAPBOX_TOKEN) with types=place,postcode,neighborhood,locality and limit 4; skip when the input looks like an MLS number. Picking a place applies its bbox as map bounds.

Picking a listing navigates to its canonical detail slug.
PromptsRepliers

Market stats / aggregates (Repliers)

Prompt · Lovable / CursorMarket stats / aggregates (Repliers)
Build a market stats panel from Repliers, server-side only (REPLIERS-API-KEY).

Summary: GET /listings?city=&propertyType=&type=sale&status=U&lastStatus=Sld&minSoldDate={12 months ago}&statistics=avg-soldPrice,med-soldPrice,avg-daysOnMarket,med-daysOnMarket,cnt-closed,pct-aboveBelowList&listings=false. Cohorts: city, neighborhood (add neighborhood=), similar (minBeds=maxBeds, minBaths=maxBaths).

Monthly: the same call with statistics=grp-mth,avg-soldPrice,med-soldPrice,cnt-closed,avg-daysOnMarket, plus a new-listings call with minListDate and statistics=grp-mth,cnt-new. Read statistics.soldPrice.mth[YYYY-MM].avg/med and statistics.closed.mth / statistics.new.mth (number or {count}).

Every stats call includes type=sale. Use one shared property-type label map. If the neighbourhood cohort has fewer than 24 sales in 12 months, fall back to city and say so. Label approximations. Return aggregates only.
PromptsRESO

Market stats / aggregates (RESO)

Prompt · Lovable / CursorMarket stats / aggregates (RESO)
A RESO OData feed has no reliable live aggregates. Add a sync edge function (service role or agent only) that pages /Property ($top=1000) for Active rows and for Closed Sold/Sold Conditional rows whose ModificationTimestamp falls in the last N months (CloseDate can't be used in $filter). Upsert into a private snapshot table on listing_key (RLS on, no public policies) and log each run.

Add a public market-stats function that reads only that table and returns avgSoldPrice, avgListPrice, avgNewListPrice, avgDOM, totalSold, totalNew, snlr = round(sold/new*100), marketStatus (<40 Buyer's, >60 Seller's, else Balanced), and monthly buckets for months in 3|6|9|12|18|24. Never return raw rows. Schedule the sync with a committed cron job.
PromptsBoth

Leads / inquiry / save listing

Prompt · Lovable / CursorLeads / inquiry / save listing
Add listing leads and favourites.

Inquiry: on the detail page, a form with Inquiry / Request a Showing modes (showing adds date and time). Fields: name, email, phone, message (default "I'm interested in {MLS} at {address}"). POST /api/inquiry with mls, listing id, address, page URL, and any resolved agent IDs. Server: honeypot field, reject submissions faster than 3 s or older than 6 h, 15 per hour per IP, optional Turnstile when TURNSTILE_SECRET_KEY is set. Insert into an inquiries table with the service role (RLS on, no public read). Resolve email recipients on the server (agent roster by ID, else an env-configured default). Never accept a recipient email from the client. Show success only after a 200.

Inaccuracy report: a modal on the detail page that stores lead_type inaccuracy_report with listing_ref "{address} (MLS# {id})".

Favourites: saved_listings(user_id, mls_number, address, price, image, beds, baths, slug, created_at), unique (user_id, mls_number), owner-only RLS, keyed by the same ID as the detail slug. Heart on cards and detail; redirect to login when signed out. The account page lists saved rows from the snapshot (or re-fetches by ID).
17

RESO/AMPRE build sequence

10 ordered prompts that build a complete RESO listings site, ending with acceptance checks. Give them in order.

PromptsRESO

Prompt 1: project and schema

Prompt · Lovable / CursorRESO/AMPRE build sequence · Prompt 1: project and schema
Build a RESO/AMPRE listings site. Create a Vite + React + TypeScript app (TanStack Start if you want SSR, otherwise React Router) with Tailwind, shadcn/ui, TanStack Query and a Supabase client. Browser env: VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY, read from the environment at every call site — never hardcode them in vite.config.ts define.

Supabase migrations (RLS on everywhere):

- profiles (id = auth.users.id, email, full_name, tos_accepted_at, tos_version) with a trigger that copies full_name from raw_user_meta_data on signup

- favourites (user_id, mls_number text, unique pair, owner-only RLS) — mls_number stores the AMPRE ListingKey

- user_roles with app_role enum 'agent' and has_role()

- leads (lead_type: contact, book_visit, evaluation, guide_download, inaccuracy_report; name, email, phone, address, message, source_url, metadata jsonb, status; agent-only read)

- optional: proptx_listings (listing_key text PK, mls_number, standard_status, mls_status, transaction_type, property_type, property_sub_type, city, district, city_region, postal_code, street parts, unparsed_address, list_price, close_price, bedrooms_total, bathrooms_total, parking_total, living_area_range, list_office_name, days_on_market, listing_contract_date, original_entry_timestamp, modification_timestamp, close_date, raw jsonb, synced_at) with RLS on and NO policies; proptx_sync_runs readable by agents.

After db push, re-grant the standard Supabase anon/authenticated/service_role table grants. AMPRE_BEARER_TOKEN is an edge-function secret only.
PromptsRESO

Prompt 2: AMPRE edge function (server builds OData)

Prompt · Lovable / CursorRESO/AMPRE build sequence · Prompt 2: AMPRE edge function (server builds OData)
Add a Deno edge function fetch-listings for this RESO/AMPRE listings site. Base URL https://query.ampre.ca/odata. Headers: Authorization: Bearer ${AMPRE_BEARER_TOKEN}, Accept: application/json. Never return the token; if it is missing return 500 JSON.

Encode query params so spaces are %20, never +. Escape OData strings by doubling single quotes. Retry 5xx/network errors up to 3 times with 300ms, 600ms, 900ms delays; return 4xx immediately; if every attempt throws return 502 JSON (never 200).

Always scope Property queries to {SCOPE_FILTER} (e.g. PropertyType eq 'Residential Condo & Other' and CityRegion in ({WHITELIST})). Never put CloseDate or MLSAreaDistrictToronto in $filter (PropTx error 1108). DaysOnMarket is not orderable; dom sorts use ListingContractDate.

Routes (query param route):

- default list: status (A active, U closed), pageNum, resultsPerPage, neighbourhood, listingType sale|rent|lease, minPrice, maxPrice, minBeds, maxBeds, minBaths, parking, propertyType (→ PropertySubType eq), q (→ contains on City, UnparsedAddress, CityRegion), sortBy (price-asc, price-desc, newest, dom-asc, dom-desc; default ModificationTimestamp desc). Map to $filter/$orderby/$top/$skip/$count=true/$select. Generate $select from the same field list the mapper reads.

  Anonymous calls: add (InternetEntireListingDisplayYN eq null or InternetEntireListingDisplayYN eq true).

  status=U: StandardStatus eq 'Closed' and MlsStatus in Sold/Sold Conditional (sale) or Leased/Leased Conditional (lease); price field becomes ClosePrice. Requires VOW access (Prompt 7) or return 401 { error, vowRequired: true } BEFORE calling AMPRE.

  Then batch /Media for the page's ListingKeys in chunks of 5: $filter=ResourceRecordKey in (...) and MediaCategory eq 'Photo', $select MediaURL,Order,ImageSizeDescription,PreferredPhotoYN,ResourceRecordKey, $orderby ResourceRecordKey,Order, $top 800. Keep ImageSizeDescription eq 'Largest', prefer PreferredPhotoYN (true, "true", "Y", 1), then Order, dedupe, https only, max 5 per listing.

  Return { listings, page, numPages, count } using @odata.count. Do not post-filter after $top without fixing count.

- route=detail&key={ListingKey}: GET /Property('{key}'), /Media ($top 200, Photo, all Largest), /PropertyRooms ($filter ListingKey eq '{key}', $top 100, sort by Order) in parallel. 401 { vowRequired: true } if the row is Closed or InternetEntireListingDisplayYN is false and the caller lacks VOW access.

- route=neighbourhoods: page Active rows ($top 1000, max 8 pages, $orderby ListingKey) and count by CityRegion in code (OData groupby ignores in()). Return name, slug, saleCount, leaseCount.

Do not use $expand. Verify the caller with supabase.auth.getUser(x-user-jwt); never trust the anon Authorization header as the user.
PromptsRESO

Prompt 3: normalized listing model and client data layer

Prompt · Lovable / CursorRESO/AMPRE build sequence · Prompt 3: normalized listing model and client data layer
Create src/lib/listings.ts with a Listing type: id and listingKey (= ListingKey), mlsNumber (= ListingId when available), address, streetNumber, streetName, unitNumber, neighbourhood (CityRegion), city, price, status Active|Sold|Leased, listingType sale|lease, beds, dens, baths, sqft (+ sqftRange), dom, image, images[], propertyType, description, maintenanceFee, taxes, taxYear, yearBuilt, parkingSpaces, parkingType, exposure, balcony, locker, pets, heating, cooling, basement, exteriorFinish, lat, lng, brokerageName (ListOfficeName), listAgentName (ListAgentFullName), rooms[]. Map in the edge function (transformListing) — the browser never sees raw RESO. Only read field names that exist in AMPRE (no LivingArea, OnMarketDate, Heating, Stories, GarageSpaces, Appliances, StoriesTotal).

Helpers: formatPrice (en-CA, CAD, 0 decimals), formatBeds ("2+1" when dens > 0), slug /listing/{address-slug}-{beds}bed-{baths}bath-{type}--{ListingKey} and extractKeyFromSlug (text after the last --).

Hooks call /functions/v1/fetch-listings with Authorization Bearer + apikey = VITE_SUPABASE_PUBLISHABLE_KEY and, when a session exists, x-user-jwt = session.access_token. Query keys: ["listings", params], ["listing", key, userId or "anon"], ["neighbourhoods"]. staleTime 5 minutes for listings, 15 for neighbourhoods. Invalidate ["listing", key] when auth state changes. Dedupe by listingKey.

If you use SSR loaders, prefetch the same keys with anon headers only (never a JWT) and dehydrate.
PromptsRESO

Prompt 4: cards and galleries

Prompt · Lovable / CursorRESO/AMPRE build sequence · Prompt 4: cards and galleries
Home: hero, a 12-per-page card grid of active listings, and a neighbourhood grid fed by route=neighbourhoods (links must resolve through the same whitelist the router uses).

ListingCard: photo carousel of up to 5 URLs with dots and arrows; on image error advance to the next URL, then a placeholder icon. Badges For Sale / For Lease / Sold / Leased; DOM badge "New" when dom is 0, else "{dom}d". Price CAD, address, neighbourhood, beds (2+1), baths, sqft when > 0, favourite heart (opens auth when signed out), Book a Visit when Active. The whole card links to the slug in the same tab.

Pagination with numbered pages (first, last, current ± 1, ellipses) and the API count. Loading skeletons, inline empty and error states. Media is batched server-side — never one /Media call per card.
PromptsRESO

Prompt 5: full listing page

Prompt · Lovable / CursorRESO/AMPRE build sequence · Prompt 5: full listing page
Route /listing/$slug. key = extractKeyFromSlug(decodeURIComponent(slug)). Fetch route=detail.

Sections: breadcrumb (Home / neighbourhood / address), gallery (large photo, up to 6 thumbnails, +N overflow, prev/next, lightbox with arrow keys and Escape; drop broken URLs), status badge, H1 address, "{neighbourhood}, {city} · MLS® {mlsNumber}", stat badges (beds, baths, size with sqft/m² toggle, DOM, parking, type, exposure), PublicRemarks, property-details grid (skip empty values), maintenance fee if > 0, annual tax with year if > 0, rooms table with ft/m toggle (0.3048), "Listed by {ListOfficeName}", Report an Inaccuracy, Book a Visit if Active, favourite button.

Fee-includes chips must use the same token the API returns. Do not hardcode a site agent on top of the listing brokerage without labelling them as the site broker. No similar-listings block unless backed by a real query. No map until Latitude and Longitude are in $select.

SEO: title "{address} — {beds}BR {type} {For Sale|For Rent|Sold|Leased} in {neighbourhood} | {SITE}", description with beds/baths/price, og:image = first photo, canonical URL. If the detail loader fails (including 401) render generic noindex metadata.
PromptsRESO

Prompt 6: search and filters

Prompt · Lovable / CursorRESO/AMPRE build sequence · Prompt 6: search and filters
ListingsFilter: For Sale / For Rent, min and max price, beds 1+..4+, baths 1+..3+, property subtype ({SUBTYPES}), parking 1+/2+, keyword (350 ms debounce), sort with option values exactly price-asc, price-desc, newest. Every control must change the server query (test each one). Write all filter state and the page number to the URL query string and restore it on load. Changing a filter resets to page 1.

Sold/Leased is a separate toggle that sets status=U. If signed out, open the register modal and do not request closed rows.
PromptsRESO

Prompt 7: VOW (server-enforced, Pattern A)

Prompt · Lovable / CursorRESO/AMPRE build sequence · Prompt 7: VOW (server-enforced, Pattern A)
Supabase email/password auth. Register requires a Terms/VOW checkbox; the expandable VOW text covers the consumer–broker relationship with {BROKERAGE} (e.g. "Acme Realty"), bona fide interest, personal non-commercial use, and {BOARD} ownership of MLS® VOW data. emailRedirectTo {origin}/vow-confirmed; password reset {origin}/reset-password. Store profiles.tos_accepted_at and tos_version once a session exists (on the confirm landing page if confirmation delays the session). /vow-confirmed strips the URL hash, explains sold data and favourites, and is noindex.

In fetch-listings, hasVowAccess(req) = x-user-jwt verifies with supabase.auth.getUser AND email_confirmed_at is set AND profiles.tos_accepted_at is not null. Without it: status=U returns 401 before any AMPRE call; detail returns 401 { vowRequired: true } for Closed rows and InternetEntireListingDisplayYN=false; lists always include the IDX display filter. On a 401 the detail page opens the register modal (do not silently redirect home).

Footer: listings and statistics sourced from {BOARD} and PropTx, deemed reliable but not guaranteed; brokerage identification; not intended to solicit buyers or sellers currently under contract. Terms page includes the VOW agreement, {BOARD}/PropTx copyright and audit consent; Privacy mentions sharing registration data with PropTx/{BOARD} for audit. Anonymous HTML must never contain a closed listing or a listing with InternetEntireListingDisplayYN=false. Decide and document whether InternetAddressDisplayYN is enforced.
PromptsRESO

Prompt 8: favourites and leads

Prompt · Lovable / CursorRESO/AMPRE build sequence · Prompt 8: favourites and leads
Favourites keyed by ListingKey. The account page loads favourites by those keys (detail fetches or a dedicated route), including sold ones for VOW users — never "first 100 active listings".

Edge function send-inquiry: types book-visit, contact, inaccuracy-report, evaluation. Insert leads with the service role (listing address, ListingKey and absolute listing URL in metadata) and email the brokerage via a transactional template. Recipient addresses come from an env var, not code. Book a Visit and "Request a viewing" must carry the ListingKey and address. Mount Report an Inaccuracy on the detail page.
PromptsRESO

Prompt 9: optional snapshot and market stats

Prompt · Lovable / CursorRESO/AMPRE build sequence · Prompt 9: optional snapshot and market stats
Add sync-proptx-listings (POST { months }, default 3, max 60). Allow service role or the agent role. Pull Active For Sale rows in scope and Closed Sold/Sold Conditional rows with ModificationTimestamp ge the cutoff (UTC midnight on the 1st of the month `months` ago) — never CloseDate in $filter. Pages of 1000 ordered by ListingKey, max 30. Dedupe by listing_key, upsert in batches of 500, store raw $select JSON, never photos. Log proptx_sync_runs. Commit a pg_cron + pg_net daily schedule (service-role bearer, {"months":3}) in a migration, or document the external scheduler.

Add market-stats (GET, public, service-role read only): neighbourhood, months in 3|6|9|12|18|24, optional beds. Return avgSoldPrice, avgListPrice, avgNewListPrice, avgDOM, totalSold, totalNew, snlr (round(sold/new*100)), marketStatus (< 40 Buyer's, > 60 Seller's, else Balanced) and monthly buckets. Never return raw rows.
PromptsRESO

Prompt 10: acceptance checks

Prompt · Lovable / CursorRESO/AMPRE build sequence · Prompt 10: acceptance checks
Verify:

1. AMPRE_BEARER_TOKEN exists only as a server secret; repo and client bundle contain no token value; the browser never calls query.ampre.ca.

2. Anonymous GET fetch-listings?status=U returns 401 and the grid shows no sold prices.

3. Anonymous detail for a known Active IDX listing returns 200 with https MediaURL (Largest) and the SSR HTML (if SSR) contains the listing slug.

4. The same detail for a Closed listing returns 401 and the page head is noindex without the sold price.

5. A confirmed user with TOS stored receives closed listings and InternetEntireListingDisplayYN=false listings; an unconfirmed user does not.

6. propertyType changes the result set; sortBy=price-asc orders by ListPrice; every filter appears in the URL and survives reload.

7. Every neighbourhood link resolves (one whitelist).

8. A page of cards issues ceil(n/5) Media calls, not n.

9. market-stats returns non-zero sold aggregates after a sync and no raw rows.

10. Book a Visit creates a leads row containing the ListingKey; favourites survive reload and include sold favourites after sign-in.

11. If the proxy is generic, a request to any path other than /Property, /Media, /PropertyRooms returns 400, and $top above 200 is rejected.
18

Repliers build sequence

11 ordered prompts that build a complete Repliers listings site, ending with acceptance checks. Give them in order.

PromptsRepliers

Prompt 1: shared types and helpers

Prompt · Lovable / CursorRepliers build sequence · Prompt 1: shared types and helpers
Build a Repliers listings site. Create a framework-free TypeScript module (shared package if you will also build a mobile app) with a normalized Listing type: id, mlsNumber, source (board id), market, lat, lng, price, address, city, beds, baths, sqft, sqftMax, image, images, agent, attributedAgents[{name,boardAgentId,agentId}], listedBy, coListedBy, presentedBy, type "house"|"condo"|"townhouse", propertyType, propertyClass, transactionType "sale"|"lease", status, lastStatus, soldPrice, soldDate, listDate, daysOnMarket, description, style, yearBuilt, garage, garageSpaces, parkingSpaces, fireplaces, basement, heating, cooling, pool, extras, inclusions, exclusions, annualTaxes, condoFee, lotFront, lotDepth, rooms[{type,width,length,level,features}].

Add formatPrice(value, currency = "CAD") using en-CA with 0 fraction digits; generateListingSlug(address) = lowercased address, non-alphanumerics to "-", plus "-" + mlsNumber; parseMlsFromSlug = last hyphen segment, uppercased. No React.
PromptsRepliers

Prompt 2: server-only Repliers client and boards

Prompt · Lovable / CursorRepliers build sequence · Prompt 2: server-only Repliers client and boards
Add a server-only Repliers client for this Repliers listings site. Base https://api.repliers.io/listings. Header REPLIERS-API-KEY with the board's key; also Content-Type: application/json. Board table: {BOARDS} (e.g. board_a/ontario/agent → REPLIERS_KEY_BOARD_A, board_b/ontario/brokerage → REPLIERS_KEY_BOARD_B, board_c/florida/agent → REPLIERS_KEY_BOARD_C; optional REPLIERS_KEY_ATTRIBUTION for attribution only). Single-board sites use REPLIERS_API_KEY.

fetchRepliersListings(key, params): translate page → pageNum (Repliers ignores page), default status=A and resultsPerPage=50, emit array values as repeated keys (OR), cache 300 s, and treat HTTP 400 whose body mentions pageNum as an empty page. Keep raw.* keys with the raw. prefix. Never call RESO or OData.

Image helper: relative path → https://cdn.repliers.io/{path}?class=medium; absolute URL → append class=medium; cap images at 20; lightbox uses class=large. Allow cdn.repliers.io for next/image if using Next.js.
PromptsRepliers

Prompt 3: normalize()

Prompt · Lovable / CursorRepliers build sequence · Prompt 3: normalize()
Write normalize(raw, board) → Listing: price from listPrice (strip non-numeric) else price; address from streetNumber streetName streetSuffix streetDirection; city = address.city + ", " + address.state; lat/lng from map (drop 0,0 and non-finite); beds = numBedrooms + numBedroomsPlus; baths = numBathrooms + numBathroomsPlus; sqft and sqftMax from a "min-max" string; type house|condo|townhouse from details.propertyType and class; transactionType lease when type/listingType contains lease or rent; pass through status, lastStatus, soldPrice, soldDate, listDate, daysOnMarket; cooling ← details.airConditioning; pool ← details.swimmingPool; condoFee ← condoFee else HOAFee; annualTaxes ← taxes.annualAmount; lot width/depth; rooms from rooms[] with description.

Attribution: listedBy = office.brokerageName or agents[0].officeName; coListedBy = distinct agents[].brokerage.name and raw.CoListOfficeName/3/4 (when fields include raw), excluding listedBy, joined " / ". Keep only "our" agents (no brokerage name, or matches {BROKERAGE_REGEX}, e.g. /acme realty/i). On brokerage-attribution boards set agent "" and presentedBy "{BROKERAGE_DISPLAY_NAME}" but keep attributedAgents internally.
PromptsRepliers

Prompt 4: website API (list, detail, token)

Prompt · Lovable / CursorRepliers build sequence · Prompt 4: website API (list, detail, token)
GET /api/token returns "{unixSeconds}.{hex}" = HMAC-SHA256 of the timestamp with INTERNAL_API_SECRET, TTL 30 minutes. (This is scrape friction, not security — say so in a code comment.)

GET /api/listings and GET /api/listings/[mlsNumber] require Authorization: Bearer <token> or x-internal-secret.

List params: page (≥ 1), pageSize (clamp 10–200, default 50), market (selects boards; never sent to Repliers), class, type sale|lease, status A|U, minPrice, maxPrice, minBeds, maxBeds, minBaths, maxBaths, city, neighborhood, postalCode (≤ 120 chars, reject <>;), repeated propertyType option ids, bounds "swLng,swLat,neLng,neLat" → map=[[[swLng,swLat],[neLng,swLat],[neLng,neLat],[swLng,neLat],[swLng,swLat]]] (then omit city/neighborhood/postalCode). Invalid bounds → 400.

Fan out per board with Promise.allSettled; put a failed board in _meta.error. Dedupe by mlsNumber across boards. Return { listings, page, pageSize, hasMore, totalCount, _meta }.

Detail: try each board with mlsNumber, status=A, fields=*,raw; first non-empty wins; status=U only when the caller passes the VOW check (Prompt 8). 404 if none.

status=U without VOW access → 403 { error: "Sold listing data is available to VOW members only. Please sign in." }. Add lastStatus=Sld when the UI means "sold".
PromptsRepliers

Prompt 5: property-type maps

Prompt · Lovable / CursorRepliers build sequence · Prompt 5: property-type maps
Map UI option ids to per-board Repliers params in one module. {PER_BOARD_MAP} — e.g. a PROPTX-style board uses propertyType: Detached + Link, Semi-Detached, Att/Row/Twnhouse, Condo Apt, Condo Townhouse, Duplex/Multiplex/Triplex/Fourplex; an ITSO-style board uses raw.ITSO_PropertyAttached=Detached / "Semi Detached", propertyType Row/Townhouse, Condo/Apt Unit; a Naples-style board uses lowercase raw.PropertySubType values.

Repeated params are OR. If a selection mixes a raw key and propertyType on the same board, drop the raw key and widen (e.g. add Single Family Residence). Skip any board that cannot satisfy a selected id. Keep a separate, clearly named map for statistics if the board's stats vocabulary differs (e.g. title case).
PromptsRepliers

Prompt 6: grid, map and detail UI

Prompt · Lovable / CursorRepliers build sequence · Prompt 6: grid, map and detail UI
/homes-for-sale (client): sale/rent toggle, market pills, min/max price (600 ms debounce), beds/baths, multi-select property type, page size 24 (150 in map view). Use a Load more button or numbered pages — and never show a count larger than what can be reached. Write filter state to the URL and read it back on load.

ListingCard: medium photo carousel, CAD price (USD for Florida rows if you split currency), address, city, bd · ba · sqft (range when sqftMax), board chip, Sold badge when lastStatus/status matches sld|sold|u|closed|leased, New when daysOnMarket <= 0 or listed within 24 h, heart. Link to /listings/{slug}.

Map (Mapbox GL, NEXT_PUBLIC_MAPBOX_TOKEN): price-pill markers, popup with address/city/brokerage/photo (escape strings), viewport changes debounced 500 ms become bounds; leaving map view clears bounds.

/listings/[slug] (server): parse MLS, redirect non-canonical slugs, fetch with the server helper (active, then sold only if VOW). Gallery: mobile carousel, desktop mosaic (1 + 4, "+N"), lightbox at class=large with arrows/Escape/scroll lock. Sections: price (sold price + SOLD badge when sold), address, beds/baths/sqft/type, description, listing details (MLS #, board, style, year, garage, parking, fireplaces, basement, heating, cooling, pool, lot, taxes, maintenance fee, listed by, co-listed by, presented by, listed on, DOM), extras/inclusions/exclusions, rooms table m/ft (×3.281), inquiry form. Title "{address}, {city}, {BRAND}", noindex unless status is A, BreadcrumbList JSON-LD. Footer: data courtesy of {BOARDS} via {BROKERAGE}, deemed reliable but not guaranteed, not intended to solicit buyers or sellers already under contract. No similar listings or schools unless backed by real data.
PromptsRepliers

Prompt 7: catalog, typeahead and sitemap

Prompt · Lovable / CursorRepliers build sequence · Prompt 7: catalog, typeahead and sitemap
Supabase listing_catalog (mls_number PK, board, market, transaction_type, property_class, property_type, status default 'A', price, street_number, street_name, unit_number, address, city, province, postal_code, address_key, full_address, lat, lng, slug, beds, baths, updated_on, last_seen_at, indexed_at) with pg_trgm GIN indexes on address, full_address, address_key, and catalog_sync_state per board. Public read where status = 'A'; writes via service role only.

GET /api/cron/catalog (CRON_SECRET or INTERNAL_API_SECRET): task=sync every 10 minutes — backfill sortBy=createdOnAsc from a cursor until full, then incremental sortBy=updatedOnDesc stopping at the last updatedOn watermark (don't advance it if the page cap is hit); resultsPerPage=100; 220 ms between pages; on 429 retry 5 times with 1,2,4,8,16 s backoff; boards sequential (shared rate limit); upsert in chunks of 500. task=sweep every 6 hours walks fields=mlsNumber, updates last_seen_at and deletes rows not seen in a full cycle. Never store photos or agent PII.

GET /api/listings/search?q=&limit=8 (q 2–120 chars, limit 1–15): search the catalog and rank exact MLS, MLS prefix, address prefix, address contains, full address contains; fall back to Repliers search= (no searchFields) if the table is missing or returns zero; Cache-Control private, max-age=15, stale-while-revalidate=60. Listing sitemaps from the catalog in chunks of 10,000, active only.
PromptsRepliers

Prompt 8: VOW portal (Pattern A)

Prompt · Lovable / CursorRepliers build sequence · Prompt 8: VOW portal (Pattern A)
Supabase email auth. Registration requires a VOW terms checkbox linked to /vow-terms (board copyright, personal non-commercial use, any brokerage-only attribution rule, IDX licence text). Signup metadata terms: "true"; trigger handle_new_user() sets profiles.vow_terms_accepted_at = now() when terms = 'true'.

isVowMember() is server-only: cookie session user via auth.getUser() AND profiles.vow_terms_accepted_at not null; any error → false. Enforce it in /api/listings, /api/listings/[mlsNumber] and the server detail page before any status=U Repliers call.

"Show Sold (VOW)" toggle: members send status=U&lastStatus=Sld; others see a modal linking to /login?next=… . Sold detail pages are noindex. saved_listings (user_id, mls, snapshot of address, price, image URL, beds, baths, slug), owner-only RLS; the heart redirects signed-out users to login. Never rely on CSS blur or localStorage to hide sold data.
PromptsRepliers

Prompt 9: statistics

Prompt · Lovable / CursorRepliers build sequence · Prompt 9: statistics
Market stats call the same /listings URL with statistics= and listings=false (or resultsPerPage=1). EVERY stats query includes type=sale. Sold metrics: status=U&lastStatus=Sld with minSoldDate/maxSoldDate; statistics=avg-soldPrice,med-soldPrice,avg-daysOnMarket,med-daysOnMarket,cnt-closed,pct-aboveBelowList. Monthly history adds grp-mth; new listings use statistics=grp-mth,cnt-new with minListDate. Read statistics.soldPrice.mth["YYYY-MM"].avg/.med, daysOnMarket.mth, closed.mth and new.mth (number or {count}). Cohorts: city, neighborhood, exact beds/baths (minBeds=maxBeds, minBaths=maxBaths). If a neighbourhood cohort has fewer than 24 sales over 12 months, fall back to city and say so. Label any sale-to-list approximation as an approximation.
PromptsRepliers

Prompt 10: leads

Prompt · Lovable / CursorRepliers build sequence · Prompt 10: leads
POST /api/inquiry: modes inquiry | showing (date + time); fields name, email, phone, message. Honeypot field, 3 s minimum / 6 h maximum form age, 15 requests/hour/IP, optional Turnstile. Store inquiries with the service role (kind, mode, mls, listing id, agent ids, showing date/time, region). Resolve recipients server-side from posted agent ids (roster lookup), else office region or nearest office to lat/lng, else a default lead manager from config. Never accept a recipient email from the client. Send via Resend (RESEND_API_KEY). Do not show a success toast unless the request succeeded.
PromptsRepliers

Prompt 11: acceptance checks

Prompt · Lovable / CursorRepliers build sequence · Prompt 11: acceptance checks
Verify:

1. No request URL contains /Property, $filter or a RESO host; every listing call is api.repliers.io/listings via the server.

2. No REPLIERS key appears in the client bundle; the browser never calls api.repliers.io.

3. /api/listings without a token returns 401; with a token it returns active rows with board source and CDN URLs containing class=medium.

4. status=U without a VOW session returns 403; a member with vow_terms_accepted_at gets rows; those detail pages are noindex; sold rows have lastStatus Sld.

5. Sending page=2 to the server yields Repliers pageNum=2; a page past the end returns an empty list, not an error.

6. Every stats request includes type=sale.

7. Brokerage-attribution boards show presentedBy and an empty public agent.

8. /listings/anything-{MLS} redirects to the canonical slug when active; an unknown MLS slug lands on the "unavailable" state.

9. Catalog sync upserts and stops at the watermark; sweep removes rows that left status=A; typeahead falls back to live search if the table is missing.

10. Inquiry POST writes a row and emails the resolved roster agent; the body cannot set the recipient.

11. Filters survive a reload (URL state) and the visible count never exceeds what can be paged to.