Docs
API guides

Pagination, filtering & sorting

Page through collections, narrow them with typed filters, and know which filters each resource supports.

Every V2 collection endpoint uses the same pagination, filtering, and sorting grammar. What differs per resource is which filters are registered — the matrix at the bottom of this page is the authoritative list.

Pagination

curl "https://v2.getresi.com/api/v2/units?per_page=100&page=3" \
  -H "Authorization: Bearer $RESI_TOKEN" \
  -H "Accept: application/json"
ParameterDefaultMax
per_page15200 (values above are clamped, not rejected)
page1—

Responses carry both a links object and a meta object:

{
  "data": [ … ],
  "links": {
    "first": "https://v2.getresi.com/api/v2/units?page=1",
    "last":  "https://v2.getresi.com/api/v2/units?page=17",
    "prev":  null,
    "next":  "https://v2.getresi.com/api/v2/units?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "to": 50,
    "last_page": 17,
    "per_page": 50,
    "total": 843,
    "path": "https://v2.getresi.com/api/v2/units",
    "links": [ … ]
  }
}

The URLs in links carry only page. Your filters, sort, per_page and account_id are not in them, so fetching links.next as-is returns the next page of the unfiltered collection, 15 rows at a time, from the token's own account. Use links.next only as the signal that another page exists, and build the next request yourself from your original parameters plus page.

Keep going while links.next is non-null:

async function* paginate(path, params, token) {
  for (let page = 1; ; page++) {
    const url = new URL(`https://v2.getresi.com/api/v2${path}`);
    for (const [key, value] of Object.entries({ ...params, page })) {
      url.searchParams.set(key, String(value));
    }
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
    });
    if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
    const body = await res.json();
    yield* body.data;
    if (!body.links?.next) return;
  }
}

// usage
for await (const unit of paginate("/units", { property_id: propertyId, per_page: 200, sort: "number" }, token)) {
  // …
}

meta.links is a rendered page-number list intended for UI paginators. Programmatic clients can ignore it.

Every collection has a stable order, so a page never repeats or skips a record. Without a sort, a collection is ordered oldest first (some, such as websites and content entries, by name or slug instead), and the record id always breaks ties, including under a sort whose values repeat. Pagination is still offset-based: records created or deleted while you page shift what later pages contain, so de-duplicate by id on long scans. See Syncing inventory for the pattern.

Filtering

Filters are query parameters. Each registered filter has a type, and the type determines the syntax you use.

exact

Match a value exactly. Usually an id, code, or enum.

?property_id=019dd604-8468-7388-9a52-4c31a2e1209a
?type=hard

boolean

true, 1, yes and on mean true; false, 0, no and off mean false, in any case. Anything else, such as a typo, is a 422.

?is_available=true&is_hidden=false

Case-insensitive partial match — a contains, not a full-text search. No relevance ranking, no fuzzy matching.

?name=riverside

range and date_range

Pass an object with any of gte, lte, gt, lt. Combine them for a bounded range.

?bedrooms[gte]=2
?min_rent[gte]=1200&min_rent[lte]=2400
?available_at[lte]=2026-10-01
?updated_at[gte]=2026-08-01T00:00:00Z

Passing a bare scalar to a range filter (?bedrooms=2) is treated as an exact match, which is a convenient shorthand.

URL-encoded, the bracket syntax is min_rent%5Bgte%5D=1200. Most HTTP clients do this for you if you pass nested params.

Combining filters

Filters are ANDed. There is no OR, no negation, and no cross-field expression language.

curl -G "https://v2.getresi.com/api/v2/units" \
  --data-urlencode "property_id=019dd604-8468-7388-9a52-4c31a2e1209a" \
  --data-urlencode "is_available=true" \
  --data-urlencode "is_hidden=false" \
  --data-urlencode "bedrooms[gte]=2" \
  --data-urlencode "min_rent[lte]=2500" \
  --data-urlencode "available_at[lte]=2026-10-01" \
  --data-urlencode "per_page=200" \
  -H "Authorization: Bearer $RESI_TOKEN" \
  -H "Accept: application/json"

Unrecognized parameters are ignored rather than rejected, so a typo in a filter name returns the unfiltered collection. If a query returns far more rows than you expected, check your spelling first.

account_id is not a filter

account_id selects the account the whole request runs against (see Authentication); it does not narrow a collection within an account. It must be an account the token's user belongs to, or the request fails with 403.

Sorting

