Criminal Records API · v1.0.0

Handling partial results

A cached search is always complete. A live check can fall short at one source, and when it does the envelope says exactly where, why, and whether a retry is worth it.

What you'll build

The completeness branch of a live-verification integration: code that never treats records: [] as "clear" until it has checked that every source finished, that knows which incompleteReason values are worth a retry and which are not, and that tells a reviewer which sources did and did not answer. The discipline is the same as the Sex Offender API's, with one addition particular to criminal: a source that could not be checked live may be answered from its stored copy instead, and the row says so.

Before you start

  • A live key, eventually. The sandbox never simulates a short source — every requested sandbox source answers complete — so the partial branch can be exercised only against https://api.offendersearch.app with codes from GET /v1/criminal/jurisdictions?live=true. Build and unit-test the branch against the bodies below.
  • The vocabulary: Completeness & source status and the Cached vs. live verification guide.
partial is about live sources only — and it is a lower bound. status: "partial" means at least one live source has incomplete: true. The records you did get are real; the ones you did not get are unknown. An empty records[] on a partial answer is not evidence of absence. A cached search (no live block) is never partial.

Step by step

  1. Check status before records. complete → every live source finished (or was answered from file). partial → walk sourceStatus[].
  2. Classify each sourceStatus row. ok: true, incomplete: false, live: true → searched live to the end. incomplete: true → read incompleteReason. live: false, answeredFrom: "on_file" → the live check did not run and the stored copy answered; liveOutcome says why.
  3. Read warnings[]. One sentence per condition, loudest first: INCOMPLETE SEARCH: names the short sources; LIVE CHECK NOT AVAILABLE: names the on-file fallbacks; WEBSITE NOT COVERED: names an unsupported query.website. [] is a valid completeness check.
  4. Decide: retry, narrow, or accept. Transient reasons → resend the same request. Structural reasons → accept the stored copy or drop the source. truncated_over_cap → narrow the query.
import requests

API, KEY = "https://api.offendersearch.app", "os_live_your_key"   # the sandbox never produces partial
RETRYABLE = {"timeout", "source_down", "rate_limited", "captcha_unsolved", "proxy_required"}

def classify(env):
    """Return (outcome, rows_to_retry, notes) for a live search envelope."""
    retry, notes = [], []
    for row in env["sourceStatus"]:
        code = row["code"] or "(unsupported site)"
        if row.get("answeredFrom") == "on_file":
            notes.append(f"{code}: answered from the stored copy (live check {row.get('liveOutcome')})")
        elif row["incomplete"]:
            why = row["incompleteReason"]
            notes.append(f"{code}: incomplete — {why}")
            if why in RETRYABLE:
                retry.append(code)
        else:
            notes.append(f"{code}: searched live, {row['records']} record(s)")
    if env["status"] == "complete":
        outcome = "clear" if env["counts"]["records"] == 0 else "records"
    else:
        outcome = "incomplete" if env["counts"]["records"] == 0 else "records_lower_bound"
    return outcome, retry, notes

body = {"query": {"lastName": "Rivera", "firstName": "Jordan", "dob": "1988-04-12", "state": "TX"},
        "live": {"jurisdictions": ["TX-BEXAR", "TX-DOC"]}}
r = requests.post(f"{API}/v1/criminal/search", headers={"X-API-Key": KEY}, json=body, timeout=90)
r.raise_for_status()                      # a 202 would need the async poller
env = r.json()
outcome, retry, notes = classify(env)
print(outcome, "| retry:", retry)
print("\n".join(env["warnings"] + notes))
if retry:
    env2 = requests.post(f"{API}/v1/criminal/search", headers={"X-API-Key": KEY}, json=body, timeout=90).json()
    print("after retry:", env2["status"], env2["counts"]["sourcesIncomplete"], "still incomplete")
