Criminal Records API · v1.0.0

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_…) 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.
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

  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.
# 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
The 200 — headers, then PDF bytes
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
// 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

WhatWhereRead as
Report idX-Report-Id header; printed on page 1rpt_ + 12 hex. Store it with the case; it is the reference a reviewer quotes.
FilenameContent-Dispositionoffendersearch-criminal-<reportId>.pdf.
The query and run timeprintedWhat was searched and when, from the stored search or the inline run.
Records grouped by match strengthprintedFull-date matches first, then year/age matches, then name-only candidates — the same matchState order as the envelope.
Source citationsprintedThe public jurisdiction code and the last-checked stamp for each record. Never a portal, platform or vendor name.
Records-searched count and the noticeprintedHow many records were searched, and the Not-a-consumer-report notice. A zero-match search renders a clean "no records found" attestation.
BillingDashboard → UsageOne 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/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 personRefs 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.

Product overview