?sort=name        # ascending
?sort=-updated_at # descending (leading minus)

One field per request. A field is sortable only if it is a registered filter on that resource — the matrix below, excluding property_id where it resolves through a link, and the group filters. An unrecognized sort value is ignored, so check the matrix before relying on one. Properties cannot be sorted; users always come back ordered by name; neighborhood categories default to sort_order, then name.

Group filtering

Available on the property, building, floor plan and unit collections, in addition to their own filters:

?group=<group_id>            # the group and all its descendants
?groups[]=<id>&groups[]=<id> # any of these groups

An unknown or cross-account group id returns 404.

Filter matrix

E exact · B boolean · S search (partial) · R range · D date range · G group filter

ResourceFiltersNotes
propertiesG onlyNo sort. Filter client-side; portfolios rarely exceed one page at per_page=200
unitsproperty_id building_id floor_plan_id E · number S · bedrooms bathrooms interior_sqft floor min_rent max_rent deposit R · available_at D · is_enabled is_available is_featured is_furnished is_affordable is_handicap_accessible is_model is_penthouse is_hidden B · Gnumber also matches the building-prefixed display form (149-304). No updated_at filter: sync with scan-and-diff
floor-plansproperty_id code E · name S · min_rent max_rent min_sqft max_sqft min_bed max_bed min_bath max_bath R · available_at D · is_enabled is_featured has_specials B · GSync with scan-and-diff
buildingsproperty_id code type E · name number S · is_enabled B · GSync with scan-and-diff
amenitiescode type reference_id E · name S · is_enabled is_property_marketable B · created_at updated_at D · property_id (via link)
feescode status class currency source E · name S · created_at updated_at D · property_id (via property assignment)
reviewsproperty_id source external_review_id E · reviewer_name S · rating R · review_date created_at updated_at D · is_published B
announcementselements_key E · internal_title title S · sort_order R · starts_at ends_at created_at updated_at D · is_enabled is_global B · property_id (via link)
content-blockstype media_type elements_key E · internal_title title S · sort_order R · created_at updated_at D · is_enabled is_global B · property_id (via link)
faqsfaq_topic_id E · question answer S · sort_order R · created_at updated_at D · is_global B · property_id (via link)
galleriestype elements_key E · internal_title title S · sort_order R · created_at updated_at D · is_enabled is_global B · property_id (via link)
neighborhood-placesproperty_id neighborhood_category_id place_id E · name address S · rating sort_order R · created_at updated_at D
neighborhood-categoriesname S · sort_order R · created_at updated_at D
lead-sourcesproperty_id name E · code S · created_at updated_at D
connectionstype category E · name S · enabled B · created_at updated_at D · property_id (via attachment)
mediakind (required) · attachable_type + attachable_id · tag · files: property_id folder_id reference_id E, caption S, slot (with attachable_*) · embeds: type provider E, title S · both: created_at updated_at D?kind=file or ?kind=embed — one kind per call
usersname email S · role E (role key, slug or id)Always ordered by name

Content resources, amenities, fees, lead sources, connections and media accept an updated_at range, so they can sync incrementally. Inventory resources (units, floor plans, buildings, properties) do not; they are designed for scan-and-diff, which is self-correcting across cycles. An updated_at filter never shows you deletions, so pair an incremental sync with an occasional full scan. Both patterns are covered in Syncing inventory.

Response shaping

There is no include, fields, or expand parameter. Each endpoint returns a fixed set of relations:

  • Collection responses are lean. GET /units returns unit fields, external_ids and media arrays, but not the nested property, building, floor_plan or amenities.
  • Single-resource responses are rich. GET /units/{id} includes property, building, floor_plan, and amenities.

Do not loop over a collection calling GET /{id} on each row to get relations — fetch the related collection once and join in memory:

async function collect(path, params) {
  const rows = [];
  for await (const row of paginate(path, { ...params, per_page: 200 }, token)) rows.push(row);
  return rows;
}

const [units, floorPlans] = await Promise.all([
  collect("/units", { property_id: propertyId, sort: "number" }),
  collect("/floor-plans", { property_id: propertyId, sort: "name" }),
]);
const byId = new Map(floorPlans.map((fp) => [fp.id, fp]));
const enriched = units.map((u) => ({ ...u, floor_plan: byId.get(u.floor_plan_id) ?? null }));

One paginated pass plus a hash join beats N round trips, and it keeps you far away from the rate limit.

Last updated on

On this page