# 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.

- **HTML:** https://offendersearch.app/docs/guides/criminal/matching-and-confidence
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## 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](/docs/guides/criminal/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](/docs/criminal/matching).

> **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.

**Python**
```python
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"]}')
```

**JavaScript**
```javascript
const API = 'https://sandbox.offendersearch.app';
const KEY = 'os_test_your_key';

function triage(rec) {
  const basis = new Set(rec.matchBasis.split(' '));
  if (rec.matchState === 'dob_match') return 'confirmed';
  if (basis.has('firstName:mismatch') || basis.has('lastName:prefix') || basis.has('firstName:absent')) return 'weak';
  if (rec.matchState === 'year_match' || rec.matchState === 'age_match') return 'probable';
  return 'candidate'; // no_dob_age_year: the name matched, nothing could confirm or deny it
}

async function search(query) {
  const r = await fetch(API + '/v1/criminal/search', {
    method: 'POST',
    headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query }),
  });
  if (!r.ok) throw new Error('HTTP ' + r.status);
  return (await r.json()).records;
}

for (const q of [{ lastName: 'Match', firstName: 'Possible' }, { lastName: 'Record', firstName: 'Felony', dob: '1975-06-15' }]) {
  const recs = (await search(q)).sort((a, b) => b.matchConfidence - a.matchConfidence);
  for (const rec of recs) console.log(triage(rec), rec.matchConfidence, rec.matchState, rec.matchBasis, rec.state, '—', rec.matchDetail);
}
```

*Possible Match — three candidates, trimmed to the match fields*
```json
{
  "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`, `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 `personRef`s; 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](/docs/guides/criminal/arrests-charges-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](/docs/criminal/clearances).
- Never surface `matchConfidence` to an end user as a percentage of certainty.

## Related

- [Matching & confidence](https://offendersearch.app/docs/criminal/matching): the scoring table and every token
- [Date of birth](https://offendersearch.app/docs/criminal/date-of-birth)
- [Handling false positives](https://offendersearch.app/docs/guides/criminal/false-positives)
- [Clearances](https://offendersearch.app/docs/criminal/clearances)
