# Search — POST /v1/search

> The primary endpoint: one authenticated call searches every jurisdiction, scores and de-duplicates the matches. Full parameter reference and worked examples.

- **HTML:** https://offendersearch.app/docs/search
- **Base URL:** https://api.offendersearch.app
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **All documentation as markdown:** https://offendersearch.app/docs.md

## How a search works

Every search moves through the same four stages, so the response is predictable and the same query returns the same answer:

1. **Dispatch.** Your `query` is matched across the jurisdictions you targeted — all 58 by default, or the codes in `jurisdictions`.
2. **Match & verify.** Each jurisdiction’s rows are filtered by your `match` mode, then checked against any `dob`/`age` you supplied to confirm identity.
3. **De-duplicate & score.** The same person present in multiple jurisdictions is merged into one record whose `sources[]` lists every corroborating jurisdiction, and each record gets a `matchConfidence` and `matchBasis`.
4. **Return.** You get `records`, a per-jurisdiction `sourceStatus`, and `counts` — with `status: "complete"` or `"partial"`.

## POST /v1/search — Synchronous search

The primary endpoint. One authenticated call searches the full dataset (or the jurisdictions you name), scores and de-duplicates the matches, and returns them in a single response.

**Authentication:** `X-API-Key` header.

Send a `query` describing the person you are checking. By default the search covers the full dataset at once; you can narrow it with `jurisdictions`, tune the fuzzy-match tolerance with `match`, pick a freshness tier with `freshness`, request extended detail with `include`, and cap how long you are willing to wait with `deadlineMs`.

Only know part of a name? Set `prefixMatch` to `"firstName"`, `"lastName"` or `"both"` and the name you send is treated as the start of a name — `thom` returns Thomas, Thompson and Thomason — matched against aliases as well as the registered name. Minimum 3 characters. See Partial name search.

The synchronous endpoint answers from the maintained corpus in a single round trip, and every response reports its own `elapsedMs` so you can measure it against your own traffic. If you set a `deadlineMs` bound, the response comes back with `status: "partial"` once that bound is reached, with per-jurisdiction status reported in `sourceStatus`, so you always get an answer within the time you allow.

Every record you get back carries a `matchConfidence` score, the `matchBasis` (why it matched, per field — e.g. `lastName:prefix`, `alias:prefix`), a `matchedName` saying whether the registered name or an alias matched, a `dobVerification` result, and full per-source provenance with a source citation and a `lastCheckedAt` timestamp.

### What you can do

