# Handle no-match and multiple-match background check results

> A zero-record answer is only "clear" when it is complete; a name-only hit is a lead, not an identification. This tutorial shows how to tell the shapes apart in the response and what to do with each.

- **HTML:** https://offendersearch.app/docs/learn/no-match-and-multiple-match-results
- **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

## Three shapes of a no-match background check result

When a [background check API](/background-check-api) search returns zero records, the body looks the same at a glance: `records: []`. It is not the same thing three times. The fields around that empty array say whether everything was searched and nothing found, whether a same-name person was dropped because their identifiers contradicted yours, or whether the search could not finish. Only the first is clear.

| Shape | What the response says | What it means |
| --- | --- | --- |
| Complete, zero records | `status` `complete`, `counts.records` 0, every `sourceStatus[]` row `complete` | Nothing in the 58 US registries matched. Record a clear. |
| Contradiction | The same as above, with a date of birth in the query | A same-name person existed but their date of birth disagreed with yours, so the record was dropped. Also a clear, for that date of birth. |
| Incomplete | `status` `partial`, `counts.sourcesIncomplete` above 0, rows with `incomplete: true` and an `incompleteReason` | Some sources did not answer. Not a clear. Re-run. |

The [criminal records API](/criminal-records-api) uses the same vocabulary on `POST /v1/criminal/search`, and the sandbox name "Clear Record" is the criminal twin of "Clear Visitor"; the examples here use the registry search because its captured responses are shorter.

## A clear no-match result: what to check before you record it

The [sandbox](/sandbox) answers "Clear Visitor" with the canonical clear result. Three things are true at once: `status` is `complete`, `counts.records` is `0`, and `counts.sourcesIncomplete` is `0` (every one of the 58 `sourceStatus[]` rows is `complete`). Check all three, not just the empty array.

*POST /v1/search, "Clear Visitor" (abridged from the captured sandbox answer)*
```json
{
  "searchId": "srch_sbx_so_clear",
  "status": "complete",
  "counts": {
    "records": 0,
    "sourcesQueried": 58,
    "sourcesIncomplete": 0,
    "sourcesComplete": 58,
    "sourcesSkippedByScope": 0,
    "recordsReturned": 0
  },
  "warnings": [],
  "sourceStatus": [
    { "source": "AK", "status": "complete", "matched": 0, "fromCache": true,
      "note": null, "incomplete": false, "incompleteReason": null },
    /* … 57 more rows, all "complete" */
  ],
  "records": []
}
```

Any name that is not a sandbox magic name answers the same way, so "Jordan Rivera, 1988-04-12" is a convenient fixture for the clear path. A clear with a non-empty `warnings[]` is still a clear; log the warning. The full key list is on the [search reference](/docs/search).

## Two results that look like a no-match but are not the same thing

### A contradiction: right name, wrong date of birth

The sandbox person "Exact Match" was born 1980-01-01. Search that name with `dob` 1980-01-01 and you get one record with `matchState` `dob_match`. Search it with `dob` 1990-05-05 and you get **zero records**: the record exists, but its date of birth contradicts yours, and a contradicted record is dropped rather than returned with a low score. Surfacing a person whose date of birth disagrees with yours would only produce false positives.

So a clear with a date of birth is a stronger statement than one without, and a typo in the date you sent turns a real hit into a silent miss. `dobTolerance`, below, is the remedy; the rules are on the [date-of-birth matching](/docs/date-of-birth) page.

### An incomplete answer: partial status

When one or more sources did not finish inside the deadline, `status` is `partial`, `counts.sourcesIncomplete` is above zero, and the affected `sourceStatus[]` rows carry `incomplete: true` with an `incompleteReason`. The records you did receive are real, but an empty `records[]` on a partial answer proves nothing about the sources that did not report.

*The shape of a partial answer (values elided)*
```json
{
  "status": "partial",
  "counts": { "records": 0, "sourcesQueried": 58, "sourcesIncomplete": 2, "sourcesComplete": 56, "recordsReturned": 0 },
  "warnings": [ "…" ],
  "sourceStatus": [
    { "source": "…", "status": "incomplete", "matched": 0, "fromCache": false,
      "note": "…", "incomplete": true, "incompleteReason": "…" },
    /* … 57 more rows */
  ],
  "records": []
}
```

