# Coverage and data sources

> A clear answer is only as good as the sources behind it. The catalog is public, the freshness is per source, and every search says which sources stood behind it.

- **HTML:** https://offendersearch.app/docs/guides/criminal/coverage-and-sources
- **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

Two things. A coverage check you can run before you buy and keep running afterwards — which federal, state and county sources the platform holds, which are live today and which are known but not on file yet, and when each was last refreshed. And a per-search reading of the `coverage` block so your outcome logic can say "clear across N sources, M not on file yet" instead of a bare "clear".

## Before you start

- `GET /v1/criminal/sources` needs **no key** and works on both hosts; it is the public coverage statement and is cached for up to five minutes. On the sandbox it returns the production catalog unchanged.
- A test key (`os_test_…`) for the search half. Send `query.state` so the `coverage` block is scoped, and `include: ["coverage"]` to list every source in scope.
- The rendered catalog and the human list: [Coverage today](/docs/criminal/coverage) and [All data sources](/docs/criminal/data-sources).

> **Freshness never filters.** A source contributes its stored records to a cached search whatever its `health.ageSeconds` says. Read the catalog to know a source's currency, not to predict which sources will answer. To get a source's answer as of now, run a live check ([Cached vs. live verification](/docs/guides/criminal/cached-vs-live)).

## Step by step

1. **Read the catalog.** `GET /v1/criminal/sources`. `catalog` totals the rows; each `sources[]` row has a public `id` (the jurisdiction code), a `name`, `covers`, `scope`, `recordType` in words, `status` and a `health` block.
2. **Separate live from pending.** `status: "live"` is on file today. `status: "pending"` is known and not yet available — it contributes nothing to any search until it turns live.
3. **Read freshness per source.** `health.lastSuccessAt` and `ageSeconds` say when the stored copy was last refreshed; `freshFor` restates it as the tiers it falls inside (`daily` ≈ ≤36 h, `weekly` ≈ ≤8 days). `[]` means never refreshed.
4. **Read coverage on a search.** Send `state` (and `county`). The envelope's `coverage` block and `counts.sourcesOnFile`/`sourcesPending` say what stood behind this answer; `coverage.pendingSources[]` names what did not.

**cURL**
```bash
# the public catalog — no key
curl -s https://sandbox.offendersearch.app/v1/criminal/sources | head -c 1200

# a scoped search with the full in-scope source list
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": "Clear", "state": "TX", "county": "Bexar" }, "include": ["coverage"] }'
```

**Python**
```python
import requests

API, KEY = "https://sandbox.offendersearch.app", "os_test_your_key"

cat = requests.get(f"{API}/v1/criminal/sources").json()          # no key
print(cat["catalog"])                                            # {'total': …, 'live': …, 'comingSoon': …, 'states': …}
stale = [s for s in cat["sources"] if s["status"] == "live" and not s["health"]["freshFor"]]
tx_pending = [s["id"] for s in cat["sources"] if "TX" in s["covers"] and s["status"] == "pending"]
print(len(stale), "live sources older than a week;", len(tx_pending), "Texas sources not on file yet")

r = requests.post(f"{API}/v1/criminal/search", headers={"X-API-Key": KEY}, json={
    "query": {"lastName": "Record", "firstName": "Clear", "state": "TX", "county": "Bexar"},
    "include": ["coverage"],
})
env = r.json()
cov = env.get("coverage") or {}
if env["status"] == "complete" and env["counts"]["records"] == 0:
    print(f"clear across {cov.get('sourcesOnFile', 0)} sources on file; "
          f"{cov.get('sourcesPending', 0)} in scope not on file yet: "
          f"{[p['code'] for p in cov.get('pendingSources', [])]}")
    if cov.get("scope", {}).get("countyRecognized") is False:
        print("no source of Bexar County's own is in the catalog; statewide/federal sources answered")
```

**JavaScript**
```javascript
const API = 'https://sandbox.offendersearch.app';
const KEY = 'os_test_your_key';

const cat = await (await fetch(API + '/v1/criminal/sources')).json(); // no key
console.log(cat.catalog);
const stale = cat.sources.filter((s) => s.status === 'live' && s.health.freshFor.length === 0);
const txPending = cat.sources.filter((s) => s.covers.includes('TX') && s.status === 'pending').map((s) => s.id);
console.log(stale.length, 'live sources older than a week;', txPending.length, 'Texas sources not on file yet');

const r = await fetch(API + '/v1/criminal/search', {
  method: 'POST',
  headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ query: { lastName: 'Record', firstName: 'Clear', state: 'TX', county: 'Bexar' }, include: ['coverage'] }),
});
const env = await r.json();
const cov = env.coverage ?? {};
if (env.status === 'complete' && env.counts.records === 0) {
  console.log('clear across ' + (cov.sourcesOnFile ?? 0) + ' sources on file; ' + (cov.sourcesPending ?? 0) +
    ' in scope not on file yet: ' + (cov.pendingSources ?? []).map((p) => p.code).join(', '));
}
```

*GET /v1/criminal/sources, trimmed to two rows*
```json
{
  "catalog": { "total": 1410, "live": 727, "comingSoon": 683, "states": 50 },
  "coverage": { "jurisdictions_total": 1410, "jurisdictions_live_connector": 727, "jurisdictions_pending_connector": 683, "states_covered": 50 },
  "sources": [
    { "id": "TX-BEXAR", "name": "Bexar County, TX", "covers": ["TX"], "scope": "county",
      "recordType": "County jail roster", "status": "live",
      "health": { "lastSuccessAt": "2026-10-24T05:12:00+00:00", "ageSeconds": 30074, "freshFor": ["daily", "weekly"] } },
    { "id": "TX-TRAVIS", "name": "Travis County, TX", "covers": ["TX"], "scope": "county",
      "recordType": "Court records", "status": "pending",
      "health": { "lastSuccessAt": null, "ageSeconds": null, "freshFor": [] } }
  ],
  "count": 1410
}
```

