County Property Records API — Owner, Value, Tax & Assessor Data
Pricing
from $42.50 / 1,000 property record resolveds
County Property Records API — Owner, Value, Tax & Assessor Data
County assessor records as one API: property owner lookup by address or parcel ID, real estate owner search (company and LLC portfolios), assessed home value, property tax and sales history — one normalized schema across 1,350 US counties in 42 states, from public records. For agents, not listings.
Pricing
from $42.50 / 1,000 property record resolveds
Rating
0.0
(0)
Developer
Scott Helvick
Maintained by CommunityActor stats
1
Bookmarked
53
Total users
30
Monthly active users
a day ago
Last modified
Categories
Share
County Property Records API — Owner, Value, Tax & Sales
County property records are public but fragmented across county assessor and recorder systems. This Actor turns county assessor records into one API: it accepts US property addresses, parcel IDs, and owner names, and returns one normalized record per accepted lookup — owner, assessed and market values, tax history, sale history, and property characteristics where published — usable as a single county property database by agents and pipelines.
The registry spans 1,371 county and county-equivalent routes in 44 states. That count describes registered routes, not identical capabilities: some counties support both address and parcel lookup, some parcel lookup only, some return partial records, and a small number are explicitly unavailable. Read the run's COVERAGE artifact for the authoritative capability snapshot.
{"query": "827 Krenson Woods Ln, Lakeland, FL 33813","query_type": "address","input_index": 0,"status": "completed","billing_eligible": true,"county": "Polk","state": "FL","owner_name": "PROGRESS TAMPA 1 LLC","parcel_id": "232912140174000660","assessed_value": 235922,"last_sale_date": "2024-05","last_sale_price": 262500,"tax_history": [],"sale_history": [],"field_notes": []}
What this does
- Resolves full street addresses to normalized public property records.
- Resolves exact parcel/account IDs supplied as
STATE/County/ParcelID. - Searches real estate owners by name (
STATE/Owner NameorSTATE/County/Owner Name) and returns each matching parcel plus a free portfolio summary row — suited to company and LLC holdings. - Returns the same top-level schema across counties, with
nullandfield_notesexplaining unavailable data. - Supports ordered batches and returns one dataset row per accepted input occurrence.
- Pushes each row before any corresponding charge and never bills misses.
Typical uses include portfolio enrichment, owner-of-record verification, assessment research, underwriting inputs, and multi-county property pipelines.
Records come straight from official county assessor and parcel systems, as-is. Rolls can lag recent sales and occasionally contain clerk-side errors — for anything with legal weight, verify against the official record at the source.
Real estate owner search (company and LLC portfolios)
ownerLookups runs a real estate owner search: every parcel a name holds in scope, plus one free owner_portfolio summary row with parcel count, per-county breakdown, assessed and market totals, and an absentee count. Statewide owner search covers Florida, North Carolina, New York, and Indiana; county-scoped search covers standard parcel layers plus selected county portals in Arkansas (Benton, Boone, Calhoun, Pulaski, Saline) and Virginia (Montgomery, Williamsburg). Those Arkansas county records also include county-published appraised and assessed values, land and improvement values, and sales/deed history. The live per-county list is the owner_search flag in the COVERAGE artifact below — a county whose portal offers no owner-name search returns an explicit not-covered row rather than an empty result. California is excluded because its public rolls redact owner names. It is most useful on entity holdings — pass a company or LLC name to map its property portfolio across counties:
{"query": "IH2 Property Florida","query_type": "owner","status": "completed","owner_portfolio": {"parcel_count_returned": 3,"total_matches": 214,"truncated": true,"counties": { "Broward": 2, "Palm Beach": 1 },"assessed_value_total": 81399694,"totals_scope": "all_matches"}}
Owner-search property records bill at the standard per-record event; the portfolio summary row is always free. The use limits below apply: no FCRA-regulated screening.
Assessed home value lookup and property sales history
Every completed record carries the county's published valuation set — assessed_value, market_value, land_value, improvement_value, and tax_year — so a batch doubles as an assessed home value lookup across jurisdictions. sale_history, last_sale_price, and last_sale_date return recorded property sales history where the county publishes it; when sales live in a separate clerk system, field_notes says so structurally instead of returning silent nulls.
Batch API Quickstart
All three examples submit the same batch: two addresses followed by one parcel lookup. Each branches on status before reading nullable fields.
curl
curl -X POST \"https://api.apify.com/v2/acts/shelvick~county-property-records/run-sync-get-dataset-items?token=YOUR_TOKEN" \-H "Content-Type: application/json" \-d '{"addresses": ["827 Krenson Woods Ln, Lakeland, FL 33813","1001 Preston St, Houston, TX 77002"],"parcelLookups": ["IL/Cook/17-09-100-001-0000"],"maxRecords": 3}' | jq -r '.[] | if .status == "completed" then "\(.input_index): \(.owner_name // "owner unavailable")" elif .status == "failed" then "\(.input_index): retry or correct input — \(.error)" else "\(.input_index): not covered — \(.error)" end'
Python
from apify_client import ApifyClientbatch = {"addresses": ["827 Krenson Woods Ln, Lakeland, FL 33813","1001 Preston St, Houston, TX 77002",],"parcelLookups": ["IL/Cook/17-09-100-001-0000"],"maxRecords": 3,}client = ApifyClient("YOUR_TOKEN")run = client.actor("shelvick/county-property-records").call(run_input=batch)for record in client.dataset(run["defaultDatasetId"]).iterate_items():if record["status"] == "completed":print(record["input_index"], record.get("owner_name"), record.get("assessed_value"))elif record["status"] == "failed":print(record["input_index"], "retry or correct input", record.get("error"))else:print(record["input_index"], "not covered", record.get("error"))
JavaScript
import { ApifyClient } from 'apify-client';const batch = {addresses: ['827 Krenson Woods Ln, Lakeland, FL 33813','1001 Preston St, Houston, TX 77002',],parcelLookups: ['IL/Cook/17-09-100-001-0000'],maxRecords: 3,};const client = new ApifyClient({ token: 'YOUR_TOKEN' });const run = await client.actor('shelvick/county-property-records').call(batch);const { items } = await client.dataset(run.defaultDatasetId).listItems();for (const record of items) {if (record.status === 'completed') {console.log(record.input_index, record.owner_name ?? null, record.assessed_value ?? null);} else if (record.status === 'failed') {console.log(record.input_index, 'retry or correct input', record.error ?? null);} else {console.log(record.input_index, 'not covered', record.error ?? null);}}
Batch contract and limits
- Accepted order is all nonblank
addresses, followed byparcelLookups, thenownerLookups. Every output row carries the corresponding zero-basedinput_index; an owner query may emit multiple parcel rows plus one free portfolio summary row at that index. - Blank entries are ignored when mixed with usable inputs. An all-blank request fails validation rather than producing an empty successful run.
- Parcel lookups must be
STATE/County/ParcelID. A bare ID is also accepted with acountyhint alone, provided only one covered state has a county by that name; otherwise addstate(the error names the candidate states). Malformed parcel strings fail validation. - Exact normalized duplicates resolve once and charge at most once, but still produce one row per submitted occurrence with distinct
input_indexvalues. maxRecordsdefaults to 50 and may be raised to 1000. If nonblank submitted lookups exceed it, the run fails clearly; no input is silently dropped. RaisemaxRecordsor split the batch.- Each input array accepts at most 1000 strings. Larger workloads should be split into multiple async runs.
Coverage contract
Coverage is capability-specific, not a binary promise. The COVERAGE key-value record written by each completed run is authoritative for that run:
{"updated_at": "2026-08-01T12:00:00Z","counties": [{"state": "TX","county": "harris","address_lookup": true,"parcel_lookup": true,"owner_search": true,"completeness": "full"}]}
address_lookup,parcel_lookup, andowner_searchstate whether each lookup path is structurally available.completeness: "full"means the configured source can return its normal record shape; individual fields may still be null because counties publish different data.completeness: "partial"marks structurally thin coverage, such as parcel-only counties where records are frequently sparse.completeness: "unavailable"marks a registered county route that cannot currently return assessment data by either lookup type.
Read the artifact from the run's default key-value store:
coverage_record = client.key_value_store(run["defaultKeyValueStoreId"]).get_record("COVERAGE")coverage = coverage_record["value"]
Capability may improve, regress, or change as public sources change. Do not assume monotonic growth or infer address support from the registry count alone.
Input reference
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
addresses | array of strings | one lookup source | [] | Full US street addresses; include city, state, and preferably ZIP. |
parcelLookups | array of strings | one lookup source | [] | Exact STATE/County/ParcelID values. |
ownerLookups | array of strings | one lookup source | [] | Owner searches as STATE/Owner Name or STATE/County/Owner Name; bare names require a state hint. |
address | string | no | blank | Convenience alias appended after the addresses array. |
parcelLookup | string | no | blank | Convenience alias appended after the parcelLookups array. |
ownerLookup | string | no | blank | Convenience alias appended after the ownerLookups array. |
county | string | no | blank | County hint, without the word “County”. |
state | string | no | blank | Two-letter state hint; pair with county for bare parcel IDs. |
includeHistory | boolean | no | true | Include history where published. Set false for a smaller current snapshot. |
maxResultsPerOwner | integer | no | 50 | Maximum property records returned per owner query, 1–500. |
maxRecords | integer | no | 50 | Maximum accepted nonblank lookups, 1–1000. Overflow fails validation. |
Output contract
Every row always emits query, query_type, input_index, status, error_class, billing_eligible, tax_history, sale_history, and field_notes, including failed and not-covered rows. Other fields are nullable.
field_notes identifies structural omissions with fields, reason, and detail. It distinguishes data omitted from the public record from data not carried by the county source used for that row.
Derived output fields are absentee_owner (a conservative situs-versus-mailing comparison), county_fips (the five-digit Census county code), and owner_portfolio (set only on the free summary row emitted after completed owner-search records).
The OUTPUT key-value record contains:
submitted: accepted nonblank input occurrences.deduplicated: unique normalized lookups actually resolved.completed,failed,not_covered: emitted property-record counts by status; free owner portfolio summaries are excluded.thin: completed rows that did not clear the billing answer bar.billing_eligible: unique delivered lookups that cleared the answer bar (each one charged).charged: unique eligible lookups charged.budget_limited: unique lookups stopped by the run's maximum cost — returned asfailedrows with abudget-exhaustederror, never delivered or charged.portfolio_summaries: free owner portfolio summary rows emitted separately from property-record status counts.
Use limits
Records come from public county assessor and recorder data. This Actor must not be used for FCRA-regulated tenant, employment, credit, or insurance screening, and it returns no non-public personal data.
Statuses and retry policy
completed: a property record matched. Consume nullable fields defensively and retaininput_indexfor correlation. Do not retry a completed input merely because optional fields are null.failed: either no match or a transient source/adapter failure. Transient errors are retry-safe;no-matchgenerally means correct or enrich the input before retrying.not_covered: the requested capability is structurally unavailable. Do not retry unchanged input against the same coverage snapshot.
Failure taxonomy: when to retry
status alone cannot tell a county portal that is down for an afternoon from an
address that does not exist. Every non-completed row carries error_class, a stable
machine-routable value. Branch on it rather than on the prose in error, which may
be reworded at any time. It is null on completed rows.
Retry the same request later, unchanged:
source_error— the county records source could not be reached. County portals have scheduled maintenance and unplanned outages, sometimes lasting a day; this is the source being unavailable, not a problem with the lookup.budget_exhausted— the run's maximum cost was reached before this lookup ran.
Do not retry unchanged; correct the input or the expectation first:
no_match— the source answered and nothing matched.malformed_input— the entry could not be parsed.unroutable— the address could not be routed to a county.address_mismatch— the parcel found was not the address asked for.not_covered— the county is not yet supported; checkCOVERAGE.owner_search_requires_county— name the county and search again.
Every retry is a new run and may be billable if it returns an eligible completed record. Pipeline callers should persist completed keys and deduplicate them before retrying a failed subset.
Pricing and billing semantics
The Store Pricing tab is authoritative for current pricing.
Failed and not-covered lookups are never billed. A completed record is billing-eligible only when it returns an identifier, owner, value, or sale price the caller did not supply. A record that only echoes the searched identifier, or only adds secondary characteristics, is returned as a thin completed row with billing_eligible: false.
The Actor pushes rows before charging them. Exact normalized duplicates are resolved and charged at most once while each occurrence remains visible in the dataset. An answer is only ever delivered when it can be charged: once the run reaches its maximum cost, remaining lookups are not resolved and come back as failed rows with a budget-exhausted error, at no charge. Set the maximum cost per run to cover the batch, or resubmit the stubbed remainder in a new run. Compare billing_eligible, charged, and budget_limited in OUTPUT to reconcile the run.
Automation: schedules, agents, and MCP
For scheduled or larger batches, start an async run, wait for terminal status, then retrieve the default dataset by defaultDatasetId. Read OUTPUT from defaultKeyValueStoreId and alert when failed or not_covered exceeds your threshold; keep the dataset rows for exact input_index-level remediation.
Agents can call the Actor through Apify's agent tooling. Instruct them to branch on status, treat nullable fields as optional, inspect field_notes, and consult the run's COVERAGE artifact before promising a lookup capability. Do not let an agent infer success from the presence of a dataset row alone.
Performance and sizing
Lookups run with five-way concurrency. Typical records complete in roughly 1–3 seconds, but public sources have long tails and transient slowdowns.
Use the synchronous dataset endpoint for small batches; 25 or fewer lookups is a conservative ceiling for staying comfortably inside its five-minute response window under normal conditions. Use async runs for larger batches, retrieve completed chunks as they become visible, and split very large workloads so one slow source does not dominate recovery.
High-volume needs
Running thousands of lookups a month, or need specific counties prioritized? Open a conversation on the Actor's Issues tab. High-volume feedback directly drives coverage and roadmap priorities.
FAQ
Why is a field null?
Counties publish different data. Inspect field_notes for structural omissions; an empty note list means no known structural explanation was attached.
What happens for an unsupported county or lookup type?
The row returns status: "not_covered", is not billed, and includes a machine-readable error. Check COVERAGE before retrying.
Can I look up by parcel number?
Yes. Use STATE/County/ParcelID, or pass a bare ID with a county hint — add state as well when the county name is shared by several states.
Are duplicate inputs removed from output?
No. They are deduplicated for resolution and billing, then expanded back to one row per occurrence with distinct input_index values.
What this does not do
- Consumer listing or asking-price data.
- Guaranteed nationwide or uniform field coverage.
- Owner phone numbers, email addresses, or skip tracing.
- Guaranteed completeness for fields a county does not publish.
For active for-sale listings and asking prices, use a listings-portal scraper instead. For owner contact details or skip tracing, use a people-data enrichment tool instead. Use this Actor when you need the official county record behind an address or parcel: owner of record, assessed value, tax history, and recorded sales.
Design notes: www.scotthelvick.com/tools/county-property-records
Related Actors
| Actor | Use it when |
|---|---|
| Secretary of State Business Search + UCC Liens | the property owner is an LLC or corporation and you need the entity behind it — status, officers, UCC liens |
| Property Deed & Lien Records Search | you need the recorded documents behind a property — deeds, mortgages, releases, and liens from county recorder indexes |