- **Search all or some jurisdictions.** Omit jurisdictions for a nationwide search, or pass codes like ["TX","NY"] to scope it.
- **Name, DOB, age, or location.** Match on any combination — last name or q is the only hard requirement; DOB or age dramatically improves confidence.
- **Nickname-aware name search.** A firstName is expanded to its nicknames/variants (John ↔ Johnny ↔ Jack) for recall, and the expansion is echoed back in each record’s nicknames[].
- **Partial (prefix) name search.** Set prefixMatch to search on the start of a name — "thom" returns Thomas, Thompson and Thomason — on the first name, the last name, or both, and across aliases as well as the registered name.
- **Geographic radius.** Provide lat/lng + radiusMiles (defaults to 1, capped at 100) to find registrants near a point; each address carries offender/predator flags and lat/lng. Radius matching selects on published coordinates, so use a name search when you need selection that is independent of address coordinates.
- **Free-text and fuzzy street match.** Use q for a single free-text field, or address for a fuzzy street-address match.
- **Tunable fuzzy matching.** Choose strict, balanced, or broad to trade recall for precision uniformly across every jurisdiction.
- **Extended per-state detail.** Add include: ["stateData"] to get offenses[], photos, vehicles, and state-specific fields.
- **De-duplicated people.** The same person appearing in several sources is merged into one record with a sources[] array.
- **Deadline control.** Set deadlineMs and onDeadline to bound latency, or let it run to completeness.

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | `object` | required | The identity fields to search on. See the query table below. |
| `jurisdictions` | `string[] \| null` | optional · default `null` | Jurisdiction codes to search. null or omitted searches the full dataset, e.g. ["IL","IN"]. |
| `locationScoped` | `boolean` | optional · default `false` | Scope control: when true AND query.state is set, only the jurisdictions covering that state are queried, instead of all 58. It is never inferred from query.state alone — state is a residence filter, not a jurisdiction selector, and a record can be held by one jurisdiction while the registrant has an address in another. When this narrows the fan-out, the response reports counts.sourcesSkippedByScope and a NARROWED SEARCH warning. |
| `freshness` | `"daily" \| "weekly"` | optional · default `"daily"` | Which tier answers the search. "daily" is the default and the most current tier, billed at +$0.01/call. "weekly" carries no surcharge and is one tier behind; identity fields — name, date of birth, offence history — are equivalent between the two. Every record carries its own lastCheckedAt on either tier, and sourceStatus reports per-source freshness on every response. |
| `match` | `"strict" \| "balanced" \| "broad"` | optional · default `"balanced"` | Fuzzy-match tolerance applied uniformly across all registries. See Matching & confidence. |
| `include` | `string[]` | optional | Request extra detail: "stateData" (per-state extended fields, offenses[], photos, vehicles) and/or "raw". |
| `recordTypes` | `string[]` | optional · default `["sex_offender"]` | Which record types to return. Additive as new types ship. |
| `deadlineMs` | `integer` | optional · default `120000` | How long to wait, in ms (max 300000). Completeness-first default of 2 minutes; lower it for a fast bounded response. |
| `onDeadline` | `"partial" \| "error"` | optional · default `"partial"` | On timeout, return partial results (default) or a 504 error. |

### Parameters — query

lastName (or q, or a lat/lng radius) is the primary key; every other field is an optional filter or verifier.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `firstName` | `string` | optional | Given name. Optional but improves ranking and enables nickname matching. |
| `lastName` | `string` | optional | Surname — the main search key. |
| `prefixMatch` | `"firstName" \| "lastName" \| "both"` | optional | Partial-name search: treat the name(s) you sent as the START of a name, so "thom" returns Thomas, Thompson and Thomason. Minimum 3 characters; also matches aliases. See Partial name search. |
| `nameMatch` | `object` | optional | Per-field control over how names are matched: { firstName: ["prefix","nickname"], lastName: ["prefix"], aliases: true }. Overrides prefixMatch and match. See Partial name search. |
| `dob` | `date (YYYY-MM-DD)` | optional | The strongest verifier; a match on DOB pushes confidence toward 1.0. |
| `age` | `integer` | optional | Used to verify identity when a DOB is not available. |
| `ageTolerance` | `integer (0–10)` | optional · default `1` | How many years of slack the age comparison allows when you send a dob and the record publishes only an age. Eleven registries publish an age and no date at all, so a name + DOB search compares your date against a published age — and how much slack that allows is a risk decision that belongs to you. The default of 1 is not arbitrary: a published age with an unpublished birthday is consistent with two birth years, and the age is anchored to the date we read that registry page rather than to today. Raise it for a high-recall screening pass — more same-name strangers returned, fewer true matches missed. |
| `onAgeMismatch` | `"drop" \| "flag"` | optional · default `"drop"` | What to do with a record that matches on NAME but whose published age contradicts the dob you sent. "drop" omits it. "flag" returns it labelled matchState: "age_mismatch" so you can judge it yourself — "silently omitted" and "checked, and the age contradicts your date" are different facts. A flagged record is always unverified and can never be read as a confirmed identification. |
| `city` | `string` | optional | Residence city filter. |
| `state` | `string` | optional | 2-letter USPS code or the full state name — identical results; an unresolvable value returns 422 rather than an empty result. It is a FILTER, not a jurisdiction selector: it does NOT change which jurisdictions run — all 58 are queried and state narrows the answer. Pass locationScoped: true when you explicitly want the narrower, cheaper fan-out. It FILTERS as a UNION, not as a plain residence test: a record is kept when either it has an address in that state OR that state's jurisdiction is the one holding it. Those halves are different populations — 105,028 records are held by a jurisdiction they have no address in, and 70,973 carry no address state at all and are reachable only by the second half. Every record returns registrationState and addressStates so you can tell which half matched without a second call. For the registration half alone, send jurisdictions instead. |
| `zipcode` | `string` | optional | Residence ZIP filter. |
| `address` | `string` | optional | Fuzzy street-address match. |
| `lat / lng` | `number` | optional | Coordinates for a radius search. |
| `radiusMiles` | `number` | optional · default `—` | Radius around lat/lng (max 100). Returns registrants near the point. |
| `q` | `string` | optional | Free-text search across name, aliases, city, ZIP, and address in one field. |
| `createdAtStart / createdAtEnd` | `date-time` | optional | Filter on when we first recorded the record (source.scrapedAt) — inclusive range bounds. |
| `updatedAtStart / updatedAtEnd` | `date-time` | optional | Filter on when the source last changed the record (source.sourceUpdatedAt) — inclusive range bounds. |