Do not store a partial as a clear. Re-run the same request a little later (after the `Retry-After` seconds if a `429` accompanied it), or hand it to `POST /v1/searches`, which answers `202` with a `searchId` and a `resultsUrl` to poll until `complete`. The [incomplete results guide](/docs/guides/sex-offender/incomplete-results) and [result completeness](/docs/result-completeness) describe `onDeadline`, which chooses between a partial `200` and a `504` when `deadlineMs` is hit.

*Re-run as an async search*
```bash
# Async: submit once, keep the searchId, poll — never resubmit a synchronous search in a loop
curl -X POST https://sandbox.offendersearch.app/v1/searches \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Jordan", "lastName": "Rivera", "dob": "1988-04-12"}}'
# 202 { "searchId": "srch_…", "status": "pending", "jurisdictionsQueried": 58,
#       "estimatedCompletionSec": …, "resultsUrl": "…" }

curl https://sandbox.offendersearch.app/v1/searches/srch_… -H "X-API-Key: $OFFENDERSEARCH_API_KEY"
```

## Multiple-match results: when the name is all you have

The opposite problem is several records for one query. The sandbox name "Name Only" returns three possible matches in three states, none with a published date of birth, which is what a common name produces when you send no date of birth. The criminal twin is "Possible Match": three records, three states, no date of birth on any.

**curl**
```bash
curl -X POST https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Name", "lastName": "Only"}}'
```

**Python**
```python
import os, requests

r = requests.post(
    "https://sandbox.offendersearch.app/v1/search",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_API_KEY"]},   # os_test_…
    json={"query": {"firstName": "Name", "lastName": "Only"}},
    timeout=130,
)
r.raise_for_status()
body = r.json()
print(body["status"], body["counts"]["records"])           # complete 3
for rec in sorted(body["records"], key=lambda x: -x["matchConfidence"]):
    print(rec["matchConfidence"], rec["matchState"], rec["registrationState"], rec["matchBasis"])
```

**Node**
```javascript
// Node 18+ (global fetch)
const res = await fetch("https://sandbox.offendersearch.app/v1/search", {
  method: "POST",
  headers: { "X-API-Key": process.env.OFFENDERSEARCH_API_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({ query: { firstName: "Name", lastName: "Only" } }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
const body = await res.json();
console.log(body.status, body.counts.records);              // complete 3
for (const rec of [...body.records].sort((a, b) => b.matchConfidence - a.matchConfidence)) {
  console.log(rec.matchConfidence, rec.matchState, rec.registrationState, rec.matchBasis);
}
```

The keys that matter here are `matchConfidence`, `matchBasis`, `matchDetail`, `matchState` and `unverified`; the [record object](/docs/record-object) reference has the rest.

*One of the three records (abridged; registry values elided)*
```json
{
  "recordId": "rec_…",
  "personRef": "prs_…",
  "recordType": "sex_offender",
  "matchConfidence": 0.6,
  "matchBasis": ["lastName", "firstName", "lastName:exact", "firstName:exact", "name_match"],
  "matchDetail": {
    "strategies": { "lastName": "exact", "firstName": "exact" },
    "fieldsPresent": ["lastName", "firstName"],
    "signals": ["matchedLegalName"],
    "matchedNameType": "legal",
    "verification": null
  },
  "name": { "first": "Name", "middle": "", "last": "Only", "suffix": "", "full": "Name Only" },
  "dob": null,
  "registrationState": "…",
  "images": [ { "url": "…" } ],
  "addresses": [ { "type": "residence", "city": "…", "state": "…" /* … */ } ],
  "sources": [ { "jurisdiction": "…", "recordUrl": "…" /* … */ } ],
  "matchState": null,
  "unverified": true
}
```

Note `matchState`. The query sent no date of birth or age, so nothing was verified and the value is `null`. Send a date of birth for a person whose registry publishes none and it becomes `no_dob_age_year`: you offered a date, the registry had nothing to compare. Both mean "a lead, not an identification", and both carry `unverified: true`.

## Ranking multiple matches by matchConfidence and matchBasis

`matchConfidence` (0–1) is the sort key. It is not a probability that the record is your subject; it scores how much of your query the record agreed with. The captured "Exact Match" answer scores `0.9` with a verified date of birth; a name-only hit scores lower. Sort descending.

