Real Estate Data API: Buy One or Build One?
A field guide to property data APIs — what the vendors actually sell, where the gaps are, and how to assemble the equivalent from public listing pages when licensing does not fit.
Four different things get called a property data API
The category is muddled, and picking wrong costs months. Public-record aggregators sell county assessor and deed data: ownership, parcel geometry, tax assessments, sale history. They are authoritative on who owns what and what it last sold for, and they know nothing about what is on the market today.
MLS syndicators sell active listings through RESO Web API feeds. This is the real deal for on-market data, and it comes with membership requirements, per-market agreements and display rules that govern what you may show and to whom. Valuation APIs sell an estimate and nothing else. And rental-market APIs sell aggregated rent comparables, usually at ZIP or neighbourhood granularity rather than per unit.
Then there is the fifth option, which is the one people arrive at after pricing the other four: build a narrow pipeline that collects exactly the fields you need from public listing pages, and own it.
- Public records: authoritative ownership and sales, no on-market data, coverage varies by county.
- MLS via RESO: complete on-market data, gated by membership and display rules.
- Valuation: one number, no underlying detail.
- Rental comps: aggregated, rarely unit-level.
- Build your own: exactly your fields, your cadence, your maintenance burden.
When licensing is clearly the right answer
If you are building a consumer-facing portal that displays listings, license. If you need nationwide ownership and lien data, license — that information lives in county systems, not on listing pages, and the aggregators have already done the unglamorous work of normalizing 3,000 counties. If you are regulated, license, because provenance matters more than cost.
Building makes sense when your need is narrow and specific: a handful of markets, a handful of fields, a cadence nobody sells. Tracking price cuts across 400 buildings in three cities is a weekend of work and a few dollars a month to run. No vendor will sell you that shape at that price.
The build: one endpoint, three switches
Irmu's whole API is `GET /crawl`. Pass a `url`, and optionally `premium` for residential IPs, `js` to control JavaScript rendering, and `ai_query` to get fields back instead of markup. That is the entire surface, which is deliberate — there is nothing to learn and nothing that drifts.
For property data the pattern is a thin wrapper that returns typed rows:
import json
from dataclasses import dataclass
from typing import Optional
import requests
CRAWL = "https://app.irmu.com/api/crawl"
FIELDS = (
"address, city, state, zip, price_usd, beds, baths, sqft, lot_sqft, "
"year_built, property_type, status, listed_date, hoa_monthly, "
"annual_tax_usd, latitude, longitude"
)
@dataclass
class Property:
address: str
price_usd: Optional[float]
beds: Optional[float]
baths: Optional[float]
sqft: Optional[int]
raw: dict
def get_property(url: str, *, api_key: str, premium: bool = True) -> Property:
params = {
"url": url,
"js": "true",
"ai_query": (
f"Extract this property listing as a JSON object with keys: {FIELDS}. "
"Numbers as numbers. null when the page does not state a value. "
"Return only JSON."
),
}
if premium:
params["premium"] = "true"
res = requests.get(
CRAWL,
headers={"Authorization": f"Bearer {api_key}"},
params=params,
timeout=180,
)
res.raise_for_status()
text = res.text
data = json.loads(text[text.index("{"): text.rindex("}") + 1])
return Property(
address=data.get("address", ""),
price_usd=data.get("price_usd"),
beds=data.get("beds"),
baths=data.get("baths"),
sqft=data.get("sqft"),
raw=data,
)Normalization is the actual work
Crawling is the easy half. The half that decides whether your data is usable is normalization, and it is unglamorous in a way that surprises people who have only worked with clean vendor feeds.
Addresses arrive in a dozen shapes for the same building. Areas are quoted in square feet on one portal and square metres on another, sometimes including a garage and sometimes not. Currencies differ, bathroom counts are half-integers, and property type is a free-text field with hundreds of distinct values across sources. Decide your canonical representation before you write a single row, because retrofitting a unit convention onto a live table is genuinely painful.
- Store one canonical unit and keep the original alongside it — `sqft` plus `area_raw`.
- Normalize addresses through a single library or service; key on the normalized form, never the display string.
- Map property type onto a small closed enum and log anything unmapped.
- Attach a `source`, a `source_url` and an `observed_at` to every row. Provenance beats cleverness.
Serving it as an internal API
Once rows land in Postgres, put a thin read API in front of them so consumers never touch the crawler. Two things matter: the query interface your team actually wants, which is usually geography plus a price band plus a freshness bound, and an explicit staleness signal on every response so callers know how old the answer is.
Crawl on a schedule, serve from the database, never crawl inside a request. A synchronous crawl behind a user-facing endpoint turns a 200 ms lookup into a 30 second wait and burns credits on repeated views of the same page.
-- Comparables: same ZIP, similar size, seen in the last 30 days.
select p.listing_id,
p.address,
o.price_usd,
p.sqft,
round(o.price_usd / nullif(p.sqft, 0), 2) as price_per_sqft,
o.observed_at
from properties p
join lateral (
select * from observations
where listing_id = p.listing_id
order by observed_at desc
limit 1
) o on true
where p.zip = $1
and p.sqft between $2 * 0.8 and $2 * 1.2
and o.observed_at > now() - interval '30 days'
order by price_per_sqft;Budgeting it honestly
The credit multipliers are published and there is no per-seat or per-endpoint pricing on top: 1 for a fetch, 5 rendered, 10 premium, 25 rendered premium, and 5 more for an `ai_query`. Geotargeting adds nothing. So a fully-extracted listing costs 30 credits, and the $90 Standard plan's million credits covers about 33,000 of them per month.
Compare that against the vendor quotes you are weighing. Licensed feeds typically start in the high hundreds per month for a single market and rise steeply with coverage — which is often correct value for what they include, and often absurd if you needed four fields from 600 buildings.
Remember what counts as billable: any response the target served, including 404s and anti-bot blocks. Failures on Irmu's side and uncovered domains like `.gov` are not billed — which matters here, because a fair amount of public-record data lives on `.gov` hosts you will need another route to reach.
Prototype on the 200 free signup credits: six fully-extracted listings is enough to prove your field list is right before you commit to a plan.
The decision, compressed
Buy when you need breadth, authority or the right to redistribute. Build when you need depth in a narrow slice, a cadence nobody offers, or fields that live on the page but never make it into a feed. Plenty of teams do both: license the spine, crawl the specifics.
The mistake is treating it as an identity question rather than a scope question. Write down the fields, the markets and the refresh rate first. The answer usually falls out of that list before you have talked to a single vendor.
Keep reading
How to Scrape Google Maps (2026 Guide)
Collect business listings, ratings and reviews from Google Maps reliably — without maintaining a browser farm.
GuidesScrape Amazon Product Data with Python
A practical walkthrough for pulling prices, buy box, stock and reviews from Amazon at scale.
EngineeringCloudflare Bypass: What Actually Works in 2026
A technical look at Turnstile, TLS fingerprinting and why most open-source bypasses stopped working.
Start building with Irmu today
200 free credits every month, no card required. Every API, every integration, one key.