Irmu
Guides13 minIrmu Engineering

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:

property_api.py
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.

comps.sql
-- 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.

Start building with Irmu today

200 free credits every month, no card required. Every API, every integration, one key.