API Documentation
Saved Searches ¶
Overview
A Saved Search stores a set of listing-search criteria (price range,
bedroom count, property types, geographic boundaries, etc.) tied to a brand.
Saved searches can be linked to contacts (with a delivery frequency
and, for non-Instant frequencies, a delivery schedule) so that
newly-matching listings can be delivered as alerts. They can also be linked
to boundaries — geographic polygons that the listing must fall within.
Identity and ownership
| Field | Type | Association | Description |
|---|---|---|---|
| id | uuid | Internal identifier of the saved search | |
| brand | uuid | brands(id) | Brand to which this saved search belongs (ownership only — not a search criterion) |
| created_by | uuid | users(id) | User who created the search (server-controlled; nullable for portal-created searches) |
| from | uuid | users(id) | Sender associated with notifications (server-controlled) |
| title | string | Human-readable title | |
| created_at | number | Epoch seconds when created | |
| updated_at | number | Epoch seconds of last update | |
| deleted_at | number | Epoch seconds of soft-delete (nullable) |
Price, area, room counts, year built, parking
| Field | Type | Description |
|---|---|---|
| minimum_price | number | Lower bound of price range |
| maximum_price | number | Upper bound of price range |
| currency | string | ISO 4217 code; defaults to USD |
| minimum_square_meters | number | Lower bound of interior area in m² |
| maximum_square_meters | number | Upper bound of interior area in m² |
| minimum_lot_square_meters | number | Lower bound of lot area in m² |
| maximum_lot_square_meters | number | Upper bound of lot area in m² |
| minimum_bedrooms | number | Lower bound, integer |
| maximum_bedrooms | number | Upper bound, integer |
| minimum_bathrooms | number | Lower bound (fractional bathrooms permitted) |
| maximum_bathrooms | number | Upper bound, integer |
| minimum_year_built | number | Lower bound, year |
| maximum_year_built | number | Upper bound, year |
| minimum_parking_spaces | number | Minimum total number of parking spaces (covered + uncovered) |
Property types and statuses
| Field | Type | Description |
|---|---|---|
| property_types | string[] | One or more property_type enum values (e.g. Residential, Commercial) |
| property_subtypes | string[] | One or more property_subtype enum values (e.g. RES-Single Family) |
| listing_statuses | string[] | One or more listing_status enum values to include |
Geographic filters
| Field | Type | Description |
|---|---|---|
| points | object[] | Polygon vertices {latitude, longitude} defining a custom area; closes the ring |
| postal_codes | string[] | ZIP / postal codes to include |
Boundaries are not a column on saved_searches — they live in the
saved_searches_boundaries join table and are managed via the create /
update payload (see “Boundaries” below).
points is optional, but every saved search must carry at least one
anchor — a criterion that limits whose listings are in scope or where
they are. Geographic: points, boundaries. Scope: brands, agents,
list_agents, selling_agents, list_offices, selling_offices, offices.
A search with none of them would match every listing in the brand’s MLSes, so
create and update reject it with 400 — "Request too broad. Either define mls, brand, or geographic boundaries" (the same rule the alert matcher
applies). Send "points": null to clear a polygon; [] is rejected as a
too-short ring, and an empty scope array is not an anchor either. Note brand
(owner) is never an anchor.
Agent and office filters
| Field | Type | Description |
|---|---|---|
| list_agents | string[] | Match the listing’s list_agent_mui |
| selling_agents | string[] | Match the listing’s selling_agent_mui |
| agents | string[] | Match any of the listing’s 8 agent slots (list, co-list ×3, selling, co-selling ×3) |
| list_offices | string[] | Match the listing’s list_office_mls_id |
| selling_offices | string[] | Match the listing’s selling_office_mls_id |
| offices | string[] | Match any of the listing’s 4 office slots (list, co-list, selling, co-selling) |
Brand filters
brands is not a column on saved_searches — it lives in the
saved_searches_brands join table and is managed via the create / update
payload (see “Update a saved search” below). A listing matches when its
agents belong to any of the linked brands (OR semantics).
| Field | Type | Description |
|---|---|---|
| brands | uuid[] | One or more brands; the listing must match at least one of them |
brand remains the owner brand of the saved search and is no longer used
as a listing-search criterion.
Boolean amenities
For pool, pets, application_fee, appliances, furnished,
fenced_yard: passing true requires the listing’s flag to be true;
passing false requires it to be false or NULL (treated as “not
present”). Passing null (or omitting) skips the filter.
| Field | Type |
|---|---|
| pool | boolean |
| pets | boolean |
| application_fee | boolean |
| appliances | boolean |
| furnished | boolean |
| fenced_yard | boolean |
office_exclusive is checked strictly — true/false must equal the
listing’s value (no NULL fallback).
| Field | Type |
|---|---|
| office_exclusive | boolean |
Pets and miscellaneous
| Field | Type | Description |
|---|---|---|
| number_of_pets_allowed | number | Lower bound — listing’s number_of_pets_allowed must be at least this |
| open_house | boolean | When true, only listings with an active open-house match |
| minimum_sold_date | number | Epoch seconds; only constrains Sold listings — non-Sold rows pass |
Full-text search
| Field | Type | Description |
|---|---|---|
| search | string | Free-text query matched via search_listings(websearch_to_tsquery('english', search)) |
| content | string | Free-text matched against the listing’s content column via plainto_tsquery('english', content) |
| address | string | Free-text matched against the listing’s address column via plainto_tsquery('english', address) |
Sorting
Listing-search endpoints that return listings accept a sort field in the
request body — an ordered array of { by, dir } descriptors (primary sort
first, then tie-breakers):
"sort": [
{ "by": "price", "dir": "desc" },
{ "by": "list_date", "dir": "asc" }
]
| Key | Type | Description |
|---|---|---|
| by | string | Sort field; one of the values below |
| dir | string | asc (default) or desc |
Supported by values: status, price, close_price, mls, list_date,
bedrooms, bathrooms, square_feet, lot_size, year_built.
Nullable fields (list_date, bedrooms, bathrooms, square_feet,
lot_size, year_built) sort NULLS LAST regardless of direction. Omit
sort to return results unsorted.
Delivery schedule
Like frequency, the schedule is a per-contact value stored on
saved_searches_contacts. At create time the client sends one value (flat on
the body) and every linked contact gets it. The timezone is derived from the
owning user (not client-supplied).
| Field | Type | Required with | Description |
|---|---|---|---|
send_time |
number | Daily / Weekly / Monthly | Minutes since midnight, 0–1439 (e.g. 540 = 09:00) |
send_weekday |
number | Weekly | 0 (Sunday) through 6 (Saturday) |
send_monthday |
number | Monthly | 1 through 31 (clamped to the month’s last day) |
Instant and Never ignore these fields.
Endpoints
Create a saved search ¶
Create a saved searchPOST/brands/:brand/saved-searches
from defaults to req.user.id if not provided.
Example URI
List a brand's saved searches ¶
List a brand's saved searchesGET/brands/:brand/saved-searches
Example URI
- brand
string(required) Example: 262de8a9-a55c-4490-9ed8-12c20a049720
200Body
{
"code": "OK",
"data": [
{
"type": "saved_search",
"id": "3a2a95db-5271-4700-94c9-981a32c867ac",
"title": "My Search",
"minimum_price": 100000,
"maximum_price": 500000,
"currency": "USD",
"minimum_square_meters": null,
"maximum_square_meters": null,
"minimum_lot_square_meters": null,
"maximum_lot_square_meters": null,
"minimum_bedrooms": 2,
"maximum_bedrooms": null,
"minimum_bathrooms": null,
"maximum_bathrooms": null,
"minimum_year_built": null,
"maximum_year_built": null,
"minimum_parking_spaces": null,
"property_types": [
"Residential"
],
"property_subtypes": null,
"listing_statuses": [
"Active"
],
"points": [
{
"longitude": -179,
"latitude": -89,
"type": "location"
},
{
"longitude": 179,
"latitude": -89,
"type": "location"
},
{
"longitude": 179,
"latitude": 89,
"type": "location"
},
{
"longitude": -179,
"latitude": 89,
"type": "location"
},
{
"longitude": -179,
"latitude": -89,
"type": "location"
}
],
"postal_codes": null,
"list_agents": null,
"list_offices": null,
"selling_agents": null,
"selling_offices": null,
"agents": null,
"offices": null,
"pool": null,
"open_house": null,
"pets": null,
"number_of_pets_allowed": null,
"application_fee": null,
"appliances": null,
"furnished": null,
"fenced_yard": null,
"office_exclusive": null,
"minimum_sold_date": null,
"minimum_list_date": null,
"maximum_list_date": null,
"search": null,
"created_at": 1791656538.191142,
"updated_at": 1791656538.191142,
"deleted_at": null,
"proposed_title": "Active, $100K-$500K, 2+ Beds"
}
],
"info": {
"count": 1,
"total": 0
}
}Get a saved search by id ¶
Get a saved search by idGET/brands/:brand/saved-searches/:id
Example URI
- brand
string(required) Example: 262de8a9-a55c-4490-9ed8-12c20a049720- id
string(required) Example: 3a2a95db-5271-4700-94c9-981a32c867ac
200Body
{
"code": "OK",
"data": {
"type": "saved_search",
"id": "3a2a95db-5271-4700-94c9-981a32c867ac",
"title": "My Search",
"minimum_price": 100000,
"maximum_price": 500000,
"currency": "USD",
"minimum_square_meters": null,
"maximum_square_meters": null,
"minimum_lot_square_meters": null,
"maximum_lot_square_meters": null,
"minimum_bedrooms": 2,
"maximum_bedrooms": null,
"minimum_bathrooms": null,
"maximum_bathrooms": null,
"minimum_year_built": null,
"maximum_year_built": null,
"minimum_parking_spaces": null,
"property_types": [
"Residential"
],
"property_subtypes": null,
"listing_statuses": [
"Active"
],
"points": [
{
"longitude": -179,
"latitude": -89,
"type": "location"
},
{
"longitude": 179,
"latitude": -89,
"type": "location"
},
{
"longitude": 179,
"latitude": 89,
"type": "location"
},
{
"longitude": -179,
"latitude": 89,
"type": "location"
},
{
"longitude": -179,
"latitude": -89,
"type": "location"
}
],
"postal_codes": null,
"list_agents": null,
"list_offices": null,
"selling_agents": null,
"selling_offices": null,
"agents": null,
"offices": null,
"pool": null,
"open_house": null,
"pets": null,
"number_of_pets_allowed": null,
"application_fee": null,
"appliances": null,
"furnished": null,
"fenced_yard": null,
"office_exclusive": null,
"minimum_sold_date": null,
"minimum_list_date": null,
"maximum_list_date": null,
"search": null,
"created_at": 1791656538.191142,
"updated_at": 1791656538.191142,
"deleted_at": null,
"proposed_title": "Active, $100K-$500K, 2+ Beds"
}
}Update a saved search ¶
Update a saved searchPUT/brands/:brand/saved-searches/:id
Accepts a partial update — fields not present in the body retain their
existing values. boundaries and brands have special semantics:
-
Omitted — existing linked boundaries / brands are left untouched.
-
[]— all linked boundaries / brands are removed. -
[uuid, …]— replace the linked set with exactly the given UUIDs.
points follows three-valued logic instead: omit it to keep the current
polygon, send null to clear it, or send a closed ring of 4+ vertices to
replace it. Clearing the last anchor (points while no boundaries /
brands remain) is rejected with 400.
Example URI
- brand
string(required) Example: 262de8a9-a55c-4490-9ed8-12c20a049720- id
string(required) Example: 3a2a95db-5271-4700-94c9-981a32c867ac
Body
{
"title": "Updated Search",
"maximum_price": 750000
}200Body
{
"code": "OK",
"data": {
"type": "saved_search",
"id": "3a2a95db-5271-4700-94c9-981a32c867ac",
"title": "Updated Search",
"minimum_price": 100000,
"maximum_price": 750000,
"currency": "USD",
"minimum_square_meters": null,
"maximum_square_meters": null,
"minimum_lot_square_meters": null,
"maximum_lot_square_meters": null,
"minimum_bedrooms": 2,
"maximum_bedrooms": null,
"minimum_bathrooms": null,
"maximum_bathrooms": null,
"minimum_year_built": null,
"maximum_year_built": null,
"minimum_parking_spaces": null,
"property_types": [
"Residential"
],
"property_subtypes": null,
"listing_statuses": [
"Active"
],
"points": [
{
"longitude": -179,
"latitude": -89,
"type": "location"
},
{
"longitude": 179,
"latitude": -89,
"type": "location"
},
{
"longitude": 179,
"latitude": 89,
"type": "location"
},
{
"longitude": -179,
"latitude": 89,
"type": "location"
},
{
"longitude": -179,
"latitude": -89,
"type": "location"
}
],
"postal_codes": null,
"list_agents": null,
"list_offices": null,
"selling_agents": null,
"selling_offices": null,
"agents": null,
"offices": null,
"pool": null,
"open_house": null,
"pets": null,
"number_of_pets_allowed": null,
"application_fee": null,
"appliances": null,
"furnished": null,
"fenced_yard": null,
"office_exclusive": null,
"minimum_sold_date": null,
"minimum_list_date": null,
"maximum_list_date": null,
"search": null,
"created_at": 1791656538.191142,
"updated_at": 1791656538.24339,
"deleted_at": null,
"proposed_title": "Active, $100K-$750K, 2+ Beds"
}
}List email campaigns for a saved search ¶
List email campaigns for a saved searchGET/brands/:brand/saved-searches/:id/email-campaigns
Ordered by executed_at DESC, NULLs last. Supports ?limit= and
?start=; info.total reflects the unpaged total.
Example URI
- brand
string(required) Example: 262de8a9-a55c-4490-9ed8-12c20a049720- id
string(required) Example: 3a2a95db-5271-4700-94c9-981a32c867ac- limit
string(required) Example: 10- start
string(required) Example: 0
200Body
{
"code": "OK",
"data": [],
"info": {
"count": 0,
"total": 0
}
}