# Jurisdiction and location filtering

> Two kinds of place: where the record is (jurisdiction) and where the person lives (residence). The request fields tell them apart, and so should you.

- **HTML:** https://offendersearch.app/docs/guides/criminal/jurisdiction-filtering
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## What you'll build

A search that is scoped to the places that matter for your case: the state and county a facility sits in, the places a person has lived, or the specific jurisdiction a records website belongs to. Scoping is how a common-name search stays answerable (`state` and `firstName` are what the `query_too_broad` refusal asks for) and how a reviewer sees the records that could be the person rather than every namesake in the country.

## Before you start

- A test key (`os_test_…`) on `https://sandbox.offendersearch.app`. The sandbox people live in Sampleville, ZIP `00000`; `Felony Record` is an Ohio (`OH`) court record, `Misdemeanor Record` a Texas (`TX`) one, so state scoping is visible there.
- Jurisdiction codes. `<ST>-<COUNTY>` for a county (`TX-BEXAR`, `TX-PALM-BEACH`-style hyphenation) and `<ST>-<RECORD-KIND>` for a statewide or federal system (`TX-DOC`, `OK-COURT`, `US-WARRANT`). Take them from `GET /v1/criminal/jurisdictions` or `GET /v1/criminal/sources`; some county codes carry more tokens than the county name, so never build them by hand.
- The full filter reference: [Filtering & locations](/docs/criminal/filtering) and [Jurisdictions & codes](/docs/criminal/jurisdictions).

> **Flat place fields and locations[] are exclusive.** `query.state`, `county`/`jurisdiction`, `city`, `zipcode`/`zip` and `address`/`streetAddress` are the simple form. `locations[]` is the rich form (kind, radius, up to ten places, ORed). Send one form or the other; both together is `422 ambiguous_location`.

## Step by step

1. **Scope by jurisdiction.** `state` (and optionally `county`) filter on the jurisdiction that holds the record — the court, jail or agency — not on where the person lives. `state` also drives the `coverage` block.
2. **Scope by residence.** `city`, `zipcode` and `address` match the record's published residence `address`. A record with no residence on file can never match them, so they are narrower than they look.
3. **Use locations[] for several places or a radius.** Each entry has a `kind` (`residence`, `jurisdiction`, `any`), a place, and optionally `lat`/`lng`/`radiusMiles` (0.1–100). A record is kept when it matches any entry and lists which in `matchedLocations`.
4. **Look up and resolve codes.** `GET /v1/criminal/jurisdictions?state=TX&live=true` lists what is covered and live-reachable; `POST /v1/criminal/resolve` maps a records website you already know to its code. Neither is metered.

**cURL**
```bash
# 1. jurisdiction scope — OH returns the record, TX does not
curl -s https://sandbox.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: os_test_your_key" -H "Content-Type: application/json" \
  -d '{ "query": { "lastName": "Record", "firstName": "Felony", "dob": "1975-06-15", "state": "OH" } }'

# 2. several places, ORed, with the rich form (no flat state/county in query)
curl -s https://sandbox.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: os_test_your_key" -H "Content-Type: application/json" \
  -d '{ "query": { "lastName": "Rivera", "firstName": "Jordan", "dob": "1988-04-12" },
        "locations": [ { "kind": "residence", "zipcode": "77060" },
                       { "kind": "jurisdiction", "state": "OK" },
                       { "kind": "any", "lat": 32.78, "lng": -96.8, "radiusMiles": 25 } ] }'

# 3. what is covered in Texas and reachable live
curl -s "https://sandbox.offendersearch.app/v1/criminal/jurisdictions?state=TX&live=true" \
  -H "X-API-Key: os_test_your_key"

# 4. map a website you know to a code
curl -s https://sandbox.offendersearch.app/v1/criminal/resolve \
  -H "X-API-Key: os_test_your_key" -H "Content-Type: application/json" \
  -d '{ "website": "https://sheriff.examplecounty.gov/inmates" }'
```

