# Evidence and supporting documents

> The registry's own website, printed to one PDF per registry behind a cover page with a hash. Asynchronous, polled, and downloadable for 30 days.

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

A verification report says what the platform found. Evidence shows what the registry **itself** displayed at that moment: every result page and each matching registrant's detail page, printed as shown, behind a cover page carrying the capture time and a SHA-256 of the document. Compliance teams ask for it when a decision may be questioned later. You will build the flow: send `live.evidence: "pdf"` with the registries to print, poll the job, read `evidence[]`, download each PDF and store it with its hash.

## Before you start

- A test key. On the sandbox the documents are fixed `SAMPLE` PDFs, the first poll is already `complete`, and `billed` is `false`.
- The request is an ordinary `POST /v1/search` with a `live` block that has `evidence: "pdf"` and **`jurisdictions`** naming at most **20** registries. `live.scope` is refused for evidence (`422`), and `query.lastName` is required.
- Price: **$0.25 per completed document**, at most **$5.00** per search, on top of the live search's own charge ($0.02 per completed source). An incomplete document bills nothing ([pricing](/pricing)).

> **The one thing people get wrong.** An evidence search **always** answers `202`, never a `SearchResponse`. The `searchId` begins `evs_`, the records and the documents arrive on `GET /v1/searches/{searchId}` (the `pollUrl`), and the download link in each `evidence[]` entry works for **30 days**. Treat the `202` as a job handle and persist it.

## Step by step

1. **Submit.** Search **Exact Match** with `dob` `1980-01-01` and `"live": {"jurisdictions": ["OH"], "evidence": "pdf"}`. Store the `evs_` id from the `202`.
2. **Poll.** `GET` the `pollUrl` every 2–5 seconds. While `pending`/`running` the body has zeroed counts and `evidence: []`; when finished it is the live `SearchResponse` plus `evidence[]`, `usage.liveEvidence`, `usage.liveEvidenceUsd` and `sourceSearchId`.
3. **Read `evidence[]`.** One entry per registry: `complete` with `downloadUrl`, `sha256`, `pages` and often an inline `pdfBase64`; or `incomplete` with an `incompleteReason` and `billed: false`.
4. **Download and file.** `GET /v1/searches/{evs_id}/evidence/{CODE}.pdf` returns the PDF (`application/pdf`); verify the SHA-256 and store it with the `evs_` id and the `sourceSearchId`.

### Steps 1–4 in code

**cURL**
```bash
# 1. submit → 202
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"], "evidence": "pdf"}}'
# 2. poll the pollUrl from the 202
curl -s https://sandbox.offendersearch.app/v1/searches/evs_41dabad43b4cdfd43e663573 -H "X-API-Key: $OFFENDERSEARCH_KEY"
# 4. download one registry's document
curl -s -o evidence-OH.pdf https://sandbox.offendersearch.app/v1/searches/evs_41dabad43b4cdfd43e663573/evidence/OH.pdf \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

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

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

def evidence_search(query, registries):
    r = requests.post(BASE + "/v1/search", headers=H, timeout=90,
                      json={"query": query, "live": {"jurisdictions": registries, "evidence": "pdf"}})
    if r.status_code == 429:
        raise RuntimeError("evidence capacity busy; wait Retry-After=%s" % r.headers.get("Retry-After"))
    if r.status_code == 503:
        raise RuntimeError(r.json()["error"]["message"])     # not run, not billed
    r.raise_for_status()                                      # 202
    return r.json()                                           # {searchId: evs_..., pollUrl, registries, ...}

def poll(path, every=3, max_wait=900):
    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["error"])                 # nothing billed
        time.sleep(every)
    raise TimeoutError(path)

def download(doc):
    r = requests.get(BASE + doc["downloadUrl"], headers=H, timeout=120)
    r.raise_for_status()                                      # 404 once the 30 days are up
    assert hashlib.sha256(r.content).hexdigest() == doc["sha256"]
    return r.content

acc = evidence_search({"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}, ["OH"])
result = poll(acc["pollUrl"])
for doc in result["evidence"]:
    if doc["status"] == "complete":
        pdf = download(doc)
        open(f"{acc['searchId']}-{doc['jurisdiction']}.pdf", "wb").write(pdf)
    else:
        print(doc["jurisdiction"], doc["incompleteReason"], "retry" if doc["incompleteReason"] in RETRYABLE else "record")
print(result["usage"]["liveEvidence"], result["usage"]["liveEvidenceUsd"], result["sourceSearchId"])
```