### Request

**cURL**

```bash
curl https://api.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "firstName": "John",
      "lastName": "Doe",
      "dob": "1980-04-12"
    },
    "jurisdictions": null,
    "freshness": "daily",
    "match": "balanced",
    "include": ["stateData"]
  }'
```

**Node**

```javascript
const res = await fetch("https://api.offendersearch.app/v1/search", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.OFFENDERSEARCH_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    query: { firstName: "John", lastName: "Doe", dob: "1980-04-12" },
    jurisdictions: null,     // null = search the full dataset
    freshness: "daily",
    match: "balanced",
    include: ["stateData"],  // extended per-state detail
  }),
});

const { records, sourceStatus, counts, status } = await res.json();
// status is "complete", or "partial" when a deadlineMs bound is set
for (const r of records) {
  console.log(r.matchConfidence, r.name.full, r.source.jurisdiction);
}
```

**Python**

```python
import os, requests

resp = requests.post(
    "https://api.offendersearch.app/v1/search",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
    json={
        "query": {"firstName": "John", "lastName": "Doe", "dob": "1980-04-12"},
        "jurisdictions": None,   # None = search the full dataset
        "freshness": "daily",
        "match": "balanced",
        "include": ["stateData"],
    },
    timeout=130,
)
data = resp.json()
print(data["status"], data["counts"])
for r in data["records"]:
    print(r["matchConfidence"], r["name"]["full"], r["source"]["jurisdiction"])
```

### Response