`matchBasis` says what contributed: an array such as `["lastName", "firstName", "dob", "lastName:exact", "firstName:exact", "name_match"]` in the registry search, a string such as `"lastName:exact firstName:exact dob:match"` in the [criminal search](/docs/criminal/matching). `matchDetail.strategies` names the strategy per field: `exact` is what you want to see; the match modes also allow `nickname`, `initial`, `prefix`, `fuzzy`, `middle` and `alias`, and a first name matched by `initial` or `fuzzy` deserves a harder look. `matchDetail.matchedNameType` says whether the hit was the legal name or an alias; the sandbox name "Alias Match" exercises that branch (`matchedName.type` `alias`, `alias:exact` in `matchBasis`).

The [matching and confidence guide](/docs/guides/sex-offender/matching-and-confidence) explains how `match` (`strict`, `balanced`, `broad`) changes which strategies run; `balanced` is the default, `strict` is for a list that is too long.

## The decision table for background check match states

`matchState` turns a list into an action. It compares the date of birth or age you sent with what the registry publishes, and has exactly these values:

| matchState | Meaning | Action |
| --- | --- | --- |
| `dob_match` | Full date of birth agrees. The only automatic identification. | Act, per your policy. |
| `year_match` | The registry publishes a year only, and it agrees. | Review: a person compares photo and addresses. |
| `age_match` | Age agrees within `ageTolerance` (default 1). | Review, as above. |
| `no_dob_age_year` | Nothing published to verify against; name only. | Lead only. |
| `null` | You sent no date of birth or age. | Lead only; ask for a date of birth. |
| `dob_near_match` | Only with `dobTolerance`: the dates differ within it. | Typo candidate, always `unverified: true`. Confirm the date, search again. |

The table as a function. It handles the partial case too, because a router that forgets `status` is how partials become clears.

*decide.py*
```python
def decide(response: dict) -> tuple[str, list]:
    """Return (outcome, records worth showing a reviewer) for one POST /v1/search answer."""
    if response["status"] == "partial":
        return "rerun", []                      # not a clear answer: some sources did not finish
    records = sorted(response["records"], key=lambda r: -r["matchConfidence"])
    if not records:
        return "clear", []                      # complete answer, zero records
    verified = [r for r in records if r["matchState"] == "dob_match"]
    if verified:
        return "act", verified                  # the only automatic identification
    review = [r for r in records if r["matchState"] in ("year_match", "age_match")]
    if review:
        return "review", review                 # a person compares photo, addresses, offenses
    typo = [r for r in records if r["matchState"] == "dob_near_match"]
    if typo:
        return "typo_candidate", typo           # only with dobTolerance; always unverified
    return "lead", records                      # no_dob_age_year or null: name only
```

*decide.js*
```javascript
function decide(response) {
  if (response.status === "partial") return { outcome: "rerun", records: [] };
  const records = [...response.records].sort((a, b) => b.matchConfidence - a.matchConfidence);
  if (records.length === 0) return { outcome: "clear", records: [] };
  const by = (...states) => records.filter((r) => states.includes(r.matchState));
  const verified = by("dob_match");
  if (verified.length) return { outcome: "act", records: verified };
  const review = by("year_match", "age_match");
  if (review.length) return { outcome: "review", records: review };
  const typo = by("dob_near_match");
  if (typo.length) return { outcome: "typo_candidate", records: typo };
  return { outcome: "lead", records };            // no_dob_age_year or null: name only
}
```

> **Not a consumer report.** A `dob_match` on the standard API identifies a registry record; it is not a permission to make a hiring, housing, credit or insurance decision. Those run as FCRA orders through [FCRA Partner Access](/fcra); see [the HR and ATS tutorial](/docs/learn/background-check-api-for-hr-ats).

## Showing a reviewer what to compare on a possible match

A review needs a human, and the record carries what the human needs. Put these four things beside what you know about the subject:

- **`images[]`** — each entry is a `{ url }`. A photo settles more reviews than any other field.
- **`addresses[]`** — typed entries (`residence`, `employment`) with `line1`, `city`, `county`, `state`, `zipcode`; the captured "Exact Match" record has two, both in OH.
- **`offenses[]`** — `crime`, `statute`, `tier`, `convictionDate`, `offenseDate`, `registrationDate`, `caseNumber`, `convictionCounty`.
- **`sources[].recordUrl`** — the registry page itself, one per registry listing the person, with `lastCheckedAt` and `sourceUpdatedAt` beside it.

