Syncing inventory
Keep an external system continuously current with Resi properties, floor plans, and units.
This is the most common integration Resi sees: a vendor mirrors a client's inventory into their own database and keeps it fresh. This guide covers the loop that works today, including the two limitations you have to design around.
Two sync strategies
Resi supports two patterns, and a well-built integration uses both:
- Scan-and-diff for inventory. Properties, buildings, floor plans and units have no change filter, so they are scanned in full each cycle and diffed against your mirror. This is also the right pattern for anything where availability and pricing matter, because it is self-correcting: a missed cycle repairs itself on the next run rather than leaving a permanent gap.
- Incremental pull for content. Amenities, fees, reviews, FAQs, galleries, announcements, content blocks, neighborhood places and media all accept
?updated_at[gte]=, so you fetch only what changed since your last run.
V2 has no change webhooks, so both patterns are polling.
Scanning is cheaper than it sounds. At per_page=200 (the maximum), a 5,000-unit portfolio is 25 requests, well inside the 120 requests a minute each user gets.
The sync loop
1. Full-scan properties → ~1 request
2. Full-scan floor plans → ~1–3 requests
3. Full-scan buildings → ~1–3 requests
4. Full-scan units → N/200 requests
5. Diff each collection against your mirror by `id`
6. Apply creates / updates / soft-deletes
7. Incrementally pull content by `updated_at` since your last runRun inventory every 10–15 minutes. Availability and pricing are what actually move; the rest is nearly static, and a slower cadence for content is fine.
The endpoints: GET /api/v2/properties, GET /api/v2/floor-plans, GET /api/v2/buildings and GET /api/v2/units. If your token's user belongs to more than one account, add account_id to target another of them; the X-Resi-Account-Id response header names the account each response came from (see Authentication).
Step 1: scan a collection
async function collect(path, token) {
const rows = [];
let url = `https://v2.getresi.com/api/v2${path}`;
while (url) {
const res = await request(url, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
});
const body = await res.json();
rows.push(...body.data);
url = body.links?.next ?? null;
}
return rows;
}
const units = await collect("/units?per_page=200&sort=number", token);Page in a fixed order. Every collection has a stable default order (oldest first, ties broken by id), so a static scan never skips or repeats a record. A record created or deleted mid-scan still shifts later pages, so de-duplicate by id, and filter by updated_at[gte] for incremental runs.
Properties support no sorting at all. In practice this is harmless: portfolios rarely exceed one page at per_page=200. If a client has more properties than that, scan them twice and reconcile, or fetch each property by the ids you already hold.
Step 2: diff against your mirror
Key on Resi's id. It is a UUID and it is stable. Do not key on slug (it changes when a name changes) or on reference_id (nullable, not guaranteed unique).
function diff(remote, localById) {
const created = [], updated = [], seen = new Set();
for (const row of remote) {
seen.add(row.id);
const local = localById.get(row.id);
if (!local) created.push(row);
else if (local.updated_at !== row.updated_at) updated.push(row);
}
const removed = [...localById.keys()].filter((id) => !seen.has(id));
return { created, updated, removed };
}Comparing updated_at is enough to detect a change and avoids diffing every field. It is also cheap: you already have both values.
Step 3: handle disappearances carefully
A record that vanishes from the collection means one of four things, and only one of them is a deletion:
| Cause | How to tell |
|---|---|
| Genuinely deleted | It 404s on GET /api/v2/units/{unit} |
Disabled (is_enabled: false) | It is still in the collection. You filtered it out. |
| Moved outside the groups your user can see | Other records also vanished at the same time |
| A partial scan (network failure mid-pagination) | Your scan did not complete |
Never hard-delete on absence. Soft-delete with a timestamp, and only purge after a record has been absent from N consecutive complete scans. Abort the diff entirely if the scan did not finish. A half-scan that gets treated as truth will wipe a client's listings.
Step 4: sync content incrementally
Here you can do the efficient thing:
curl -G "https://v2.getresi.com/api/v2/reviews" \
--data-urlencode "updated_at[gte]=2026-08-06T09:00:00Z" \
--data-urlencode "sort=-updated_at" \
--data-urlencode "per_page=200" \
-H "Authorization: Bearer $RESI_TOKEN"Store the high-water mark from the response, not your local clock, and overlap it by a minute or two to absorb clock skew:
const highWater = new Date(Math.max(...rows.map((r) => Date.parse(r.updated_at))));
const nextSince = new Date(highWater.getTime() - 120_000).toISOString();Collections that accept updated_at[gte] and sort=updated_at: amenities, fees, reviews, announcements, content blocks, FAQs, galleries, neighborhood places, neighborhood categories, lead sources, connections and media.
A deletion does not show up in an incremental pull. Run a full scan of each content collection now and then (daily is plenty) to catch records that were removed.
What V2 gives you, and what only V1 has
The V2 property includes its primary address, with coordinates and timezone:
{
"address": {
"street": "123 Main St",
"street_2": null,
"city": "Pawnee",
"county": "Delaware",
"state": "IN",
"zipcode": "47302",
"country": "US",
"latitude": 40.19,
"longitude": -85.38,
"timezone": "America/Indiana/Indianapolis"
}
}address is null when the property has none, and latitude/longitude are null when no coordinates are stored.
V2 units and floor plans carry both rents: min_base_rent/max_base_rent is base rent, and min_rent/max_rent is the total monthly leasing price (TMLP): base rent plus the monthly equivalent of the mandatory fees. They also carry the fees behind the difference: monthly_fee_total, upfront_fee_total and the itemized fee_breakdown. V2 returns all of these unfiltered, whatever the property's pricing display settings say.
Writing rent. Each rent bound has one authoritative side. Base rent is authoritative unless the property's PMS connection sends all-in rents (pricing_includes_fees). Send whichever side you hold and Resi calculates the other from it and the fees in effect; the response carries both. Sending both sides of one bound (min_rent and min_base_rent) is a 422. When fees change later, Resi recalculates the calculated side and keeps the one you sent.
Two things a syndication or listing integration may need come only from the public V1 delivery API.
Availability and rent rollups
GET /api/v1/property/{property} adds the property-level rollups that V2 does not compute:
curl "https://v2.getresi.com/api/v1/property/$PROPERTY_ID"{
"data": {
"id": "019dd604-8468-7388-9a52-4c31a2e1209a",
"name": "Pawnee Place",
"unitsCount": 240,
"availableUnitsCount": 17,
"hasAvailability": true,
"minRent": 1195,
"maxRent": 2450,
"minBaseRent": 1150,
"maxBaseRent": 2395
}
}The response is abridged here. Note the camelCase: V1 payloads are shaped for website rendering and do not match V2 naming. V1 is also where the property's pricing display settings are applied, so a price the client has chosen to hide can come back null. Fetch it once per property per sync and merge. The itemized fee lines behind each total are on the V1 units and floor plans endpoints; see Pricing and fees.
Lease-term pricing
min_rent/max_rent is a marketed range, not a term-by-term price. For the full lease-term matrix, use GET /api/v1/property/{property}/unit/{unit}/price-matrix:
curl "https://v2.getresi.com/api/v1/property/$PROPERTY_ID/unit/$UNIT_ID/price-matrix"It returns one row per lease start date, each with a rent for every available term length (1 to 24 months). If Resi has no stored matrix for the unit, it queries the property's PMS connection live, so this endpoint can be much slower than a normal read. If the PMS fails, you get an empty array rather than an error. Call it for units you actually display, not for the whole portfolio, and cache aggressively.
Writing back
If your integration also writes to Resi:
PATCHonly the fields you own. Sending a field back unchanged risks overwriting a concurrent edit made in the Resi app or by a PMS sync.- Expect the PMS to win. For a property with an active PMS connection, the fields the connection syncs (usually availability, pricing, unit numbers and dates) are refused with a
422naming the connection, because the next sync would overwrite them. Write to fields the PMS does not own (marketing copy, media, tags), or ask the client to turn off sync for that field in the connection's settings. - Set
reference_idon anything you create so you can find it again.
Checklist
- Page until
links.nextis null, sending your filters andper_pagewith every page (thenextlink carries onlypage) -
per_page=200and an explicitsorton every deep scan - Abort the diff if a scan does not complete
- Key on
id; never onslug - Soft-delete on absence, purge only after repeated complete scans
-
updated_athigh-water marks taken from response data, with overlap - A periodic full scan of content collections to catch deletions
- Retry
429/5xxwith backoff; treat401/403/422as terminal - Availability rollups pulled from V1 if you need them
- No writes to PMS-owned fields
Last updated on