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.mdPer-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.
| status | Meaning |
|---|---|
| ok | Queried successfully; matched holds the count from this source. |
| pending | Still running (async searches) — results not yet in. |
| error | This source did not complete for this request; see note. |
| restricted | This source is not commercially available for this request; see note. |
| no_coverage | The source does not cover the queried location. |
{
"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": [ /* … */ ]
}{
"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": [ /* … */ ]
}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 happened | What 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 |
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.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.
| Key | Type | Meaning |
|---|---|---|
| records | integer | How 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. |
| sourcesQueried | integer | How many jurisdictions this request fanned out to. 58 on an unscoped search; fewer when you set jurisdictions or locationScoped. |
| sourcesComplete | integer | How 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. |
| sourcesIncomplete | integer | The 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. |
| sourcesSkippedByScope | integer | How 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.
| Value | What to do | What it means |
|---|---|---|
| deadline | Retry the same request | This source did not complete within the time bound set for this request, so it contributed no records. Transient — the identical request will usually complete. |
| truncated | Narrow the query — a retry returns the same answer | The 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. |
| excluded | Remove the parameter from your request | A parameter on your request (onStale: "omit") excluded this source from the search. Remove it and this source is queried again. |
| not_searched | Treat as not determined; retry later | This source did not contribute to this request, so a zero from it is not an answer about anyone. |
| unavailable | Retry with backoff | This source did not respond to this request and contributed no records. |
| error | Retry with backoff | This source returned no usable answer for this request and contributed no records. |
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.
| Limit | Value | What reaches it |
|---|---|---|
| truncated | 15,000 candidate rows per jurisdiction | Your 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. |
| truncated | 2,000 typo candidates per jurisdiction | match: "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. |
| deadline | 15 seconds per jurisdiction | That jurisdiction did not complete within the bound and contributed zero records. |
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.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 shape | Behaviour |
|---|---|
lastName + state | Safest. Use this shape for any automated reconciliation job. |
lastName alone | Fine 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 lastName | Declined 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 name | Reaches 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. |
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.