Developer docs · v1.0.0

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.

Updated November 3, 2026

Three shapes of a no-match background check result

When a 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.

ShapeWhat the response saysWhat it means
Complete, zero recordsstatus complete, counts.records 0, every sourceStatus[] row completeNothing in the 58 US registries matched. Record a clear.
ContradictionThe same as above, with a date of birth in the queryA 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.
Incompletestatus partial, counts.sourcesIncomplete above 0, rows with incomplete: true and an incompleteReasonSome sources did not answer. Not a clear. Re-run.

The 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 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)
{
  "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.

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 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)
{
  "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 and result completeness describe onDeadline, which chooses between a partial 200 and a 504 when deadlineMs is hit.

Re-run as an async search
# 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 -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"}}'

The keys that matter here are matchConfidence, matchBasis, matchDetail, matchState and unverified; the record object reference has the rest.

One of the three records (abridged; registry values elided)
{
  "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. 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 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:

matchStateMeaningAction
dob_matchFull date of birth agrees. The only automatic identification.Act, per your policy.
year_matchThe registry publishes a year only, and it agrees.Review: a person compares photo and addresses.
age_matchAge agrees within ageTolerance (default 1).Review, as above.
no_dob_age_yearNothing published to verify against; name only.Lead only.
nullYou sent no date of birth or age.Lead only; ask for a date of birth.
dob_near_matchOnly 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
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
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; see the HR and ATS tutorial.

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 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
# 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.
  • `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 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
# 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 covers product: "criminal". A person monitor 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 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. 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