# Regular vs. live searches

> Regular answers from the continuously updated copy in under a second. Live asks the named registries now, costs a little more, and tells you exactly which ones answered.

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

Every search is **Regular** unless you say otherwise: the request is answered from the continuously updated copy of all 58 US registries, and each record carries `sources[].lastCheckedAt` so you know when its registry was last read. A **live** search re-verifies the registries you name at query time and merges what they return through the same matcher. Use it for a contested or high-stakes decision, for a point-in-time answer you will file, or when a reviewer asks "is this still true right now".

You will build a search function that sends a `live` block, handles the two ways it can answer (inline, or as a background job you poll), and reads the live rows and the charge.

## Before you start

- A test key on `https://sandbox.offendersearch.app`. The sandbox answers a `live` block deterministically: each named registry completes and adds its row, with the production `usage` quote; nothing is billed.
- The `live` block has three fields: `jurisdictions` (registry ids or two-letter codes to re-check), `scope: "matched"` (re-check whichever registries produced the Regular hits) and `evidence` (`none` or `pdf`, covered in [Evidence and supporting documents](/docs/guides/sex-offender/evidence-documents)). Omit `live` for Regular; `live: {}` resolves no sources and is also Regular.
- Price: the search itself plus **$0.02 per completed live source**, at most **$2.00** per search. A source that fails is reported and not billed. Live reporting adds `usage` and `meterEvents` to the response ([pricing](/pricing)).

> **The one thing people get wrong.** A live search does not always answer inline. When the block resolves to more than two sources, or to a registry that can only be re-checked in the background, `POST /v1/search` answers **HTTP 200** with `status: "pending"`, `mode: "async"` and a `poll` path — not a `SearchResponse`. Branch on `body.status === "pending"` and poll `GET /v1/searches/{searchId}` with your API key.

## Step by step

1. **Run a Regular search.** Search **Exact Match** with `dob` `1980-01-01` and no `live` block. No `usage` key, no `meterEvents` key; every `sourceStatus` row has `fromCache: true`.
2. **Re-check one registry live.** Add `"live": {"jurisdictions": ["OH"]}`. One source resolves, the search runs inline, and the response gains a live row for OH plus `usage`.
3. **Re-check the registries that matched.** Send `"live": {"scope": "matched"}` instead. The Regular pass finds the person in OH, so OH is re-checked — without you naming it.
4. **Ask for three registries and poll.** Send `"live": {"jurisdictions": ["OH", "TX", "FL"]}`. The answer is the `200` acknowledgement; poll until `status` is `complete` or `partial`.

### Step 2 — one registry live, inline

**cURL**
```bash
curl -s https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "live": {"jurisdictions": ["OH"]}}'
```

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

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

def live_search(query, live):
    r = requests.post(BASE + "/v1/search", headers=H, json={"query": query, "live": live}, timeout=90)
    r.raise_for_status()
    body = r.json()
    if body["status"] == "pending":                    # > 2 sources, or a background-only registry
        return poll(body["poll"])
    return body

def poll(path, every=3, max_wait=300):
    deadline = time.time() + max_wait
    while time.time() < deadline:
        r = requests.get(BASE + path, headers=H, timeout=30)
        r.raise_for_status()
        body = r.json()
        if body["status"] in ("complete", "partial"):
            return body
        if body["status"] == "error":
            raise RuntimeError(body.get("error"))      # nothing was billed for a failed job
        time.sleep(every)
    raise TimeoutError(path)

