Export to a warehouse or BI tool
Land Resi data in Snowflake, BigQuery, or a spreadsheet for portfolio reporting.
Who this is for: client internal analytics teams and BI partners building occupancy, pricing, and marketing reporting across a portfolio.
What you'll build: a scheduled extract that lands Resi entities in your warehouse with enough history to trend availability and pricing over time.
What Resi can and cannot give you
It can give you the current state of every property, building, floor plan, unit, amenity, fee, review, and content record in an account, plus lead sources and connection configuration, all through V2.
It cannot give you history. The API returns current state only: there is no "availability as of last Tuesday" endpoint and no change log. If you want trends, snapshot on ingest: every extract writes a dated row. Miss a day and that day is gone for good.
Leads, tour bookings and demand events are not readable through the API. The API accepts them but has no endpoint that lists them, so a warehouse built on the API covers inventory, pricing and content, not the lead funnel.
The snapshot decision is the single most important one in a Resi BI pipeline, and it has to be made before the first run.
Extract design
Two tiers, matching what the API supports:
| Tier | Resources | Method | Cadence |
|---|---|---|---|
| Full snapshot | properties, buildings, floor plans, units | Full scan (no updated_at filter) | Hourly or daily |
| Incremental | amenities, fees, reviews, announcements, content blocks, FAQs, galleries, neighborhood places, lead sources, connections | ?updated_at[gte]= | Daily, plus a weekly full scan |
An updated_at query never returns a deleted record, so the incremental tier cannot see deletions. The weekly full scan is what catches them.
import os, requests
from datetime import datetime, timezone
BASE = "https://v2.getresi.com/api/v2"
HEADERS = {"Authorization": f"Bearer {os.environ['RESI_TOKEN']}", "Accept": "application/json"}
def collect(path, **params):
params.setdefault("per_page", 200)
rows, url, account_id = [], f"{BASE}{path}", None
while url:
res = requests.get(url, headers=HEADERS, params=params, timeout=30)
res.raise_for_status()
account_id = res.headers["X-Resi-Account-Id"]
body = res.json()
rows.extend(body["data"])
url = body["links"]["next"] # carries only ?page=N; params are sent again
return rows, account_id
extracted_at = datetime.now(timezone.utc).isoformat()
units, account_id = collect("/units", sort="number")
for u in units:
u["_extracted_at"] = extracted_at
u["_account_id"] = account_idlinks.next carries only the page parameter, not your filters or per_page. Send your parameters again with every page, as above. If you drop them, the second page onward comes back unfiltered at the default page size of 15.
Recommended warehouse model
dim_property current state, SCD-2 on name / description / enabled / address
dim_building current state
dim_floor_plan current state
dim_unit current state, SCD-2 on floor_plan_id / building_id
fact_unit_snapshot ← the important one: one row per unit per extract
(unit_id, extracted_at, is_available, is_enabled, is_hidden,
is_model, is_guest_suite,
min_rent, max_rent, min_base_rent, max_base_rent,
available_at, vacate_at, made_ready_at, specials, updated_at)
dim_amenity, dim_fee, fact_review, dim_lead_sourcefact_unit_snapshot is what makes the pipeline worth building. From it you can derive:
- Occupancy and availability over time: count of available units by day, by property
- Rent trend: average and median
min_rentby floor plan by week - Days on market: the span between a unit first appearing available and disappearing
- Concession tracking: when
specialsappears and disappears - Turn time:
vacate_at→made_ready_at→available_at
None of these are answerable from a single extract. All are trivial once you have the fact table.
Metrics that need care
Rent. min_rent / max_rent is the total monthly leasing price (TMLP): base rent plus the monthly equivalent of the property's mandatory fees. min_base_rent / max_base_rent is base rent alone. Load both and label them clearly. Properties also differ in which one they advertise (pricing_display_settings.pricing_display_mode on the property), so store that per property if a dashboard should match what renters see.
Rents are numbers or null, and max_rent is often null. Keep null as null: if it becomes 0, your averages will be silently wrong.
"Available" is several flags, not one.
CASE WHEN is_enabled AND is_available AND NOT is_hidden
AND NOT is_model AND NOT is_guest_suite
THEN 1 ELSE 0 END AS is_marketableStore the raw flags and derive the metric in the warehouse, so the definition is visible and changeable.
Unit counts. Total units ≠ marketable units ≠ leasable units. Model apartments, guest suites, and disabled records all inflate a naive count. Agree the definition with the business before publishing a number anyone will quote.
Multi-account portfolios
A token is pinned to one account, and every V2 call runs against that account by default. If the token's user belongs to several accounts, pass account_id to run a call against another of them: one token can extract them all. GET /api/v2/accounts lists the accounts the user can reach.
Every V2 response names the account it ran against in the X-Resi-Account-Id header. Stamp it on every row at ingest, as the collect() above does. Most payloads do not include an account id, and without it you cannot tell two accounts' data apart once it is in the same table.
Fields V2 does not carry
- Fee-by-fee breakdown. V2 units and floor plans carry the itemized lines behind the total in
fee_breakdown, withmonthly_fee_totalandupfront_fee_totalalongside, as Resi last stored them (fees_synced_at). Load the lines into their own table keyed by unit or floor plan id andfee_id. - Availability rollups. Counts such as
availableUnitsCountare onGET /api/v1/property/{property}. You can also derive them fromfact_unit_snapshot.
The property's street address and coordinates are on the V2 property as address, so no V1 call is needed for mapping.
Scheduling and cost
- 120 requests/minute per user. A daily full extract of a 5,000-unit portfolio is about 30 requests. Hourly is fine too.
- Use a dedicated token created by a separate service-account user. The limit is per user, so the extract's budget stays apart from live integrations.
- Run off-peak and stagger accounts rather than firing them all at once.
- Log row counts per resource per run and alert on a swing beyond about 10%. A sudden drop usually means a partial scan, not a real change, and a partial scan loaded as truth will corrupt the trend for that day.
Checklist
- Snapshot table designed before the first run
-
_extracted_atstamped on every row - Account id from
X-Resi-Account-Idstamped on every row - Query parameters re-sent on every page
- Weekly full scan to catch deletions
-
nullrents preserved, never coerced to 0 - Both
min_rentandmin_base_rentloaded and labeled - Marketability derived in SQL from raw flags
- Dedicated service-account token
- Row-count alerting on every run
Last updated on