# Selecting registries and jurisdictions

> By default a search asks all 58 US registries. Here is how to ask fewer on purpose, what that costs you in coverage, and how to tell a filter from a scope.

- **HTML:** https://offendersearch.app/docs/guides/sex-offender/selecting-jurisdictions
- **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

## When to use this

Three different questions get confused under "search Texas": *is this person registered **by** Texas*, *does this person **live in** Texas*, and *only ask the Texas registry*. The API answers each with a different field — `jurisdictions`, `query.state` and `locationScoped` — and reports what each one did to the fan-out in `counts` and `warnings`. You will build a search that uses the right one for the question you are actually asking, and a reader that notices when a scope has hidden something.

## Before you start

- A test key on `https://sandbox.offendersearch.app`. The sandbox person **Out State** is registered by Oklahoma's registry and lives in Texas (`registrationState: "OK"`, `addressStates: ["TX"]`) — exactly the case these fields are about.
- Codes: the 58 registry ids (`AK … WY`, plus `DC`, `PR`, `GU`, `AS`, `CNMI`, `USVI`, `TRIBAL` and the national index) and any two-letter code a registry covers, in any case. `GET /v1/sources` lists them, no key required.
- An unrecognised code is a `422`, never an empty result: `["XX"]` and `["TEXAS"]` are refused; `["tx"]` is accepted.

> **The one thing people get wrong.** `query.state` does **not** choose registries. It keeps records whose `registrationState` **or** one of whose `addressStates` is that state, and still asks all 58 registries. Only `jurisdictions` or `locationScoped: true` narrow the fan-out — and when they do, a registry that was never asked contributes a zero that means *unknown*, not *no match*.

## Step by step

1. **Filter by state.** Search **Out State** with `"state": "TX"`. One record: the person lives in Texas. `sourcesQueried` is 58 — nothing was narrowed; the Oklahoma registry that holds the record was asked.
2. **Scope by state.** Add `"locationScoped": true`. Now only the registries covering Texas run; `sourcesQueried` drops to 2, `sourcesSkippedByScope` is 56, and the record is gone — it is held by a registry that was not asked. A `NARROWED SEARCH:` warning says so.
3. **Name a registry.** Drop `state` and send `"jurisdictions": ["OK"]`. The Oklahoma registry (and a tribal registry whose coverage lists Oklahoma) run; the record is back, `sourcesQueried` 2, nothing skipped by scope.
4. **Look up a code.** Call `GET /v1/sources` to see each registry's id, the codes it covers, its legal status and health.

### Steps 1–3 — three requests, three answers

**cURL**
```bash
# 1. filter: all 58 registries asked, records kept if registered OR resident in TX
curl -s https://sandbox.offendersearch.app/v1/search -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Out", "lastName": "State", "state": "TX"}}'

# 2. scope: only the registries that cover TX are asked
curl -s https://sandbox.offendersearch.app/v1/search -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Out", "lastName": "State", "state": "TX"}, "locationScoped": true}'

# 3. name the registry: the Oklahoma roster
curl -s https://sandbox.offendersearch.app/v1/search -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Out", "lastName": "State"}, "jurisdictions": ["OK"]}'
```

**Python**
```python
import os, requests

BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")
H = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}

def search(body):
    r = requests.post(BASE + "/v1/search", headers=H, json=body, timeout=60)
    r.raise_for_status()
    return r.json()

def summary(b):
    c = b["counts"]
    return (c["records"], c["sourcesQueried"], c["sourcesSkippedByScope"],
            [(r["registrationState"], r["addressStates"]) for r in b["records"]])

print(summary(search({"query": {"firstName": "Out", "lastName": "State", "state": "TX"}})))
# (1, 58, 0, [('OK', ['TX'])])
print(summary(search({"query": {"firstName": "Out", "lastName": "State", "state": "TX"}, "locationScoped": True})))
# (0, 2, 56, [])
print(summary(search({"query": {"firstName": "Out", "lastName": "State"}, "jurisdictions": ["OK"]})))
# (1, 2, 0, [('OK', ['TX'])])
```