A partial live answer — one source timed out (production shape, trimmed)
{
  "status": "partial",
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 2, "sourcesComplete": 1, "sourcesIncomplete": 1,
              "candidatesCapped": false, "exclusionsIncluded": false },
  "warnings": [
    "INCOMPLETE SEARCH: 1 of 2 sources could not be searched to completion (TX-DOC). The records below are a LOWER BOUND: an absent person may simply not have been reached. This is NOT evidence that someone is absent — retry, or narrow the query."
  ],
  "sourceStatus": [
    { "code": "TX-BEXAR", "source": "TX-BEXAR", "ok": true,  "incomplete": false, "incompleteReason": null,      "records": 1, "lastCheckedAt": "2026-10-25T15:04:11+00:00", "live": true },
    { "code": "TX-DOC",   "source": "TX-DOC",   "ok": false, "incomplete": true,  "incompleteReason": "timeout", "records": 0, "lastCheckedAt": "2026-10-25T15:04:11+00:00", "live": true }
  ],
  "records": [ { "recordCategory": "jail", "liveChecked": true, "sources": [ { "code": "TX-BEXAR", "live": true, "…": "…" } ], "…": "…" } ],
  "usage": { "liveChecks": 1, "liveChargeUsd": 0.02, "capped": false, "display": "1 live checks · $0.02" },
  "meterEvents": [ { "kind": "criminal_call", "quantity": 1, "chargeCeilingUsd": null },
                   { "kind": "criminal_live_source", "quantity": 1, "chargeCeilingUsd": 2.0 } ]
}
The on-file fallback — status stays complete, the row says live: false
{
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 2, "sourcesComplete": 2, "sourcesIncomplete": 0, "…": "…" },
  "warnings": [
    "LIVE CHECK NOT AVAILABLE: TX-DOC could not be checked live (not_searched) and was answered from the stored copy. …"
  ],
  "sourceStatus": [
    { "code": "TX-BEXAR", "source": "TX-BEXAR", "ok": true, "incomplete": false, "incompleteReason": null, "records": 1, "lastCheckedAt": "2026-10-25T15:04:11+00:00", "live": true },
    { "code": "TX-DOC",   "source": "TX-DOC",   "ok": true, "incomplete": false, "incompleteReason": null, "records": 0, "lastCheckedAt": "2026-10-25T15:04:11+00:00",
      "live": false, "answeredFrom": "on_file", "liveOutcome": "not_searched" }
  ],
  "usage": { "liveChecks": 1, "liveChargeUsd": 0.02, "capped": false, "display": "1 live checks · $0.02" }
}

In the second body the Texas prison system was not checked live (it has no live connector in scope, hence not_searched), but its stored records were in the query's state scope, so the row was answered from file: ok: true, incomplete: false, live: false, not billed — usage.liveChecks counts only the one real live check. The warnings[] sentence is quoted in shape; read the real one from the response.

Reading the result

FieldValuesDecision
statuscomplete · partialpartial iff any row has incomplete: true. The gate for "may I call this clear".
counts.sourcesQueried / sourcesComplete / sourcesIncompleteintegersLive rows, finished rows, short rows. sourcesIncomplete > 0 → lower bound.
sourceStatus[].codepublic code or nullnull only on an unsupported_site row from live.websites.
sourceStatus[].ok / incompletebooleansok = reached and read; incomplete = not searched to the end. Read incomplete, not ok, for completeness.
sourceStatus[].incompleteReasontimeout · source_down · rate_limited · captcha_unsolved · proxy_required · paid_gateway · login_required · truncated_over_cap · unsupported_site · not_searchedTransient (first five): resend the same request. Structural (paid_gateway, login_required, unsupported_site, not_searched): a retry will not help. truncated_over_cap: narrow the query. Non-null only when incomplete.
sourceStatus[].recordsintegerMatcher survivors from this source; a lower bound on an incomplete row.
sourceStatus[].livebooleantrue = checked live now. false = on-file fallback row.
sourceStatus[].answeredFrom / liveOutcomeon_file / an incompleteReason tokenPresent only on a fallback row. The stored copy answered; liveOutcome is why the live check did not run (not_searched when the source has no live connector).
warnings[]INCOMPLETE SEARCH: · LIVE CHECK NOT AVAILABLE: · WEBSITE NOT COVERED:Human sentences, loudest first. Show them to the reviewer verbatim.
usage.liveChecksintegerCompleted live checks only. Failed and on-file rows are not billed.

Edge cases and pitfalls

  • The service already retries transient failures once. Within a synchronous live request, sources that failed with source_down, timeout, captcha_unsolved or rate_limited are re-tried once inside a 20-second window before the answer is built. A partial you receive has therefore already survived one retry; back off before yours.
  • `not_searched` is immediate, not a timeout. A source with no live connector, or a job that hit its source cap, reports not_searched at once. Check GET /v1/criminal/jurisdictions?live=true before asking for it live, or accept the on-file answer.
  • An entirely unsupported `live.websites` list is `200 partial`, not `422`. Each site becomes a code: null row with unsupported_site; resolution[] says what each mapped to. One unrecognised jurisdiction code among good ones is an ok: false row; a list where nothing resolves is 422 no_live_sources.
  • A complete job can hold a partial result. On an async job, the poll's JobRecord says status: "complete" while result.status may be partial. Read the inner status.
  • `records[]` on a partial answer are real. Act on what you have — a dob_match from the source that did answer is a finding — and separately record which sources are outstanding.
  • Partial is never billed for the failed source. meterEvents[].criminal_live_source.quantity and usage.liveChecks count completed sources only; the criminal_call is still metered.
  • Batch is never partial. No live in batch, so a partial row cannot occur; a row that could not run is not_run instead.

Production checklist

  • Gate "clear" on status == "complete" and counts.records == 0; anything else is "records" or "incomplete".
  • Retry transient incompleteReason values once with backoff; log structural ones and move on.
  • Persist sourceStatus[] and warnings[] with the decision; a reviewer must be able to see which sources answered.
  • Treat an answeredFrom: "on_file" row as a stored-copy answer in your outcome text, not as a live confirmation.
  • Alert on a rising share of partial answers for a source you depend on; pair it with the catalog's health.

Product overview