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.
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_…) onhttps://sandbox.offendersearch.app. The sandbox renders the production PDF for its sample records and bills nothing. - A
searchIdfrom a sandbox search — runFelony Record(1975-06-15) throughPOST /v1/criminal/searchfirst and keepsearchIdfrom the envelope — or use an inline query. - The reference: 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, whose reports are a different product.
Step by step
- Pick the input.
{"searchId": "crs_…"}to render a stored search on your account (report charge only). Or{"query": {...}}, optionally withlive, to run and render in one call (search charges plus the report). Exactly one of the two. - Add the viewer fields.
viewerName(aliasrequesterName),viewerEmail,purpose,referenceare printed at the top of the document as a record of who ran it and why. They are not an access gate. - Read the bytes and the headers. The
200body isapplication/pdf.X-Report-Id(rpt_+ 12 hex) is also printed on the document;Content-Dispositionnames the attachmentoffendersearch-criminal-<reportId>.pdf. - Handle the three refusals.
400 no_report_input(neither input),404 not_found(unknownsearchId, another account's, or a job still pending),422(an inline query that failed validation). All are the{"error": {"code", "message"}}shape.
# 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.pdfHTTP/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 …// 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. |
Edge cases and pitfalls
- A pending async job cannot be rendered yet.
POST /v1/criminal/reportwith itssearchIdis404 not_found("no stored result to render"). Poll to200first, then render. - A batch row has no `searchId`. To attest a person screened in a batch, run them through
POST /v1/criminal/searchand render that id. - An inline query files a new search. It appears in
GET /v1/criminal/historywith its ownsearchId(tier: "live"if any live source completed, elsecore) and the stored result is kept atperPage: 200. - `live` on a report runs synchronously whatever the source count. There is no
202on this endpoint; a report with many live sources can be slow, and the gateway does not retry a report on an ambiguous failure, so a504 upstream_timeoutis possible. Prefer: search (async if needed) → poll → report bysearchId. - Flat `lastName` counts as an inline query.
{"lastName": "Record"}without aquerywrapper is accepted and runs a search; wrap it inqueryso the intent is explicit. - Clearances do not apply to a report. A
subjectIdhas no effect here; the PDF shows the stored result as searched. Note clearedpersonRefs in your own file. - The body is not JSON on success. Branch on
Content-Typebefore calling a JSON parser; the error shape on4xxis JSON.
Production checklist
- Render from a stored
searchIdwhenever one exists; it is cheaper and reproduces exactly what the reviewer saw. - Store
X-Report-Id, thesearchId, the viewer fields you sent and the PDF together. - Branch on
Content-Typeand handle400/404/422byerror.code,401/402bydetail. - Set a generous client timeout (120 s) and never render inline with a long
live.jurisdictionslist. - Keep the Not-a-consumer-report notice visible wherever you show the PDF; do not use it for an FCRA-covered decision.