*The coverage block on a scoped search (include: ["coverage"]), trimmed*
```json
{
  "status": "complete",
  "counts": { "records": 0, "recordsReturned": 0, "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0,
              "candidatesCapped": false, "exclusionsIncluded": false, "sourcesOnFile": 4, "sourcesPending": 1 },
  "coverage": {
    "scope": { "state": "TX", "county": "Bexar", "countyRecognized": true },
    "sourcesOnFile": 4,
    "sourcesPending": 1,
    "pendingSources": [ { "code": "TX-COURT", "name": "Texas — statewide court records", "recordType": "Court records", "scope": "state" } ],
    "sources": [
      { "code": "TX-BEXAR",     "name": "Bexar County, TX",                 "recordType": "County jail roster", "scope": "county",  "status": "live" },
      { "code": "TX-DOC",       "name": "Texas — statewide prison system",  "recordType": "Prison system",      "scope": "state",   "status": "live" },
      { "code": "US-WARRANT",   "name": "Federal — Warrant system",         "recordType": "Warrant system",     "scope": "federal", "status": "live" },
      { "code": "US-EXCLUSION", "name": "Federal — Exclusion list",         "recordType": "Exclusion list",     "scope": "federal", "status": "live" },
      { "code": "TX-COURT",     "name": "Texas — statewide court records",  "recordType": "Court records",      "scope": "state",   "status": "pending" }
    ]
  },
  "records": []
}
```

Read the two together: zero records across four sources on file — the county's jail roster, the state prison system and two federal lists — with the statewide court source for Texas not on file yet. That is a materially different sentence from "no criminal records found", and it is the one to put in front of a reviewer. (The codes and counts above are illustrative; read yours from the response.)

## Reading the result

| Field | Values | Read as |
| --- | --- | --- |
| `sources[].status` | `live` · `pending` | On file today, or known and not yet available. Only `live` rows ever contribute records. |
| `sources[].health.lastSuccessAt` / `ageSeconds` | ISO timestamp / integer, or `null` | When the stored copy was last refreshed. `null` = never. Computed when the catalog is served (cached up to five minutes). |
| `sources[].health.freshFor` | `[]` · `["weekly"]` · `["daily", "weekly"]` | The tiers the snapshot falls inside; a restatement of `ageSeconds`, not a filter. |
| `sources[].recordType` | `County jail roster` · `Court records` · `Prison system` · `Statewide registry` · `Warrant system` · `Exclusion list` · `Records` | What kind of records the source holds, in words. The record-level twin is `recordCategory`. |
| `catalog` | `{total, live, comingSoon, states}` | Totals over the same rows as `sources[]`, so the two always agree. |
| `coverage.scope` | `{state, county, countyRecognized}` | `state` is `null` unless a two-letter state was sent. `countyRecognized: false` = no source of that county's own is in the catalog. |
| `coverage.sourcesOnFile` / `sourcesPending` | integers (also on `counts`) | Sources behind this answer, and sources in scope not on file yet. `sourcesPending > 0` → read `pendingSources` before calling an empty result a clear. |
| `coverage.pendingSources[]` | present when `state` was sent | The pending sources, named by code. |
| `coverage.sources[]` | present with `include: ["coverage"]` and a `state` | Every source in scope with its status. |
| `sourceStatus[]` | `[]` on a cached search | Per-source outcomes exist only for live checks; `coverage` is the cached-search equivalent. |

## Edge cases and pitfalls

- **"Not on file yet" is not "searched and found nothing".** A `pending` source in scope means nothing from it could have been returned. Carry `sourcesPending` and the names into your outcome text.
- **`coverage` is absent, not empty, when the catalog cannot be read.** Treat a missing key as "unknown coverage" and surface it; do not default it to zero pending.
- **No `state` means an unscoped `coverage.scope.state: null`** and no `pendingSources`. If you want the coverage sentence, send the state.
- **Freshness is per source, not per record.** A record's own currency is `sources[].lastCheckedAt` and its `lifecycle`; the catalog's `health` describes the source's last refresh.
- **The catalog grows.** Counts change as coverage grows; never hard-code a total or a code list. The public endpoint is cheap and cacheable (`Cache-Control: public, max-age=300`).
- **`503 catalog_unavailable`** means the catalog file could not be read; the gateway serves its last cached copy when it can. Retry with backoff; do not fail the search path on it.
- **`GET /v1/criminal/jurisdictions` is the keyed twin** with `recordKind` and `live` flags but no `health`; use it for codes, `sources` for freshness.

## Production checklist

- Cache `GET /v1/criminal/sources` for five minutes and alert when a source you depend on leaves `freshFor` or turns `pending`.
- Send `state` on every search; store `coverage.sourcesOnFile`, `sourcesPending` and `pendingSources[].code` with the result.
- Word every clear outcome as "no records across N sources on file" and name the pending ones.
- Use a live check for a point-in-time answer from a specific source; never infer it from `health`.
- Keep the Not-a-consumer-report notice (`legal.notice`) with any coverage statement you show.

## Related

- [Coverage today](https://offendersearch.app/docs/criminal/coverage): the rendered catalog
- [All data sources](https://offendersearch.app/docs/criminal/data-sources)
- [Jurisdictions & codes](https://offendersearch.app/docs/criminal/jurisdictions)
- [Completeness & source status](https://offendersearch.app/docs/criminal/result-completeness)
- [Data coverage across products](https://offendersearch.app/docs/data-coverage)
