# County Property Records API — Owner, Value, Tax & Assessor Data (`shelvick/county-property-records`) Actor

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.

- **URL**: https://apify.com/shelvick/county-property-records.md
- **Developed by:** [Scott Helvick](https://apify.com/shelvick) (community)
- **Categories:** Real estate, Business, Developer tools
- **Stats:** 27 total users, 14 monthly users, 99.7% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $42.50 / 1,000 property record resolveds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## 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.

```json
{
  "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 `null` and `field_notes` explaining 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

```bash
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

```python
from apify_client import ApifyClient

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,
}

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

```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 nonblank `parcelLookups`. Every output row carries the corresponding zero-based `input_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 both `state` and `county` hints 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_index` values.
- `maxRecords` defaults to 50 and may be raised to 1000. If nonblank submitted lookups exceed it, the run fails clearly; no input is silently dropped. Raise `maxRecords` or 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:

```json
{
  "updated_at": "2026-08-01T12:00:00Z",
  "counties": [
    {
      "state": "TX",
      "county": "harris",
      "address_lookup": true,
      "parcel_lookup": true,
      "completeness": "full"
    }
  ]
}
```

- `address_lookup` and `parcel_lookup` state 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:

```python
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 retain `input_index` for 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-match` generally 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](https://www.scotthelvick.com/tools/county-property-records)

# Actor input Schema

## `addresses` (type: `array`):

Full US property street addresses to resolve to county property records, e.g. "827 Krenson Woods Ln, Lakeland, FL 33813". Each address is matched to the right county assessor/recorder and returned as one normalized record. Include city + state (and ZIP if known) for reliable county resolution. Provide this and/or `parcelLookups`; at least one is required.

## `parcelLookups` (type: `array`):

Direct parcel or assessor account lookups when you already know the ID, one per line in the form STATE/County/ParcelOrAccount, e.g. "FL/Polk/232912140174000660". Use this for exact-record retrieval; use `addresses` when you only have the street address. Provide this and/or `addresses`; at least one is required.

## `address` (type: `string`):

Convenience single-address alias of `addresses`: pass one full US property street address here instead of a list, e.g. "827 Krenson Woods Ln, Lakeland, FL 33813". Folded into `addresses`; use `addresses` for a batch. Provide at least one lookup across `addresses`/`address`/`parcelLookups`/`parcelLookup`.

## `parcelLookup` (type: `string`):

Convenience single alias of `parcelLookups`: one STATE/County/ParcelOrAccount lookup, e.g. "FL/Polk/232912140174000660". Folded into `parcelLookups`; use `parcelLookups` for a batch.

## `county` (type: `string`):

Optional county name (without the word 'County') to scope or disambiguate lookups, e.g. "Polk". Useful when addresses are ambiguous across county lines. Leave blank to auto-resolve each address to its county.

## `state` (type: `string`):

Optional two-letter US state code to scope lookups, e.g. "FL". Combined with `county` to disambiguate same-named counties across states.

## `includeHistory` (type: `boolean`):

When true (default), each record includes full tax-year history and recorded sale history where the county publishes it. Set false for a current-snapshot-only record (owner, value, latest tax year) — faster and smaller.

## `maxRecords` (type: `integer`):

Safety cap on the number of records resolved in one run (across addresses + parcelLookups). Protects against oversized batches. Default 50.

## Actor input object example

```json
{
  "addresses": [
    "827 Krenson Woods Ln, Lakeland, FL 33813"
  ],
  "parcelLookups": [],
  "includeHistory": true,
  "maxRecords": 50
}
```

# Actor output Schema

## `results` (type: `string`):

Normalized property records for this run (one per accepted lookup).

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "addresses": [
        "827 Krenson Woods Ln, Lakeland, FL 33813"
    ],
    "parcelLookups": [],
    "address": "",
    "parcelLookup": "",
    "county": "",
    "state": ""
};

// Run the Actor and wait for it to finish
const run = await client.actor("shelvick/county-property-records").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "addresses": ["827 Krenson Woods Ln, Lakeland, FL 33813"],
    "parcelLookups": [],
    "address": "",
    "parcelLookup": "",
    "county": "",
    "state": "",
}

# Run the Actor and wait for it to finish
run = client.actor("shelvick/county-property-records").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "addresses": [
    "827 Krenson Woods Ln, Lakeland, FL 33813"
  ],
  "parcelLookups": [],
  "address": "",
  "parcelLookup": "",
  "county": "",
  "state": ""
}' |
apify call shelvick/county-property-records --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=shelvick/county-property-records",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/9EezIhXgxEUIGgPN7/builds/ypwmiG6HN8YmlyN3P/openapi.json
