# Performing your first search

> One request, one envelope. This guide runs three sandbox searches, reads each answer field by field, and moves the same code to production.

- **HTML:** https://offendersearch.app/docs/guides/sex-offender/first-search
- **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 will build

A function that takes a first name, a last name and a date of birth, calls `POST /v1/search`, and returns one of three outcomes: **clear** (the search finished and matched nobody), **candidates** (one or more registrants to review) or **incomplete** (at least one registry did not finish). That is the whole job of a first integration; everything else in this product is a refinement of it.

You will run it three times against the sandbox — once for a person who is not registered, once for a confirmed registrant, once for a common name that returns candidates without a date of birth — and then switch the host and key to production without changing a line of logic.

## Before you start

- A **test key** (`os_test_…`) from [Dashboard → API keys](/dashboard/keys). Test keys work only on `https://sandbox.offendersearch.app`; a live key (`os_live_…`) works only on `https://api.offendersearch.app`. Either host refuses the other kind with a `401` that names the right host.
- The header is `X-API-Key`. Bodies are JSON. The search endpoint is `POST /v1/search` on both hosts.
- The sandbox answers from fixed test people. This guide uses **Clear Visitor** (no match), **Exact Match** with `dob` `1980-01-01` (one confirmed registrant in OH) and **Name Only** (three same-name registrants in AZ, GA and MI, none publishing a birth date). Any other name returns a complete answer with zero records. The full list is on the [Sandbox](/docs/sandbox) page.
- Nothing you send to the sandbox is billed or metered; every sandbox body carries `"sandbox": true`.

> **The one thing people get wrong.** An empty `records` array is only a clear answer when `status` is `"complete"`. When `status` is `"partial"`, some registries did not finish and the list is a lower bound. Read `status` before `records`, every time.

## Step by step

1. **Search for a person who is not registered.** Send the smallest useful query: first name, last name and date of birth. The sandbox name **Clear Visitor** matches nobody.
2. **Search for a confirmed registrant.** Change the name to **Exact Match** and keep `dob` `1980-01-01`. One record comes back with `matchState: "dob_match"` — the registry publishes a full birth date and it equals yours.
3. **Search a common name.** Send **Name Only** with a `dob` of `1988-04-12`. Three candidates come back, each `unverified: true` with `matchState: "no_dob_age_year"`: their registries publish no birth date, so the name is the only evidence. This is the shape your review queue must handle.
4. **Move to production.** Replace the host with `https://api.offendersearch.app` and the key with a live key. The request body and the response shape are identical; the `sandbox` key disappears.

### Step 1 — the request

**cURL**
```bash
curl -s https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Clear", "lastName": "Visitor", "dob": "1988-04-12"}}'
```

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

BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")
KEY = os.environ["OFFENDERSEARCH_KEY"]          # os_test_... on the sandbox

def search(first_name, last_name, dob):
    r = requests.post(
        BASE + "/v1/search",
        headers={"X-API-Key": KEY},
        json={"query": {"firstName": first_name, "lastName": last_name, "dob": dob}},
        timeout=60,
    )
    r.raise_for_status()
    return r.json()

def outcome(result):
    if result["status"] != "complete":
        return "incomplete"
    return "clear" if result["counts"]["records"] == 0 else "candidates"

body = search("Clear", "Visitor", "1988-04-12")
print(outcome(body), body["searchId"])        # clear srch_...
```

**JavaScript**
```javascript
// Node 18+
const BASE = process.env.OFFENDERSEARCH_BASE ?? 'https://sandbox.offendersearch.app';
const KEY = process.env.OFFENDERSEARCH_KEY;     // os_test_... on the sandbox

async function search(firstName, lastName, dob) {
  const res = await fetch(BASE + '/v1/search', {
    method: 'POST',
    headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query: { firstName, lastName, dob } }),
  });
  if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + (await res.text()));
  return res.json();
}

function outcome(result) {
  if (result.status !== 'complete') return 'incomplete';
  return result.counts.records === 0 ? 'clear' : 'candidates';
}

