Criminal Records API · v1.0.0

Matching and confidence scores

The surname anchors, the first name scores, the birth date decides. Three fields explain every match, and `matchConfidence` is the one you should trust least on its own.

What you'll build

A triage step that sorts every returned record into one of three bins — confirmed (the birth date agreed), candidate (the name agreed and nothing could confirm or deny it), or weak (prefix name match, first name absent or mismatched) — and puts the second and third bins in front of a human. The API does the scoring; your job is to read it correctly and not to turn a candidate into a finding.

The matcher is deliberately conservative in one direction: a surname must equal your value or (by default) start with it, and a record whose published birth date contradicts yours is dropped and never shown. Misses are therefore rare; extra candidates on common names are the normal failure, and this guide is about handling them. Handling false positives covers what to do once a reviewer has decided.

Before you start

  • A test key (os_test_…) and the sandbox host https://sandbox.offendersearch.app.
  • Two sandbox people: Possible Match (three records in three states, none with a birth date — the review-queue case) and Felony Record, born 1975-06-15 (the confirmed case).
  • The scoring table and the full token list on Matching & confidence.
Do not gate on matchConfidence alone. matchConfidence is a ceiling on the evidence held, capped at 0.99, not a probability. A name-only match of a common name scores 0.80 and is still only a candidate. Gate on matchState (dob_match for anything consequential) and read matchBasis for why; use the number to order a queue, never to decide it.

Step by step

  1. Search with everything you know. Send firstName and dob when you have them. Without a date every record reads no_dob_age_year and unverified: true, because there was nothing to check against.
  2. Read matchState first. dob_match → confirmed. year_match / age_match → agreed as far as the source publishes; strong but not a full-date confirmation. no_dob_age_year → candidate.
  3. Read matchBasis for the name. Tokens are field:how. lastName:exact or lastName:prefix; firstName:exact|prefix|absent|mismatch|notQueried; then one dob: token. A firstName:mismatch or lastName:prefix on a candidate is your weak bin.
  4. Queue, order, decide. Confirmed records go to the outcome logic. Candidates and weak matches go to a reviewer, ordered by matchConfidence descending, with matchDetail as the one-line explanation.
import requests

API, KEY = "https://sandbox.offendersearch.app", "os_test_your_key"

def triage(record):
    state = record["matchState"]
    basis = set(record["matchBasis"].split())
    if state == "dob_match":
        return "confirmed"
    if "firstName:mismatch" in basis or "lastName:prefix" in basis or "firstName:absent" in basis:
        return "weak"
    if state in ("year_match", "age_match"):
        return "probable"          # birth year / age agreed; no full date to compare
    return "candidate"             # no_dob_age_year: name only

def search(query):
    r = requests.post(f"{API}/v1/criminal/search", headers={"X-API-Key": KEY}, json={"query": query})
    r.raise_for_status()
    return r.json()["records"]

for q in ({"lastName": "Match", "firstName": "Possible"},
          {"lastName": "Record", "firstName": "Felony", "dob": "1975-06-15"}):
    for rec in sorted(search(q), key=lambda x: -x["matchConfidence"]):
        print(f'{triage(rec):10} {rec["matchConfidence"]:.2f} {rec["matchState"]:16} '
              f'{rec["matchBasis"]:45} {rec["state"]}  {rec["matchDetail"]}')
