County Property Records API — Owner, Value, Tax & Assessor Data
Design notes for a property records API that turns an address or parcel ID into a normalized US property record — owner (where public), assessed value, tax and sale history — across 1,348 counties in 42 states (twenty of them statewide plus New York and Minnesota partial) behind one schema.
County Property Records API — Owner, Value, Tax & Assessor Data
Design notes for county-property-records, a property records API that turns a street address or an assessor parcel ID into a normalized property record — owner (where it’s public), parcel ID, assessed and market value, tax history, and sale history — sourced from public county assessor and recorder records and returned as one JSON shape across many counties. It’s built for property owner lookup by address, called by an AI agent: one tool, one output schema, no per-county special-casing. Coverage today spans 1,348 counties across 42 states — including twenty entire states (Massachusetts, Maryland, New Jersey, Connecticut, Montana, Wisconsin, Mississippi, Arkansas, Georgia, Idaho, Vermont, Maine, Florida, North Carolina, Wyoming, Nebraska, North Dakota, Utah, Indiana, and the District of Columbia) covered statewide, plus most of New York (outside New York City), most of Minnesota, and most of Tennessee through their state systems, and clusters of counties served together because they run the same assessor-software vendor — and grows a config row at a time. Most are served from a single statewide GIS layer over direct HTTP; Florida (10.8M parcels) and North Carolina, too large or flaky to query live, and Indiana, which publishes no live statewide layer at all — only per-county assessment files — are served from a companion index service that extracts each source once and answers address lookups in milliseconds.
What this is
US property data is public, but it’s fragmented across roughly 3,000 counties, each with its own assessor and recorder, its own portal, its own field names, and — increasingly — its own rules about which fields it will and won’t expose. For a human that’s an afternoon of clicking; for an agent it’s 3,000 integrations. The value isn’t in any one county. It’s in collapsing that heterogeneity into a single, predictable contract.
This Actor does that. You pass an address ("425 Addison Rd, Riverside, IL 60546") or a parcel lookup ("IL/Cook/15362060520000"); it resolves the right county, fetches that county’s public record, and returns a PropertyRecord with a fixed set of fields: owner_name, parcel_id, assessed_value, market_value, land_value, improvement_value, tax_year, tax_history, last_sale_date, last_sale_price, sale_history, characteristics, and a field_notes array that explains anything structurally absent.
The decision the Actor makes on every call is the whole product: which county, which public source, how to map that source’s columns onto the unified schema, and — the part I underestimated at first — which fields that source legally or structurally does not carry, and why. The caller never sees that machinery. They see one shape.
It’s deliberately scoped to public-record data: assessor valuations, recorded ownership (where the state publishes it), recorded sales. Not for-sale listings, not estimated “what’s it worth” numbers, not MLS feeds. That keeps it on clean, reusable ground and makes the output something you can build on without a data-licensing problem.
Why I built it this way
One schema is the entire point
A scraper for a single county is a commodity — it solves a tiny slice of the problem and breaks the day that county redesigns its site. The defensible thing is the unification: one call, many counties, identical output. So the architecture is a registry of per-county adapters behind a single resolve(lookup) -> PropertyRecord contract. Adding a county never changes the call you make or the shape you parse; it just lights up another value in the coverage set.
Per-county sources differ; the output must not
Counties don’t agree on how to publish. Some expose clean government open-data APIs — Socrata, Carto, and above all ArcGIS REST, which turns out to be the dominant no-signup machine API for large county assessors. Others only have a rendered web portal. So the registry is config-driven where it can be: a clean ArcGIS county is now one row — an endpoint plus a column map — not a new class. That one dialect unlocked most of the recent growth (the Texas, Florida, Carolina, Georgia, Washington, Tennessee, Missouri, Arizona, and California metros all arrived as config). Clean open-data counties are fetched directly over HTTPS — no proxy, no browser — so they sit at the platform cost floor. The internal path varies wildly; the external contract is byte-identical.
The address is part of the contract too
Real addresses arrive messy: "5003 Atlantic Ave, Ventnor City 08406" (state omitted), "…, NJ, 08406" (a comma after every field), ZIP+4s, "Unit B" suffixes, "425 Addison Rd" in a county that stores 425 ADDISON ROAD. Early on I treated malformed input as the caller’s problem; run telemetry showed real callers — human and agent alike — feeding exactly these shapes and getting a false not covered back. So resolution now meets the address where it is. A trailing ZIP alone pins the county when the state token is missing (a bundled ZIP→county crosswalk, ~41,000 ZIPs); secondary-unit markers are stripped before matching; each adapter folds the query into that source’s stored form — suffixes, ordinals, and directionals fold differently per county because counties store them differently; parcel IDs match punctuation-insensitively, so the dashed and undashed forms hit the same record. And because ZIP boundaries ignore county lines, a no-match on the routed county retries the ZIP’s other covered counties before giving up — the “Bothell, WA” class of miss, where the mailing city says King County but the parcel sits in Snohomish. None of this is glamorous; all of it is the difference between a lookup that works on geocoder-clean strings and one that works on addresses as people actually paste them. (Singular address / parcelLookup inputs are accepted alongside the list forms — agents pass one string more often than a batch.)
Not all nulls are the same
This is the design decision I’m happiest with, and it came from a simple question: when a field comes back null, why is it null? There are two very different reasons, and a bare null hides the difference:
- Omitted by law. The datum is not part of the public record. Texas is a non-disclosure state, so sale prices are not collected publicly anywhere — there is nothing to fetch. California redacts owner names from public records by statute (Gov. Code §7928.205).
- Not in this source. The datum is public somewhere — a recorder’s office, a CAMA card, a companion GIS layer — but it isn’t on the surface this adapter currently reads.
An agent should treat those two cases completely differently. The first is final: don’t retry, don’t flag it as a data error, don’t go looking elsewhere — it genuinely doesn’t exist. The second is a coverage gap that may fill later. So every record carries a field_notes array, where each entry names the absent fields, a reason (omitted_by_law or not_in_source), and a short human detail. A Texas record explains that sale price is non-disclosure; a California record explains the owner redaction and cites the statute. An empty array means everything was sourced normally. It turns “we don’t have this” from an apology into a piece of structured, actionable data.
Including California without owner names
California is the instructive case. My first instinct was to drop it: the state redacts owner names, owner is a headline field, so California “fails the contract.” That was wrong, and catching it changed the coverage story.
The redaction is owner-name only. Assessed value, land and improvement splits, characteristics, parcel ID, situs address — all still public. So instead of returning not_covered for the most populous state in the country, the Actor returns California records owner-less: owner_name is null, a field_notes entry explains the legal redaction, and every other public field is populated. A value-and-characteristics record for a Los Angeles or San Diego parcel is far more useful than nothing, and it’s the most compliant posture — we honor the redaction by never collecting or working around the owner name. The unified contract didn’t have to bend; owner is simply one nullable field among many, and the note says why it’s null.
Lowest-hanging fruit first; build the shared fix for a pattern
Some counties keep sale prices, year-built, or owner on a second layer or behind a bulk export — reachable, but only with a join or a separate indexing service. The temptation is to write that bespoke fetch per county. I don’t. If the fill is easy and in-layer, I do it (one Florida county publishes its sale date as three integer columns, so the adapter composes them). If it’s cumbersome, I leave the field null, tag it not_in_source with a note about where it lives, and move on. The shared join gets built once, when enough counties are queued behind it to justify the mechanism — not one county at a time.
That moment arrived. Once four counties had the same “the datum is on a companion layer keyed by the same parcel id” shape, I built a single best-effort enricher: after the primary record resolves, it fires one more query against the configured second layer and fills the missing fields — sale and year for the Seattle metro’s Snohomish County, year-built for Riverside and Tucson, year-and-area for Cincinnati. When the parcel isn’t on that companion layer (a recent-sales layer only covers recently-sold parcels, say), the record keeps its not_in_source note; when it is, the note’s filled fields drop away. A failure never fails the record. The same instinct produced the bigger lever: a few states — Massachusetts, Maryland, New Jersey, Connecticut, Montana, Wisconsin, Mississippi, Arkansas, Georgia, Idaho, Vermont, Wyoming, Nebraska, North Dakota, and the District of Columbia — publish one denormalized statewide parcel layer, so a single config row lights up the whole state, every municipality, the per-record county read from the data (or, where a state abolished county government like Connecticut or Vermont, from a town→county map). One mechanism, dozens of counties, no per-county code. Maine combines both levers at once: its statewide layer carries the address and parcel id but not the assessment, so the same parcel-keyed enricher joins it to a statewide assessment table to fill owner, value, sale, and year. (Minnesota nearly got mis-filed: a first probe of its statewide layer — served through a state proxy — came back as if the proxy rejected the compound address query, which would have meant the slower index treatment; a re-probe showed the proxy answers the query fine, so it’s one more config row covering most of the state’s population through its opt-in county layer.) New York was the instructive one: its 3.8-million-row layer 500s on any whole-address scan, so it first shipped on a workaround — the house number and street live in separate indexed columns, so querying the indexed fields and matching the exact house number in memory made a layer that timed out resolve in seconds. It has since graduated to the companion index (the same layer, extracted once, answering in well under a second) — and building that index enabled an audit the live queries never could: counting rows per county showed the “statewide” source actually carries only 33 of New York’s 57 non-city counties (Nassau and Monroe are among the missing). So New York is registered as an honest partial, and an address in an absent county returns not covered rather than pretending a real search came up empty. A coverage claim is only as true as the source behind it, and holding a bulk extract is what lets you check. The sources that still don’t fit the clean path stay deferred rather than scraped fragilely (a Texas county whose only host blocks datacenter IPs) — noted, queued, not forced.
Build for the vendor — and verify before you scrape
Two recent moves came from the same realization: a lot of counties that look bespoke actually share infrastructure. Hundreds of small Texas appraisal districts run the same third-party search portal, so one host-keyed adapter — a county-to-host map and a single response parser — lights up every county on that vendor at once, no per-county code. It’s the statewide-layer idea applied to a software vendor instead of a state.
The corollary is a discipline about when not to build. A first survey of Georgia reported that its data sat behind a many-county web portal protected by bot-detection — the kind of target that needs a fragile headless-browser scraper, the most expensive and brittle path in the whole system. The tempting move is to build the scraper. The right move was to look harder: a second pass found a clean statewide parcel layer serving 158 of Georgia’s 159 counties over plain HTTP — owner, value, and sale included — turning a 130-county scraping project into a single config row. The lesson I keep relearning is that “there’s no machine-friendly source” is a hypothesis, not a fact. The clean source is there more often than the first look suggests, and it’s worth pressure-testing that claim before paying for the hard path.
Pay only for data you get
Coverage is partial and always will be — it grows continuously. Charging for a lookup in a county we don’t cover yet, or one that doesn’t resolve, would punish callers for our gaps. So billing is push-then-charge on success only: a record is charged after it’s pushed, and not_covered / failed results come back labeled and free. An agent can fire a batch of mixed addresses and pay only for the ones that returned data.
Coverage is a fact to query, not a list to recite
I deliberately don’t enumerate every supported county in the docs — a wall of county names is thin content that goes stale instantly. Instead, each run writes the live supported-county set to the key-value store (COVERAGE). An agent that needs to know “do you cover this county” asks the manifest, which is always current, instead of trusting prose that drifts.
How to use it
A mixed batch — one address, one known parcel ID, history included:
{
"addresses": ["8321 Faust Ave, Los Angeles, CA 91304"],
"parcelLookups": ["IL/Cook/15362060520000"],
"includeHistory": true
}
from apify_client import ApifyClient
client = ApifyClient("YOUR_TOKEN")
run = client.actor("shelvick/county-property-records").call(run_input={
"addresses": ["8321 Faust Ave, Los Angeles, CA 91304"],
"parcelLookups": ["IL/Cook/15362060520000"],
})
for r in client.dataset(run["defaultDatasetId"]).iterate_items():
print(r["county"], r["owner_name"], r["assessed_value"], r["field_notes"])
A resolved California record — note the owner-less shape and the field_notes that explains it:
{
"query": "8321 Faust Ave, Los Angeles, CA 91304",
"status": "completed",
"county": "Los Angeles",
"state": "CA",
"parcel_id": "2004001003",
"owner_name": null,
"assessed_value": 1033121,
"land_value": 725922,
"improvement_value": 307199,
"tax_year": 2025,
"characteristics": { "year_built": 1973, "building_sqft": 2090, "lot_sqft": 9685 },
"field_notes": [
{ "fields": ["owner_name"], "reason": "omitted_by_law",
"detail": "California redacts property owner names from public records (Gov. Code §7928.205 / AB 1785)." }
]
}
If you’re calling from an MCP-enabled agent, the same call works as a tool on the Apify MCP server — the input schema is self-describing, so the model can construct the call from the tool description, and it’s billable per call over x402 or Skyfire. This is the path I actually optimize for: an agent that needs assessed-value-and-characteristics by address shouldn’t have to know which county portal to scrape, or which states withhold which fields.
How it compares
| Approach | Many counties, one schema | Public-record sourced | Pay-per-use, no minimum | Agent-callable |
|---|---|---|---|---|
| Single-county scraper | — | varies | OK | varies |
| Enterprise property-data platforms | OK | OK | — (subscription + contract) | — |
| county-property-records | OK | OK | OK | OK |
The single-county scraper makes sense when you only ever need that one county. The enterprise platform makes sense when you need nationwide coverage with a contract and a procurement cycle. This Actor is for the case in between — you need several counties, you want public-record data you can reuse, you want to pay per record with no commitment, and you want an agent to call it directly. It also makes a different honesty trade than most: where a field is structurally unavailable, it says so in the data rather than returning a silent blank or a fabricated value.
Pricing model
Pay-per-event, on success only. One charge per resolved property record, after it’s pushed to the dataset; not_covered and failed results are free; the per-run Actor-start event is amortized across the batch. The model rewards breadth — a big batch costs only for the records that came back with data.
Current per-record rates are on the Apify Store Pricing tab.
Open questions / future work
- The long tail. Twenty whole states are now live — most as a single statewide layer (Maine’s pairs an address-only statewide layer with a parcel-keyed join to its assessment table), Florida, North Carolina, and Indiana from a built parcel index, and Utah as a 29-county cluster on one GIS organization (its single statewide layer carries geometry but no value, so the value lives in the per-county tax-roll services). A sweep of the never-probed greenfield states added five clean wins — Wyoming, Nebraska, North Dakota, Utah, and the District of Columbia — and surfaced the pattern in the misses: statewide parcel geometry is common and free, but statewide assessed value is the real differentiator. Virginia, West Virginia, Rhode Island, New Hampshire, and Hawaii each publish a statewide parcel layer that deliberately omits value, pushing it behind per-municipality vendor portals — the effort tier that isn’t worth paying for a whole low-to-mid-population state at once, so the statewide builds stay deferred. Individual high-demand jurisdictions there still get built on their own portals on demand: Arlington County, Virginia (the D.C. metro) was added this way once telemetry showed real customer lookups for it — its own assessment portal carries the full owner/value/sales record even though the statewide Virginia layer carries only geometry. The frontier is the states with no clean live statewide layer: where the whole-state data is only a bulk download or a gated/stale service, the play is to ship the big metros as config rows now and build a parcel index for the rest later (Indiana went that route to a whole state: nine metros stay on their own live layers while the other eighty-three counties — Indiana publishes assessment data only as per-county fixed-width files, not a live layer — come through the index, which downloads and parses each county’s file; that also picked up a metro whose live host blocks datacenter IPs; Minnesota needed no index after all — a re-probe showed its statewide proxy serves the lookup, so most of the state’s population is covered by one config row, the opt-in counties the layer carries). The richest source wins per county — a metro keeps its own live layer even once the statewide index lands.
- The deferred owner-redacted-plus counties. A few large counties expose neither owner nor assessed value on any no-signup REST surface — value lives only behind an interactive web tool, or address is absent entirely — and a few host their only value-bearing layer behind datacenter-IP blocks. They’re parked until a clean (or gateway-fetched) value/address source is worth wiring, rather than scraped fragilely.
- Sale-history depth. The companion-layer enricher now fills the latest sale where a county keeps it on a second layer; a recorder/deeds source could add the full transaction chain for the counties where history is the use case — the same machinery, pointed at a deeper source.