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"| Parameter | Default | Max |
|---|---|---|
per_page | 15 | 200 (values above are clamped, not rejected) |
page | 1 | — |
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=hardboolean
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=falsesearch
Case-insensitive partial match — a contains, not a full-text search. No relevance ranking, no fuzzy matching.
?name=riversiderange 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:00ZPassing 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 groupsAn 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
| Resource | Filters | Notes |
|---|---|---|
| properties | G only | No sort. Filter client-side; portfolios rarely exceed one page at per_page=200 |
| units | property_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 · G | number also matches the building-prefixed display form (149-304). No updated_at filter: sync with scan-and-diff |
| floor-plans | property_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 · G | Sync with scan-and-diff |
| buildings | property_id code type E · name number S · is_enabled B · G | Sync with scan-and-diff |
| amenities | code type reference_id E · name S · is_enabled is_property_marketable B · created_at updated_at D · property_id (via link) | |
| fees | code status class currency source E · name S · created_at updated_at D · property_id (via property assignment) | |
| reviews | property_id source external_review_id E · reviewer_name S · rating R · review_date created_at updated_at D · is_published B | |
| announcements | elements_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-blocks | type 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) | |
| faqs | faq_topic_id E · question answer S · sort_order R · created_at updated_at D · is_global B · property_id (via link) | |
| galleries | 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) | |
| neighborhood-places | property_id neighborhood_category_id place_id E · name address S · rating sort_order R · created_at updated_at D | |
| neighborhood-categories | name S · sort_order R · created_at updated_at D | |
| lead-sources | property_id name E · code S · created_at updated_at D | |
| connections | type category E · name S · enabled B · created_at updated_at D · property_id (via attachment) | |
| media | kind (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 |
| users | name 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 /unitsreturns unit fields,external_idsand media arrays, but not the nestedproperty,building,floor_planoramenities. - Single-resource responses are rich.
GET /units/{id}includesproperty,building,floor_plan, andamenities.
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