**JavaScript**
```javascript
const BASE = process.env.OFFENDERSEARCH_BASE ?? 'https://sandbox.offendersearch.app';
const H = { 'X-API-Key': process.env.OFFENDERSEARCH_KEY, 'Content-Type': 'application/json' };

async function search(body) {
  const res = await fetch(BASE + '/v1/search', { method: 'POST', headers: H, body: JSON.stringify(body) });
  if (!res.ok) throw new Error('HTTP ' + res.status);
  return res.json();
}
const summary = (b) => [b.counts.records, b.counts.sourcesQueried, b.counts.sourcesSkippedByScope,
  b.records.map((r) => [r.registrationState, r.addressStates])];

console.log(summary(await search({ query: { firstName: 'Out', lastName: 'State', state: 'TX' } })));
// [1, 58, 0, [['OK', ['TX']]]]
console.log(summary(await search({ query: { firstName: 'Out', lastName: 'State', state: 'TX' }, locationScoped: true })));
// [0, 2, 56, []]
console.log(summary(await search({ query: { firstName: 'Out', lastName: 'State' }, jurisdictions: ['OK'] })));
// [1, 2, 0, [['OK', ['TX']]]]
```

*Step 2 response (trimmed) — the scope hid the record*
```json
{
  "searchId": "srch_5e1443b11d9ab4002bed9443",
  "status": "complete",
  "counts": { "records": 0, "recordsReturned": 0, "sourcesQueried": 2, "sourcesComplete": 2, "sourcesIncomplete": 0, "sourcesSkippedByScope": 56 },
  "sourceStatus": [
    { "source": "TRIBAL", "status": "ok", "matched": 0, "fromCache": true, "incomplete": false, "incompleteReason": null },
    { "source": "TX",     "status": "ok", "matched": 0, "fromCache": true, "incomplete": false, "incompleteReason": null }
  ],
  "records": [],
  "warnings": [
    "NARROWED SEARCH: `locationScoped: true` ran only the 2 registries that cover the requested state and did NOT query the other 56. A person REGISTERED by another state's registry while RESIDING in the one you asked for is not in this answer — 7.0% of our records list a residence outside the registry holding them. …"
  ],
  "sandbox": true
}
```

`status` is `complete` and `records` is empty, and yet the person is registered. The scope, not the registries, produced the zero — which is why `sourcesSkippedByScope` and the warning exist. If you scope, carry them with the outcome.

*Step 3 response (trimmed) — the registry named, the record back*
```json
{
  "counts": { "records": 1, "sourcesQueried": 2, "sourcesComplete": 2, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
  "sourceStatus": [
    { "source": "OK",     "status": "ok", "matched": 1, "fromCache": true, "incomplete": false, "incompleteReason": null },
    { "source": "TRIBAL", "status": "ok", "matched": 0, "fromCache": true, "incomplete": false, "incompleteReason": null }
  ],
  "records": [ { "recordId": "rec_21b7a06132139de2ba35", "registrationState": "OK", "addressStates": ["TX"], "matchConfidence": 0.9, "matchState": null } ],
  "warnings": []
}
```

### Step 4 — the catalog

```bash
curl -s https://api.offendersearch.app/v1/sources | head -c 600
```

*GET /v1/sources → 200 (two of 58 entries)*
```json
[
  { "id": "AL", "name": "Alabama Sex Offender Registry (ALEA)", "covers": ["AL"], "registries": 1,
    "scope": "state", "status": "live", "legal": { "commercialUse": "restricted" },
    "health": { "lastSuccessAt": "2026-10-25T03:12:40+00:00", "typicalLatencyMs": 820, "ageSeconds": 39600, "freshFor": ["daily", "weekly"] } },
  { "id": "TX", "name": "Texas DPS Sex Offender Registry", "covers": ["TX"], "registries": 1,
    "scope": "state", "status": "live", "legal": { "commercialUse": "unknown" },
    "health": { "lastSuccessAt": "2026-10-25T02:58:03+00:00", "typicalLatencyMs": 1210, "ageSeconds": 40500, "freshFor": ["daily", "weekly"] } }
]
```