One record can come from more than one registry. The sandbox name "Multi State" returns a single person merged from FL and GA: `sources[]` lists both, `registrationState` names one, `addressStates[]` lists where the addresses are. Show every entry in `sources[]`, not just `source` (the first of them). "Out State" with `state` TX shows the other common case: `registrationState` `OK`, `addressStates` `["TX"]`. The [sex offender API](/sex-offender-api) page describes the merged-person model.

## Narrowing a multiple-match result, and remembering the verdict

Three request options shrink a long list without changing what a hit means:

*jurisdictions, match: strict, dobTolerance*
```bash
# 1. Restrict the registries searched to where the person has lived
curl -X POST https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Name", "lastName": "Only"}, "jurisdictions": ["IL", "IN"]}'

# 2. Turn off the looser name strategies
curl -X POST https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Name", "lastName": "Only"}, "match": "strict"}'

# 3. Allow a one-digit date-of-birth typo (the hit comes back as dob_near_match, unverified)
curl -X POST https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-02", "dobTolerance": 1}}'
```

- **`jurisdictions`** restricts the registries searched to the states the person has lived in; `counts.sourcesSkippedByScope` counts the rest. Codes: [jurisdictions](/docs/jurisdictions).
- **`match: "strict"`** turns off the looser name strategies, so only exact first- and last-name agreement survives.
- **`dobTolerance`** (0–2, default 0) lets a date of birth a day or two off still match, as `dob_near_match` with `unverified: true`: a typo candidate, never an identification. Confirm the date with the subject and search again at 0.

### personRef and clearances

When a reviewer decides a returned person is not the subject, you should not decide it again next month. Every record carries a `personRef` (`prs_` plus 20 hex characters), the stable key for the person across searches and registries; a `recordId` can change when a registry re-issues a record, the `personRef` does not. [Clearances](/docs/clearances) attach the verdict to that key: `POST /v1/clearances` with your `subjectId`, the `personRef`, the `searchId` that showed the person (within your 14-day history) and `verdict: "not_this_person"`.

*Record a verdict, then search with subjectId*
```bash
# The reviewer decided the returned person is not the subject. Record it once…
curl -X POST https://sandbox.offendersearch.app/v1/clearances \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -d '{"subjectId": "applicant-48213", "personRef": "prs_28db9639d712c4c61e8e",
       "recordId": "rec_28db9639d712c4c61e8e", "searchId": "srch_sbx_so_exact",
       "verdict": "not_this_person", "reviewer": "desk-3"}'

# …and every later search that names the same subject suppresses that person.
curl -X POST https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "subjectId": "applicant-48213", "clearanceMode": "suppress"}'
# → counts.clearedSuppressed 1, cleared[] lists the hidden person, records[] without them
```

From then on, any search that sends the same `subjectId` honours the clearance. `clearanceMode` decides what happens to the cleared person: `suppress` (the default with `subjectId`) removes them from `records[]` and lists them in `cleared[]` with `counts.clearedSuppressed`; `annotate` keeps them with a `clearance` object so an interface can grey them out; `ignore` answers as if no clearance existed. A `this_person` verdict keeps and annotates the confirmed match. Platforms screening for several facilities send `clientId` too, so one facility's verdicts never reach another's; [criminal clearances](/docs/criminal/clearances) covers `product: "criminal"`. A [person monitor](/docs/monitoring/person-monitoring) keys on the same `personRef`.

## Handle no-match and multiple-match results step by step