Possible Match — three candidates, trimmed to the match fields
{
  "status": "complete",
  "counts": { "records": 3, "recordsReturned": 3, "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0, "candidatesCapped": false, "exclusionsIncluded": false },
  "records": [
    { "recordCategory": "jail",  "state": "CO", "externalId": "SBX-BK-2024-000302",
      "dob": { "date": null, "birthYear": null, "birthMonth": null, "age": null, "ageAsOf": null, "dobPrecision": "none" },
      "matchConfidence": 0.80, "matchState": "no_dob_age_year", "unverified": true, "dobVerification": "unverified",
      "matchBasis": "lastName:exact firstName:exact dob:absent",
      "matchDetail": "name agrees; no date/age/year on either side to confirm or deny" },
    { "recordCategory": "court", "state": "KY", "externalId": "SBX-CR-2017-000303",
      "dob": { "date": null, "birthYear": null, "birthMonth": null, "age": null, "ageAsOf": null, "dobPrecision": "none" },
      "matchConfidence": 0.80, "matchState": "no_dob_age_year", "unverified": true, "dobVerification": "unverified",
      "matchBasis": "lastName:exact firstName:exact dob:absent",
      "matchDetail": "name agrees; no date/age/year on either side to confirm or deny" },
    { "recordCategory": "court", "state": "NV", "externalId": "SBX-CR-2019-000301",
      "dob": { "date": null, "birthYear": null, "birthMonth": null, "age": null, "ageAsOf": null, "dobPrecision": "none" },
      "matchConfidence": 0.80, "matchState": "no_dob_age_year", "unverified": true, "dobVerification": "unverified",
      "matchBasis": "lastName:exact firstName:exact dob:absent",
      "matchDetail": "name agrees; no date/age/year on either side to confirm or deny" }
  ]
}

Three people with the same name in three states and nothing on any record to compare with a birth date: the API cannot tell them apart and says so on each one. Sending dob: "1988-04-12" would not drop them — a record with no birth evidence is kept as no_dob_age_year — so the queue is unavoidable here; the records are ordered by confidence, then surname, first name and externalId. Felony Record with its date, by contrast, scores 0.99 with matchState: "dob_match", matchBasis: "lastName:exact firstName:exact dob:match" and dobVerification: "record".

Reading the result

FieldValuesDecision it supports
matchStatedob_match · year_match · age_match · no_dob_age_year (null only on a record fetched by id)The gate. dob_match is the only full-date confirmation. A dob_mismatch is dropped and never returned.
matchBasislastName:exact|prefix · firstName:exact|prefix|absent|mismatch|notQueried · dob:match|year|monthYear|age|ageFromDob|absent|sourceConfirmed|sourceNotConfirmedWhy. Split on spaces; test membership. dob:ageFromDob means your full date was checked against a published age only.
matchDetailone sentenceThe same reading for a human; show it in the queue row.
matchConfidence0.0–0.99Surname exact 0.55 or prefix 0.42; first name exact +0.25, prefix +0.12, absent −0.05, mismatch −0.20; dob_match +0.40, year_match +0.30, age_match +0.18; sourceNotConfirmed −0.15. Order the queue; do not decide with it.
unverifiedbooleantrue iff no_dob_age_year. The one-bit version of "this is a candidate".
dobVerificationrecord · source_confirmed · source_not_confirmed · unverified · nullWhere the check happened. The source_* values appear only on a live search that carried a full dob; source_confirmed means the source returned this record for your date even though it publishes none.
nameStrategy (request)prefix (default) · exactprefix keeps Rivera-Ortiz for Rivera; exact does not. A surname under three characters is matched exactly whatever you send. Surnames are never nickname-folded; first names are (Bob ↔ Robert).

Edge cases and pitfalls

  • Every record is `no_dob_age_year` when you sent no date. That is not a weak population of records; it is the API telling you it had nothing to check. Send dob, birthYear or age and the same records re-score.
  • A missing first name is a flag, not a drop. firstName:absent costs 0.05 and keeps the record; firstName:mismatch costs 0.20 and also keeps it on a live check. On a cached search a record whose first name does not start with yours is never read at all.
  • `prefixMatch` makes a short prefix an error, not a wider net. Naming a field in prefixMatch whose value is under three characters is 422 prefix_too_short. Prefix on the surname is already the default; prefixMatch only declares it deliberate.
  • Two records can share a `personRef` only within one source. The same person at two sources is two records with two personRefs; a clearance for one does not hide the other. Reviewers see both; that is intended.
  • Order is deterministic. (-matchConfidence, last, first, externalId). Ties in confidence are common on name-only searches, so do not read position as rank within a tie.
  • A high confidence on a jail booking is still a booking. Identity confidence and the weight of the record are two different questions; see Arrests vs. charges vs. convictions.

Production checklist

  • Gate automated outcomes on matchState == "dob_match"; everything else is reviewed.
  • Render matchDetail, matchBasis, dob.dobPrecision and state/county on every queue row.
  • Always send firstName and dob when you hold them; collect birthYear or age when you do not.
  • Record reviewer verdicts with POST /v1/clearances so the same candidate is not re-reviewed — see Clearances.
  • Never surface matchConfidence to an end user as a percentage of certainty.

Product overview