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.
| 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 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.
{
"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.
{
"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.
# 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.
{
"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:
| 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.
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 onlyfunction 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
}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) withline1,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
lastCheckedAtandsourceUpdatedAtbeside 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:
# 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.sourcesSkippedByScopecounts 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_matchwithunverified: 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".
# 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 themFrom 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
- Check status before you read records. If
statusispartial(orcounts.sourcesIncompleteis above zero), the answer is incomplete. Do not record a clear. Re-run the request later or submit it toPOST /v1/searchesand poll. - Treat a complete zero-record answer as clear. With
statuscomplete,counts.records0 and everysourceStatus[]rowcomplete, 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. - Sort the records by matchConfidence. Order
records[]bymatchConfidencedescending and readmatchBasisandmatchDetail.strategiesto see which name strategy produced each hit. - Route by matchState.
dob_matchcan act automatically;year_matchandage_matchgo to a reviewer;no_dob_age_yearandnullare leads;dob_near_matchis a typo candidate and always unverified. - Give the reviewer the evidence. Show
images[],addresses[],offenses[]and eachsources[].recordUrlside by side with what you know about the subject. - Narrow when the list is long. Send
jurisdictionsfor the states the person has lived in,match: "strict"to turn off looser name strategies, ordobTolerance1–2 when a typo is suspected. - Record the verdict as a clearance. POST the reviewer's
not_this_personverdict to/v1/clearanceswith thepersonRef, then sendsubjectIdon 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
- The full request body and every response key: the search reference and the record object.
- Deciding whether a criminal record is a conviction: arrests, charges and convictions.
- Retrying a
429or504, and why a repeated synchronous search is billed twice: rate limits, retries and idempotency. - Using a match for a hiring, housing or credit decision: that is an FCRA order, covered by the HR and ATS tutorial.