1. **Check status before you read records.** If `status` is `partial` (or `counts.sourcesIncomplete` is above zero), the answer is incomplete. Do not record a clear. Re-run the request later or submit it to `POST /v1/searches` and poll.
2. **Treat a complete zero-record answer as clear.** With `status` `complete`, `counts.records` 0 and every `sourceStatus[]` row `complete`, nothing in the 58 US registries matched the name and date of birth you sent. Remember that a record whose date of birth contradicts yours was dropped on purpose.
3. **Sort the records by matchConfidence.** Order `records[]` by `matchConfidence` descending and read `matchBasis` and `matchDetail.strategies` to see which name strategy produced each hit.
4. **Route by matchState.** `dob_match` can act automatically; `year_match` and `age_match` go to a reviewer; `no_dob_age_year` and `null` are leads; `dob_near_match` is a typo candidate and always unverified.
5. **Give the reviewer the evidence.** Show `images[]`, `addresses[]`, `offenses[]` and each `sources[].recordUrl` side by side with what you know about the subject.
6. **Narrow when the list is long.** Send `jurisdictions` for the states the person has lived in, `match: "strict"` to turn off looser name strategies, or `dobTolerance` 1–2 when a typo is suspected.
7. **Record the verdict as a clearance.** POST the reviewer's `not_this_person` verdict to `/v1/clearances` with the `personRef`, then send `subjectId` on later searches so the same person is suppressed.

Every step runs unchanged against the [public API for testing](/sandbox) with a free `os_test_` key, 25 free calls, no card; production is `https://api.offendersearch.app` with an `os_live_` key at $0.15 a call, $0.11 past 2,000 calls in a month. Python and Node.js SDKs ship with the first registry release; see [SDKs](/sdk). Until then the raw HTTP calls above are the integration.

## No-match and multiple-match questions developers ask

### What does a background check API return when there is no match?

A `200` with `status: "complete"`, `counts.records: 0`, an empty `records[]` and every `sourceStatus[]` row `complete`. Not an error, not a `404`. Check status and source counts, not just the empty array.

### Why did a known person return zero records when I sent a date of birth?

Because the date of birth you sent contradicts the one the registry publishes, and a contradicted record is dropped. Try the sandbox: "Exact Match" with 1980-01-01 returns the record; with 1990-05-05 it returns nothing. Check the date you sent, or allow a typo with `dobTolerance`.

### Is status "partial" a no-match?

No. A partial answer means some sources did not finish; an empty `records[]` on a partial proves nothing about them. Re-run the request, or use `POST /v1/searches` and poll until `complete`.

### How do I pick the right record out of several possible matches?

Sort by `matchConfidence`, route by `matchState`: only `dob_match` is automatic. Everything else needs a reviewer with `images[]`, `addresses[]`, `offenses[]` and `sources[].recordUrl`, or a narrower search with `jurisdictions`, `match: "strict"` or a date of birth.

### Can a name-only match be trusted?

It is a lead. With `matchState` `null` or `no_dob_age_year` and `unverified: true`, the only thing you know is that a name agreed. Ask for a date of birth and search again, or send the record to a reviewer with the evidence fields.

### How do I stop the same false positive coming back every time?

Record a `not_this_person` clearance against the person's `personRef` with `POST /v1/clearances`, and send `subjectId` on later searches for that subject. The person is suppressed (or annotated, with `clearanceMode: "annotate"`) until you revoke the clearance.

## What this page does not cover

- The full request body and every response key: the [search reference](/docs/search) and the [record object](/docs/record-object).
- Deciding whether a criminal record is a conviction: [arrests, charges and convictions](/docs/guides/criminal/arrests-charges-convictions).
- Retrying a `429` or `504`, and why a repeated synchronous search is billed twice: [rate limits, retries and idempotency](/docs/learn/rate-limits-retries-idempotency).
- Using a match for a hiring, housing or credit decision: that is an FCRA order, covered by the [HR and ATS tutorial](/docs/learn/background-check-api-for-hr-ats).

## Related

- [Background check API response explained](https://offendersearch.app/docs/learn/background-check-api-response-explained)
- [Check a registered sex offender by API](https://offendersearch.app/docs/learn/check-registered-sex-offender-api)
- [Search criminal records by name and date of birth](https://offendersearch.app/docs/learn/search-criminal-records-by-name-and-dob)
- [Test a background check integration](https://offendersearch.app/docs/learn/test-a-background-check-integration)
- [Rate limits, retries and idempotency](https://offendersearch.app/docs/learn/rate-limits-retries-idempotency)
- [Matching and confidence (reference)](https://offendersearch.app/docs/matching)
- [Date of birth matching](https://offendersearch.app/docs/date-of-birth)
- [Result completeness](https://offendersearch.app/docs/result-completeness)
- [Clearances](https://offendersearch.app/docs/clearances)
- [Public API for testing (the sandbox)](https://offendersearch.app/sandbox)
- [Background check API](https://offendersearch.app/background-check-api)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