```json
{
  "searchId": "srch_9f2a7c",
  "status": "complete",
  "freshness": "daily",
  "elapsedMs": 142,
  "counts": { "records": 1, "sourcesQueried": 2, "sourcesComplete": 2,
              "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
  "page": 1,
  "perPage": 1,
  "totalPages": 1,
  "warnings": [],
  "sourceStatus": [
    { "source": "NJ",    "status": "ok", "matched": 1, "fromCache": true,
      "note": "served from nightly cache",
      "incomplete": false, "incompleteReason": null },
    { "source": "NSOPW", "status": "ok", "matched": 1, "fromCache": true,
      "note": "served from nightly cache",
      "incomplete": false, "incompleteReason": null }
  ],
  "records": [
    {
      "recordId": "rec_4b1e",
      "uuid": "b1e4-…",
      "recordType": "sex_offender",
      "matchConfidence": 1.0,
      "matchBasis": ["lastName", "firstName", "dob", "lastName:exact", "name_match"],
      "matchDetail": {
        "strategies": { "lastName": "exact" },
        "fieldsPresent": ["lastName", "firstName", "dob"],
        "signals": ["matchedLegalName"],
        "matchedNameType": "legal",
        "verification": "dob_match"
      },
      "name": { "first": "John", "middle": "A", "last": "Doe", "full": "John A. Doe" },
      "aliases": ["Johnny Doe"],
      "nicknames": ["john", "johnny", "jack"],
      "dob": "1980-04-12",
      "birthYear": 1980,
      "dobPrecision": "exact",
      "age": "46",
      "sex": "male", "race": "white", "height": "5'10\"", "weight": "180",
      "addresses": [
        { "type": "residence", "line1": "12 Main St", "city": "Trenton",
          "county": "Mercer", "state": "NJ", "zipcode": "08608",
          "lat": 40.2171, "lng": -74.7429 }
      ],
      "registrationState": "NJ",
      "addressStates": ["NJ"],
      "offense": { "tier": "II", "riskLevel": "", "statute": "2C:14-2",
                   "crime": "Sexual assault", "registrationDate": "2015-06-01" },
      "offenses": [
        { "crime": "Sexual assault", "statute": "2C:14-2", "tier": "II",
          "convictionDate": "2014-11-03", "registrationDate": "2015-06-01" }
      ],
      "stateData": {
        "stateOffenderId": "NJ-00123", "status": "active", "designation": "Tier II",
        "registrationEnds": "2035-06-01", "lawAgency": "Example County"
      },
      "flags": { "absconder": false, "predator": false },
      "images": [{ "url": "https://sor.example-state.gov/offender/NJ-00123/image" }],
      "matchState": "dob_match",
      "dobVerification": "dob_match",
      "unverified": false,
      "source": {
        "jurisdiction": "NJ",
        "registryName": "State Sex Offender Registry",
        "recordUrl": "https://sor.example-state.gov/offender/NJ-00123",
        "scrapedAt": "2026-07-25T09:14:00Z",
        "lastCheckedAt": "2026-07-25T09:14:00Z",
        "sourceUpdatedAt": "2026-07-20T00:00:00Z"
      },
      "sources": [
        { "jurisdiction": "NJ", "registryName": "State Sex Offender Registry",
          "recordUrl": "https://sor.example-state.gov/offender/NJ-00123",
          "scrapedAt": "2026-07-25T09:14:00Z",
          "lastCheckedAt": "2026-07-25T09:14:00Z",
          "sourceUpdatedAt": "2026-07-20T00:00:00Z" },
        { "jurisdiction": "NSOPW", "registryName": "NSOPW (National Sex Offender Public Website)",
          "recordUrl": "https://sor.example-state.gov/offender/NJ-00123",
          "scrapedAt": "2026-07-25T09:14:00Z",
          "lastCheckedAt": "2026-07-25T09:14:00Z",
          "sourceUpdatedAt": null }
      ]
    }
  ]
}
```

counts summarizes the search; sourceStatus reports every jurisdiction touched (ok / error / restricted / no_coverage / pending) with a lastCheckedAt timestamp; records holds the scored, de-duplicated matches.

### Worked examples

**Search on a partial name (prefixMatch)** — Only know part of a name? Set prefixMatch and the name you send is treated as the START of a name — "thom" returns Thomas, Thompson and Thomason. Use "firstName", "lastName", or "both". Minimum 3 characters (shorter returns 422). Prefixes are matched against every recorded alias as well as the registered name, and each record’s matchedName tells you which one matched. An exact match always ranks above a prefix match.

```json
{
  "query": {
    "lastName": "thom",
    "prefixMatch": "lastName",
    "state": "TX"
  }
}

// Returns Thomas, Thompson, Thomason, Thom, Hamilton-Thompson ...
// but NOT Bothomley - we anchor at the start of a name, never mid-word.
//
// Each record reports how it matched:
//   "matchBasis":  ["lastName:prefix", "name_match"]
//   "matchedName": { "value": "Ana Thompson", "type": "legal" }
//   "matchConfidence": 0.60      <- capped: a prefix hit is a CANDIDATE
```

