# Retrieving reports

> A report is the PDF attestation that a search was performed: the query, the time, the records grouped by match strength, the public source citations, and the notice that it is not a consumer report.

- **HTML:** https://offendersearch.app/docs/guides/criminal/verification-reports
- **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

The document step of a screening workflow: when a reviewer closes a case, render the PDF that attests what was searched and what came back, file it with the case, and record the report id. Two input modes exist. Rendering a stored `searchId` re-uses the result you already paid for and meters only the report; an inline `query` runs a new search and renders it in one call, metering the search as well.

## Before you start

- A test key (`os_test_…`) on `https://sandbox.offendersearch.app`. The sandbox renders the production PDF for its sample records and bills nothing.
- A `searchId` from a sandbox search — run `Felony Record` (`1975-06-15`) through `POST /v1/criminal/search` first and keep `searchId` from the envelope — or use an inline query.
- The reference: [Verification reports](/docs/criminal/verification-reports).

> **The PDF is an attestation, not a consumer report.** The document prints the Not-a-consumer-report notice prominently and the API's terms forbid using it for employment, tenant, credit or insurance decisions. If your workflow makes those decisions, that path is the [FCRA Partner API](/docs/fcra), whose reports are a different product.

## Step by step

1. **Pick the input.** `{"searchId": "crs_…"}` to render a stored search on your account (report charge only). Or `{"query": {...}}`, optionally with `live`, to run and render in one call (search charges plus the report). Exactly one of the two.
2. **Add the viewer fields.** `viewerName` (alias `requesterName`), `viewerEmail`, `purpose`, `reference` are printed at the top of the document as a record of who ran it and why. They are not an access gate.
3. **Read the bytes and the headers.** The `200` body is `application/pdf`. `X-Report-Id` (`rpt_` + 12 hex) is also printed on the document; `Content-Disposition` names the attachment `offendersearch-criminal-<reportId>.pdf`.
4. **Handle the three refusals.** `400 no_report_input` (neither input), `404 not_found` (unknown `searchId`, another account's, or a job still pending), `422` (an inline query that failed validation). All are the `{"error": {"code", "message"}}` shape.

**cURL**
```bash
# from a stored search (report charge only)
curl -s https://sandbox.offendersearch.app/v1/criminal/report \
  -H "X-API-Key: os_test_your_key" -H "Content-Type: application/json" \
  -d '{ "searchId": "crs_7f2a1c9e0b4d6a8f1e23",
        "viewerName": "Case Worker", "purpose": "Records verification", "reference": "FILE-2026-0142" }' \
  -D headers.txt --output report.pdf
grep -i "x-report-id\|content-disposition" headers.txt

# one shot: run a cached search inline and render it (search + report charges)
curl -s https://sandbox.offendersearch.app/v1/criminal/report \
  -H "X-API-Key: os_test_your_key" -H "Content-Type: application/json" \
  -d '{ "query": { "lastName": "Record", "firstName": "Felony", "dob": "1975-06-15" } }' \
  --output report-inline.pdf
```

**Python**
```python
import requests

API, KEY = "https://sandbox.offendersearch.app", "os_test_your_key"
H = {"X-API-Key": KEY}

# 1. the search you are attesting — keep its searchId with the case
env = requests.post(f"{API}/v1/criminal/search", headers=H,
                    json={"query": {"lastName": "Record", "firstName": "Felony", "dob": "1975-06-15"}}).json()
search_id = env["searchId"]

# 2. render it
r = requests.post(f"{API}/v1/criminal/report", headers=H, json={
    "searchId": search_id,
    "viewerName": "Case Worker", "viewerEmail": "reviewer@example.com",
    "purpose": "Records verification", "reference": "FILE-2026-0142",
}, timeout=120)
if r.headers.get("content-type", "").startswith("application/pdf"):
    report_id = r.headers["X-Report-Id"]                       # rpt_ + 12 hex, also printed on the PDF
    with open(f"offendersearch-criminal-{report_id}.pdf", "wb") as f:
        f.write(r.content)
    print("saved", report_id, r.headers.get("Content-Disposition"))
else:
    err = r.json()
    if r.status_code in (401, 402):
        raise RuntimeError(err["detail"])                      # gateway shape
    raise RuntimeError(f"{r.status_code} {err['error']['code']}: {err['error']['message']}")
```

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

const API = 'https://sandbox.offendersearch.app';
const H = { 'X-API-Key': 'os_test_your_key', 'Content-Type': 'application/json' };

const env = await (await fetch(API + '/v1/criminal/search', {
  method: 'POST', headers: H, body: JSON.stringify({ query: { lastName: 'Record', firstName: 'Felony', dob: '1975-06-15' } }),
})).json();

const r = await fetch(API + '/v1/criminal/report', {
  method: 'POST', headers: H,
  body: JSON.stringify({ searchId: env.searchId, viewerName: 'Case Worker', purpose: 'Records verification', reference: 'FILE-2026-0142' }),
});
if ((r.headers.get('content-type') ?? '').startsWith('application/pdf')) {
  const reportId = r.headers.get('x-report-id'); // rpt_ + 12 hex, also printed on the PDF
  await writeFile('offendersearch-criminal-' + reportId + '.pdf', Buffer.from(await r.arrayBuffer()));
  console.log('saved', reportId, r.headers.get('content-disposition'));
} else {
  const err = await r.json();
  if (r.status === 401 || r.status === 402) throw new Error(err.detail);          // gateway shape
  throw new Error(r.status + ' ' + err.error.code + ': ' + err.error.message);     // criminal shape
}
```

*The 200 — headers, then PDF bytes*
```http
HTTP/1.1 200 OK
Content-Type: application/pdf
X-Report-Id: rpt_3c9a1f0e7b2d
Content-Disposition: attachment; filename="offendersearch-criminal-rpt_3c9a1f0e7b2d.pdf"

%PDF-1.7 …
```

*The refusals*
```json
// body: {}
{ "error": { "code": "no_report_input", "message": "provide a searchId (a prior search) or an inline query" } }

// body: { "searchId": "crs_0000000000000000dead" }  — unknown, another account's, or still pending
{ "error": { "code": "not_found", "message": "no such search" } }
{ "error": { "code": "not_found", "message": "this search has no stored result to render (still pending?)" } }

// body: { "query": { "firstName": "Felony" } }
{ "error": { "code": "guard_unbounded_query", "message": "a lastName predicate is required — an unbounded query returns a lottery, not an answer" } }
```

## Reading the result

| What | Where | Read as |
| --- | --- | --- |
| Report id | `X-Report-Id` header; printed on page 1 | `rpt_` + 12 hex. Store it with the case; it is the reference a reviewer quotes. |
| Filename | `Content-Disposition` | `offendersearch-criminal-<reportId>.pdf`. |
| The query and run time | printed | What was searched and when, from the stored search or the inline run. |
| Records grouped by match strength | printed | Full-date matches first, then year/age matches, then name-only candidates — the same `matchState` order as the envelope. |
| Source citations | printed | The public jurisdiction code and the last-checked stamp for each record. Never a portal, platform or vendor name. |
| Records-searched count and the notice | printed | How many records were searched, and the Not-a-consumer-report notice. A zero-match search renders a clean "no records found" attestation. |
| Billing | Dashboard → Usage | One `criminal_report` event (`$0.02` ceiling). An inline `query` adds one `criminal_call` plus one `criminal_live_source` per completed live source. The PDF response carries no `meterEvents`; reconcile in [Usage](/dashboard/usage). |

## Edge cases and pitfalls

- **A pending async job cannot be rendered yet.** `POST /v1/criminal/report` with its `searchId` is `404 not_found` ("no stored result to render"). Poll to `200` first, then render.
- **A batch row has no `searchId`.** To attest a person screened in a batch, run them through `POST /v1/criminal/search` and render that id.
- **An inline query files a new search.** It appears in `GET /v1/criminal/history` with its own `searchId` (`tier: "live"` if any live source completed, else `core`) and the stored result is kept at `perPage: 200`.
- **`live` on a report runs synchronously whatever the source count.** There is no `202` on this endpoint; a report with many live sources can be slow, and the gateway does not retry a report on an ambiguous failure, so a `504 upstream_timeout` is possible. Prefer: search (async if needed) → poll → report by `searchId`.
- **Flat `lastName` counts as an inline query.** `{"lastName": "Record"}` without a `query` wrapper is accepted and runs a search; wrap it in `query` so the intent is explicit.
- **Clearances do not apply to a report.** A `subjectId` has no effect here; the PDF shows the stored result as searched. Note cleared `personRef`s in your own file.
- **The body is not JSON on success.** Branch on `Content-Type` before calling a JSON parser; the error shape on `4xx` is JSON.

## Production checklist

- Render from a stored `searchId` whenever one exists; it is cheaper and reproduces exactly what the reviewer saw.
- Store `X-Report-Id`, the `searchId`, the viewer fields you sent and the PDF together.
- Branch on `Content-Type` and handle `400`/`404`/`422` by `error.code`, `401`/`402` by `detail`.
- Set a generous client timeout (120 s) and never render inline with a long `live.jurisdictions` list.
- Keep the Not-a-consumer-report notice visible wherever you show the PDF; do not use it for an FCRA-covered decision.

## Related

- [Verification reports](https://offendersearch.app/docs/criminal/verification-reports): the reference
- [Async & long-running](https://offendersearch.app/docs/criminal/async)
- [Errors & status codes](https://offendersearch.app/docs/criminal/errors)
- [FCRA Partner API](https://offendersearch.app/docs/fcra)