**Python**
```python
import requests

API, KEY = "https://sandbox.offendersearch.app", "os_test_your_key"
H = {"X-API-Key": KEY}

# a person and the places they have lived: ORed, each record says which it matched
body = {
    "query": {"lastName": "Rivera", "firstName": "Jordan", "dob": "1988-04-12"},
    "locations": [
        {"kind": "residence", "zipcode": "77060"},
        {"kind": "jurisdiction", "state": "OK"},
        {"kind": "any", "lat": 32.78, "lng": -96.8, "radiusMiles": 25},
    ],
}
r = requests.post(f"{API}/v1/criminal/search", headers=H, json=body)
if r.status_code == 422:
    raise SystemExit(r.json()["error"])            # invalid_location, too_many_locations, radius_too_large, ambiguous_location
env = r.json()
print(env["counts"]["records"], "kept;", env["counts"].get("filteredOut"), "removed")
for rec in env["records"]:
    print(rec["state"], rec["county"], "matched locations", rec["matchedLocations"])

# the codes you may use in live.jurisdictions for Texas
codes = requests.get(f"{API}/v1/criminal/jurisdictions", headers=H, params={"state": "TX", "live": "true"}).json()
print(codes["count"], [j["code"] for j in codes["jurisdictions"][:5]])

# a website → a code (never metered)
res = requests.post(f"{API}/v1/criminal/resolve", headers=H, json={"website": "https://sheriff.examplecounty.gov/inmates"}).json()
print(res["supported"], res["code"], res["note"])
```

**JavaScript**
```javascript
const API = 'https://sandbox.offendersearch.app';
const H = { 'X-API-Key': 'os_test_your_key', 'Content-Type': 'application/json' };

const r = await fetch(API + '/v1/criminal/search', {
  method: 'POST', headers: H,
  body: JSON.stringify({
    query: { lastName: 'Rivera', firstName: 'Jordan', dob: '1988-04-12' },
    locations: [
      { kind: 'residence', zipcode: '77060' },
      { kind: 'jurisdiction', state: 'OK' },
      { kind: 'any', lat: 32.78, lng: -96.8, radiusMiles: 25 },
    ],
  }),
});
const env = await r.json();
if (r.status === 422) throw new Error(env.error.code + ': ' + env.error.message);
console.log(env.counts.records, 'kept;', env.counts.filteredOut, 'removed');
for (const rec of env.records) console.log(rec.state, rec.county, 'matched locations', rec.matchedLocations);

const codes = await (await fetch(API + '/v1/criminal/jurisdictions?state=TX&live=true', { headers: H })).json();
console.log(codes.count, codes.jurisdictions.slice(0, 5).map((j) => j.code));

const res = await (await fetch(API + '/v1/criminal/resolve', {
  method: 'POST', headers: H, body: JSON.stringify({ website: 'https://sheriff.examplecounty.gov/inmates' }),
})).json();
console.log(res.supported, res.code, res.note);
```

*A locations[] answer, trimmed to the scoping keys*
```json
{
  "status": "complete",
  "counts": { "records": 2, "recordsReturned": 2, "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0,
              "candidatesCapped": false, "exclusionsIncluded": false,
              "filteredOut": { "locations": 5, "noResidence": 3 }, "unknownKept": {}, "quarantined": 0 },
  "appliedFilters": {
    "locations": [ { "kind": "residence", "zipcode": "77060" },
                   { "kind": "jurisdiction", "state": "OK" },
                   { "kind": "any", "lat": 32.78, "lng": -96.8, "radiusMiles": 25 } ],
    "unknown": "keep", "chargeMatch": "any"
  },
  "records": [
    { "state": "TX", "county": "Harris", "address": { "type": "residence", "line1": "…", "city": "Houston", "county": "Harris", "state": "TX", "zipcode": "77060", "lat": null, "lng": null },
      "matchedLocations": [0] },
    { "state": "OK", "county": "Tulsa", "address": null, "matchedLocations": [1] }
  ]
}
```

*GET /v1/criminal/jurisdictions?state=TX&live=true and POST /v1/criminal/resolve, trimmed*
```json
{
  "coverage": { "jurisdictions_total": 1410, "jurisdictions_live_connector": 727, "jurisdictions_pending_connector": 683, "states_covered": 50 },
  "jurisdictions": [
    { "code": "TX-BEXAR", "state": "TX", "county": "Bexar", "recordKind": "jail-roster", "level": "county", "live": true },
    { "code": "TX-DOC",   "state": "TX", "county": "(statewide)", "recordKind": "doc-inmate", "level": "state", "live": true }
  ],
  "count": 2
}

{ "input": "https://sheriff.examplecounty.gov/inmates", "supported": true, "code": "TX-BEXAR",
  "state": "TX", "county": "Bexar", "note": "equivalent data available for this jurisdiction" }
```

