# How to read the PARCEL API docs

The API documents itself. The pages below are generated from the running code, so
they are never out of date with the deployment you are pointed at - if a route
changed, the page changed with it.

**Start at [`/developers`](/developers)**, the documentation home. It links every
public document, and every one of them is readable without a key:

| Document | Page | As data |
|---|---|---|
| This guide | [`/docs/guide`](/docs/guide) | [`/docs/api.md`](/docs/api.md) (Markdown) |
| The API reference | [`/docs`](/docs), [`/redoc`](/redoc) | [`/openapi.json`](/openapi.json) (OpenAPI 3) |
| What changed, newest first | [`/docs/changelog`](/docs/changelog) | [`/changelog`](/changelog) (JSON), [`/changelog.md`](/changelog.md), [`/changelog.atom`](/changelog.atom) (feed) |
| A one-screen index for agents | | [`/llms.txt`](/llms.txt) |

To hear of every change, poll `GET /changelog?since=<the version you built against>`
or subscribe to the feed; [The changelog](#the-changelog) below says what it holds.

There are **two** documents, and which one you want depends on who you are.

| Page | URL | Contains | Who may read it |
|---|---|---|---|
| **Customer Swagger UI** | `/docs` | The `/v1` read routes | anyone |
| **Customer ReDoc** | `/redoc` | ditto | anyone |
| **Customer spec** | `/openapi.json` | ditto | anyone |
| **Internal Swagger UI** | `/docs/internal` | every route, every scope | bearer or Basic |
| **Internal ReDoc** | `/redoc/internal` | ditto | bearer or Basic |
| **Internal spec** | `/openapi-internal.json` | ditto | bearer or Basic |

All six are on `https://13-140-33-19.sslip.io`. Locally, run
`uvicorn parcel.api.app:app --port 8790` and use `http://127.0.0.1:8790/docs`.

The customer document is public on purpose: a developer has to be able to see what
they would be buying before they have a key. It lists paths and shapes, not data,
and every request it describes still needs a credential.

Three more, for a reader that is not a browser:

| Page | URL | Contains | Who may read it |
|---|---|---|---|
| **This guide, as Markdown** | `/docs/api.md` | this file, minus the sections marked internal | anyone |
| **This guide, whole** | `/docs/api-internal.md` | this file with the sections marked internal kept | bearer or Basic |
| **The one-screen index** | `/llms.txt` | what PARCEL is, the auth header, the document URLs, the routes that answer most questions | anyone |

`GET /docs/api.md` serves **this document**. Sections about running the machine -
where the master token lives, how to rotate it, the scraper console, the storage
report, the audit log, the invoice run - are marked with an HTML comment,
`<!-- internal -->`, on the line under their heading, and are dropped on the way
out. The marker is invisible in every Markdown renderer, so the file still reads
as one document here. `parcel/api/ops.py` does the stripping (`strip_internal`),
and `tests/test_api_docs.py` fails if anything operational reaches the served
copy. `GET /docs/api-internal.md` is the whole file, those sections kept, behind
the credential the internal documents take: the master token, **any named key**
(a customer's `zillow` key reads it, as it reads `/openapi-internal.json`; 0.52.9),
or HTTP Basic with the master token.

## Authentication & keys

There are two kinds of credential, and both go in the same header:

```
Authorization: Bearer <credential>
```

### Named keys

A key you mint through the API. It looks like `pk_<8 chars>_<32 chars>`. Its
SHA-256 is what authenticates it, so a database dump does not hand anyone a
working credential. With `API_KEY_VAULT_SECRET` set on the box, the create call
also stores a sealed copy and `POST /keys/{id}/reveal` shows the key again (see
"Showing a key again"); without it, the plaintext is returned exactly once, by the
call that creates it.

Each key has a name (unique, and it is what shows up in the audit log), a **plan**,
a set of scopes, a status, an optional expiry, and optional per-key overrides for
its rate limit and monthly quota. Customer keys also carry an `owner_email` and a
`customer_id` so the usage export can be turned into an invoice. Any key can be
switched off - revoked or suspended - without disturbing anybody else's access.
A key's `vote_trust` decides whether its buyer votes count toward the shared
ranking (see "Buyer votes"); it is off unless an admin turns it on.

### Scopes

| Scope | Opens |
|---|---|
| `read` | The **product**: counties, parcels, images, owners, buyer matching, export and changes, and `GET /usage` - plus one write, `POST /match/votes`, a customer's own vote on a buyer we suggested. |
| `write` | Owner decisions, splits, rule overrides and aliases; match feedback and weights. |
| `admin` | Everything else - including GETs that expose how the machine runs. |
| `zillow` | The Zillow-derived data: `GET /market`, `GET /homes` and `/homes/{property_id}`, the parcel `zillow` field and the `zillow_status` / `zillow_sold_after` filters, which a `/v1` caller otherwise sees as null or a 403. Zillow's numbers and links are for our own use, so it is granted per key to our own apps (Presaling's XRAY), never by a plan. |

A ladder: `admin` implies `write` implies `read`. Give a caller the least that
works - the Vercel viewer needs `["read"]` and nothing else. `zillow` sits beside
the ladder: it implies `read`, and `admin` implies it. An admin changes an existing
key's scopes in place with `POST /keys/{key_id}/scopes` (`{"scopes": ["read",
"zillow"]}`): the secret stays the same, the change is immediate and audited.

**`read` is no longer "every GET".** Selling the API split GETs in two. Reading a
parcel is the product. Reading `/status`, a run's counters, a raw scrape capture or
the owner verdict list is reading how the product is built, and a customer has no
business in any of it - so those are `admin` even though they are GETs:

`/status` · `/runs/*` · `/counties/{fips}/runs` · `/counties/{fips}/runs/status` ·
`/counties/{fips}/raw/*` · `/owners/review` · `/owners/stats` ·
`GET /owners/aliases` · `/match/stats` · `/match/scorecard` · `GET /match/weights` ·
`GET /match/feedback` · `/match/events` · `/match/rules/proposals` · `/match/config` ·
`GET /backups` · `/audit` · `/keys/list` ·
`/keys/{id}/usage` · `/usage/export`

The whole mapping is **one table**, `ROUTE_SCOPES` in `parcel/api/auth.py`, applied
to every request by an app-wide dependency before any route's own check runs. A
route missing from that table defaults to `admin`, so a new route is closed until
somebody decides otherwise, and `tests/test_api_docs.py` fails if one is added
without a decision.

A credential the API does not recognise (unknown, revoked, expired, or garbage) is
a **401**. One it recognises that lacks the scope the route wants is a **403**
whose message names the missing scope, so a client can tell "wrong key" from
"right key, wrong permission". One that has been **suspended** is a **402** - see
"Plans & limits" below. The whole table of statuses, stable codes and error bodies
is in "Errors" further down.

## Plans & limits

Every key is on a plan. A plan is a rate limit, a monthly request quota, and the
scopes a key on it may hold.

| Plan | Requests / minute | Requests / month | Scopes |
|---|---|---|---|
| `free` | 60 | 5,000 | `read` |
| `starter` | 300 | 100,000 | `read` |
| `pro` | 1,000 | 1,000,000 | `read` |
| `enterprise` | 3,000 | unlimited | `read` |
| `internal` | 6,000 | unlimited | `read`, `write`, `admin` |

Those are the **seeds**. `plans` is a real table and what it says is what applies,
so changing a whole tier's allowance is one `UPDATE` and no deploy:

```sql
UPDATE plans SET monthly_quota = 250000 WHERE name = 'starter';
```

A key's own `rate_limit_per_min` and `monthly_quota` columns win over the plan's
when they are set, so one customer can be given a different number without
inventing a plan for them. `internal` is the default for a key with no plan - which
is what every key minted before plans existed became, so nothing that worked
stopped working.

Two fallbacks, and they differ on purpose: a key with **no** plan reads as
`internal` (it was one), and a key pointing at a plan that has been **deleted**
reads as `free` (tightest, never unlimited).

### The two ceilings

The **rate limit** is requests a minute, a token bucket allowing a full minute of
burst. Over it is `429 {"detail": "rate limit exceeded"}` with `Retry-After`.

The **quota** is requests a calendar month, UTC, resetting at midnight on the 1st.
Over it is:

```json
{"detail": "monthly quota exhausted", "quota": 5000, "used": 5000,
 "resets_at": "2026-10-01T00:00:00+00:00"}
```

also 429, but retrying before `resets_at` will not help. A key holding `admin` is
**never** quota-limited: our own credentials must not be able to lock us out of the
machine over a number on an invoice.

Every authenticated response carries where the caller stands:

| Header | Means |
|---|---|
| `X-Plan` | The plan this key is on. |
| `X-RateLimit-Limit` | Requests a minute, or `unlimited`. |
| `X-RateLimit-Remaining` | What is left in the bucket right now. |
| `X-Quota-Limit` | Requests this month, or `unlimited`. |
| `X-Quota-Used` | What has been spent of it. |
| `X-Quota-Reset` | Midnight UTC on the 1st, ISO-8601. |

All six are in the CORS `expose_headers` list, so browser callers can read them.

### Suspending a key

Three different ways a key stops working, because they mean three different things:

| Action | Answer | Reversible? |
|---|---|---|
| `POST /keys/{id}/revoke` | **401** | No. Security. |
| `POST /keys/{id}/suspend` | **402** + the reason | Yes. Billing. |
| `expires_at` passes | **401** | No (mint a new one). |

```bash
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"reason": "invoice 4412 unpaid"}' "$PARCEL/keys/7/suspend"
curl -s -X POST -H "Authorization: Bearer $TOKEN" "$PARCEL/keys/7/reactivate"
```

`reason` is required and is **returned to the caller** in the 402, so write it for
them: `"invoice 4412 unpaid"`, not `"deadbeat"`. Both take effect immediately - the
resolver's 60-second cache is cleared by the call rather than waited out.

402 is deliberate. The credential is still real and the customer is still a
customer; they have stopped paying. A client can tell that apart from a wrong key
(401) and from a permission they do not have (403) without guessing.

## Local Buyers

Owners who bought lots like this one nearby lately, and hold enough to matter, are
`GET /match/buyers` with its filters. They are optional and combine; whoever is left
is ranked exactly as before.

```bash
# who bought a house like this within 3 miles in the last 12 months, owns 3+ today,
# and does not live in what they buy
curl -s -H "Authorization: Bearer $KEY" "$PARCEL/v1/match/buyers?property_id=13153-0W050J08B000\
&radius_miles=3&months=12&same_type=1&min_owned_now=3&absentee=1"
```

| Parameter | Meaning |
|---|---|
| `radius_miles` | 0.1-25, default 10 (as before). |
| `months` / `bought_after` | Only purchases in the last N months (1-600), or on or after a date (`YYYY-MM-DD`); one or the other. |
| `same_type` | `1`: only purchases in this property's type bucket. |
| `min_owned_ever` / `min_owned_now` | Keep buyers who have owned / own today at least N lots. |
| `absentee` | `1`: buyers who bought one of these lots to hold, not to live in; `0`: owner-occupants only. |

Each buyer carries `owned_ever`, `owned_now` and `absentee`, and the answer carries
`filters` (what was applied) and `excluded_by_filters`. `owned_now` is the parcels on
the current roll in the buyer's name, every county; `owned_ever` adds every lot a
deed on file shows them buying, counting a lot bought and still held once - deeds go
back as far as each county publishes them, so it is a floor. `absentee` is `true` for
an LLC or other organisation, and for a person or trust when a lot is not at one of
their mailing addresses; `null` when there is no address to compare. One investor
buying through several differently named LLCs is still several buyers until someone
links them (`POST /owners/decisions`). The route description in `/docs` has the full
table and how the ranking scores.

## Buyer votes

Every buyer `GET /match/buyers` suggests can be voted up or down by your users,
your admins and ours. The full contract for a client - the fields, the answers,
worked examples - is `docs/MATCH-VOTES.md` in the repository; this is the short
version.

```bash
curl -s -X POST "$PARCEL/v1/match/votes" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"property_id":"13153-0W050J08B000","buyer_id":"BID-000123",
       "voter_id":"presaling:1842","voter_role":"user","vote":"up"}'
# -> {"ok":true,"vote":{...},"totals":{"up":3,"down":1,"net":2},
#     "status":"verified","crossed":true,"rematch":{"at":"...","kind":"verified","reason":"..."}}
```

- **One vote per voter** per buyer per property. `vote` is `up`, `down` or `null`
  (withdraw) and must be present. A `user` weighs 1, an `admin` 2.
- **Only trusted keys' votes count** toward the status, the pins and hides, the
  exclusion and the score. PARCEL marks a key trusted (`vote_trust`, set by
  `POST /keys/{key_id}/vote-trust`); keys holding `admin` and the master token always
  are. An untrusted key's votes are stored and shown back to it alone, and its
  `voter_role: "admin"` is recorded as `user`. In an untrusted key's answers the
  status, the pins and hides and the totals are its own key's votes alone. Every
  answer says whether your vote was `counted`, and `own` is your key's votes and
  the status they alone add up to. On the box, `python -m parcel.api.apikeys trust
  <key name> on` does what the route does.
- **`status`** comes from the weighted net on that property against
  `votes.threshold` (2): `verified` (pinned first on `/match/buyers`), `rejected`
  (moved into `hidden`), `auto` in between. One admin or two users verify a buyer
  on their own; there is no override beyond the weight.
- **The score** gains `human_signal` (weight 15): 0.5 for every buyer when nobody
  has voted, so an unvoted ranking is unchanged; votes here move it, with a
  12-month half-life, and the buyer's net everywhere (capped at 5) adds a prior.
  `score` stays 0-100: it is **normalised to the weights in force**,
  `round(points / sum(weights) * 100, 1)`, and each buyer also carries `points`, the
  raw weighted sum of its `components`.
- **Excluded everywhere**: 3 weighted down votes across properties with no up vote
  anywhere drop a buyer from every ranking (`excluded_flagged`).
- **`crossed`** is `true` when the vote changed the status; **`rematch`** is the
  newest vote event on the property. The ranking is live, so the next
  `GET /match/buyers` already reflects the vote.
- **Your key's votes are yours**: `GET /v1/match/votes?property_id=` lists them
  with every voter's totals per buyer (`by_buyer`); a `voter_id` another key
  used on the same pair is a **409** `vote_conflict`, for a vote and for a
  withdrawal alike; and a buyer's `feedback` shows only your key's newest vote.
- **Both ids**: send `identity_id` or `buyer_id`; with both, `identity_id` wins.

The thresholds are data, not code: `GET /match/config` and `POST /match/config`
(admin). Every vote event - a crossing, an overturn, an exclusion, a rule decision -
is in `GET /match/events` (admin). `python -m parcel.match learn` reads the votes
nightly and writes rule **proposals** (`GET /match/rules/proposals`), which change
`transaction_rules` only when an admin applies one
(`POST /match/rules/proposals/{proposal_id}`).

## Customer vs internal surface

There is one set of route implementations and two ways to reach them.

**Customer-facing** (`read`) - the routes we sell:

counties list · county detail · county coverage · county export · county changes ·
county parcels · parcels count · parcels GeoJSON · cross-county GeoJSON · parcel by
APN · parcel search · parcel by property id · parcel by stored key · parcel images ·
parcel images by variant · image bytes · image bytes with ETag · `GET /coverage` ·
`GET /changes` · `GET /keys` (what `property_id` means) · owners list · owner detail ·
`/match/buyers` · `GET /buyers` · `GET /buyers/{buyer_id}` · `GET /match/votes` ·
`POST /match/votes` · `GET /usage`

`POST /match/votes` is the one write on this surface, and it is `read` on purpose:
a customer's vote on a buyer we suggested is their own opinion, stored against the
key that sent it, which alone can replace or withdraw it.

**Internal** - everything else, `write` or `admin`, and not in the public document
at all.

The split is enforced by `ROUTE_SCOPES` in `parcel/api/auth.py`, and the public
OpenAPI document is **derived from that same table** rather than from a second
list, so the document and the enforcement cannot disagree about what is being sold.
`tests/test_api_docs.py` asserts both: that every route in the app has an explicit
entry, and that the entries marked `read` are exactly the list above.

Note that `GET /keys` (the parcel-identifier statement) is customer-facing while
`/keys/list`, `/keys/{id}/*` and `POST /keys` are admin. Same prefix, different
things - the table is keyed on the exact path and method, not on a prefix.

## Use restrictions

Some states restrict what name-and-address lists derived from public records may
be used for. A county under such a rule carries a code in `use_restriction`, which
every parcel row returns (null everywhere else). The statute text is in
`docs/research/last-mile.md`.

| Code | Where | Why |
|---|---|---|
| `ks-45-230` | every Kansas county (FIPS `20xxx`) | K.S.A. 45-220(c)(2) and 45-230 |

A request is on the customer surface when its path starts with `/v1`, or when its
credential lacks the `admin` scope, whichever path it used. On the customer surface,
for a restricted county:

- Every parcel row (list, search, count-matched rows, both GeoJSON layers, the CSV
  and NDJSON export, `GET /v1/parcels/{property_id}` and the by-key and by-APN
  lookups) returns `owner_name`, `owner_address`, `owner_city`, `owner_state`,
  `owner_zip`, `owner_id` and `owner_display` as `null`, `owner_names` as `[]`, and
  `use_restriction` set. `owner_occupied` and `owner_verified` are kept. The
  messy-title flag goes too (`messy_title_likely` and `messy_title` null,
  `messy_title_reasons` `[]`): it is read partly from the owner line and is a lead
  list's filter. The full record also returns `owners` as `[]`, `grantor` and
  `grantee` on each sale as `null`, and no `sections`.
- The owner filters (`owner`, `owner_id`, `owner_address`, `owner_state`,
  `owner_zip`) and the messy-title filters (`messy_title`, `messy_title_reason`)
  leave restricted counties out, so they cannot be used to rebuild the withheld
  names.
- `GET /v1/owners` does not list the county's owners, and
  `GET /v1/owners/{entry_id}` is a 404 for one of them. An owner elsewhere does
  not list its restricted entries, parcels or name spellings.

On every surface, buyer matching skips restricted counties: their sales are never
turned into transactions, never count toward a buyer's statistics, and
`GET /match/buyers` for a property in one answers **422** with
`error.code = "use_restricted"` and the code in `use_restriction`.

The internal surface (the same routes without `/v1`, called with an admin
credential, which is what the admin site does) returns the names. `parcel/restrictions.py` holds the rule and the redaction;
`tests/test_use_restriction.py` holds both surfaces to it.

## County GIS links

Where a person goes to look up a county's parcels: `GET /v1/gis-links` (every county,
JSON or `format=csv`) and `GET /v1/counties/{fips}/gis-links` (one). Built from the
collectors PARCEL runs for each county (`parcel/gislinks.py`), so the list stays as
current as they are; it is rebuilt at most every ten minutes.

| Field | What it is |
|---|---|
| `gis_link` | The best link the county has: the first of `property_site`, `parcel_map`, `tax_roll`, `data_download`. Null when PARCEL knows of none. |
| `link_type` | Which of the four `gis_link` is. |
| `link_source` | The PARCEL collector it came from (`qpublic`, `vgsi`, `flagship`, `arcgis`, `file` ...). |
| `property_site` | The county's own property-record or map site (qPublic, VGSI, Flagship, iasWorld, EagleWeb, DEVNET and the rest), or a statewide portal where one serves the county (Kansas ORKA, Wisconsin assessordata). |
| `parcel_map` | The county's ArcGIS parcel layer in ArcGIS's free map viewer; `parcel_layer` is the layer's REST URL. |
| `layer_covers` | `county` for the county's own layer, `statewide` for one several counties share - the map then opens on the whole state. |
| `tax_roll` | An Oklahoma county treasurer's tax roll. |
| `data_download` | A bulk parcel file or data portal, no map. |

The parcel's full record (`GET /v1/parcels/{property_id}`) carries its county's links as
`county_gis`, plus `parcel_map_here`: the county's parcel layer opened on the parcel
(`center=lng,lat&level=18`) when it has a layer and a point.

`format=csv` answers the columns of the county-gis-links spreadsheet (FIPS, State,
County, GIS link, Link type, Parcel map, Parcel layer (ArcGIS REST), Layer covers,
Property site, Data download) - the copy to keep beside a hand-kept list.

## The property id

One identifier names a property, everywhere:

```
{county FIPS}-{county parcel number, normalized}      13153-0W050J08B000
```

The left half is the five-digit county FIPS code. The right half is the county's
own parcel number uppercased with spaces, `+`, `.`, `/` and `-` removed, so it can
never contain a dash and the id always splits at its first one. Feed it back in any
spelling you like: `13153-0w050j 08b000` and `13153-0W050J++++08B000` resolve to the
same property.

Because it is built from the county's own number, it is identical across
re-imports, rebuilds and machines. It is a stored generated column on `parcels`
with a unique index, so it cannot drift from its parts, and `parcel/ids.py` is the
one place that builds or reads one.

**A parcel object carries `property_id` and no other identifier.** The internal row
number (`parcels.id`) is a join key inside the process; it is not in the list, the
detail, the GeoJSON properties, the CSV export, the change feed or the buyer
evidence, and no route takes one. The single exception is `next_cursor`, which is
an opaque paging token: feed it straight back as `cursor=` and do not store it.

Routes that take the id:

| Route | Takes |
|---|---|
| `GET /parcels/{property_id}` | The id. A malformed one and an unknown one are both a **404** - either way there is no such property. |
| `POST /parcels/sales` | A list of ids in the body. A malformed or unknown one is never an error: it is named in `missing[]` and the rest are answered. |
| `GET /parcels/{property_id}/images[/{variant}]` | The same. |
| `GET /match/buyers?property_id=` | The id. A malformed one is a **400**, because it is a parameter you control. |
| `POST /match/feedback` | `property_id` in the body. |
| `GET /match/feedback?property_id=` | The id. |
| `POST /match/votes` | `property_id` in the body. A property in a use-restricted county is a **422**. |
| `GET /match/votes?property_id=` | The id (required with a customer key). |
| `GET /parcels/by-key/{key}` | Either spelling: `13153-0W050J08B000`, or the old `13153:0W050J08B000`. **This release only.** |
| `GET /counties/{fips}/parcels/by-apn/{apn}` | The county's own parcel number, any spelling. Unchanged. |

`GET /keys` returns the same statement as JSON.

An owner and a buyer have readable ids of their own; they are the section below.

One name survives the rename and is worth not confusing with an id: `parcel_id=`
on the parcel list routes. It is a **search filter** - the county's own parcel
number in any spelling, matched exactly against `parcel_id_norm`, which is the
half of `property_id` after the dash. It was never a row number and it did not
change.

### What changed in 0.8.0

`parcel_key` was `{fips}:{apn}`. It is now `property_id`, `{fips}-{apn}`, on the
parcel row, in `parcel_tombstones` and in `transactions`. The numeric `id` left
every parcel object, and the detail and image routes take the property id instead
of a row number. There are no paying API customers yet, so there is no
compatibility layer beyond `/parcels/by-key/{key}` still accepting the colon
spelling for this one release.


## The owner id and the buyer id

Two more identifiers, and one sentence each.

```
{county FIPS}_{NAME}[~n]     13153_CHONG_AARON_H     one owner, one county
BID- + six digits            BID-000123              one investor, everywhere
```

### The owner entry id

An **entry** is the owner a person reads: one county, one name, one mailing
address. It is named for the name - `{FIPS}_{LAST}_{FIRST}_{MIDDLE}` for a person,
with a generational suffix as another word (`13153_SMITH_JOHN_A_JR`), and `{FIPS}_`
plus the words of the name for a company or an agency
(`13153_CAL_MAR_HOMES_INC`, `13153_SECRETARY_OF_VETERANS_AFFAIRS`). Punctuation is
dropped and accents are folded, so an id is typeable and a URL needs no escaping.

When one county holds two owners of the same name at different mailing addresses,
the second is `~2`, the third `~3`, in the order they were first seen. That number
is assigned once and stored, so an id never changes meaning. 23,995 names in the
database need one today; the busiest name has 21 addresses in one county.

| Route or parameter | Takes |
|---|---|
| `GET /owners/{entry_id}` | The entry id. An all-digits path is read as the internal identity id instead, which is what the review page links to. |
| `GET /owners?q=` | A substring of the display name, or an entry id pasted whole. |
| `GET /parcels?owner=` | An entry id - everything that owner holds in that county. Any other value is still a substring of the county's owner line. |
| `POST /owners/{entry_id}/verify` | The entry id. Admin only. |

Every parcel object carries `owner_id` (the entry of its first owner line),
`owner_verified` and `owner_display` (that entry's name). `owner_name` is
unchanged and is still the line the county printed: the two answer different
questions, what the record says and who we think that is.

**Behind the entry** is the identity - the matcher's cluster of the same owner
across counties. It stays internal. An entry page lists it as
`same_owner_elsewhere`, and `?owner_id=` on the parcel list still filters by it for
this release.

The two numeric forms are load-bearing and go together, not separately. The site's
parcels page and map forward `?owner_id=<number>` to the parcel list and to the
GeoJSON route, and its geo proxy re-validates that value as a positive integer, so
it would drop an entry id passed the same way; its parcel detail page links owner
lines to `/owners/<identity_id>`, which is what the all-digits path answers.
**Removing either means changing the site first**, and the geo proxy's integer
check is the part that fails silently rather than loudly.

### The buyer id

`BID-000123` names an **investor**: one number for the firm behind every entity and
every county it buys through, minted the first time it appears as the grantee of a
qualifying purchase. 169,410 exist on the first mint.

| Route or field | Holds |
|---|---|
| `GET /buyers/{buyer_id}` | The investor: its owner entries, its profile, its 50 newest purchases. |
| `GET /buyers?q=` | Search investors by name. |
| `GET /match/buyers` | Every ranked row carries `buyer_id` and `entry_ids`. |
| `POST /match/votes` | Takes `buyer_id` (or `identity_id`) in the body; a retired number votes on the survivor. |
| `GET /match/votes` | `by_buyer` is keyed by `buyer_id`. |
| `GET /owners`, `GET /owners/{entry_id}` | `buyer_id`, once that owner has bought something. |

A BID that is later retired - when two investors are proved to be one - keeps
resolving: `GET /buyers/{retired}` answers with the survivor, `retired: true`, and
`requested` naming what you asked for.

### Verified

`verified` on an owner, `owner_verified` on a parcel: an admin has confirmed this
owner rather than the matcher answering alone. It is true when someone has
thumbed up any matcher pair that built this owner's identity, or when
`POST /owners/{entry_id}/verify` has been called on the owner itself. A ruling made
through that route is final until it is ruled on again - the nightly rebuild only
moves the flag on owners nobody has ruled on.


## Every route

The whole surface, grouped by the tag it carries in the OpenAPI document (which is
also its billing family). **Scope** is the minimum credential the route opens for,
straight from `ROUTE_SCOPES` in `parcel/api/auth.py`; `public` means no credential
at all. Every path here is also served under `/v1` - same endpoint, same scope.

This table is the one written-down list of routes in the repository.
`tests/test_docs_current.py` walks the live app and fails if a route is missing
from it, so a new endpoint cannot ship without a line here.

### Health

| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | `/health` | public | Is the process alive, and is it running a collector? Touches no database. |
| GET | `/healthz` | public | Database reachable, version, newest finished run, `stale`. |

### Counties

| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | `/counties` | read | List counties (`?state=`, `?with_data=1`). Counts from the county copy; `counts_age_s`, `ETag`. |
| GET | `/counties/summary` | admin | The monitoring table: every county being collected, in one call (`?state=`, `?all=1`, `?limit=`, `?offset=`, `?sort=parcels\|name\|coverage`, `?order=asc\|desc`, `?fresh=1`). Returns `total`; each row carries `est_parcels`, `est_basis` and `coverage_pct`. |
| GET | `/stats/collection` | admin | How fast parcels are being collected and when all of them will be: totals against the estimated US parcel count, rates (new parcels and records stored per second over 1 h, 24 h, 7 d and the best day), 7 days hourly, 90 days daily, and the projection at four rates. See "Collection rate and projection". |
| GET | `/counties/{fips}` | read | One county: counts per source, images, recent runs, `counts_age_s`. |
| GET | `/gis-links` | read | Every county's GIS link - its own property site, its ArcGIS parcel map, a tax roll or a data download - with `link_type` and `link_source`; `state=`, `link_type=`, `format=csv` (the spreadsheet's columns). See "County GIS links". |
| GET | `/counties/{fips}/gis-links` | read | One county's GIS links. The parcel's full record carries them as `county_gis`, with `parcel_map_here`. |
| GET | `/counties/{fips}/coverage` | read | Depth per source, `last_good_run`, and an ETA while a run is active; `counts_age_s`. |
| GET | `/coverage` | read | One row per county, nationwide: how much we hold and whether collection is healthy (`?state=`). |
| GET | `/coverage/gaps` | admin | Every county still under 100 parcels: the reason, whose move it is, a parcel estimate (`?state=`). Reasons come from `config/coverage-gaps.json`, built by `scripts/coverage_gaps.py`. |

### Parcels

| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | `/parcels` | read | Cross-county search by address (`q=`) or by radius (`near=lat,lng&radius_m=`, no `q=` needed); one of the two is required. The radius is capped at 8047 m (five miles, 422 `radius_too_large`), `limit` up to 5000 with `near=`, nearest first. |
| GET | `/parcels/{property_id}` | read | One parcel by its property id, `{fips}-{apn}`. **Store this one.** |
| POST | `/parcels/sales` | read | The full sale history of up to 1,000 parcels in one call: body `{"property_ids": [...]}`, answer `items[]` (one per parcel on file, in the order asked, each with `sales[]` newest first plus the row's own `last_sale_date`/`last_sale_price`), `count`, and `missing[]` for ids malformed or not on file. One metered request whatever its size; over the limit is a 400. A POST only because a thousand ids do not fit a query string - it changes nothing. |
| GET | `/parcels/{property_id}/comps` | read | Recent similar sales around one property in one call: `radius_m` (default 1609, max 8047), `months` (24, max 60), `beds`/`baths` plus or minus (1), `sqft_pct` (25), `lot_pct` (50), `year` plus or minus (off unless sent), `arms_length` (1), `same_type`, `limit` (25, max 200), `order`, `widen`, `fields=`. Each comp carries its sale, whether it was arm's length, `distance_m`, key facts and `similarity`; `summary` holds the median price and price per square foot; `widened` when the radius doubled. `ETag`. See "Comps". |
| GET | `/parcels/by-key/{key}` | read | One parcel by a stored key, `{fips}-{apn}` or the old `{fips}:{apn}`. Accepted this release only. |
| GET | `/counties/{fips}/parcels` | read | List/search inside one county; the full filter vocabulary. |
| GET | `/counties/{fips}/parcels/count` | read | `{total}` for a filter, without paging. The same count is reused for two minutes (so paging a list does not recount it). |
| GET | `/counties/{fips}/home-types` | read | The county's parcels counted by `home_type` (single family, multi-family, condo ...) as of its last classification, each with how many were `inferred`; `homes` sums the kinds of home. See "Home types". |
| GET | `/counties/{fips}/parcels.geojson` | read | Polygons for a map layer (bbox-bounded, ≤5,000). Simplified to about a pixel by default (`simplify=`, `simplify=0`), `count=0` skips `total`, repeat windows served from memory. |
| GET | `/parcels.geojson` | read | The same layer with no county in the path - a map window crosses county lines. `bbox=` (area-capped at 0.03 sq deg) or a `near=` circle (at most five miles); `simplify=` thins the shapes. `geometry=point` answers points instead of polygons, over up to 0.1 sq deg and 20,000 features. |
| GET | `/sales` | read | Every recorded sale in a `bbox` (up to 0.1 sq deg) or `near` circle, in a window (`sold_after`/`sold_before`, default 12 months), newest first: one row per deed with the parcel's id, point and use word, the parties, `deed_type`, `arms_length`, `qualified`, and the buyer as `grantee_owner_id` / `grantee_buyer_id`. `transfers=1` adds $0 and nominal deeds; `arms_length=1`, `use=`, `limit`/`offset`. |
| GET | `/sales/histogram` | read | The same sales counted per `bucket` (`day`, `week`, `month`): every period with `sales`, `priced`, `median_price`, `total_price`. |
| GET | `/parcels/{property_id}/sales/siblings` | read | The other parcels on one sale's deed: `sale_date` (required), `deed_book_page` (optional). A sibling is a parcel of the same county with a sale that day under the same deed reference (`via: ["deed"]`), or one a client flagged together with this parcel (`via:` that key's name, `client` for another customer's). A reference over 200 parcels is a county placeholder and lists nothing (`truncated: true`). See "Package sales". |
| GET | `/acqtrack/regions` | read | National AcqTrack: every county, state or ZIP (`scope`) ranked by one measure (`sort`: `dollars`, `dollars_per_lot`, `sales`, `all_sales`, `parcels`, `buyers`, `acres`, `distressed`, `flip`, `fliprate`) over a window ending this month (`months` 1-48) and property uses (`use`); `state=` narrows; `limit` (100, at most 50,000; 1,000 with `series=1`), `offset`; `series=1` adds each region's months; `month=YYYY-MM` answers that one month instead (a Timeline step); `fields=` keeps only those fields. `ETag`. See "National AcqTrack". |
| GET | `/acqtrack/regions/{scope}/{key}` | read | One county (FIPS), state or ZIP: the window's totals, every month (`months`, default 48) and `buyers_top`, its ten biggest buyers. 404 when the region was never built. |
| GET | `/acqtrack/spots` | read | Neighborhood AcqTrack: the best non-overlapping circles of `radius` 0.25, 0.5 or 1 mile, ranked by `sort` (the regions' sorts; `flip` and `fliprate` need 3 flips). Nationwide (top 500) or `state=` (top 100) for `months` 3, 6, 12, 18, 24, 36 or 48 and `use` all or `residential`; with `bbox=w,s,e,n` ranked live for any `months` 1-48 and any `use`. `limit` (100, at most 500), `offset`. `ETag`. See "Neighborhood AcqTrack". |
| GET | `/acqtrack/spots/{key}` | read | One circle by its `key`: its measures over `months` (default 48) and `use`, `series`, `buyers_top` (ten) and `parcel_ids` (every lot whose own last sale in the window is inside it). 404 for a malformed key. |
| GET | `/acqtrack/cells` | read | The 1,000-ft grid squares with a deed in the window inside `bbox` (required, up to about 300 km a side), with the AcqTrack measures over `months` (1-48) and `use`, each square's centre, majority ZIP and city; busiest first, `limit` (5,000, at most 20,000), `offset`. |
| GET | `/outlines` | read | One state's county (`level=county`) or ZIP (`level=zip`) outlines as GeoJSON, simplified for the zoom (`detail=low`, about 500 m, or `high`, about 60 m), feature `id` = the `/acqtrack/regions` key; `state=US` for every county. Gzipped, `ETag`, cached a day. See "Drawing National AcqTrack lightly". |
| GET | `/parcels/{property_id}/deeds` | read | Every recorded deed this parcel is on, newest first, each a deed record: `county_fips`, `deed_book_page`, `sale_date`, `price`, `parcel_count`, `multi_parcel` and `parcels[]` (every parcel of the county on that deed, this one included). `multi_parcel=1` keeps only package deeds. See "Package sales". |
| GET | `/counties/{fips}/deeds` | read | One deed by its reference as the county writes it: `deed_book_page` (required), `sale_date` (optional). One deed record per day the reference was recorded on (normally one). A reference with no digit 1-9 is a 400. |
| POST | `/sales/flags` | read | Flag one sale as covering several parcels: body `{"property_ids": [...], "sale_date": "YYYY-MM-DD"}` (or `sale_id` in place of the date), plus optional `deed_book_page`, `flag` (`multi_parcel`), `flagger_id` and `note`; up to 200 parcels. Recorded under the sending key's name and shown on every sale row's `flag_sources`, never as the county's; the parcels flagged together list each other as siblings. Flagging again changes nothing (`already`); `"remove": true` takes back this key's flags. A customer's own opinion, like a vote, so a read key may send it. |
| GET | `/counties/{fips}/parcels/by-apn/{apn}` | read | One parcel by the county's own number, any spelling. |
| GET | `/keys` | read | What each identifier means. Not the key-management routes. |
| GET | `/counties/{fips}/raw/{source}/{key}` | admin | The raw capture a collector stored, verbatim. |

### Images

| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | `/parcels/{property_id}/images` | read | Image metadata for a parcel. |
| GET | `/parcels/{property_id}/images/{variant}` | read | The same, narrowed to `full` or `thumb`. |
| GET | `/images/{image_id}` | read | Image bytes. A link-only image is fetched from its source and stored on the first request; `503` + `Retry-After` when the source refuses. |
| GET | `/images/{image_id}/bytes` | read | Image bytes with `ETag`/`Cache-Control`; honours `If-None-Match` → 304. Fetches a link-only image on the first request, like `/images/{image_id}`. |

### Owners

| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | `/owners` | read | Search owners: one per county and name. Each hit: `entry_id`, `display_name`, `kind`, `county_fips`, `county_name`, `parcel_count`, `total_value`, `identity_id`, `buyer_id`, `verified`, `mailing_address`/`_city`/`_state`/`_zip`. `identity_id=` lists one owner's entries in every county; `mailing_zip=` (+ `mailing_address=`) every owner at one mailing address, any county. |
| GET | `/owners/{entry_id}` | read | One owner: parcels, the same owner elsewhere, the evidence. An all-digits path answers the identity behind one. |
| GET | `/owners/{entry_id}/parcels` | read | One owner's whole portfolio as a parcel list, every county: `scope=identity` (default: the same owner in every county), `entry` (this county) or `mailing` (every owner at this mailing address, any county). Parcel filters, `limit` 1-500 + `cursor` paging and `geometry=1` as on the county list; an `owner` block carries `entry_id`, `identity_id`, `parcel_count`, `total_value` and `entries[]` per county. |
| POST | `/owners/holdings` | read | How many parcels each of up to 500 owners holds today, in one call: body `{"names": [...], "owner_ids": [...], "county": null}`, answer `items[]` in the order asked, each with `matched`, `parcels`, `counties` (biggest first), `ambiguous` and `owners[]` (`owner_id`, `display_name`, `kind`, `parcels`, `counties`, `by_county`, `mailing_states`, `entry_ids`). A person's name can be several owners (two JOHN A SMITHs at two addresses): each is listed, `ambiguous` is true, and the headline counts are the biggest one's - pass `owner_ids` (a sale row's `grantee_owner_id`) for one owner exactly. Counts the current owner line, so a lot bought and resold is not held. A POST only because a list of names does not fit a query string - it changes nothing. |
| GET | `/owners/review` | admin | The verdict list: pairs the matcher has already ruled on. |
| GET | `/owners/stats` | admin | Owner-matching statistics. |
| GET | `/owners/aliases` | admin | The learned first-name aliases. |
| POST | `/owners/review/{candidate_id}` | write | Confirm or override one verdict. |
| POST | `/owners/decisions` | write | Decide any two keys directly. |
| POST | `/owners/split` | write | Split one key out of an identity. |
| POST | `/owners/rules/{rule}` | write | Force a matcher rule to auto or to manual. |
| POST | `/owners/aliases` | write | Teach a first-name alias. |
| POST | `/owners/{entry_id}/verify` | admin | Stand behind one owner, or take that back. |

### Links / buyer matching

| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | `/match/buyers` | read | Rank likely buyers for one property, with the evidence, each buyer's `votes`, `status` and `human_signal`, and `rematch`. `voter_id=` fills `votes.mine`. Local Buyers filters: `months` / `bought_after`, `same_type`, `min_owned_ever`, `min_owned_now`, `absentee`; each buyer carries `owned_ever`, `owned_now`, `absentee`. |
| POST | `/match/votes` | read | One voter's thumbs up or down (or `null` to withdraw) on one buyer for one property. The one write a customer key makes; answers the stored vote, the pair's weighted `totals`, `status`, `crossed` and `rematch`. Audited as `match.vote`. |
| GET | `/match/votes` | read | A customer key: its own votes on `property_id` (required) plus `by_buyer`, every voter's totals per buyer. An admin key: every key's votes, filtered by `property_id`, `voter_id`, `key_id` (`0` = master), `since`, paged with `limit` (1-500) and `offset`. |
| GET | `/buyers` | read | Search investors by name. A repeated search is answered from memory for 5 minutes (the tables behind it change only when the nightly match build runs), with an `ETag`. |
| GET | `/buyers/{buyer_id}` | read | One investor: its entities, its profile, its purchases. |
| GET | `/match/stats` | admin | Buyer-matching coverage per county; the counts come from the county copy (`counts_age_s`). |
| GET | `/match/scorecard` | admin | The latest backtest of the ranking. |
| GET | `/match/weights` | admin | The scoring weights. |
| GET | `/match/feedback` | admin | The votes on one property, in the old verdict shape. |
| GET | `/match/events` | admin | Vote events, newest first: `verified`, `rejected`, `overturned`, `auto`, `excluded`, `reinstated`, `proposal_applied`, `proposal_rejected`, `feedback_migrated`, `merged` (votes followed an owner identity merge). Filters `since`, `kind`, `property_id`; paged. |
| GET | `/match/rules/proposals` | admin | The rule proposals the vote learner wrote, with their evidence (`scope`, and `buyers_matching` for a name rule). `status=` narrows to `proposed`, `applied`, `rejected` or `expired` (the votes behind it are gone). |
| POST | `/match/rules/proposals/{proposal_id}` | admin | `{action: apply\|reject, decided_by}`, in one transaction that locks the proposal. Applying writes the rule into `transaction_rules` and logs `proposal_applied`; a proposal no longer `proposed` is a 409 `already_decided`. |
| GET | `/match/config` | admin | The vote thresholds (`votes.threshold`, `votes.half_life_months`, `votes.global_cap`, `votes.exclude_downs`). |
| POST | `/match/config` | admin | Set one threshold (`{key, value}`); every status and score re-reads it at once. |
| POST | `/match/weights` | write | Set one scoring weight (seven components since 0.16.0). |
| POST | `/match/feedback` | write | The old thumbs up/down; casts an admin vote (weight 2) by `decided_by`. |

### Export & sync

| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | `/counties/{fips}/export` | read | Stream a whole county as CSV or NDJSON. |
| GET | `/counties/{fips}/changes` | read | Incremental feed: upserts **and** tombstoned deletions. |
| GET | `/changes` | read | The same feed across every county, or one state with `state=`. One cursor covers them all. |

### Keys & usage

| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | `/usage` | read | Your own meter for the month. No id parameter. |
| POST | `/keys` | admin | Mint a key. The plaintext is returned, and sealed for `reveal` when the vault is on. |
| GET | `/keys/list` | admin | Every key ever minted, with plan, status and month-to-date usage. |
| GET | `/keys/{key_id}/usage` | admin | One key's month, by day and by family. |
| POST | `/keys/{key_id}/revoke` | admin | Kill a key permanently (401 thereafter). |
| POST | `/keys/{key_id}/reveal` | admin | Show a sealed key's plaintext again; 409 `not_retrievable` with a `reason` when it cannot. |
| POST | `/keys/{key_id}/suspend` | admin | Pause a key for billing (402 + the reason). |
| POST | `/keys/{key_id}/scopes` | admin | `{scopes: [...]}`: replace a live key's scopes in place (same secret), e.g. `["read", "zillow"]`; immediate and audited (`key.scopes`, before and after). |
| POST | `/keys/{key_id}/reactivate` | admin | Undo a suspension. |
| POST | `/keys/{key_id}/vote-trust` | admin | `{vote_trust: true\|false}`: whether this key's buyer votes count toward the shared ranking. Keys holding `admin`, and the master token, always count. |
| GET | `/usage/export` | admin | The invoice run: one CSV line per key for a month. |

### Documentation

| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | `/` | public | Redirects to `/developers`. |
| GET | `/developers` | public | The documentation home: what PARCEL is, a first call, and a link to every public document. |
| GET | `/docs/guide` | public | This guide as a web page, with a contents list and a link on every heading. |
| GET | `/docs/api.md` | public | This guide, as `text/markdown`, minus the sections marked internal. |
| GET | `/docs/changelog` | public | The public changelog as a web page; each version heading is `#v` and the version without dots (`#v0270`). |
| GET | `/changelog` | public | The public changelog as JSON, one object a version; `?since=0.26.0` or `?since=2026-10-01` keeps what came after. |
| GET | `/changelog.md` | public | The public changelog as `text/markdown`. |
| GET | `/changelog.atom` | public | The public changelog as an Atom feed, one entry a version. |
| GET | `/llms.txt` | public | One screen of plain text: what PARCEL is, the auth header, the document URLs, the routes that answer most questions. |
| GET | `/docs/files` | admin | Every document in the repository - README, CHANGELOG, `docs/`, `deploy/` - with size and modified time. The admin site's Docs page reads it. |
| GET | `/docs/files/{path}` | admin | One of them as `text/markdown` with a download disposition. Only a path the listing names; anything else is 404. |

The six Swagger/ReDoc/spec pages and `/docs/api-internal.md` (this guide whole) are
in neither OpenAPI document and are not scoped like the rest - they run their own
bearer-or-Basic guard. `/docs`, `/redoc` and `/openapi.json` are public;
`/docs/internal`, `/redoc/internal`, `/openapi-internal.json` and
`/docs/api-internal.md` are not.

## The changelog

Every change a caller can see is written down in the public changelog, newest
version first: what was **added**, **changed**, **deprecated**, **removed** or
**fixed** in the API, and what **data** was added. A breaking change is always
named, with how to move to the new form. A version with nothing a caller can see
says so in one line.

It is one document served four ways, none of which needs a key:

* [`/docs/changelog`](/docs/changelog) - a page. Each version's heading has the id
  `v` plus the version without its dots, so `/docs/changelog#v0270` is 0.27.0.
* [`/changelog`](/changelog) - JSON: `latest`, `count`, and `versions`, each with
  `version`, `date`, `url` (its place on the page), `sections` (`{"Added": [...]}`,
  each entry Markdown) and `note` (the one line of a version with no entries).
  `since=0.26.0` keeps the versions after 0.26.0; `since=2026-10-01` the ones released
  after that day; anything else is a `400`.
* [`/changelog.md`](/changelog.md) - the Markdown it is written in.
* [`/changelog.atom`](/changelog.atom) - an Atom feed, one entry a version.

```bash
# what changed since the version this client was built against
curl "https://13-140-33-19.sslip.io/changelog?since=0.26.0"
```

## Coverage

`GET /coverage` answers, for every county we have a source for **or** any parcels
in, how much of it we hold and whether collection is healthy. One row per county,
one request. It is the route a national map is coloured from.

`GET /counties/{fips}/coverage` answers the same question for one county and costs
a query per county to fan out; doing that for the whole country is 3,143 requests.
This one is answered from a copy the API keeps in memory, so it costs no query
whatever the county count; `counts_age_s` says how old its oldest numbers are. See
[Caching and conditional requests](#caching-and-conditional-requests).

```bash
curl -s -H "Authorization: Bearer $KEY" "$PARCEL/v1/coverage?state=GA"
```

```json
{"items": [{"fips": "13153", "state": "GA", "name": "Houston County",
            "status": "ok", "parcel_count": 67544, "depth_pct": 65.2,
            "photos_pct": 32.4, "sales_pct": 65.1, "located_pct": 100.0,
            "detail_status": "partial", "missing": ["sale prices"], "not_published": [],
            "last_good_run_at": "2026-09-14T03:21:55+00:00",
            "last_error": null,
            "reason": "records are collecting cleanly; no sale prices yet"}],
 "generated_at": "2026-09-22T09:14:02+00:00", "counts_age_s": 42}
```

### The three colours

| `status` | Means |
|---|---|
| `ok` | Records collect cleanly, and something has collected recently. |
| `degraded` | We hold records, and collection has a problem: a source not collecting cleanly, a run that stopped, no collector configured, or nothing complete in 45 days. |
| `none` | We hold no parcels for this county. `reason` says why. |

That middle one is the point. A county with no rows and a county that fails every
night used to look the same from outside - "no data" - and they are different
problems that want different colours.

The decision, in order: no parcels is `none` (with the reason from the newest
verdict, or "no source configured"); then any configured source whose newest check
is not `ok` is `degraded`; then a newest run that failed or was abandoned;
then a newest **complete** run older than 45 days; otherwise `ok`. The verdicts are
`parcel/checks.py`'s, the same ones `/counties/checks` grades with.

`reason` is always a plain sentence, written to be shown to whoever is looking at
the map. `last_error` is the same kind of sentence for the most recent run that did
not finish, and is `null` when the latest run of every source finished - so a
county can be `degraded` for staleness alone with nothing having failed.

Neither field ever carries scraper internals. Inside, a verdict's reason names
Cloudflare, the proxy, the layer URL and the counters, because it is written for
whoever fixes it; on this route the verdict is mapped to a neutral sentence
(`checks.PUBLIC_REASONS`) and never the internal text, so no rewording can leak
through.

### What the records carry

`status` is about collection: whether the county's records arrive cleanly. It says
nothing about how much each record carries, and a county whose map layer collects
every night while nothing has filled in a sale, an owner or a building is `ok`.
Two fields say that part, and `reason` repeats it:

| Field | Means |
|---|---|
| `missing` | The kinds of detail under 10% of the county's parcels carry, from `owners`, `sale prices`, `buildings` (a year built or a living area) and `values`, leaving out what is `not_published`. Empty when nothing is missing. |
| `not_published` | The kinds no free official bulk source publishes for this county, as far as we have looked. Sale prices in the twelve non-disclosure states (Alaska, Idaho, Kansas, Louisiana, Mississippi, Missouri, Montana, New Mexico, North Dakota, Texas, Utah, Wyoming), where no deed records one; and the findings in `config/not-published.json`, each with its evidence: owner names in Los Angeles, San Diego, Riverside, Alameda and Orange counties, King County WA owners (its download terms) and Allegheny PA owners; sale prices in Los Angeles and Monterey. Never counted as missing; an entry is removed the day a source is found. |
| `detail_status` | `full` (nothing missing), `partial`, or `none` - every kind a source could publish is missing, so the parcels carry little beyond the map shape. |

When something is missing, `reason` says so after a semicolon: "records are
collecting cleanly; no sale prices or buildings yet", or "...; only the map shape is
on file so far"; what is not published follows the same way ("...; owners and sale
prices are not published here"). A reader of the sentence alone is not told that a
county of outlines is in good shape. `GET /counties/{fips}/coverage` carries the
same fields, with `fields_pct` (the share carrying each column) and
`not_published_why` (the reason per kind) beside them.

### The percentages

All four are shares of `parcel_count`, to one decimal, and `0.0` when we hold no
parcels.

| Field | Share of parcels that |
|---|---|
| `depth_pct` | carry a full record: opened by a detail collector, or with an owner, a value, a sale date and a year built or living area. |
| `owner_pct` / `mailing_pct` / `value_pct` / `sale_price_pct` / `year_built_pct` / `sqft_pct` / `beds_pct` | carry that column. A sale price is never public in a non-disclosure state (Texas, Utah, Idaho and others). |
| `photos_pct` | have at least one image. |
| `sales_pct` | have at least one recorded sale, from a detail collector. |
| `sale_date_pct` | carry a last sale date, from any source - a map layer or a roll fills it without a sale record. Zero means a `sold_after` filter finds nothing in this county. |
| `located_pct` | carry a latitude. |

`counts_age_s` is how many seconds old the oldest of those counts are. They come
from one copy counted in the background - in full on a timer, and county by county
the moment a collection run on that county finishes - because scanning every row
costs minutes and a national map does not need the count to the second. A county
recounted after a run is fresher than the age says; `GET /counties/{fips}/coverage`
gives that county's own `counted_at` and `counts_age_s`. `generated_at` is when the
answer was assembled.

## Images

Three routes return image metadata and two return bytes:

| Route | Returns |
|---|---|
| `GET /parcels/{property_id}/images` | every image for a parcel |
| `GET /parcels/{property_id}/images/{variant}` | the same, narrowed to `full` or `thumb`, plus `sha256` and `bytes_url` |
| `GET /parcels/{property_id}` | the same objects inside the full record, as `images[]` |
| `GET /images/{id}` | the bytes |
| `GET /images/{id}/bytes` | the bytes with an `ETag`, so a repeat fetch is a 304 |

All three metadata routes are assembled by one query, so an image object is the
same object wherever it appears:

| Field | Means |
|---|---|
| `stored` | The bytes of **this row** are on this box. `stored: false` is a row held as a link only: the first request to `url` fetches the bytes from `source_url`, stores them and serves them, while `source_expires_at` has not passed (see "Fetched on first request" below). |
| `source_expires_at` | When `source_url` stops working: the expiry qPublic signs into the link. A `stored: false` row past it answers `404` until the parcel's next scrape records a fresh link. `null` when the link carries no expiry. |
| `caption` | The county page's own text for the picture (`Property Photo 1`, `Building Sketch 1`), when it gave one. |
| `fetched_at` | When the bytes were stored. `null` while only the link is held. |
| `has_full` | The full-resolution version of this same photo is stored. On a `thumb` row, `has_full: false` means the thumbnail is all there is - so a screen can say "thumbnail only" instead of enlarging a blur - unless `full_id` is set. On a `full` row it is the same answer as `stored`. |
| `full_id` | The id of the full-size row of this same picture, stored or not (a `full` row names itself); `null` when the county gave no full size. Under `images=urls` nothing is stored before somebody asks, so `has_full` stays `false` on a thumbnail until then, and `/images/{full_id}/bytes` is how to ask for the full size: it is fetched on that first request. |
| `source_url` | The county's own URL the image was captured from. **Provenance, not a URL to serve**: qPublic signs those and they expire roughly 24 hours after the fetch. Always present - the collector records where it fetched from. |
| `url` / `bytes_url` | Where we serve the bytes. `bytes_url` is the ETag-cached route and is only on the per-variant listing. |
| `variant` | `full` or `thumb`. |
| `kind` | `photo` or `sketch`. |

Only thumbnails are stored for most counties: full-resolution photos are a storage
decision (roughly 40 GB a county), not a code gap. `has_full` and `source_url` are
what let a client live with that honestly - show the thumbnail, say it is one, and
go to the county itself for the full-size image while its link is alive.

### Fetched on first request

With the collector's `images` setting at `urls`, a scrape stores no image bytes. It
writes one row per picture and variant with the link, the position and the caption,
and the bytes are fetched the first time a client asks for them through either byte
route:

| The row | What the byte route does |
|---|---|
| has bytes | Serves them, as always: same `ETag`, same `Cache-Control`. |
| has only a link, still valid | Fetches it once from `source_url` with the collector's User-Agent through the scraper's main proxy, stores it, serves it. The caller waits at most 20 seconds, whether the source is slow to connect, to answer or to send. Only a real JPEG, PNG, GIF or WebP is stored, and the content type served is the one its bytes say, not the one the source's header claimed. Concurrent requests for the same image wait for that one fetch, and at most four fetches run at once. Redirects are followed up to three times, and only on the same host over https. Every later request is served from storage, and stored bytes are never fetched again (only the backfill's `--refresh` does that). |
| has only a link, and the source refuses | **`503`** with `Retry-After: 900` and `Cache-Control: no-store`. The body names `source_url` and carries `stored: false`, `reason` and `retry_after_s`, plus the usual `error` envelope (`code: unavailable`). Nothing is stored. Refusals are a 403, a 429, any other non-200, a Cloudflare challenge page (recognised and left alone, never answered, whatever its content type), anything that is not one of the image formats above (a web page, JSON, an SVG), an empty body, a body over 25 MB, a redirect to another host, a host that resolves to a private address, and the 20 second deadline. |
| asked again within fifteen minutes of a refusal | The same **`503`** at once, without asking the source, `reason` ending "not asked again yet" and `Retry-After` counting down the rest of the fifteen minutes. The memory is per source URL: other pictures are asked as normal, and a re-scrape that records a fresh link for this picture is a new URL and is tried. |
| this server is busy | **`503`** with `Retry-After: 30`: four fetches were already running, or another request for the same image took too long. Not remembered; nothing is wrong with the source. |
| has only a link, past its expiry | Not tried. **`404`** with the old body, `{error: "image bytes not stored", stored: false, source_url}`, plus `reason` and `source_expires_at`. The parcel's next scrape refreshes the link on the same row, and the same id works again. |
| has only a link, and it is not https on a known image host | Not tried. Only https links on the hosts the collector records its pictures from are fetched (`IMAGE_HOSTS` in `parcel/sources/qpublic.py`: `venturi.blob.core.windows.net`, `qpublic.schneidercorp.com`, `beacon.schneidercorp.com`), so nothing on a caller's request can reach the server's own network. The same **`404`**, with `reason` saying so. |

So a screen that shows `stored: false` pictures should ask for them the same way it
asks for stored ones, and treat a `503` as "not yet": show a placeholder and try
again after `Retry-After`. The first view of a picture can take up to 20 seconds.
`python -m parcel.images fetch --county FIPS` stores a county's link-only images in
advance, at the scraper's pace, which is the way to have them before anybody asks. Its
`--refresh` fetches the stored ones again and replaces their bytes, for a county whose
photos have changed.

## Where a coordinate came from

Every parcel row carries `geocode_source` and `geocode_precision` beside
`latitude`/`longitude`, because a parcel centroid and a street-interpolated point
are not the same claim and a map that draws a pin on one as confidently as the
other is lying.

| `geocode_source` | `geocode_precision` | What the point is |
|---|---|---|
| `gis` | `parcel` | The county GIS layer's own centroid for this parcel. The best we hold. |
| `census` | `street` | The US Census geocoder matched the situs address and interpolated a point along that street segment. Near the property, not on it. |
| `census` | `centroid` | The Census geocoder matched something close to, but not, the address we asked for. The right neighbourhood. |
| `null` | `null` | No coordinate, or one located before we recorded its origin. |

`geocode_match` is the Census geocoder's own word for the match (`Exact`,
`Non_Exact`) and is what `geocode_precision` is derived from; it is null unless
`geocode_source` is `census`. `rooftop` is reserved and nothing produces it today:
no source we read returns a rooftop point.

Both fields are null whenever `latitude` is. They ride on every surface that serves
a parcel - the lists, the full record, the GeoJSON layer and the export - because
they are derived in the one place every parcel row passes through
(`filters.coerce_row`). `fields=` narrows them away like any other column; ask for
`geocode_precision` by name and the columns behind it are fetched for you.

## Credits a licence asks for

A few counties publish their data under a licence that allows reuse, commercial
reuse included, on condition that the source is credited. Every parcel of such a
county carries `attribution`: a list of credits, each with `text`, `source`,
`source_url`, `license`, `license_url` and the `fields` it covers. Wherever you
show those fields, show `text` with links to `source_url` and `license_url`.

| County | Source | Licence | Fields |
|---|---|---|---|
| Pitt County, NC (`37147`) | Pitt County Government, "Parcel Building Info" | CC BY-SA 4.0 | `bedrooms`, `full_baths`, `half_baths`, `year_built` |

The key is absent on every other county's parcels. It rides on every surface that
serves a parcel, whatever `fields=` asked for, and the county's export has one
more column, `attribution`, last.

## Address line 2

Every parcel row carries each of its addresses twice: as the county writes it
(`situs_address`, `owner_address`, unit included) and as two lines, the unit on the
second - `situs_address_line1` / `situs_address_line2` and `owner_address_line1` /
`owner_address_line2`:

| `situs_address` | `situs_address_line1` | `situs_address_line2` |
|---|---|---|
| `5560 CONSTITUTION AVE STE 5552` | `5560 CONSTITUTION AVE` | `STE 5552` |
| `12 MAIN ST CONDO 242` | `12 MAIN ST` | `CONDO 242` |
| `68 BRANCH RD UNIT 342 BLDG 3` | `68 BRANCH RD` | `UNIT 342 BLDG 3` |
| `4773 DON MIGUEL DR NO 101` | `4773 DON MIGUEL DR` | `NO 101` |
| `34 GLENCOE CT # 102-B` | `34 GLENCOE CT` | `# 102-B` |
| `109 FREEMAN DR` | `109 FREEMAN DR` | `null` |

Line 2 is the unit words at the end of the line, kept as written: a designator
(USPS's - `APT`, `UNIT`, `STE`/`SUITE`, `BLDG`, `FL`, `RM`, `SPC`, `LOT`, `TRLR`,
`OFC`, `PH`, `PMB` ... - plus `CONDO`, and `NO` as Los Angeles County writes it)
followed by an id with a digit or a single letter, or `#` and an id. `0 NO ADDRESS`
and `123 OCEAN KEY DR` stay one line. The lines are derived where every parcel row
passes (`filters.public_parcel`), so the lists, the full record, the GeoJSON layer
and the export all carry them; `fields=situs_address_line2` fetches the address
behind it. Home cards (`GET /homes`) carry the same split as `address.line1` /
`address.line2`, and the owner's as `owner.mailing_address_line1` / `_line2`.

## Collection rate and projection

`GET /stats/collection` answers the counties page's two charts from the county copy,
so it costs no query on the request. Two rates, because they answer different
questions: `new_parcels_per_s` counts parcels first seen (`parcels.first_seen_at`) -
what moves coverage - and `records_per_s` counts every raw record a collector stored
(`raw_records.fetched_at`), which includes a qPublic backfill fetching the full record
of a parcel already on file. Both are quoted over the last hour, day and week and for
the best day of the last 30. `hourly` covers 7 days and `daily` 90, with
`parcels_on_file` at each day's end (today's total less everything first seen since).
`projection` divides the remaining estimated parcels by four rates - `7d`, `24h`,
`best_day_30d` and `throughput_7d` (the collectors' whole record rate, as if every
record were a new parcel) - and gives `eta_s` and `eta_at`, null at a rate of zero.
The rates and the hourly series are recounted every five minutes and the daily series
every half hour; both columns are indexed (`parcels_first_seen_idx`,
`raw_records_fetched_idx`, built `CONCURRENTLY` on the box).

## Caching and conditional requests

The aggregate routes - `/counties`, `/counties/summary`, `/counties/checks`,
`/counties/{fips}`, `/counties/{fips}/coverage`, `/coverage`, `/coverage/gaps` and
`/match/stats` - no longer scan the parcel table on the request. They read one
**county copy** the API builds in a background thread: one row per county with its
parcel, detail, sales, buildings, owner, raw-record and image counts and its
newest runs per source, plus the buyer-matching totals. The copy is rebuilt every
`STATS_REBUILD_S` seconds (default 300) and within about 30 seconds of any
collection run finishing; it is saved to the `county_stats` table, so a restarted
API answers from the last copy at once and rebuilds behind it. A copy past its age
is still served and triggers one rebuild; a failed rebuild keeps the old copy.
Every one of these routes reports the copy's age as `counts_age_s`.

The JSON routes built that way - `/counties`, `/counties/summary`,
`/counties/checks`, `/coverage`, `/coverage/gaps`, `/match/stats` - and
`/runs/recent` and `/parcels/{property_id}/comps` answer with:

| Header | Value |
|---|---|
| `Cache-Control` | `private, max-age=60` |
| `ETag` | A weak tag. For a route read from the copy it is derived from the copy's build time plus the path and query string, so it changes when the numbers can have changed. For `/runs/recent`, `/match/stats`, the comps and `/counties/checks` with `tag=` or `since=` it is a hash of the body, leaving out `generated_at` and `counts_age_s`. |
| `Vary` | `Authorization` |

Send the tag back as `If-None-Match` and an unchanged answer is a **304** with no
body. `private` is deliberate: every route is bearer-authenticated and a key
decides what it may see, so no shared cache may keep an answer.

```bash
etag=$(curl -sI -H "Authorization: Bearer $KEY" "$PARCEL/v1/coverage" | grep -i '^etag' | cut -d' ' -f2- | tr -d '\r')
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $KEY" \
     -H "If-None-Match: $etag" "$PARCEL/v1/coverage"      # 304
```

The per-county map layer, `GET /counties/{fips}/parcels.geojson`, keeps the last
answers in memory for ten minutes, keyed by the county, the bbox rounded to four
decimals and every other parameter; see [Map layers](#map-layers).

## Change feed

`GET /counties/{fips}/changes` and `GET /changes` are how a downstream copy stays
in step with PARCEL without re-downloading a county. PARCEL stays the place the
data is corrected; the feed tells you what moved.

Two routes, one contract. The per-county one is `GET /counties/{fips}/changes`; the
cross-county one is `GET /changes`, optionally narrowed with `state=GA`. A sync
that follows more than one county wants the second: one cursor covers all of them
and there is nothing to fan out.

### An item

```json
{ "property_id": "13153-0W050J08B000", "op": "upsert", "at": "2026-09-14T03:21:55+00:00" }
```

`op` is `upsert` or `delete`. `property_id` is the stable key - `{fips}-{apn}`,
with a dash, since 0.8.0 - and it names the county, so one cross-county feed is
enough to route a change to the right row. Fetch the record with
`GET /parcels/{property_id}`; a `delete` means there is nothing left to fetch.

### The cursor

Every answer carries **`next_cursor`**, an opaque token. Feed it straight back as
`cursor=`:

```bash
curl -s -H "Authorization: Bearer $TOKEN" "$PARCEL/changes?state=GA&limit=1000"
# → {"items":[…], "count":1000, "next_cursor":"eyJ0cyI6…", …}
curl -s -H "Authorization: Bearer $TOKEN" "$PARCEL/changes?cursor=eyJ0cyI6…&limit=1000"
```

It is base64url of `{ts, op, id}` - the timestamp of the last item, whether that
item was an upsert or a delete, and its tie-break (the internal row number for an
upsert, the `property_id` for a delete). **It will decode, and that shape is free
to change.** Store the string, feed it back, do not parse it. Anything that is not
a cursor this API issued is a **400**.

`next_since` is still there and still works. It is the older, cruder watermark: a
timestamp, compared strictly greater-than, and ambiguous when several rows share
one instant. Use it only if you cannot store a string. Passing both `since` and
`cursor` uses the cursor.

### Ordering, and what you are promised

Items come **oldest first**, in a strictly increasing order: `at`, then upserts
before deletes at the same instant, then the tie-break. Follow `next_cursor` and
you see every change **exactly once** - none skipped, none repeated, however many
rows share a timestamp.

`since=` cannot promise that on its own, so the feed protects it: a page that would
end in the middle of one timestamp is cut short instead, which is why a page can
come back shorter than `limit`. The one case it cannot fix is a full page where
every item shares a single timestamp; that page is returned as-is, and only
`cursor=` gets past it.

What the feed does **not** do is replay history. It reports a parcel's current
state, not each edit: **a row that changed three times between two polls appears
once**, with its latest `updated_at`. That is what a cache wants. It also means the
feed is not an audit trail - `GET /counties/{fips}/raw/{source}/{key}` is.

### Deletes and tombstone retention

A `delete` is a **tombstone**: a full enumeration of every source we have for that
county stopped listing the parcel, and the collector wrote it off in
`parcel_tombstones`. Drop the row.

Tombstones are kept **90 days** (`TOMBSTONE_RETENTION_DAYS` in
`parcel/api/ops.py`). Resume from a watermark older than that and the feed refuses
with **409** rather than handing back a page that would quietly leave rows in your
copy that we have deleted:

```json
{
  "detail": "you are further behind than the 90-day tombstone retention, …",
  "code": "too_far_behind",
  "retention_days": 90,
  "oldest_usable": "2026-06-23T00:00:00+00:00",
  "full_reload": "/counties/13153/export?format=ndjson",
  "error": { "code": "too_far_behind", "message": "…", "request_id": "…" }
}
```

Recovering from it is three steps: reload the county with
`GET /counties/{fips}/export?format=ndjson`, treat anything **not** in that file as
deleted, then start the feed again with no `since` and no `cursor`. Starting with
neither is always allowed and is never "too far behind" - a caller holding nothing
has nothing to delete.

Nothing prunes `parcel_tombstones` automatically today, so in practice the rows go
back further than 90 days. The number is the **promise**, not a measurement: a
pruner must not be written to a shorter window without changing the constant, the
model and this section together.

### Renumbering

If a county renumbers a parcel, the old `property_id` is **tombstoned** and the new
one arrives as an **upsert**. There is no edit that changes a `property_id` in
place. A cache keyed on it therefore never has to merge two rows - it deletes one
and inserts the other, which is exactly what the feed already told it to do.

### A sync recipe

1. **First run.** `GET /counties/{fips}/export?format=ndjson` per county you want,
   or walk `GET /changes` from the beginning with no `since` and no `cursor`. Each
   NDJSON line is one `ParcelSummary`, the same object the list routes return.
2. **Keep the cursor.** Store `next_cursor` beside your copy, one per feed.
3. **Poll.** `GET /changes?cursor=…&limit=1000` on whatever cycle suits you -
   nightly is plenty, counties are recollected on their own cycles - and again on
   demand when a person opens a property. Keep paging while `items` is non-empty.
4. **Apply.** `upsert` → fetch `GET /parcels/{property_id}` and write it;
   `delete` → remove that row. Both are idempotent, so a replayed page is harmless.
5. **Save the new cursor** only after the page is applied. Crash in the middle and
   you replay a page, which step 4 makes safe.
6. **Handle 409.** `code: "too_far_behind"` → go back to step 1 for that county.

Stay inside the documented rate limit (600/min by default, or your plan's) and this
costs a handful of requests a night.

## Map layers

`GET /counties/{fips}/parcels.geojson` and `GET /parcels.geojson` return a GeoJSON
`FeatureCollection` of the parcels inside a bounding box, polygons included. The
second has no county in the path, because a map window does not know where county
lines are - at a metro boundary one window covers three counties.

The per-county layer requires **`bbox=w,s,e,n`** (400 without it). The
cross-county layer takes `bbox=` **or a circle**, `near=lat,lng&radius_m=` (400
with neither; see "A circle instead of a box" below). Both force `geometry` on and
accept the whole parcel filter vocabulary. Each feature is `{type, id, geometry,
properties}` where `id` is the `property_id` and `properties` is a `ParcelSummary`
narrowed by whatever `fields=` you asked for.

### Limits on the cross-county layer

| Guard | Answer |
|---|---|
| Neither `bbox` nor `near`, or either malformed | **400** |
| `bbox` area over **0.03 square degrees** (`GEOJSON_MAX_AREA_DEG2`, about 18 × 18 km at Georgia's latitude; 0.02 before 0.17.0) | **422** with `code: "bbox_too_large"`, naming the maximum and the area you asked for |
| `near` with `radius_m` over **8047 metres** (`MAX_CROSS_COUNTY_RADIUS_M`, five miles) | **422** with `code: "radius_too_large"` and `max_radius_m` |
| More than **5,000** parcels in the window (`GEOJSON_MAX_FEATURES`) | The first 5,000, plus `truncated: true` and the real `total` |

0.03 square degrees is room for the box around a five-mile circle at Georgia's
latitude (about 0.025). The box around the same circle grows as the longitude
degree shrinks, and passes 0.03 north of about 45.8 degrees - northern Minnesota,
North Dakota, Montana, Washington, Maine. There, draw the circle with `near=`
instead: a circle is bounded by its radius and is never area-checked.

### A circle instead of a box

`near=lat,lng&radius_m=` on `GET /parcels.geojson` draws every parcel inside a
circle (radius default 1000, at most 8047), whichever counties it crosses: the
five-mile picture around one property in one call. Features come nearest first
and each carries `distance_m`, so when the 5,000 cap bites you hold the closest
5,000 rather than an arbitrary slice. `bbox` and `near` together keep what is
inside both, and the circle is then the bound: the box beside it is not
area-checked. The same circle on `GET /parcels` returns the rows without polygons,
paged, up to 5,000 a page. On both routes any radius over 8047, including one past
the per-county 50,000, is the 422 `radius_too_large`.

`count`, `total` and `truncated` sit beside `features` on the collection.
`truncated` is the one to read: when it is true you are looking at an arbitrary
5,000 of `total`, and the fix is to zoom in - there is no paging on a map layer.
`total` costs a second `COUNT(*)`, so it is only computed when the cap actually
bit; otherwise the page **is** the whole answer and `total` equals `count`.

The **per-county** layer has no area limit. It does not need one: the county is
already the bound, and adding one would break every client drawing a county.

### The per-county layer: simplification, `count=0`, and the window cache

`GET /counties/{fips}/parcels.geojson` **simplifies by default**. Without
`simplify=` the tolerance is the window's east-west width in metres divided by
2,000, clamped to 0.5-5 m: about one screen pixel, so a zoomed-in view keeps its
corners and a wide one sheds the vertices nobody can see. `simplify=<metres>` sets
the tolerance exactly as on the cross-county layer, and `simplify=0` returns full
fidelity.

`count=0` skips the second `COUNT(*)` behind `total`. `total` is then the feature
count when the window was not capped and `null` when it was; `truncated` says which.
The default, `count=1`, keeps `total` exact for existing clients. A map that only
draws should send `count=0`.

The same window asked for again within **ten minutes** is answered from memory:
the key is the county, the bbox rounded to four decimals (about 11 m), every other
query parameter as sent, and the surface (the customer surface withholds
use-restricted owner names, so it never shares an entry with the admin one). The
cache holds at most 256 windows and 64 MB. A finished run for the county, or a
rebuild of the county copy, drops its entries.

### `simplify=<metres>`

`simplify=` thins each polygon with Ramer-Douglas-Peucker, dropping every position
closer than that to the line its neighbours already describe. The tolerance is
converted to degrees at the centre latitude of your `bbox`, so the same number
means the same distance in both directions. On the cross-county layer `simplify=0`,
or no `simplify`, returns full fidelity; the per-county layer has a default (above). Sensible values are a fraction of what a pixel covers: 1-5 m at
street zoom, 10-25 m at city zoom.

A simplified shape's positions are also rounded to six decimal places (about
0.1 m, five times finer than the smallest default tolerance). Sources hand over
14-15 places, and that noise was about a third of the gzipped map payload.
`simplify=0` leaves positions exactly as stored.

**It happens in Python, not in PostGIS.** Parcel geometry is stored as GeoJSON in a
`jsonb` column (`parcels.geometry`) and there is no PostGIS extension on this box,
so there is no `ST_SimplifyPreserveTopology` to call and no shapely in
`requirements.txt`; the implementation is `douglas_peucker` in
`parcel/api/geo.py`. Two consequences are worth knowing. Shared edges between
neighbouring parcels are simplified independently and can pull apart by up to the
tolerance, so slivers appear between polygons at large tolerances - fine for
drawing, wrong for area arithmetic. And a ring that would fall below four positions
is returned exactly as it was rather than shipped invalid.

If this layer ever becomes the bottleneck, the answer is **vector tiles**
(`GET /tiles/parcels/{z}/{x}/{y}.mvt`), which make the cap, the simplification and
the area limit disappear into the tile grid and cache for free. It is not built.

## Sales in an area

Every recorded sale around a property, and its counts per week, in two calls.

```bash
# twelve months of sales in a five-mile circle, newest first, buyers resolved
curl -s -H "Authorization: Bearer $KEY" "$PARCEL/v1/sales?near=32.6130,-83.6241&radius_m=8047"
# the same window counted per week: 53 buckets, empty weeks included
curl -s -H "Authorization: Bearer $KEY" "$PARCEL/v1/sales/histogram?near=32.6130,-83.6241&radius_m=8047&bucket=week"
```

- **One row per recorded deed** on every parcel in the area (`bbox=w,s,e,n` up to
  0.1 sq deg, or a `near=` circle up to five miles), dated in the window
  (`sold_after` / `sold_before`, or `from` / `to`; default the last 12 months). A
  county that records only each lot's last sale (ArcGIS, file, ORKA, Socrata) gives
  that sale as the row, with `sale_id: null` and `source: "last_sale"`.
- **Each row**: `property_id`, `county_fips`, `latitude`, `longitude`,
  `situs_address`, `situs_city`, `property_use`, `sale_id`, `source`, `sale_date`,
  `sale_price`, `grantor`, `grantee`, `deed_type`, `deed_book_page`,
  `plat_book_page`, `reason`, `arms_length`, `sale_flags`, `qualified`,
  `disqualified_reason`, `grantee_owner_id`, `grantee_buyer_id`.
- **`transfers`**: `0` (default) leaves out deeds at $1,000 or less and is the fast
  path; `1` is every recorded deed, $0 family transfers included - slower on an area
  the database has not read lately.
- **`arms_length`** on a row is `false` for a price of $1,000 or less or a county
  reason a transfer rule excludes (quit-claim, family, estate, correction), `true`
  for a reason no rule excludes, `null` when the county gave no reason. The
  `arms_length=1` parameter drops the `false` ones.
- **`grantee_owner_id`** is the buyer as an owner entry (`GET /owners/{entry_id}`),
  **`grantee_buyer_id`** their investor number (`GET /buyers/{buyer_id}`);
  `qualified` / `disqualified_reason` are the buyer matcher's verdict. These four
  come from the nightly match build and are null until it has seen the sale.
- `use=residential,vacant` keeps those use words; `limit` (up to 5,000) and
  `offset` page, with `truncated` and `next_offset`.
- **The histogram** (`bucket=day|week|month`, weeks start Monday) answers every
  period of the window with `start`, `end`, `sales`, `priced`, `median_price` and
  `total_price`, and the same totals for the whole window.

The same sale fields - `deed_type`, `arms_length`, `sale_flags`, `flag_sources`,
`deed_parcels`, `qualified`, `disqualified_reason`, `grantee_owner_id`,
`grantee_buyer_id` - are on every `sales[]` of `GET /parcels/{property_id}` and
`POST /parcels/sales`.

### Package sales

A county records one deed for one price even when it conveys several lots, and that
price lands on every lot it names: a "$700,000 sale" that was four $175,000 houses.
Such a sale carries **`multi_parcel`** in `sale_flags`, and **`flag_sources`** says
who said so, per flag:

- `county`: the county's own sale reason names it ("Multiple Parcels"). Only some
  Georgia counties write that reason.
- `deed`: the deed is shared. Two or more parcels of the same county carry a sale
  that day under the same deed reference, a sale row's `deed_book_page` or the
  parcel's own `last_sale_deed`. **`deed_parcels`** is how many. A reference with no
  digit 1-9 (`0 0`, `DEED`) names no deed (`deed_parcels: null`), and one shared by
  more than 200 parcels is a county's placeholder, which flags nothing.
- a key's name: a client flagged it with `POST /sales/flags`. A customer sees its
  own key's name, and any other customer's flag as `client`.

`GET /parcels/{property_id}/sales/siblings?sale_date=` lists the other parcels on
the deed, each with `via` (`deed`, a key's name, or both).

The deed itself is a record. **`GET /parcels/{property_id}/deeds`** lists every deed a
parcel is on (`multi_parcel=1`: only the package deeds), and **`GET
/counties/{fips}/deeds?deed_book_page=`** finds one deed by its reference. Each deed
carries `county_fips`, `deed_book_page`, `sale_date`, `price`, `parcel_count`,
`multi_parcel` and `parcels[]`, with each parcel's `property_id`, address and its own
`sale_price`. `price` is the deed's price when every priced lot carries the same one
(the county wrote the package price on each lot) and null when the lots carry
different shares (`prices_differ: true`). The record is read from the sale data on
every call, so it is never older than the sales. A reference is matched as the
county writes it: one deed spelt two ways by two sources (`12348 1` and `12348 0001`)
is two references. A parcel's own last sale
(`last_sale_date` / `last_sale_deed`, the only sale most bulk rolls record) gets the
same reading as `last_sale_deed_parcels`, `last_sale_flags` and
`last_sale_flag_sources` on the full record and on each `POST /parcels/sales` item.
A comp (`GET /parcels/{property_id}/comps`) carries the flag in its `sale_flags`.

To flag a package the county did not label, send its parcels once:

```
POST /v1/sales/flags
{"property_ids": ["13021-N0740410", "13021-N0740411"], "sale_date": "2025-12-12",
 "flag": "multi_parcel", "flagger_id": "user-42"}
```

The answer lists the parcels `marked`, `already` flagged by this key, and
`missing` (malformed or not on file). The flag rides the parcel and the sale date, so
a re-load of the county keeps it, and a lot whose sale the county never recorded can
be flagged too. `"remove": true` with the same parcels and date takes back this key's
flags.

## National AcqTrack

What AcqTrack draws for one circle, pre-built for every county, state and ZIP, so
the country can be ranked and mapped: `GET /acqtrack/regions?scope=county&months=12
&sort=dollars&limit=100`, and `GET /acqtrack/regions/{scope}/{key}` for one region's
months and top buyers.

**What is counted.** Each lot's own last sale, as AcqTrack's circle reads it from
`/parcels.geojson`, with flips from the lot's sale history, as the circle reads them
from `POST /parcels/sales`. A county whose roll records only each lot's last sale
counts as fully as one whose assessor pages give the whole history; flips need the
history.

| Field | Means |
|---|---|
| `all_sales` | Every recorded deed in the window, at any price. |
| `sales` | The arm's-length ones: not $1,000 or less, no excluding transfer reason. A deed with no price and no reason counts (a non-disclosure state's). |
| `parcels` | Lots whose arm's-length last sale is in the window: one per lot. |
| `priced_sales` | Of those, the ones with a price over $1,000. |
| `dollars` | Their prices, a package deed counted once (one deed, one price written on several lots: each lot carries its share). Where the county records no deed reference, lots sold to one owner on one day at one price are taken for one package. |
| `dollars_per_lot` | Every lot's own price summed, as the circle sums it. `avg_price` and `avg_price_per_lot` divide each by `priced_sales`. |
| `buyers` | Distinct owners (the last sale's buyer owns the lot today). Exact per county for the stops 3, 6, 12, 18, 24, 36 and 48 months with one use or all; `buyers_exact: false` where owners are added across months, uses or counties (a state, a ZIP crossing counties). |
| `acres`, `distressed`, `occupied`, `absentee`, `package_lots` | Over the same lots: land (a lot claiming over 50,000 acres is left out), a foreclosure or bank-sale reason, owner-occupied or not today, lots on a shared deed or package. |
| `flips`, `avg_hold_days` | Lots whose last sale came within 730 days of their previous arm's-length sale, counted in the month of the exit; the mean hold. `sort=flip` puts the smallest first. |
| `rated_flips`, `per_day` | The flips with both prices over $1,000, and their summed gain over their summed days. `sort=fliprate` puts the largest first. Fewer than 2 flips sorts last on both. |
| `latest_sale` | The newest sale counted: "data through". |

**Honest gaps.** A use-restricted region (Kansas, `use_restriction: "ks-45-230"`) is
listed with every measure null. No priced sale (a non-disclosure state) nulls
`dollars`, the averages and `per_day`; the counts stand. No lot with an earlier sale
on file nulls the flip measures (`flips_known: false`): the county records only the
last sale, so a flip cannot be told. A county not collected yet is absent.

The rollup is rebuilt per county by `python -m parcel.acq` (nightly with `--changed`:
counties with a run finished since their last build, and every county at a new
month); `built_at`, `oldest_build` and `counts_age_s` say how fresh it is.

## Drawing National AcqTrack lightly

A map of the rollup needs shapes and numbers. Both can be fetched small:

```
GET /v1/outlines?level=county&state=FL&detail=low            # 67 county shapes, simplified
GET /v1/outlines?level=zip&state=FL&detail=high              # Florida's ZIP shapes, from PARCEL (no TIGERweb)
GET /v1/acqtrack/regions?scope=county&state=FL&month=2026-03&fields=key,dollars&limit=50000
```

**Outlines.** `GET /outlines` gives one state's county or ZIP outlines (`state=US`:
every county) as a GeoJSON FeatureCollection whose feature `id` is the region's
`key`, so a value from `/acqtrack/regions` joins to its shape by id. `detail=low`
keeps lines within about 500 m (a whole state on screen), `high` within about 60 m
(a county or a city). Built once from the Census cartographic boundary files
(counties 2021 - the last edition with Connecticut's eight counties, which are
PARCEL's - and ZIP Code Tabulation Areas 2020) by `python -m parcel.outlines build`;
a ZIP across a state line is in both states' files, and a PO-box-only ZIP has no
shape. Served gzipped with an `ETag` and a day's `Cache-Control`.

**One month per call.** `month=YYYY-MM` on `/acqtrack/regions` answers that month
alone (any of the last 60), so a Timeline step costs one small call instead of the
whole window's series; `fields=key,dollars` trims each region to the fields asked.
A month's `buyers` are that month's distinct owners: exact for one use in one
county part, otherwise added (`buyers_exact: false`).

Vector tiles are not served: the outlines above at two details, drawn for one state
at a time, do the same work for a map that renders GeoJSON.

## Neighborhood AcqTrack

The fourth stop beside County, State and ZIP: the best circles of a quarter, half or
one mile in the country (or in one state, or inside a map box), wherever the sales
actually are - a county or ZIP line splits a hot spot that straddles it; a circle
does not.

```
GET /v1/acqtrack/spots?radius=0.5&months=12&use=residential&state=GA&sort=fliprate&limit=5
GET /v1/acqtrack/spots?radius=0.25&months=9&use=residential,vacant&bbox=-83.75,32.55,-83.55,32.68&sort=sales
GET /v1/acqtrack/spots/s805:5070:3739:3647?months=48
GET /v1/acqtrack/cells?bbox=-83.75,32.55,-83.55,32.68&months=12&use=residential
```

**The grid.** Every located lot is one pin in exactly one 1,000-ft square, on an
equal-area projection in metres: EPSG:5070 for the lower 48 and DC, EPSG:3338 for
Alaska, EPSG:26904 for Hawaii (the territories have no squares). A square is
`(floor(x / 304.8), floor(y / 304.8))`, so a pin on a line belongs to the square
east and north of it. The pin is the lot's own county-GIS point (`geocode_precision:
parcel`) or its exactly matched address (`street`). A Census near-miss (`centroid`)
has no pin, nor does a point a crowd of unrelated lots share (25 or more lots on one
spot spanning 5 or more streets: a placeholder, not a hot spot; a condo building on
one street keeps its pins). A lot with no pin still counts in its county, state and
ZIP. The squares are tallied in the same pass, by the same rules, as the ZIP rows, so
a circle's numbers and the regions' agree on the same ground.

**A circle** of radius r around a square is every square whose offset (dx, dy)
satisfies dx² + dy² ≤ (r / 304.8)²: 5 squares at a quarter mile, 21 at a half and 89
at a mile. Its measures are those squares' rows summed - each lot once - with the
regions' fields and derivations (`avg_price`, `avg_hold_days`, `per_day`) and the
same honest gaps: no priced sale nulls the dollars, no sale history nulls the flips
(`flips_known: false`), and a use-restricted county (Kansas) has no squares at all.
`buyers` adds each square's and month's distinct owners, so `buyers_exact` is always
false. Candidate centres are the squares with a sale in 48 months; `lat`/`lng` is the
centre square's centre and `zip`/`city` its lots' majority.

**Ranking.** A circle ranks on `flip` only with 3 flips (smallest mean hold first), on
`fliprate` only with 3 rated flips (largest $/day first), on `dollars` and
`dollars_per_lot` only with a priced sale, on anything else with a sale. Down the
sorted list a circle is kept only if its centre is more than 2r from every circle
already kept: listed circles never overlap, so the Top 100 are 100 different places
and no lot is counted in two of them.

**What is precomputed.** Nightly after the rollup (`python -m parcel.acq_spots`), for
each radius, the seven look-back stops (3, 6, 12, 18, 24, 36, 48 months) and the use
mixes all and `residential`: the top 500 nationwide and the top 100 per state, for
every sort. Without `bbox` the route answers those, and any other window or mix is a
400. With `bbox=w,s,e,n` (up to about 600 km a side) the circles centred inside the
box are ranked live from the squares for any `months` 1-48 and any `use`; `state=`
and `bbox=` are not combined.

**One circle.** `GET /acqtrack/spots/{key}` (`s<radius_m>:<zone>:<cx>:<cy>`, from a
list) sums the circle over `months` (default 48) and `use`, with its `series`,
`buyers_top` (the ten owners with the most purchases among its lots) and `parcel_ids`
(the `property_id` of every lot whose own last sale in the window puts its pin
inside it) - the lot-by-lot circle, ready to draw.

**The squares.** `GET /acqtrack/cells?bbox=` returns the squares themselves (`key`
`c:<zone>:<cx>:<cy>`, centre, majority ZIP and city, every measure) for a heat map or
any area's totals with county lines disregarded.

`built_at` is the nightly list's build (the rollup's newest county build for the
live answers); `counts_age_s` says how old it is.

## Home types

Every parcel row carries `home_type`: what kind of home it is, in a home search's
words, so a list can be filtered the way a home search filters.

| `home_type` | What it is |
|---|---|
| `single_family` | A house: one dwelling on its lot. |
| `multi_family` | Two to four units: duplex, triplex, fourplex. |
| `apartments` | Five units or more. |
| `condo` | A condominium or co-op unit. |
| `townhouse` | A townhouse, row house or attached unit. |
| `manufactured` | A mobile or manufactured home. |
| `land` | A lot or tract with no building. |
| `farm`, `commercial`, `industrial`, `exempt`, `other` | Not a home. |
| `unknown` | The county records nothing to tell from. |

It is read from the county's own columns - `property_class`, `building_style` and
`property_use` - in that order of trust: words first ("DUPLEX", "Res-Condo",
"Townhouse Owner Dvlpmnt"), then the state's use-code table (Florida's DOR codes,
Texas's state property codes, Ohio and Indiana land use, New York's RPS classes,
Massachusetts and Connecticut use codes, Washington, New Jersey, Michigan,
Illinois, Maryland, Virginia, Oregon, Arizona, Colorado and others, plus county
tables where a county has its own scheme), then the coarse use. A code means only
what it means in its own state: Ohio's `401` is apartments and Michigan's a house.

`home_type_basis` says how it was read: `text`, `code`, `use`, or `inferred` - a
class that says "residential" and nothing finer, read as single family, or as land
when the record shows no building. `home_type_strict=1` on a list leaves the
inferred ones out.

```
GET /v1/counties/13153/parcels?home_type=single_family,multi_family&min_beds=3&min_baths=2&max_value=350000
GET /v1/counties/12086/parcels?home_type=condos&sold_after=2026-01-01&order=sale_date_desc
GET /v1/counties/13153/home-types
```

`GET /v1/counties/{fips}/home-types` counts the county by type, as of its last
classification (`classified_at`; null for a county not classified yet, when every
row's `home_type` is null too).

## Messy title

Every parcel row carries `messy_title_likely` and `messy_title_reasons`: whether the
title is likely messy - an estate not settled, a foreclosure or tax sale,
quit-claims passed around - read from the deeds PARCEL holds (`parcel/title.py`).

| Reason | Read from |
|---|---|
| `estate_deed` | An estate, probate, executor's, administrator's, personal representative's or fiduciary deed, a decree of distribution or a year's support, or a deed whose grantor is an estate or its executor - for nothing or a nominal price: the estate passing to heirs. An estate *selling* at a price is a probate sale, a purchase like any other. A recorded death certificate is not a reason (a surviving joint owner taking the whole). |
| `foreclosure` | A foreclosure, sheriff's, referee's or marshal's deed, a deed in lieu, a deed under power, a public or substitute trustee's deed, a trustee's deed in a deed-of-trust state, a Florida certificate of title, or a deed to HUD, Fannie Mae, Freddie Mac or the VA. |
| `tax_deed` | A tax deed, tax sale or forfeiture, or a deed from a treasurer or tax collector. |
| `quit_claim` | A quit-claim deed. On its own a reason to filter on, but not enough for `messy_title_likely`: one quit-claim is everyday paperwork as often as not. |
| `quit_claims` | Two or more quit-claims within three years of each other. |
| `owner_estate` | The owner line says "estate of", "heirs" or "deceased", names an executor or personal representative, or reads "<a person> ESTATE". |

`messy_title_likely` is true for any reason but a lone quit-claim; `messy_title=1`
filters on it, and `messy_title_reason=quit_claim` still finds the lone ones.

A deed counts only when it was recorded in the last ten years and no purchase came
after it: walking the deeds from the newest, the first one with none of these words
that is not a nominal transfer ($1,000 or less) ends the walk, and everything older
sits behind a sale. A day with a priced warranty deed is a sale too - a $0
quit-claim recorded the same day is the closing's own paperwork. The owner line is
today's, so it always counts. Deeds of one day are one deed otherwise - sources
repeat each other, and one quit-claim read twice is not "several".

What is read is each sale's county reason, its deed type and both parties, and the
parcel's own last sale. Most county rolls record no deed type, so there only the
parties and the owner line can flag a lot: `false` means "nothing on file says so",
not "clean". The field is null until the county has been checked. Liens and unpaid
taxes need paid data and are not read.

```
GET /v1/counties/39153/parcels?messy_title=1&home_type=houses
GET /v1/parcels.geojson?bbox=-84.42,33.70,-84.30,33.80&messy_title_reason=estate,foreclosure
GET /v1/parcels/13153-0W050J08B000
```

The full record adds `messy_title`: `{likely, reasons, since, evidence, checked_at}`,
`since` the newest flagged deed's date and each `evidence` entry `{reason, date,
read_from, text}` - `read_from` is `deed` (its type or reason), `grantor`,
`grantee` or `owner`, and `text` is what the county wrote. Rebuilt nightly for the
parcels updated or sold since the last run (`scripts/cron-title.sh`).

## Zillow homes

**The routes, for keys with the `zillow` scope.** The deep page of one home - the listing, the price history, the public tax history, the schools and every facts & features field, with the parcel it names and that parcel's county record - is `GET /zillow/homes/{zpid}` (XRAY's "Zillow Details": the route that serves the Zillow page read); `GET /zillow/homes` lists the pages read, newest first, by `zip`, `state`, `status` and more; `POST /zillow/homes/{zpid}/read` reads a page this moment when we hold no fresh full read (see "The on-demand lane" under every route). The map is `GET /homes` with `bbox=` (the home cards in a box: "Zillow Listing") and `GET /homes/clusters` for a zoomed-out box (counts per grid cell by status). All of it comes from the pages the lanes read through the proxy addresses, in the owner's order (`parcel/zillow_plan.py`), full pages only.

`parcel/zillow.py` reads Zillow home pages on demand (`python -m parcel.zillow pages --location <search slug> --count N`, or `--zpid`): each page's listing and, through a browser, its price history, public tax history, schools and facts & features (0.52.0; the nightly sitemap read it replaced is gone). A page names its county and the county's parcel number, which is the parcel's own key, so it links to exactly one parcel or none. Zillow refuses one address after about 60 pages an hour; a refusal stops the run.

Every parcel row carries `zillow` - `{zpid, status, status_at, sold_seen, price, url}` or null - on an admin key, and null on a customer key: the page read that names the parcel. `zillow_status=for_sale,pending` (or `listed`, `sold`) and `zillow_sold_after=YYYY-MM-DD` filter on it, admin keys only (403 otherwise). `status_at` is the newest event in the page's price history and `sold_seen` its newest sale. Only parcels whose page has been read carry them.

## Home search

For keys with the `zillow` scope (0.52.7; admin keys too). `GET /homes` answers the questions a home search asks - homes in a place, of a kind, in a price band, sold or listed since a date - over PARCEL's parcels joined to their home type, the Zillow home page that names them and their market:

```
GET /homes?state=TX&status=for_sale&home_type=single_family&min_beds=3&max_value=350000
GET /homes?zip=31088&status=sold&zillow_sold_after=2026-09-01&sort=newest
GET /homes?state=GA&status=for_rent&max_rent=2000&sort=rent_asc
GET /homes?bbox=-84.6,33.6,-84.2,33.95&status=listed,rent          # the map, zoomed in: the cards in the box
GET /homes/clusters?bbox=-84.6,33.6,-84.2,33.95&grid=16             # the map, zoomed out: counts per cell by status
GET /homes/clusters?level=state                                     # the nationwide map: a bubble a state
GET /homes/clusters?level=county&state=GA                           # ... then a county, then a ZIP, as it zooms
GET /homes/clusters?level=zip&bbox=-84.6,33.6,-84.2,33.95&status=listed
GET /homes?county=13153&sold_after=2025-01-01&min_price=150000&max_price=300000&sort=ppsf_desc
GET /homes?near=32.61,-83.62&radius_m=2000&home_type=multi_family&absentee=1
GET /homes/13153-0W050J08B000
GET /homes/zpid:35956751
```

Every parameter of `GET /homes`, all optional and combined with AND - one place at
least, unless a Zillow `status` is given. Booleans take `1`/`0`; dates are
`YYYY-MM-DD`; a number that cannot be read is ignored; a malformed place, an unknown
status or home type, or a radius out of range is a 400. The same list, with a type
each, is in the OpenAPI document (0.52.9), so Swagger UI has a field for every one.

| Parameter | What it is |
|---|---|
| `state` | Two letters (`GA`). |
| `county` | The county's FIPS (`13153`). |
| `city` | A city, with `state` or `county`. |
| `zip` | Five digits. |
| `bbox` | `w,s,e,n` in degrees: the map's box. |
| `near` | `lat,lng`: the centre of a circle. |
| `radius_m` | The circle's radius in metres: 1,000 by default, **at most 50,000** - `16093` is ten miles. (The 8,047 m cap is the cross-county parcel search's, not this route's.) |
| `address` | A substring of the street line. |
| `status` | Zillow's: `for_sale`, `pending`, `recently_sold`, `for_rent`, `off_market`, `other`, or the groups `listed` (for sale or pending), `sold`, `rent`; comma separated. Starts the search from the Zillow pages read, so it counts as a place. |
| `home_type` | The [Home types](#home-types) vocabulary (`single_family`, `multi_family`, `condo` ...) or `houses`, `homes`; comma separated. |
| `min_price`, `max_price` | The last sale price. |
| `min_value`, `max_value` | The assessed or market value. |
| `min_ppsf`, `max_ppsf` | The last sale's price per square foot. |
| `min_tax`, `max_tax` | The estimated annual tax. |
| `min_rent`, `max_rent` | The asking rent a month; implies `status=for_rent`. |
| `min_beds`, `max_beds` | Bedrooms. |
| `min_baths`, `max_baths` | Baths; a half bath counts half. |
| `min_sqft`, `max_sqft` | Heated square feet. |
| `min_acres`, `max_acres` | The lot. |
| `min_year_built`, `max_year_built` | The year built. |
| `sold_after`, `sold_before` | The county's last sale date. |
| `zillow_sold_after`, `zillow_sold_before` | The newest sale in the Zillow page's price history. |
| `status_after`, `status_before` | The newest event in the Zillow page's price history - the card's `status_at`. |
| `absentee` | `1`: the owner's mailing state differs from the property's; `0`: it does not. |
| `owner_occupied` | The county's owner-occupancy flag. |
| `owner` | A substring of the owner's name. |
| `on_zillow` | `1`: only homes whose Zillow page has been read; `0`: only homes without one. |
| `sort` | `newest` (default: the Zillow page change with a `status`, else the last sale), `sale_date_desc`, `price_desc`, `price_asc`, `value_desc`, `value_asc`, `sqft_desc`, `lot_desc`, `year_built_desc`, `ppsf_desc`, `status_newest`, `rent_desc`, `rent_asc`, `distance` (with `near`). |
| `limit` | Cards in the answer: 50 by default, **at most 5,000** (0.52.9; was 500) - a ten-mile circle's listings in one answer. |
| `offset` | Where the page starts: at most 10,000. The answer's `next_offset` is the next page's, null on the last. |
| `count` | `1` adds `total`, counted within 8 seconds or null. |

`GET /homes/clusters` takes `bbox` (required), `grid` (cells a side: 16, at most 64)
and `status` (the statuses to count; every one by default). That is the grid, counted
live. With `level=state`, `county` or `zip` (0.52.10) it answers instead the rows a
nightly job wrote (`python -m parcel.zillow_clusters`, `zillow_area_clusters`): for
every area that holds Zillow homes, its `count`, `by_status` with a `count` and a
`median_price` per bucket - `for_sale` (Zillow's for sale, new construction and
auction), `pending`, `sold_90d` (sold in the last 90 days) and `for_rent` - and the
point to draw its bubble at (`lat`, `lng`: the county's own center, else the homes'
mean point). `key` is the id `GET /outlines` draws - the state's two letters, the
county FIPS, the 5-digit ZIP - so the bubbles join to the shapes; a county carries its
`name`. `bbox` or `state` narrows the areas (the ZIP level needs one of them), `status`
keeps those buckets (or `listed` / `sold` / `rent`). Biggest first; `computed_at` says
which night; an `ETag` and `If-None-Match` make the repeat call a 304. A nationwide
map draws state, then county, then ZIP bubbles as it zooms, and switches to `GET
/homes` pins (`bbox=`, or `near=` with `radius_m=16093`) inside ten miles.

```json
{"level": "county", "state": "GA", "bbox": null, "computed_at": "2026-10-31T02:50:11Z",
 "areas": [{"key": "13121", "name": "Fulton", "state": "GA", "lat": 33.79, "lng": -84.47, "count": 2140,
            "by_status": {"for_sale": {"count": 1320, "median_price": 489000},
                          "pending": {"count": 210, "median_price": 455000},
                          "sold_90d": {"count": 380, "median_price": 442500},
                          "for_rent": {"count": 230, "median_price": 2350}}}],
 "total": 2140, "attribution": "Data Provided by Zillow Group"}
```

Each result is a home card: `property_id`, `zpid`, `status` and `status_at`, `sold_on_zillow`, `list_price` (Zillow's asking price, on a home for sale or pending) and `rent` (the asking rent a month, on a home for rent; `min_rent` / `max_rent` filter on it and imply `status=for_rent`, `rent_asc` / `rent_desc` sort by it), `address` (`line` as the county writes it, `line1`, `line2` - the unit, such as `STE 5552` - `city`, `state`, `zip`), `lat`/`lng`, `home_type`, `beds`, `baths`, `sqft`, `lot_acres`, `year_built`, `last_sale` (`date`, `price`, `price_per_sqft`), `value` (`total`, `land`, `improvement`, `year`), `annual_tax`, `owner` and `zillow_url`. A place is required unless a Zillow `status` is given; `city` needs `state` or `county`. `count=1` adds `total`, counted within 8 seconds (null when a whole state takes longer).

`estimate` on one home is the last priced sale (over $10,000) brought to today by the Zillow Home Value Index of the home's ZIP (its county when the ZIP has none) for its home type: price x index now / index in the sale's month. The range is 5% either way plus a point for each year since the sale, at most 25%. Null without such a sale inside the index's months.

## Market layer

`GET /market` (admin) serves Zillow Research's free indexes, loaded monthly: the
**Zillow Home Value Index** (`zhvi`, the typical value of the middle third of homes,
smoothed and seasonally adjusted) for `single_family`, `condo` and `all` homes by
state, metro, county and ZIP, and the **Zillow Observed Rent Index** (`zori`, the
typical asking rent) for `all` homes by metro, county and ZIP and for
`single_family` and `multi_family` by metro. Zillow publishes no value index for
multi-family buildings.

County rows carry `county_fips` from Zillow's own FIPS columns, so they join to the
parcels exactly; ZIP rows carry the ZIP and, where the county name Zillow gives
matches one county, its FIPS. Show "Data Provided by Zillow Group", linked to
Zillow Research, wherever the numbers appear: every answer carries it as
`attribution`.

## Comps

`GET /parcels/{property_id}/comps` (read scope, `/v1` too) answers recent sales
near one property that look like it, in one call. It replaces a client pulling
every sale in a county inside a radius and filtering them itself.

A comp is a sale dated inside the last `months` and priced over $1,000 (at or
under that it is a transfer, not a price - the same line buyer matching draws), on
a parcel whose own point is inside the circle around the subject, which is not the
subject, and whose record fits every range. The sale is any row of the parcel's
sale history (`parcel_sales`), or the parcel's own `last_sale_date` and
`last_sale_price` - the only sale an ArcGIS, file, ORKA or Socrata county records,
which is most counties. The parcel's own copy is skipped when the history already
holds that date and price. **One comp per property**: its newest qualifying sale in
the window, so a lot sold twice, or one sale two sources both recorded, is one
comp. The circle crosses county lines.

**Arm's length.** By default (`arms_length=1`) a sale whose county transfer reason
marks it as not a purchase - a quit-claim, a family or estate transfer, a
correction: the `exclude` rows of `transaction_rules`, the same policy buyer
matching qualifies sales by - is skipped *before* the newest sale is picked, so a
lot whose newest sale was a family transfer comps on the purchase before it. A
sale with no reason, which is every sale in a county that records only a last
sale, cannot be judged and stays in. `arms_length=0` keeps every priced sale.

| Parameter | Default | Range | What it bounds |
|---|---|---|---|
| `radius_m` | 1609 (a mile) | 1-8047 | The circle. Over five miles is a **422** `radius_too_large`. |
| `months` | 24 | 1-60 | Sold within this many calendar months of today (UTC). |
| `beds` | 1 | 0-10 | `bedrooms`, the subject's plus or minus this (`0` = exactly). |
| `baths` | 1 | 0-10 | Baths, plus or minus. A half bath counts half, so 2 full and 1 half is 2.5. |
| `sqft_pct` | 25 | 0-100 | `heated_sqft` within this percent of the subject's. |
| `lot_pct` | 50 | 0-500 | `land_acres` within this percent of the subject's. |
| `year` | off | 0-100 | `year_built`, the subject's plus or minus this many years. Off unless sent. |
| `arms_length` | `1` | `1`/`0` | Skip sales whose reason says they were not a purchase (above). |
| `same_type` | `1` | `1`/`0` | Keep only the subject's `type_bucket` (`sfr`, `multifamily`, `land`, `mobile_home`, `commercial`) - the buyer matcher's one definition. |
| `limit` | 25 | 1-200 | Comps returned. |
| `order` | `distance` | `distance`, `similarity`, `recent` | Nearest first, most similar first, or newest sale first; ties go to the nearer. |
| `widen` | `1` | `1`/`0` | Fewer than `limit` comps and the radius doubles, once, never past 8047. |
| `fields` | every field | - | Comma-separated subset of a comp's fields; `property_id` always stays. |
| `geometry` | `0` | `1`/`0` | Add each comp's parcel polygon. |

Any of `beds`, `baths`, `sqft_pct`, `lot_pct` and `year` may be **`any`** to switch
that range off. A bad value is a **400** naming the parameter, before any query runs.

**Zero is "not recorded."** A bedroom count, square footage, lot size or year of 0
comes back `null`, never as a real zero. When the **subject** lacks a measure, its
range is switched off and `ranges.off` says so (`subject_missing`); a range you
turned off - or `year`, when you did not send it - reads `any`; and a subject whose `type_bucket` is `other` is not matched
on type (`subject_other`). A comp lacking a measure a range needs is not a comp.

`?year=15` on 109 Freeman Dr, whose record carries no bedroom or bath count:

```json
{
  "subject": {"property_id": "13153-0W050J08B000", "county_fips": "13153",
              "situs_address": "109 FREEMAN DR", "situs_city": "WARNER ROBINS",
              "situs_zip": "31088", "latitude": 32.6105, "longitude": -83.6219,
              "type_bucket": "sfr", "property_use": "Residential", "bedrooms": null,
              "baths": null, "full_baths": null, "half_baths": null, "heated_sqft": 1684,
              "land_acres": 0.46, "year_built": 1978, "last_sale_date": "2019-06-14",
              "last_sale_price": 132000.0},
  "ranges": {"radius_m": 1609.0, "radius_requested_m": 1609.0, "months": 24,
             "sold_after": "2024-09-29", "sold_before": "2026-09-29",
             "sale_price_over": 1000, "arms_length": true,
             "bedrooms": null, "baths": null,
             "heated_sqft": {"min": 1263.0, "max": 2105.0, "pct": 25.0},
             "land_acres": {"min": 0.23, "max": 0.69, "pct": 50.0},
             "year_built": {"min": 1963.0, "max": 1993.0, "plus_minus": 15.0},
             "type_bucket": "sfr",
             "off": {"bedrooms": "subject_missing", "baths": "subject_missing"}},
  "items": [{"property_id": "13153-0W050J11C000", "county_fips": "13153",
             "situs_address": "127 FREEMAN DR", "situs_city": "WARNER ROBINS",
             "situs_zip": "31088", "sale_date": "2026-06-11", "sale_price": 162000,
             "price_per_sqft": 99.39, "sale_reason": "Fair Market Value",
             "arms_length": true, "sale_flags": [],
             "distance_m": 184.2, "similarity": 0.914,
             "bedrooms": 3, "baths": 2.0, "full_baths": 2, "half_baths": null,
             "heated_sqft": 1630, "land_acres": 0.41, "year_built": 1981,
             "type_bucket": "sfr", "property_use": "Residential",
             "latitude": 32.6118, "longitude": -83.6204}],
  "count": 1, "matched": 1, "widened": false, "capped": false, "order": "distance",
  "summary": {"comps": 1, "median_sale_price": 162000, "median_price_per_sqft": 99.39,
              "with_sale_price": 1, "with_price_per_sqft": 1}
}
```

**`similarity`** is 0-1: for every range in force, 1 at the subject's own figure
and 0 at the edge of the plus-or-minus, averaged with equal weight. It is `null`
when every range is off. It is relative to the ranges you asked for, so widening
`sqft_pct` makes the same comp more similar.

Each comp says what kind of sale it was: **`sale_reason`** is the county's own
transfer reason, verbatim (null where the county records none);
**`arms_length`** is `true` for a purchase, `false` for a transfer (seen only with
`arms_length=0`) and `null` when there was no reason to judge; **`sale_flags`**
labels a sale that still counts - `distressed` (foreclosure, bank or judgment
sale), `multi_parcel` (the price covers more than this lot), `unvalidated` (the
assessor did not verify it).

**`summary`** is over the comps returned, so it follows `limit` and every range:
`median_sale_price`, `median_price_per_sqft`, and how many comps each median was
taken over (`with_sale_price`, `with_price_per_sqft` - a comp with no recorded size
has a price but no price per square foot). `fields=` does not narrow it.

`count` is the comps returned and `matched` how many passed every filter before
`limit`. `widened` says the radius grew, and `ranges.radius_m` is the one used.
`capped` says more than 2,000 candidate sales sat inside the circle before the
type filter, so the nearest 2,000 were the pool and `matched` is a floor. A capped
pool never widens: a wider circle would return the same nearest 2,000.

A subject with no coordinates answers 200 with no comps and a `note`. Answers
carry an `ETag` and `Cache-Control: private, max-age=60`; a matching
`If-None-Match` is a `304`.

## Response schemas

`/openapi.json` declares what comes back, not just which routes exist. Every `/v1`
GET that answers JSON carries a `$ref` to a named schema under
`components.schemas`, so a consumer can generate its own columns from the document
instead of inferring them from a sample row.

The models live in one file, `parcel/api/schemas.py`, and are derived from two
things: `sql/schema.sql`, where a `NOT NULL` column becomes a **required** field
that is never null, and the builders in `parcel/api/detail.py` and
`parcel/api/filters.py`, which decide the derived fields. `ParcelSummary` is the
list row and the NDJSON export line; `ParcelDetail` extends it with `sales[]`,
`buildings[]`, `values[]`, `images[]`, `owners[]`, `permits[]` and `sections[]`.

One shape is published although no route returns it on its own: `Tombstone`,
the `parcel_tombstones` row behind a `delete` in the change feed. `CoverageRow`,
one county's line in the coverage table, is what `GET /coverage` returns inside
its `Coverage` envelope.

Three things to know before you generate code from them.

- **Nullability is the point.** A field without `| None` is `NOT NULL` in our
  database and will never come back null; everything else can be, on the first
  county whose records are thinner than the sample you tested against.
- **`fields=` narrows a row.** Ask for `fields=property_id,situs_address` and you
  get those two, not the whole model. The schema describes the full row.
- **Every model allows extra keys** (`additionalProperties: true`). Fields are
  added to responses within `/v1`; nothing already in it is renamed or removed
  without a `/v2`. The schemas document; they do not validate - they are attached
  as OpenAPI additional responses, so declaring one has never changed a byte of a
  reply.

## Versioning

Every router of the API itself is mounted **twice**: at its own path, and again
under `/v1`. Same endpoint objects, so the two can never answer differently. The
exceptions are root-only and listed under "Storage" (`/storage`, `/docs/files` and the
documentation pages).

* **`/v1/...` is the supported customer surface.** It is what external customers
  are given, what the public document describes, and the only thing an outside
  caller should write down. Routes are added to it and fields are added to
  responses; nothing already in it is renamed or removed without a `/v2`. 0.8.0
  broke that once, deliberately: `parcel_key` became `property_id` and `id` left
  the parcel object. There are no paying customers yet, which is exactly why the
  rename happened now rather than after there were.
* **The unprefixed paths are internal.** Unversioned, hidden from the public
  document, and what PARCEL-site and the owner's other apps already call - which is
  exactly why they do not move.

The prefix is a version, not a permission. `/v1/status` needs `admin` just as
`/status` does: `enforce_scope` strips the `/v1` before it looks a route up, so both
are the same row in the table.


## Errors

Every error answers with the same envelope: a machine-readable `error` object, and
the `detail` the API has always sent, unchanged, beside it.

```json
{
  "error": {
    "code": "invalid_parameter",
    "message": "bbox=w,s,e,n is required",
    "request_id": "6f1c4e0a9b2d4f1ea0c7b3d21e5a7c44"
  },
  "detail": "bbox=w,s,e,n is required"
}
```

**Switch on `error.code`.** It is a short snake_case token and it is part of the
contract: it does not change once it has shipped. `message` is written for a
person and gets reworded; `detail` is kept for anything already reading it.
`request_id` is the same id as the `X-Request-ID` header, repeated in the body so a
client that only kept the JSON can still quote it.

| Status | `error.code` | When |
|---|---|---|
| 300 | `multiple_matches` | An address matched several properties. The body carries `candidates`; pick one and retry by id. |
| 400 | `invalid_parameter` | A parameter could not be honoured. The message names it. |
| 401 | `unauthorized` | The credential is missing, malformed, unknown, revoked or expired. |
| 402 | `suspended` | The key is suspended - a billing state, not a security one. `reason` says why. |
| 403 | `forbidden` | The key is valid but does not carry the scope the route needs. The message names the scope. |
| 404 | `not_found` | No such record. A malformed `property_id` is a 404 too: either way there is no such property. |
| 405 | `method_not_allowed` | That method is not allowed on that path. |
| 409 | `conflict` | Conflicts with something already running. |
| 409 | `not_retrievable` | `POST /keys/{id}/reveal` cannot show that key: revoked, never sealed, the vault is off, or the secret changed. `reason` says which. |
| 413 | `too_large` | The request is too large. |
| 422 | `invalid_parameter` | A typed parameter would not parse. `detail` is FastAPI's own list of fields; `message` names the first offender. |
| 429 | `rate_limited` | The per-minute bucket is empty. `Retry-After` is in seconds - wait and retry. |
| 429 | `quota_exceeded` | The calendar month's quota is spent. The body carries `quota`, `used` and `resets_at`; retrying before then will not help. |
| 500 | `internal` | Our fault. Quote the `request_id`. |
| 501 | `not_implemented` | Not available on this host. |
| 503 | `unavailable` | The database could not be reached. |
| 504 | `timeout` | The underlying command did not finish in time. |

Two of these are the ones worth writing code around. **429 means two different
things** - a rate limit refills in seconds, a quota does not refill until the 1st -
and the code is how you tell them apart without parsing a sentence. **402 is not
403**: the credential is real and the customer is still a customer, they have
stopped paying, and the key works again the moment it is reactivated.

A route may name a code more specific than its status, and some do - the change
feed's `too_far_behind`, the map layer's `bbox_too_large`, buyer matching's
`use_restricted`, a vote's `vote_conflict` (that `voter_id` already voted on the
pair through another key), and a rule decision's `already_decided`, `report_only`,
`rule_exists` and `rule_changed`. It arrives in exactly
the same place, `error.code`, so a client that switches on that field needs no
special case for them.

The vocabulary lives in `parcel/api/errors.py`; `parcel/api/audit.py` turns every
exception into one of these bodies, and the rate limiter builds its own.

## Request IDs

Every response carries `X-Request-ID`.

Send your own and it comes back unchanged, as long as it is at most 64 characters
of letters, digits and `-_.:`. Send nothing, or something outside that, and you get
a fresh uuid4 - an id ends up in a log line and a database row, so it is never a
place to inject either.

The same id appears in the server's access log line for the request and in its
audit row, so quoting it is the fastest way to have a call looked up:

```bash
curl -si -H "Authorization: Bearer $TOKEN" "$PARCEL/counties" | grep -i x-request-id
```

The access log itself is one JSON object per request on the `parcel.access`
logger - timestamp, request id, the calling key, method, path, status, duration and
client address. The path is logged **without its query string**: parcel filters
routinely carry an address or an owner name, and an access log is not the place for
them. uvicorn's own access line is turned off (`--no-access-log`) so there is
exactly one.

An unhandled error is **500** with the error envelope and nothing else -
`{"error": {"code": "internal", "message": "internal error", "request_id": …},
"detail": "internal error"}`. The traceback stays on the server: it would name file
paths, package versions and local variables, and the request id is what a caller
actually needs in order to ask about it.

## Quick start with curl

The `/v1` paths, with a key:

```bash
export PARCEL=https://13-140-33-19.sslip.io KEY=pk_your_key_here
curl -s "$PARCEL/healthz"                                                      # no auth
curl -s "$PARCEL/llms.txt"                                                     # no auth
curl -s -H "Authorization: Bearer $KEY" "$PARCEL/v1/counties?with_data=1"      # what we hold
curl -s -H "Authorization: Bearer $KEY" "$PARCEL/v1/coverage?state=GA"         # and how good it is
curl -s -H "Authorization: Bearer $KEY" "$PARCEL/v1/parcels?q=109+Freeman+Dr"  # find one
curl -s -H "Authorization: Bearer $KEY" "$PARCEL/v1/parcels/13153-0W050J08B000"
curl -si -H "Authorization: Bearer $KEY" "$PARCEL/v1/usage" | grep -i '^x-quota'
```

Fetching the documents, no credential needed:

```bash
curl -s "$PARCEL/openapi.json" -o parcel.json
curl -s "$PARCEL/docs/api.md"  -o parcel-api.md
```

## How the document is organised

Routes are grouped under fifteen tags, and each tag says what it is for:

**Health** · **Counties** · **Parcels** · **Images** · **Owners** ·
**Links / buyer matching** · **Runs & coverage** · **Export & sync** ·
**Backups** · **Control** · **Scraper** · **Storage** · **Keys & usage** ·
**Audit** · **Documentation**

"Links" is the buyer-matching feature - the `/match/*` routes - under the name the
UI uses for it. The customer document shows only the tags it has routes in, and
describes "Keys & usage" as the one thing a customer reaches through it: their own
meter.

A route's tag is also its **usage family** - the line it lands on in `api_usage`
and in the export. So retagging a route changes how its traffic is invoiced, which
is a reason to think before moving one.

Every route carries a one-line summary, a description of what it returns and what
each parameter means, a realistic JSON example of the 200 body, and the error
codes it can actually raise. The parcel list routes take their filters straight off
the query string rather than through typed parameters, so their **full filter
vocabulary lives in the route description** - that is the reference for `q`,
`owner_id`, `absentee`, `bbox`, `near`, `order`, `fields` and the rest, including
the two paging modes (`next_cursor` when `order=id`, `next_offset` otherwise).

The introduction at the top of the page covers authentication and scopes, the rate
limit (`RATE_LIMIT_PER_MIN`, 429 with `Retry-After`), CORS, request ids and the
audit log, and the one thing worth reading before you store anything:
**`property_id` is the id, and a parcel object carries no other one.**