On the sandbox the same call returns the production catalog with every `health` field `null`, because the sandbox holds no ingest; the health values above are illustrative of the production shape. Read ids and names from the call, never from a hard-coded list.

## Reading the result

| Field | Values | What it tells you |
| --- | --- | --- |
| `counts.sourcesQueried` | integer | 58 on a default search, including one with `query.state`. Smaller only with `jurisdictions` or `locationScoped: true`. |
| `counts.sourcesSkippedByScope` | integer | Registries not asked because of `locationScoped`. Above 0, a zero in `records` is not a clear. |
| `warnings[]` | `NARROWED SEARCH: …` | Present whenever a scope reduced the fan-out. |
| `sourceStatus[].source` | registry id | Which registries actually ran; `matched` is each one's contribution. |
| `sourceStatus[].status` | `ok`, `error`, `restricted`, `no_coverage` | `restricted`: a legal gate kept this registry out of a commercial search — surfaced, not dropped. `no_coverage`: the registry does not cover this query. |
| `records[].registrationState` | two-letter code | The registry that holds the person. |
| `records[].addressStates[]` | codes | Where the person's addresses are. The two together say which half of a `state` filter matched. |
| `SourceInfo.legal.commercialUse` | `restricted`, `prohibited`, `unknown` (`allowed` documented, not in use) | A registry marked `prohibited` or not billable is excluded from commercial scopes; a search restricted to such registries meters nothing. |

## Edge cases and pitfalls

- **Full state names belong in `query.state`, not in `jurisdictions`.** `"state": "Florida"` is accepted and means `FL`; `"jurisdictions": ["FLORIDA"]` is a `422`. `"state": "Narnia"` is also a `422`: `Unknown state 'Narnia'`.
- **A code can select more than one registry.** `["OK"]` ran two registries above because a tribal registry's coverage lists Oklahoma. Count `sourceStatus` rows rather than assuming one.
- **To narrow to residents**, filter on `addressStates` in your own code after a `state` search; to narrow to a registry's roster, use `jurisdictions: ["FL"]`. `state` alone gives you the union.
- **Scoping is cheaper but hides out-of-state registrants**: about 7% of records list a residence outside the registry that holds them. Reserve `locationScoped` for cases where the registration state is genuinely known.
- **`jurisdictions` in a batch envelope applies to every row**; a row cannot widen it back.
- **`live.jurisdictions` is a different list** — it names registries to re-check live, and does not narrow the Regular fan-out. See [Regular vs. live searches](/docs/guides/sex-offender/regular-vs-live).
- **A `jurisdictions`-only request is valid**: `{"query": {}, "jurisdictions": ["WY"]}` is a scoped sweep and paginates at 500 records per page via the API; a nameless search is metered per record under the location-record policy.

## Production checklist

- Default searches send no `jurisdictions` and no `locationScoped`; scopes are an explicit product decision.
- `sourcesSkippedByScope > 0` is carried with the outcome and shown to reviewers.
- Codes come from `GET /v1/sources`, not from a hard-coded list; a `422` on `jurisdictions` is treated as a bug, not an empty result.
- `registrationState` and `addressStates` are both stored with a hit.

## Related

- [Jurisdictions & codes](https://offendersearch.app/docs/jurisdictions)
- [Regular & Live modes, source coverage](https://offendersearch.app/docs/freshness)
- [Pagination & response size](https://offendersearch.app/docs/pagination)
- [Data coverage & limitations](https://offendersearch.app/docs/data-coverage)
