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 hosthttps://sandbox.offendersearch.app. - Two sandbox people:
Possible Match(three records in three states, none with a birth date — the review-queue case) andFelony Record, born1975-06-15(the confirmed case). - The scoring table and the full token list on Matching & confidence.
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
- Search with everything you know. Send
firstNameanddobwhen you have them. Without a date every record readsno_dob_age_yearandunverified: true, because there was nothing to check against. - 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. - Read matchBasis for the name. Tokens are
field:how.lastName:exactorlastName:prefix;firstName:exact|prefix|absent|mismatch|notQueried; then onedob:token. AfirstName:mismatchorlastName:prefixon a candidate is your weak bin. - Queue, order, decide. Confirmed records go to the outcome logic. Candidates and weak matches go to a reviewer, ordered by
matchConfidencedescending, withmatchDetailas 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"]}'){
"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
| Field | Values | Decision it supports |
|---|---|---|
matchState | dob_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. |
matchBasis | lastName:exact|prefix · firstName:exact|prefix|absent|mismatch|notQueried · dob:match|year|monthYear|age|ageFromDob|absent|sourceConfirmed|sourceNotConfirmed | Why. Split on spaces; test membership. dob:ageFromDob means your full date was checked against a published age only. |
matchDetail | one sentence | The same reading for a human; show it in the queue row. |
matchConfidence | 0.0–0.99 | Surname 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. |
unverified | boolean | true iff no_dob_age_year. The one-bit version of "this is a candidate". |
dobVerification | record · source_confirmed · source_not_confirmed · unverified · null | Where 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) · exact | prefix 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,birthYearorageand the same records re-score. - A missing first name is a flag, not a drop.
firstName:absentcosts0.05and keeps the record;firstName:mismatchcosts0.20and 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
prefixMatchwhose value is under three characters is422 prefix_too_short. Prefix on the surname is already the default;prefixMatchonly 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.dobPrecisionandstate/countyon every queue row. - Always send
firstNameanddobwhen you hold them; collectbirthYearoragewhen you do not. - Record reviewer verdicts with
POST /v1/clearancesso the same candidate is not re-reviewed — see Clearances. - Never surface
matchConfidenceto an end user as a percentage of certainty.