body = live_search({"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
                   {"jurisdictions": ["OH"]})
live_rows = [s for s in body["sourceStatus"] if not s["fromCache"]]
print(body["status"], body["usage"], live_rows)
```

**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' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function poll(path, every = 3000, maxWait = 300000) {
  const deadline = Date.now() + maxWait;
  while (Date.now() < deadline) {
    const res = await fetch(BASE + path, { headers: H });
    if (!res.ok) throw new Error('HTTP ' + res.status);
    const body = await res.json();
    if (body.status === 'complete' || body.status === 'partial') return body;
    if (body.status === 'error') throw new Error(body.error);   // nothing billed for a failed job
    await sleep(every);
  }
  throw new Error('poll timed out: ' + path);
}

async function liveSearch(query, live) {
  const res = await fetch(BASE + '/v1/search', { method: 'POST', headers: H, body: JSON.stringify({ query, live }) });
  if (!res.ok) throw new Error('HTTP ' + res.status);
  const body = await res.json();
  return body.status === 'pending' ? poll(body.poll) : body;   // > 2 sources or background-only
}

const body = await liveSearch({ firstName: 'Exact', lastName: 'Match', dob: '1980-01-01' }, { jurisdictions: ['OH'] });
console.log(body.status, body.usage, body.sourceStatus.filter((s) => !s.fromCache));
```

*Response (trimmed: the 58 cached rows cut, the live row kept)*
```json
{
  "searchId": "srch_4bd856ef486fc1bff31f8454",
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
  "sourceStatus": [
    { "source": "OH", "status": "complete", "matched": 1, "fromCache": false, "note": null,
      "incomplete": false, "incompleteReason": null, "cappedOmitted": false }
  ],
  "records": [ { "recordId": "rec_28db9639d712c4c61e8e", "personRef": "prs_28db9639d712c4c61e8e", "matchState": "dob_match", "matchConfidence": 0.9 } ],
  "warnings": [],
  "usage": { "liveChecks": 1, "liveChargeUsd": 0.02, "capped": false, "display": "1 live checks · $0.02" },
  "meterEvents": [],
  "sandbox": true
}
```

The live row is appended after the cached rows: `fromCache: false`, `status: "complete"`. In production `meterEvents` carries `[{ "kind": "live_source", "quantity": 1, "chargeCeilingUsd": 2.0 }]`; the sandbox bills nothing, so it is `[]` — present, because it is present on every live answer.

### Step 4 — three registries: the acknowledgement, then the poll

*POST /v1/search → 200 (not a SearchResponse)*
```json
{
  "searchId": "srch_e4370c7d8000e31a71105937",
  "status": "pending",
  "mode": "async",
  "liveSourcesRequested": 3,
  "poll": "/v1/searches/srch_e4370c7d8000e31a71105937",
  "reason": "3 live sources (> 2 or not sync-eligible) — running asynchronously",
  "sandbox": true
}
```

*GET /v1/searches/{searchId} → 200 when finished (trimmed)*
```json
{
  "searchId": "srch_e4370c7d8000e31a71105937",
  "status": "complete",
  "sourceStatus": [
    { "source": "OH", "status": "complete", "matched": 1, "fromCache": false, "incomplete": false, "incompleteReason": null },
    { "source": "TX", "status": "complete", "matched": 0, "fromCache": false, "incomplete": false, "incompleteReason": null },
    { "source": "FL", "status": "complete", "matched": 0, "fromCache": false, "incomplete": false, "incompleteReason": null }
  ],
  "records": [ { "recordId": "rec_28db9639d712c4c61e8e", "matchState": "dob_match" } ],
  "usage": { "liveChecks": 3, "liveChargeUsd": 0.06, "capped": false, "display": "3 live checks · $0.06" },
  "meterEvents": []
}
```

While the job runs the poll body is `{"searchId", "status": "pending" | "running", "counts": {"records": 0, "sourcesQueried": N, "sourcesIncomplete": 0}, "sourceStatus": [], "records": [], "proof": {"status": "none"}}`. Poll every 2–5 seconds and cap your wait at a few minutes. The sandbox finishes before the first poll.

## Reading the result

| Field | Values | What it means on a live search |
| --- | --- | --- |
| `sourceStatus[].fromCache` | `true` on Regular rows, `false` on live rows | The live rows are the ones you paid for. Filter on this. |
| `sourceStatus[].status` (live row) | `complete`, `partial` | `partial` comes with `incomplete: true` and an `incompleteReason` (`unavailable`, `error`, `not_searched`, …). Regular rows use `ok`, `error`, `restricted`, `no_coverage`. |
| `status` (envelope) | `complete`, `partial` | Any failed live source makes the whole answer `partial`. |
| `usage.liveChecks` | integer | Sources that completed — the true count even when the charge is capped. |
| `usage.liveChargeUsd` | number | `min(liveChecks × 0.02, 2.00)`; `usage.capped` says whether the ceiling applied. |
| `meterEvents[]` | `[{kind: "live_source", quantity, chargeCeilingUsd}]` | The billable units, for your own reconciliation. Present only when a live block ran. |
| `records[].sources[].lastCheckedAt` | ISO timestamp | On a live hit, now. On a Regular hit, when the registry was last read. |

Which registries can be re-checked live, and each registry's health, are published by `GET /v1/sources` (no key needed) and explained on [Regular & Live](/docs/freshness).

## Edge cases and pitfalls

- **A two-letter code can resolve to more than one source.** `live.jurisdictions` expands a code to each registry that covers it, which can include a national index. Read `liveSourcesRequested` on the acknowledgement, or the live rows on the result, rather than assuming one source per code.
- **The poll needs an API key.** `GET /v1/searches/{searchId}` does not accept a dashboard session, and it answers `404` for another account's id, an unknown id, or a job older than about an hour. Collect the result promptly; the stored records stay readable at `GET /v1/searches/{searchId}/results` for 14 days.
- **Live records win.** When a live source returns the same person the Regular pass found, the live copy replaces the cached one in `records[]`.
- **A failed source is not billed** and shows as a `partial` live row. Decide in advance whether a `partial` live answer is acceptable for your use case or should be retried.
- **Batch does not take `live`.** `POST /v1/batch` is Regular only; a `live` key in a row is a `422`. Run `POST /v1/search` or `POST /v1/searches` for a live check.
- **Inside `POST /v1/searches`** a `live` block runs in the job and is billed when the sources complete; the `202` arrives before anything is charged.
- **`live: {}` is Regular.** Only `jurisdictions` or `scope` resolve sources.

## Production checklist

- Every call that sends `live` handles both the inline `SearchResponse` and the `200` acknowledgement with `poll`.
- Polling uses the API key, a 2–5 second interval and a hard cap.
- `usage` and `meterEvents` logged per live search and reconciled against [Dashboard → Usage](/dashboard/usage).
- Live reserved for the decisions that need it; Regular for volume.

## Related

- [Regular & Live modes, source coverage](https://offendersearch.app/docs/freshness)
- [Asynchronous searches](https://offendersearch.app/docs/guides/sex-offender/async-searches)
- [Evidence and supporting documents](https://offendersearch.app/docs/guides/sex-offender/evidence-documents)
- [Working with incomplete results](https://offendersearch.app/docs/guides/sex-offender/incomplete-results)
- [Pricing](https://offendersearch.app/pricing)
