Offendersearch
API Reference · v1.0.0

Result completeness & per-source status

An incomplete search is labelled, never silently empty. counts, sourceStatus and incompleteReason.

Base URL https://api.offendersearch.appThis page as Markdown/docs/result-completeness.md

Per-source status on every response

Every jurisdiction a search touches is reported individually in sourceStatus[], on every response, whether or not it contributed a record. That is what makes a result auditable: a search is never a single number you have to trust, and an incomplete search is always labelled as one.

statusMeaning
okQueried successfully; matched holds the count from this source.
pendingStill running (async searches) — results not yet in.
errorThis source did not complete for this request; see note.
restrictedThis source is not commercially available for this request; see note.
no_coverageThe source does not cover the queried location.
A complete search
200 OK · status: complete
{
  "searchId": "srch_9f2a7c",
  "status": "complete",
  "counts": {
    "records": 1,
    "sourcesQueried": 2,
    "sourcesComplete": 2,
    "sourcesIncomplete": 0,
    "sourcesSkippedByScope": 0
  },
  "warnings": [],
  "sourceStatus": [
    { "source": "NJ", "status": "ok", "matched": 1,
      "lastCheckedAt": "2026-08-13T04:12:00Z",
      "incomplete": false, "incompleteReason": null },
    { "source": "NSOPW", "status": "ok", "matched": 1,
      "lastCheckedAt": "2026-08-13T04:20:00Z",
      "incomplete": false, "incompleteReason": null }
  ],
  "records": [ /* … */ ]
}
A labelled partial search
200 OK · status: partial
{
  "searchId": "srch_31c9de",
  "status": "partial",
  "counts": {
    "records": 4,
    "sourcesQueried": 58,
    "sourcesComplete": 56,
    "sourcesIncomplete": 2,
    "sourcesSkippedByScope": 0
  },
  "warnings": [
    "2 of 58 sources did not complete: TX, WA."
  ],
  "sourceStatus": [
    { "source": "TX", "status": "ok", "matched": 0,
      "incomplete": true, "incompleteReason": "truncated" },
    { "source": "WA", "status": "error", "matched": 0,
      "incomplete": true, "incompleteReason": "unavailable",
      "note": "source did not complete for this request" },
    { "source": "NJ", "status": "ok", "matched": 4,
      "incomplete": false, "incompleteReason": null }
  ],
  "records": [ /* … */ ]
}
Read sourceStatus[].incomplete, not status. sourceStatus[].status describes whether the source responded; incomplete describes whether the search of that source finished. They are different questions and a source can be "ok" and incomplete: true at the same time.

Reading an empty result

An empty records array is not, on its own, evidence that a person is not registered. Two very different events produce the same empty list, and the response tells you which one you are holding.

What happenedWhat the response says
Every jurisdiction completed and nobody matched. This is an answer.status: "complete", counts.sourcesIncomplete: 0, counts.sourcesComplete === counts.sourcesQueried
One or more jurisdictions did not complete. This is a lower bound, not a closed answer.status: "partial", counts.sourcesIncomplete > 0, and a warnings[] sentence naming the jurisdictions
Never infer absence without checking counts.sourcesIncomplete === 0 first. A pipeline that records “not found” on an incomplete search will record people who are registered as absent. Record it as not determined and re-query more narrowly.
The three-way branch to write
const { status, counts, records } = await search(query);

if (counts.sourcesIncomplete > 0) {
  // Not an answer about anyone: a lower bound.
  return { outcome: "not_determined", counts };
}
if (records.length === 0) {
  // Every queried source completed and nobody matched.
  return { outcome: "no_match", counts };
}
return { outcome: "matches", records, counts };

How much of the search finished — counts

status is one word, and one word cannot tell you how partial a partial result is. A search that reached 57 of 58 jurisdictions and one that reached 7 of 58 both say "partial", and they are very different answers: the first you can act on, the second you should retry. The counts carry the positive number as well as the negative one.

KeyTypeMeaning
recordsintegerHow many records matched in TOTAL. This is the whole result set, not the length of one page — records.length equals it unless you asked for a page slice.
sourcesQueriedintegerHow many jurisdictions this request fanned out to. 58 on an unscoped search; fewer when you set jurisdictions or locationScoped.
sourcesCompleteintegerHow many of those jurisdictions completed. THIS IS THE NUMBER TO ACT ON. status is a single word — "partial" reads identically whether 1 of 58 sources fell short or 51 did, and those are very different answers. sourcesComplete makes them distinguishable: 57 of 58 is a result you can act on; 7 of 58 is one to retry. status: "complete" means exactly sourcesComplete === sourcesQueried.
sourcesIncompleteintegerThe complement: how many jurisdictions did not complete. Anything above 0 means the records you received are a LOWER BOUND rather than a closed answer. Each one is named in sourceStatus[] with an incompleteReason.
sourcesSkippedByScopeintegerHow many jurisdictions were not queried because YOUR request narrowed the fan-out — locationScoped: true, or an explicit jurisdictions list. It is separate from sourcesIncomplete on purpose: a jurisdiction you chose not to query is a scope decision, not an incomplete one. 0 on a nationwide search.
status: "complete" means exactly counts.sourcesComplete === counts.sourcesQueried. If you log one number per search, log that ratio rather than the word.