## Reading the result

| Field | Where | Read as |
| --- | --- | --- |
| `query.state`, `query.county` | request | Jurisdiction filters: the state and county that hold the record. `county` accepts `Harris` or `Harris County`; `jurisdiction` is an alias. |
| `query.city`, `query.zipcode`, `query.address` | request | Residence filters on the published `address`. `zipcode` is a prefix match (`"750"` reaches `75001`); `address` is a case-insensitive substring of `line1`; `city` is exact. |
| `locations[].kind` | request | `jurisdiction` tests state/county of the record; `residence` tests the address; `any` tests either — but a radius, `city` or `zipcode` always describe a residence and are `422 invalid_location` on `jurisdiction`. |
| `locations[].lat/lng/radiusMiles` | request | All three together; `0.1`–`100` miles. A residence without coordinates cannot be tested and is counted under `filteredOut.noResidence`. |
| `records[].matchedLocations` | response | Indexes into your `locations[]` that this record satisfied. Present only when `locations` was sent. |
| `counts.filteredOut` | response | What the scoping removed, by reason (`locations`, `noResidence`, plus any `filters` keys). Present whenever `filters`, `locations`, a non-default `sort` or `include: ["facets"]` was sent. |
| `appliedFilters` | response | The locations as validated — the keys each was read as. Use it to debug a filter that matched nothing. |
| `coverage.scope` | response | `{state, county, countyRecognized}`: `countyRecognized: false` means no source of that county's own is in the catalog and only statewide/federal sources answered. |
| `jurisdictions[].recordKind` | `GET /v1/criminal/jurisdictions` | `jail-roster`, `court`, `doc-inmate`, `registry`, `warrant`, `exclusion`. Maps to a record's `recordCategory` (`jail`, `court`, `prison`, `registry`, `warrant`, `exclusion`). |
| `supported`, `code`, `note` | `POST /v1/criminal/resolve` | `supported: false` with `code: null` is an outcome, not an error. The response names only state, county and the neutral code. |

## Edge cases and pitfalls

- **`state` filters the jurisdiction, not the residence.** A person who lives in Oklahoma with a Texas court case is found by `state: "TX"`, not `"OK"`. Use `locations[]` with `kind: "any"` when you mean "either".
- **A residence filter silently excludes records with no address.** Many court and prison records publish none. Prefer `state`/`county` for scoping and use `city`/`zipcode` only when the address matters.
- **A county, city or ZIP alone does not rescue a too-broad name.** The candidate walk is bounded by surname, first name and state; a `query_too_broad` refusal asks for `firstName` or `state`.
- **`locations` with a live search of more than two sources is `422 filters_with_async_live`.** Scoping works with stored searches and with live checks that run inline; narrow `live.jurisdictions` first.
- **Limits are refusals, not clamps.** An empty `locations` list is `invalid_location`; eleven entries is `too_many_locations`; `radiusMiles: 150` is `radius_too_large`; `radiusMiles: 0.05` is `invalid_location`.
- **`query.website` scopes; `live.websites` verifies.** A website in `query` resolves to a state/county scope for a cached search (an explicit `state`/`county` wins over it); an unsupported site adds a `WEBSITE NOT COVERED:` sentence to `warnings[]` and no scope. The same site in `live.websites` becomes a live check.
- **`sort` is validated on every search but applied only with filters or locations.** `sort: "newest"` is `422 invalid_filter_value`; the values are `relevance`, `offenseDate`, `filedDate`, `dispositionDate`.

## Production checklist

- Load jurisdiction codes from `GET /v1/criminal/jurisdictions` at startup or daily; never hard-code them.
- Choose one form per request: flat `query` place fields or `locations[]`.
- Send `state` on every search where you know it; it narrows, drives `coverage`, and keeps common names answerable.
- Log `appliedFilters` and `counts.filteredOut` with each search so a reviewer can see what was excluded and why.
- Handle `invalid_location`, `too_many_locations`, `radius_too_large` and `ambiguous_location` as request bugs, not retries.

## Related

- [Filtering & locations](https://offendersearch.app/docs/criminal/filtering): every filter and location key
- [Jurisdictions & codes](https://offendersearch.app/docs/criminal/jurisdictions)
- [Resolve a website](https://offendersearch.app/docs/criminal/resolve)
- [Coverage today](https://offendersearch.app/docs/criminal/coverage)