**JavaScript**
```javascript
import { createHash } from 'node:crypto';
import { writeFile } from 'node:fs/promises';

const BASE = process.env.OFFENDERSEARCH_BASE ?? 'https://sandbox.offendersearch.app';
const H = { 'X-API-Key': process.env.OFFENDERSEARCH_KEY, 'Content-Type': 'application/json' };
const RETRYABLE = new Set(['renderer_unavailable', 'source_unreachable', 'timed_out']);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function evidenceSearch(query, registries) {
  const res = await fetch(BASE + '/v1/search', { method: 'POST', headers: H,
    body: JSON.stringify({ query, live: { jurisdictions: registries, evidence: 'pdf' } }) });
  if (res.status === 429) throw new Error('evidence capacity busy; wait Retry-After=' + res.headers.get('Retry-After'));
  if (res.status === 503) throw new Error((await res.json()).error.message);     // not run, not billed
  if (res.status !== 202) throw new Error('HTTP ' + res.status + ': ' + (await res.text()));
  return res.json();                                                             // { searchId: 'evs_…', pollUrl, … }
}

async function poll(path, every = 3000, maxWait = 900000) {
  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);
    await sleep(every);
  }
  throw new Error('timed out');
}

async function download(doc) {
  const res = await fetch(BASE + doc.downloadUrl, { headers: H });
  if (!res.ok) throw new Error('HTTP ' + res.status);         // 404 once the 30 days are up
  const pdf = Buffer.from(await res.arrayBuffer());
  if (createHash('sha256').update(pdf).digest('hex') !== doc.sha256) throw new Error('hash mismatch');
  return pdf;
}

const acc = await evidenceSearch({ firstName: 'Exact', lastName: 'Match', dob: '1980-01-01' }, ['OH']);
const result = await poll(acc.pollUrl);
for (const doc of result.evidence) {
  if (doc.status === 'complete') await writeFile(acc.searchId + '-' + doc.jurisdiction + '.pdf', await download(doc));
  else console.log(doc.jurisdiction, doc.incompleteReason, RETRYABLE.has(doc.incompleteReason) ? 'retry' : 'record');
}
console.log(result.usage.liveEvidence, result.usage.liveEvidenceUsd, result.sourceSearchId);
```

*Step 1 — 202 Accepted*
```json
{
  "searchId": "evs_41dabad43b4cdfd43e663573",
  "status": "pending",
  "mode": "async",
  "registries": ["OH"],
  "pollUrl": "/v1/searches/evs_41dabad43b4cdfd43e663573",
  "sandbox": true
}
```

*Step 2 — poll while the job runs (production shape)*
```json
{
  "searchId": "evs_41dabad43b4cdfd43e663573",
  "status": "running",
  "registries": ["OH"],
  "counts": { "records": 0, "sourcesQueried": 58, "sourcesIncomplete": 0 },
  "records": [],
  "sourceStatus": [],
  "evidence": []
}
```

*Step 2 — poll when finished (trimmed)*
```json
{
  "searchId": "evs_41dabad43b4cdfd43e663573",
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
  "sourceStatus": [
    { "source": "OH", "status": "complete", "matched": 1, "fromCache": false, "incomplete": false, "incompleteReason": null }
  ],
  "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",
             "liveEvidence": 0, "liveEvidenceUsd": 0.0 },
  "meterEvents": [],
  "sourceSearchId": "srch_42006068f66b3f51abf41f49",
  "evidence": [
    {
      "jurisdiction": "OH",
      "status": "complete",
      "kind": "text",
      "pages": 2, "resultPages": 1, "detailPages": 1,
      "capturedAt": "2026-10-01T12:00:00+00:00",
      "sourceUrl": "https://offendersearch.app/docs/sandbox",
      "pageUrls": [],
      "sha256": "c48fa5f9921ccead4acfec7191378776adc5210c8f0544bd2eab6923a2e47e28",
      "bytes": 2195,
      "downloadUrl": "/v1/searches/evs_41dabad43b4cdfd43e663573/evidence/OH.pdf",
      "billed": false,
      "chargeUsd": 0.0,
      "pdfBase64": "JVBERi0xLjMKJenr…",
      "inline": true
    }
  ],
  "sandbox": true
}
```

