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
A property records API for US counties: a property owner lookup by address (or parcel ID) returning owner, assessed and market value, property tax history, and sale history — one normalized schema across 1,348 counties in 42 states, from public assessor and recorder data. For agents, not listings.
Pricing
from $42.50 / 1,000 property record resolveds
Rating
0.0
(0)
Developer
Scott Helvick
Maintained by CommunityActor stats
0
Bookmarked
25
Total users
13
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 accepts US property addresses and parcel IDs and returns one normalized record per accepted lookup, including owner, assessed and market values, tax history, sale history, and property characteristics where published.
The registry spans 1,348 counties in 42 states. That count describes registered county 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. - 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.
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 by all nonblankparcelLookups. Every output row carries the corresponding zero-basedinput_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 accepted only when bothstateandcountyhints are supplied. 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,"completeness": "full"}]}
address_lookupandparcel_lookupstate whether that 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. |
address | string | no | blank | Convenience alias appended after the addresses array. |
parcelLookup | string | no | blank | Convenience alias appended after the parcelLookups 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. |
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, 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.
The OUTPUT key-value record contains:
submitted: accepted nonblank input occurrences.deduplicated: unique normalized lookups actually resolved.completed,failed,not_covered: emitted row counts by status.thin: completed rows that did not clear the billing answer bar.billing_eligible: unique completed lookups that cleared the answer bar.charged: unique eligible lookups charged within the run budget.budget_limited: eligible unique lookups left uncharged by the budget clamp.
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.
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.
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. Budget-limited eligible records remain in the dataset with billing_eligible: true; 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 together with both state and county hints.
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.
- Owner-name search or one-to-many portfolio discovery.
- Guaranteed completeness for fields a county does not publish.
Design notes: www.scotthelvick.com/tools/county-property-records