incompleteReason — every value, and what to do about each

A closed enum of six values, and it is total over the condition it describes: incomplete: true always carries exactly one of these, and incomplete: false always carries null. There is no third state, so an exhaustive switch on this field is safe to write. Adding a seventh value would be a breaking change and is treated as one.

ValueWhat to doWhat it means
deadlineRetry the same requestThis source did not complete within the time bound set for this request, so it contributed no records. Transient — the identical request will usually complete.
truncatedNarrow the query — a retry returns the same answerThe query matches more candidate rows inside this one jurisdiction than a single search examines, and the examined set is not ranked, so a matching person can fall outside it. Deterministic: the identical request returns the identical answer. Add a first name, a date of birth or an age, or a city, and search again.
excludedRemove the parameter from your requestA parameter on your request (onStale: "omit") excluded this source from the search. Remove it and this source is queried again.
not_searchedTreat as not determined; retry laterThis source did not contribute to this request, so a zero from it is not an answer about anyone.
unavailableRetry with backoffThis source did not respond to this request and contributed no records.
errorRetry with backoffThis source returned no usable answer for this request and contributed no records.
Every value answers the same single question: did this jurisdiction complete for this request? That is the fact that decides whether a 0 from a jurisdiction means nobody matched or not determined. Per-jurisdiction currency is a separate question with its own endpoint: GET /v1/sources reports every jurisdiction live.

The defined per-source limits

Two per-jurisdiction limits are defined in the contract so that one over-broad query cannot degrade the service for everyone. Both are always reported, never silent.

LimitValueWhat reaches it
truncated15,000 candidate rows per jurisdictionYour query matches more rows inside that one jurisdiction than a single search examines. The examined set is not ranked, so a matching person can fall outside it. Reported, never silent.
truncated2,000 typo candidates per jurisdictionmatch: "broad" only. Broad evaluates a second, speculative set of candidates for spelling variants of your surname; that arm carries its own, much smaller budget so it can never spend the candidate budget above.
deadline15 seconds per jurisdictionThat jurisdiction did not complete within the bound and contributed zero records.
A dob or age is never what gets displaced. When your query constrains the birth year, the records whose birth year matches are examined first, to the full 15,000; only the leftover budget goes to records where the jurisdiction publishes no birth date. Those records are still returned when the name matches, flagged dobVerification: "unverified_no_dob_or_age", because a jurisdiction that publishes no birth date cannot rule a person out.
A cap is not a page. perPage slices an answer you can walk in full — counts.records is the true total, it does not change as you page, and page = 1 … totalPages returns every record exactly once. A candidate limit is the opposite: there is no “next 15,000” to ask for. That is why it is reported and paging is not. See Pagination.

Which query shapes reach a limit

The limit counts every row in a jurisdiction your query could touch, not the rows that finally matched. In order of safety:

Query shapeBehaviour
lastName + stateSafest. Use this shape for any automated reconciliation job.
lastName aloneFine for most surnames. Reaches the limit on short or very common surname tokens — Lee, Ford, Hill, Wood, Ward, Ray, West, James, Allen, Thomas — because alias and address matching widen the reach of a surname.
firstName with no lastNameDeclined with 422. A first name on its own has nothing to narrow on, so it cannot be answered completely. Pair it with a surname, or with a date of birth or age.
city, zipCode, dob or age with no nameReaches the limit in large jurisdictions — a single birth year still matches tens of thousands of people, so a date is not a substitute for a name. With a name, the date is never what is displaced.
Adding a state does not narrow a nameless query. A state scopes the answer, and with locationScoped: true it reduces the jurisdictions queried — but it does not shrink an individual jurisdiction’s candidate set, because state is applied when records are matched, not when they are selected. The reliable narrowing lever is a surname.

On the compatibility endpoint. /v1/compat/sexoffender mirrors the legacy envelope, which has room for exactly one completeness signal: the integer error: 503. It carries no reason, no per-source detail and no count. {"offenders": [], "error": 503} means nothing was established — not that nobody matched. If you need to tell those two apart, call POST /v1/search instead.