const body = await search('Clear', 'Visitor', '1988-04-12');
console.log(outcome(body), body.searchId);      // clear srch_...
```

*Step 1 — response (trimmed: 58 sourceStatus rows cut to one)*
```json
{
  "searchId": "srch_e27d123a7dca9a9351cb1869",
  "status": "complete",
  "elapsedMs": 0,
  "counts": {
    "records": 0, "recordsReturned": 0,
    "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0
  },
  "capped": false, "cappedReason": null, "cappedLimit": null, "cappedOmittedSources": [],
  "omittedFields": [],
  "page": 1, "perPage": 0, "totalPages": 1,
  "sourceStatus": [
    { "source": "AK", "status": "ok", "matched": 0, "fromCache": true, "note": null,
      "incomplete": false, "incompleteReason": null, "cappedOmitted": false }
  ],
  "records": [],
  "proof": { "status": "none" },
  "warnings": [],
  "sandbox": true
}
```

`status: "complete"`, `sourcesComplete` equal to `sourcesQueried`, `warnings: []` and `records: []` together are a clear answer. Keep the `searchId`: it is what a [verification report](/docs/guides/sex-offender/verification-reports) or a clearance cites later. (One sandbox difference: its cached `sourceStatus` rows read `status: "complete"`; production writes `ok`, `error`, `restricted` or `no_coverage` on cached rows and reserves `complete`/`partial` for live rows.)

### Step 2 — a confirmed registrant

*POST /v1/search body*
```json
{ "query": { "firstName": "Exact", "lastName": "Match", "dob": "1980-01-01" } }
```

*Response (trimmed to the fields that decide the outcome)*
```json
{
  "searchId": "srch_7a89158926d834f43f66856f",
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
  "records": [
    {
      "recordId": "rec_28db9639d712c4c61e8e",
      "personRef": "prs_28db9639d712c4c61e8e",
      "uuid": "SBX-OH-100001",
      "matchConfidence": 0.9,
      "matchBasis": ["lastName", "firstName", "dob", "lastName:exact", "firstName:exact", "name_match"],
      "matchDetail": {
        "strategies": { "lastName": "exact", "firstName": "exact" },
        "fieldsPresent": ["lastName", "firstName", "dob"],
        "signals": ["matchedLegalName"], "matchedNameType": "legal", "verification": "dob_match"
      },
      "matchedName": { "value": "Exact Sample Match", "type": "legal" },
      "name": { "first": "Exact", "middle": "Sample", "last": "Match", "suffix": "", "full": "Exact Sample Match" },
      "dob": "1980-01-01", "birthYear": 1980, "dobPrecision": "exact", "age": "46",
      "addresses": [ { "type": "residence", "line1": "100 Sample St", "city": "Sampleville", "county": "Sample", "state": "OH", "zipcode": "00000", "lat": null, "lng": null } ],
      "registrationState": "OH", "addressStates": ["OH"],
      "offenses": [ { "crime": "Sexual battery", "statute": "SAMPLE 2907.03", "tier": "Tier III", "convictionDate": "2004-05-12", "registrationDate": "2008-02-01" } ],
      "images": [ { "url": "https://sandbox.offendersearch.app/v1/sandbox/photos/1.svg" } ],
      "sources": [ { "jurisdiction": "OH", "registryName": "Sandbox sample registry (OH)", "recordUrl": "https://offendersearch.app/docs/sandbox", "lastCheckedAt": "2026-10-01T00:00:00+00:00" } ],
      "matchState": "dob_match",
      "unverified": false
    }
  ],
  "warnings": [],
  "sandbox": true
}
```

Read `matchState` first: `dob_match` means the registry publishes a full date of birth and it equals the one you sent. Then `matchDetail.strategies`: both name fields matched `exact` on the registered legal name. `matchConfidence` 0.9 is the score; it is not the gate (see [Matching and confidence](/docs/guides/sex-offender/matching-and-confidence)). `sources[].recordUrl` is the registry page a reviewer opens.

### Step 3 — candidates without a birth date

*POST /v1/search body*
```json
{ "query": { "firstName": "Name", "lastName": "Only", "dob": "1988-04-12" } }
```

*Response (trimmed to one of three records)*
```json
{
  "status": "complete",
  "counts": { "records": 3, "recordsReturned": 3, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
  "records": [
    {
      "recordId": "rec_fec5ef71d9deef5bc8c4",
      "personRef": "prs_fec5ef71d9deef5bc8c4",
      "matchConfidence": 0.6,
      "matchBasis": ["lastName", "firstName", "unverified_no_dob_or_age", "lastName:exact", "firstName:exact", "name_match"],
      "matchDetail": { "strategies": { "lastName": "exact", "firstName": "exact" }, "fieldsPresent": ["lastName", "firstName"],
                       "signals": ["unverifiable", "matchedLegalName"], "matchedNameType": "legal", "verification": "unverified_no_dob_or_age" },
      "dob": null, "birthYear": null, "dobPrecision": "unknown", "age": "",
      "registrationState": "AZ",
      "matchState": "no_dob_age_year",
      "unverified": true
    }
  ],
  "warnings": []
}
```

Three people share the name and none of their registries publishes a birth date, so your `dob` could neither confirm nor rule them out. They are returned, labelled `unverified: true`, at a lower score. This is the **candidates** outcome: a person reviews them against the photo, age and address, and records the verdict with a [clearance](/docs/clearances) so the next search for the same subject is quiet.

### Step 4 — production

```bash
export OFFENDERSEARCH_BASE=https://api.offendersearch.app
export OFFENDERSEARCH_KEY=os_live_...        # from Dashboard → API keys
```

The same code now searches the continuously updated copy of all 58 US registries. Each live search is billed as one call ([pricing](/pricing)); a new account has a free trial of 25 searches capped at 50 records each, and a trial search must carry a first or last name.

## Reading the result

| Field | Values | What it decides here |
| --- | --- | --- |
| `status` | `complete`, `partial` | `partial` means the answer is a lower bound; never report it as clear. |
| `counts.records` | integer | Total matches before paging. `0` with `complete` is the clear answer. |
| `counts.sourcesComplete` / `sourcesQueried` | integers | How much of the fan-out answered. Equal means complete. |
| `warnings[]` | `[]`, or sentences starting `INCOMPLETE SEARCH:`, `NARROWED SEARCH:`, `CAPPED RESULT:` | `[]` is the cheapest completeness check there is. |
| `records[].matchState` | `dob_match`, `year_match`, `age_match`, `no_dob_age_year`, `age_mismatch`, `null` | How the birth date agreed. `null` only when you sent no `dob` or `age`. |
| `records[].unverified` | boolean | `true` when the record could not be checked against the identifiers you sent. |
| `records[].personRef` | `prs_` + 20 hex | The stable key for the person — store it, clear on it. |
| `records[].recordId` | `rec_` + 20 hex | The handle for this result — cite it, fetch it with `GET /v1/records/{recordId}`. |
| `records[].sources[].recordUrl` | URL or `""` | The registry page a reviewer verifies against. |

The full field list is in [the Record object](/docs/record-object); the order to read the envelope in is [Understanding search results](/docs/guides/sex-offender/understanding-results).

## Edge cases and pitfalls

- **A query must narrow something.** `firstName` alone or `state` alone is refused with `422` and a sentence beginning `This query has nothing to narrow on`. Send a surname, a date of birth or age, a city, county, ZIP, radius or `q`.
- **`state` filters, it does not scope.** `{"query": {"lastName": "Match", "state": "OH"}}` still queries all 58 registries and keeps records registered **or** resident in OH. Narrowing the fan-out is opt-in (`locationScoped: true`); see [Selecting registries and jurisdictions](/docs/guides/sex-offender/selecting-jurisdictions).
- **The full record is the default.** `stateData`, `offenses[]` and `images[]` are all populated without asking; `omittedFields` is `[]`. For a lighter payload on big result sets send `exclude: ["stateData"]` — the response then lists it in `omittedFields`, so a `null` there is never ambiguous.
- **A record with `matchState: null` was not checked against a birth date** — you sent none. **Exact Match** without `dob` still scores 0.9; only `matchState` says the date was verified.
- **Timeouts.** Healthy searches answer in well under a second, but set an HTTP timeout of at least 60 seconds: `deadlineMs` (default 120000) is a target and a search that can finish keeps working up to the ~40 seconds the gateway allows.
- **The sandbox never answers `partial`**, `429` or `504`. Handle those branches from the contract — [Working with incomplete results](/docs/guides/sex-offender/incomplete-results) and [Error handling](/docs/guides/sex-offender/error-handling) — before you go live.
- **Searches are kept 14 days.** `GET /v1/searches/{searchId}/results` re-opens the stored records (up to 200) in that window and answers `410` after it.

## Production checklist

- Live key in a secret store; base URL switched; test key removed from production config.
- `status !== "complete"` handled as its own outcome, never as clear.
- Candidates go to a human review step; nothing auto-declines on a name match.
- `searchId`, `recordId` and `personRef` persisted with the outcome for the audit trail.
- The remaining checks in the [Go-live checklist](/docs/guides/sex-offender/go-live).

## Related

- [Quickstart](https://offendersearch.app/docs/quickstart)
- [Sandbox & test keys](https://offendersearch.app/docs/sandbox)
- [Search — POST /v1/search](https://offendersearch.app/docs/search)
- [Understanding search results](https://offendersearch.app/docs/guides/sex-offender/understanding-results)
- [The Record object](https://offendersearch.app/docs/record-object)