In production a completed document has `billed: true`, `chargeUsd: 0.25`, and `usage.liveEvidence` / `usage.liveEvidenceUsd` count it (`min(n × 0.25, 5.00)`). `sourceSearchId` is the live search on your account that produced `records[]` — that id, not the `evs_` id, is what `POST /v1/report` and a clearance cite. `pdfBase64` is present when the document is 4 MB or smaller (`inline: true`); above that, use `downloadUrl`.

*An incomplete document (production shape)*
```json
{
  "jurisdiction": "NV",
  "status": "incomplete",
  "incompleteReason": "source_unreachable",
  "detail": "the registry website did not answer within the time allowed",
  "billed": false
}
```

*Step 4 — GET /v1/searches/{evs_id}/evidence/OH.pdf*
```http
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="offendersearch-evidence-evs_41dabad43b4cdfd43e663573-OH.pdf"
```

## Reading the result

| Field | Values | What to do |
| --- | --- | --- |
| `evidence[].status` | `complete`, `incomplete` | Download on `complete`; read `incompleteReason` otherwise. |
| `evidence[].incompleteReason` | `renderer_unavailable`, `source_unreachable`, `timed_out` | Transient — retry the evidence search for that registry. |
|  | `blocked`, `no_results_page`, `render_failed`, `query_unsupported`, `no_recipe` | Not fixed by retrying: the site refused the visit, showed no results page for this query, could not be printed, cannot search on these fields, or cannot be printed yet. Record the reason. |
| `evidence[].sha256` | hex | Verify the download; the same value is printed on the cover page. |
| `evidence[].kind` | `text`, `image`, `mixed` | `text` is searchable; `image` is a full-page capture (some registries only work this way). |
| `evidence[].pages` / `resultPages` / `detailPages` | integers | What the document holds, after the cover page. |
| `evidence[].downloadUrl` | path | `GET` it with your key within 30 days; `404` afterwards, for another account, or for an incomplete document. |
| `usage.liveEvidence`, `usage.liveEvidenceUsd` | integer, number | Billed documents and the charge, separate from `liveChargeUsd`. |
| `sourceSearchId` | `srch_…` | The citable search behind the records. |

## Edge cases and pitfalls

- **`live.scope` is a `422` here**: `live.scope is not supported for evidence; name the registries in live.jurisdictions`. Run the Regular search first, read `registrationState` from the hits, then request evidence for those registries.
- **Capacity**: the evidence service answers `429` with `Retry-After: 10` when it is busy. Nothing ran; wait and resubmit.
- **Unavailable**: `503` with `{"error": {"code": "unavailable", "message": "…"}}` — not the usual `detail` envelope. The search was not run and nothing was billed; retry, or omit `live.evidence`.
- **A job that does not finish within 15 minutes** polls as `status: "error"` with `error: "the job did not finish; nothing was billed for it"`. Resubmit.
- **Up to 20 registries per search.** Split a longer list into several evidence searches; each has its own $5.00 ceiling.
- **The main host's request validation is not applied to an evidence request** — an unknown `jurisdictions[]` code or a `match` typo in the same body is not refused by it. Validate the body as you would for `POST /v1/search` before sending.
- **Poll with an API key.** `GET /v1/searches/{evs_id}` on the main host does not accept a dashboard session.
- **30 days is the document's life**, longer than the 14-day search retention and the 7-day report window. Download and store what you need to keep.

## Production checklist

- `evs_` id persisted on the `202`; poller with a 2–5 s interval and a 15-minute cap.
- Every `complete` document downloaded, hash-verified and stored with `evs_` id, `sourceSearchId`, `capturedAt` and `sha256`.
- Every `incomplete` reason recorded; transient reasons retried once.
- `usage.liveEvidenceUsd` reconciled against [Dashboard → Usage](/dashboard/usage).

## Related

- [Live evidence — the registry's page as a PDF](https://offendersearch.app/docs/live-evidence)
- [Regular vs. live searches](https://offendersearch.app/docs/guides/sex-offender/regular-vs-live)
- [Verification reports](https://offendersearch.app/docs/guides/sex-offender/verification-reports)
- [Error handling](https://offendersearch.app/docs/guides/sex-offender/error-handling)