**Partial first AND last name at once** — prefixMatch: "both" prefix-matches the first and last name together; a record must satisfy both. Add a dob or age to keep precision while widening recall — a confirmed DOB lifts the confidence cap that a partial-name match otherwise carries.

```json
{
  "query": {
    "firstName": "thom",
    "lastName": "and",
    "prefixMatch": "both",
    "dob": "1980-04-12"
  }
}

// -> Thomas Anderson, Thompson Andrews, ...
```

**Control matching per field (nameMatch)** — prefixMatch is the shorthand. Send nameMatch when you want to drive matching yourself: list exactly the strategies you want per field. Anything you leave out is OFF for that field; exact matching is always on and cannot be disabled. nameMatch overrides both prefixMatch and match. firstName accepts prefix, nickname, fuzzy and middle; lastName accepts prefix and fuzzy; aliases toggles alias matching (default true).

```json
{
  "query": {
    "firstName": "Robert",
    "lastName": "thom",
    "nameMatch": {
      "firstName": [],
      "lastName": ["prefix"],
      "aliases": true
    }
  }
}

// firstName: []          exact only - no nickname widening, so no "Bob"
// lastName:  ["prefix"]  Thompson, Thomas, Thomason ...
// aliases:   true        also match aliases (the default)
//
// -> Robert Thompson     (not Bob Thompson)
```

**Search a single state** — Pass jurisdiction codes in jurisdictions to limit the search. Naming ["TX"] searches Texas only.

```json
{
  "query": { "firstName": "Maria", "lastName": "Lopez" },
  "jurisdictions": ["TX"]
}
```

**Find registrants near a location** — Combine lat/lng with radiusMiles for a geographic search — useful for “who lives near this school / address”.

```json
{
  "query": {
    "lat": 40.7357,
    "lng": -74.1724,
    "radiusMiles": 5
  }
}
```

**Free-text query** — Pass q to search across name, aliases, city, ZIP, and address in a single field — the legacy-style catch-all.

```json
{
  "query": { "q": "john doe newark nj" },
  "match": "broad"
}
```

**High-precision check on the freshest data** — Use match: "strict" to require exact name + DOB, and freshness: "daily" — the most current tier — for a point-in-time answer you can defend.

```json
{
  "query": { "firstName": "John", "lastName": "Doe", "dob": "1980-04-12" },
  "match": "strict",
  "freshness": "daily"
}
```

**Extended per-state detail** — Add include: ["stateData"] to pull the full offenses[] list, photos, vehicles, and state-specific fields (parity with a legacy extensive mode).

```json
{
  "query": { "firstName": "John", "lastName": "Doe" },
  "include": ["stateData"]
}
```

**Fast bounded response** — Lower deadlineMs and keep onDeadline: "partial" to guarantee a quick answer within the bound you set.

```json
{
  "query": { "lastName": "Doe" },
  "deadlineMs": 4000,
  "onDeadline": "partial"
}
```

## What the envelope guarantees

- **Every top-level key is always present.** Test the value, never for key existence.
- **Coverage is reported, not implied.** `counts.sourcesQueried`, `sourcesComplete`, `sourcesIncomplete` and `sourcesSkippedByScope` appear on every response, alongside a `sourceStatus[]` entry per jurisdiction.
- **Match strength is labelled.** `matchState`, `matchBasis`, `matchDetail`, `matchedName` and `matchConfidence` let you set your own auto-accept threshold rather than inherit one.
- **The contract is additive.** New data lands as a new `recordType`; existing keys and their meanings do not change under you.
- **Ordering is total and stable.** The same query returns the same records in the same order, so paging never reshuffles.

---

## Related

- Previous: [Errors, status codes & rate limits](https://offendersearch.app/docs/errors.md)
- Next: [Matching, confidence & partial names](https://offendersearch.app/docs/matching.md)
- Index: [Offendersearch API documentation](https://offendersearch.app/docs.md)
