# Skilled nursing facilities

> Resident admission screening, staff exclusion checks, visitor screening at the desk, and a census that stays watched — mapped to the API with a runnable integration.

- **HTML:** https://offendersearch.app/docs/solutions/skilled-nursing
- **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

## How screening works in skilled nursing today

A skilled nursing facility (SNF) admits most residents from a hospital. A discharge planner sends the referral — often through a referral-management platform, with a packet that can run to hundreds of pages — and the facility's admissions coordinator or director of admissions decides, frequently the same day, whether to offer a bed. Industry reporting puts the average at more than six referrals per patient with roughly a third accepted, and describes rapid-response admission teams built around that pace. Screening therefore happens at referral review, before a bed is offered, on the identifiers the packet carries: legal name, date of birth, prior addresses.

### Residents

No federal Condition of Participation requires a registry or criminal check of a prospective resident. State law does in places, and the duties differ in kind. Illinois' Nursing Home Care Act (210 ILCS 45/2-201.5) requires a facility to request a criminal history background check within 24 hours after admission for every person 18 or older seeking admission; a resident who is an "identified offender" (210 ILCS 45/1-114.01 — which includes a registered sex offender) triggers a fingerprint-based inquiry, a State Police criminal history report and an Identified Offender Report and Recommendation that the facility must incorporate into the care plan, with a private room required for a registered sex offender (210 ILCS 45/2-201.6). Oklahoma's Long-Term Care Security Act (63 O.S. § 1-1946) and its rule (OAC 310:675-7-21) require a long-term care facility to determine, before admission or employment and no later than three business days after acceptance, whether an applicant, resident or employee is a registered offender, to post any law-enforcement notice conspicuously, and to notify the State Department of Health in writing. Minnesota (Minn. Stat. § 243.166 subd. 4b) puts the disclosure duty on the offender and the notice on law enforcement, whose fact sheet the facility must distribute to all residents when it carries a risk level. Virginia requires the facility to tell each resident they may wish to consult the registry and to assist on request (Va. Code § 32.1-138(A)(16)), and lets it ask the State Police for automatic notice when an offender registers nearby (Va. Code § 9.1-914).

### Staff and contractors

Here the federal rules are explicit. 42 CFR 483.12(a)(3) says a facility must not employ or otherwise engage anyone found guilty by a court of abuse, neglect, exploitation, misappropriation or mistreatment, anyone with such a finding on the State nurse aide registry, or anyone whose professional licence is under disciplinary action for the same reasons; "or otherwise engage" is the hook that reaches contractors. 42 CFR 483.35(d)(4) requires registry verification before an individual serves as a nurse aide. Section 6201 of the Affordable Care Act (42 U.S.C. § 1320a-7l) funded the National Background Check Program for "direct patient access employees" — a definition that covers contract staff with one-on-one resident contact — in 29 states at various times between 2010 and 2024. State statutes carry the day-to-day requirements: Illinois' Health Care Worker Background Check Act (225 ILCS 46), Florida's Level 2 screening through the Care Provider Background Screening Clearinghouse (Fla. Stat. §§ 408.809, 435.04, 435.12; rescreen every five years), Texas' Employee Misconduct Registry and nurse aide registry checks before employment with no direct consumer contact while a check is pending (Tex. Health & Safety Code § 250.003).

Exclusion lists are a separate obligation with a separate cadence. Under 42 CFR 1001.1901, no federal health care program pays for items or services furnished by an excluded individual or entity when the provider knew or should have known. The OIG's Special Advisory Bulletin on the Effect of Exclusion (May 2013) notes that providers are not required by statute to check the List of Excluded Individuals/Entities (LEIE), that the list is updated monthly, and that screening employees and contractors each month "best minimizes" overpayment and penalty exposure; CMS' State Medicaid Director Letter 09-001 recommended that states require providers to screen monthly, and many facilities also check SAM.gov (2 CFR 180.510) and their state Medicaid exclusion list. In practice this is a monthly batch run by compliance or HR against the whole roster, including agency nurses, with hits routed to a named reviewer.

### Visitors

42 CFR 483.10(f)(4) gives every resident the right to receive visitors of their choosing at the time of their choosing, subject to the resident's consent and to "reasonable clinical and safety restrictions" set out in written visitation policies (483.10(f)(4)(v)). No federal rule requires a visitor background check, and most facilities do not run one on every visitor. Where a facility does screen — visitors to a resident with a guardian, overnight visitors, a name staff recognise — it is a front-desk name check with a supervisor path, written into that policy.

> **What the rules require is counsel's call.** The statutes above are named so you can find them, not applied to your facility. Which apply, what they require of a given admission, hire or visit, and how a positive result may be used are decisions for your compliance officer and counsel. The API returns public records with their provenance; it does not make a facility compliant with any of them.

## Where the API fits

| Workflow step | Endpoint | Fields that matter |
| --- | --- | --- |
| Referral review — registry check on the prospective resident | `POST /v1/search` (all 58 US registries; never scope to one state — registrants move) | `query.firstName`, `query.lastName`, `query.dob`; `subjectId` = your resident id, `clientId` = facility; read `status`, `records[].matchState`, `matchBasis`, `registrationState`, `addressStates`, `sources[].recordUrl` |
| Referral review — criminal records in the states the person lived in | `POST /v1/criminal/search`, cached (no `live` block), one call per state | `query.lastName` (required), `firstName`, `dob`, `state`; read `records[].recordCategory`, `cases[].charges[]`, `currentStatus`, `normalized`; `counts.sourcesPending` |
| State-mandated check after a positive registry result (IL, OK) | Same `searchId`, plus `POST /v1/report` and, where the registry's own page is wanted, `live.evidence: "pdf"` | `searchId`; `evidence[].downloadUrl`; `X-Report-Id` |
| Monthly exclusion run on staff and agency nurses | `POST /v1/criminal/search` with `include: ["exclusions"]`; `POST /v1/criminal/batch` for the whole roster | `recordType: "healthcare_exclusion"`, `exclusion.program` (`medicare_medicaid`, `federal_procurement`, `state_medicaid`), `exclusion.authority`, `convictionBased`, `excludedOn`, `reinstatedOn`; `counts.exclusionsIncluded` |
| Visitor at the desk | `POST /v1/search` with `age` when no date of birth is collected | `query.age`; `subjectId` = visitor id; `status` (`partial` → "could not complete", never "clear") |
| Reviewer decides a candidate is someone else | `POST /v1/clearances` | `product: "sex_offender"` or `"criminal"`, `subjectId`, `clientId`, `personRef`, `searchId`, `verdict: "not_this_person"`, `reviewer`, `note` |
| Census and roster stay watched | `POST /v1/monitors` — one per dataset, `product` + `type: "person"` | `person.firstName/lastName/dob`, `minConfidence`, `label`, `channels.webhookUrl` or `emails[]` |
| Discharge, termination | `DELETE /v1/monitors/{monitorId}` | Evaluation stops now; not charged on any invoice prepared after the cancel |

Never send a Medicaid id, MRN or SSN: the APIs match on name, date of birth and place only, and refuse unknown keys with `422`. The referral's identifiers stay on your side of the mapping, keyed by the `subjectId` you choose.

## Reference integration

One module, three functions, one outcome vocabulary. `registry_check` serves admissions and the front desk; `criminal_check` serves admissions; `exclusion_check` serves the monthly staff run. Every call carries `subjectId` and `clientId`, so a reviewer's clearance follows the person. Run it as written against the sandbox with an `os_test_` key; the test names and expected outcomes are in the comments.

**Python**
```python
"""Skilled nursing screening — OffenderSearch reference integration (Python 3.11+, requests).

Three jobs one facility runs, in one module:
  registry_check    resident admission or visitor — Sex Offender Search, all 58 US registries
  criminal_check    resident admission — Criminal Records, cached search
  exclusion_check   staff and contractors — the same criminal search with include: ["exclusions"]

Runs against the sandbox with its fixed test names. Swap the host and key for production.
  OFFENDERSEARCH_API_KEY   os_test_… (sandbox) or os_live_… (production)
  OFFENDERSEARCH_BASE      https://sandbox.offendersearch.app (default here)
  FACILITY_ID              your id for this building — sent as clientId so clearances stay per facility
"""
from __future__ import annotations

import os
import time
from dataclasses import dataclass, field

import requests

API_KEY = os.environ["OFFENDERSEARCH_API_KEY"]
BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")
FACILITY_ID = os.environ.get("FACILITY_ID", "facility-07")
HEADERS = {"X-API-Key": API_KEY, "Content-Type": "application/json"}


@dataclass
class Outcome:
    outcome: str                       # clear | review | match | incomplete | refused | failed
    detail: str
    search_id: str | None = None
    candidates: list[dict] = field(default_factory=list)
    incomplete_sources: list[str] = field(default_factory=list)
    cleared: int = 0                   # people a reviewer already marked not_this_person for this subject


def _post(path: str, body: dict) -> requests.Response:
    """429 → wait Retry-After; 5xx → back off. Nothing was billed on either."""
    for attempt in range(4):
        r = requests.post(f"{BASE}{path}", headers=HEADERS, json=body, timeout=60)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "5")))
            continue
        if r.status_code >= 500:
            time.sleep(2 ** attempt)
            continue
        return r
    return r


# ── Sex Offender Search: residents at admission, visitors at the desk ──────────────────────

def registry_check(first: str, last: str, dob: str | None, *, subject_id: str, age: int | None = None) -> Outcome:
    """POST /v1/search across all 58 US registries. dob is 'YYYY-MM-DD' when the referral carries it;
    a visitor who gives only an age is matched ±1 year. subject_id is your resident or visitor id —
    it turns clearances on, so a person a reviewer cleared for this subject stays cleared."""
    query: dict = {"firstName": first, "lastName": last}
    if dob:
        query["dob"] = dob
    elif age is not None:
        query["age"] = age
    r = _post("/v1/search", {"query": query, "subjectId": subject_id, "clientId": FACILITY_ID})
    if r.status_code == 422:
        return Outcome("refused", f"Refused: {r.json()['detail']}")       # too broad, bad dob, unknown key
    if r.status_code != 200:
        return Outcome("failed", f"HTTP {r.status_code}: {r.text[:200]}")
    data = r.json()

    incomplete = [s["source"] for s in data["sourceStatus"] if s["incomplete"]]
    records = data["records"]
    cleared = len(data.get("cleared", []))
    if not records:
        if data["status"] == "complete":
            return Outcome("clear", "No registrant matched in any registry searched.", data["searchId"], cleared=cleared)
        return Outcome("incomplete", "No match, but some registries did not finish — not a clear.",
                       data["searchId"], incomplete_sources=incomplete, cleared=cleared)
    # A record is a candidate, never a confirmed identity. Tier by how the birth date agreed
    # (matchState), not by matchConfidence alone.
    strong = [x for x in records if x["matchState"] == "dob_match"]
    return Outcome("match" if strong else "review",
                   f"{len(records)} candidate(s); {len(strong)} with the date of birth confirmed. A person decides.",
                   data["searchId"], records, incomplete, cleared)


# ── Criminal Records: residents at admission, exclusion lists for staff ──────────────────────

def _criminal(body: dict) -> dict | Outcome:
    r = _post("/v1/criminal/search", body)
    if r.status_code == 402:
        return Outcome("failed", f"Billing: {r.json()['detail']}")       # trial used up / no subscription
    if r.status_code == 422:
        err = r.json()["error"]                                            # {"error": {"code", "message"}}
        return Outcome("refused", f"Refused ({err['code']}): {err['message']}")
    if r.status_code == 202:
        # Only a live block that resolves to more than two sources goes async. Poll every 2 s:
        # 202 (JobRecord, result null) while pending; 200 with the envelope under result; 422 refused.
        poll = r.json()["poll"]
        for _ in range(150):
            time.sleep(2)
            p = requests.get(f"{BASE}{poll}", headers=HEADERS, timeout=60)
            if p.status_code == 202:
                continue
            if p.status_code == 422:
                return Outcome("refused", "The stored query was refused when it ran.")
            if p.status_code != 200:
                return Outcome("failed", f"Poll HTTP {p.status_code}")
            return p.json()["result"]
        return Outcome("incomplete", "Live check did not finish within five minutes.")
    if r.status_code != 200:
        return Outcome("failed", f"HTTP {r.status_code}: {r.text[:200]}")
    return r.json()


def _classify_criminal(data: dict, what: str) -> Outcome:
    incomplete = [s["code"] or "unsupported_site" for s in data["sourceStatus"] if s["incomplete"]]
    records = data["records"]
    cleared = data["counts"].get("clearedSuppressed", 0)
    if not records:
        if data["status"] == "complete" and data["counts"].get("sourcesPending", 0) == 0:
            return Outcome("clear", f"No {what} matched.", data["searchId"], cleared=cleared)
        return Outcome("incomplete", f"No {what} matched, but a source did not finish or is pending — not a clear.",
                       data["searchId"], incomplete_sources=incomplete, cleared=cleared)
    strong = [x for x in records if x["matchState"] == "dob_match"]
    return Outcome("match" if strong else "review",
                   f"{len(records)} {what}(s); {len(strong)} with the date of birth confirmed. Read each record before any decision.",
                   data["searchId"], records, incomplete, cleared)


def criminal_check(first: str, last: str, dob: str | None, *, subject_id: str, state: str | None = None) -> Outcome:
    """POST /v1/criminal/search, cached (no live block). state scopes to one jurisdiction; run once per
    state the person has lived in and merge on searchId. Conviction-based exclusion-list records
    come back on this search too; administrative ones need include: ["exclusions"] (below)."""
    query: dict = {"lastName": last, "firstName": first}
    if dob:
        query["dob"] = dob
    if state:
        query["state"] = state
    data = _criminal({"query": query, "perPage": 50, "subjectId": subject_id, "clientId": FACILITY_ID})
    return data if isinstance(data, Outcome) else _classify_criminal(data, "criminal record")


def exclusion_check(first: str, last: str, dob: str | None, *, subject_id: str) -> Outcome:
    """Who is on an exclusion list — the HHS-OIG list, SAM.gov and the state Medicaid lists the
    platform holds. include: ["exclusions"] also returns administrative exclusions (a licence action,
    a debarment) that a default search omits; counts.exclusionsIncluded confirms it applied.
    When the answer decides whether to hire, keep or assign someone, run it as the criminal search
    of an FCRA order instead — see "When this is FCRA" in the article."""
    query: dict = {"lastName": last, "firstName": first}
    if dob:
        query["dob"] = dob
    data = _criminal({"query": query, "include": ["exclusions"], "perPage": 50,
                      "subjectId": subject_id, "clientId": FACILITY_ID})
    if isinstance(data, Outcome):
        return data
    assert data["counts"]["exclusionsIncluded"] is True
    data["records"] = [x for x in data["records"] if x["recordType"] == "healthcare_exclusion"]
    out = _classify_criminal(data, "exclusion-list record")
    for x in out.candidates:                       # what the reviewer sees, from the exclusion block
        e = x["exclusion"]
        print(f"  {x['name']['first']} {x['name']['last']}: {e['program']} · {e['authority']} · "
              f"conviction-based={e['convictionBased']} · excluded {e['excludedOn']} · reinstated {e['reinstatedOn']}")
    return out


if __name__ == "__main__":
    # Sandbox test names — each line's expected outcome is in the comment.
    print(registry_check("Exact", "Match", "1980-01-01", subject_id="resident-48213"))   # match: dob_match, registered in OH
    print(registry_check("Name", "Only", None, subject_id="resident-48214"))             # review: 3 candidates, no DOB published
    print(registry_check("Clear", "Visitor", None, subject_id="visitor-1001", age=42))   # clear
    print(criminal_check("Felony", "Record", "1975-06-15", subject_id="resident-48213")) # match: one court case, dob_match
    print(criminal_check("Clear", "Record", None, subject_id="resident-48214"))          # clear
    print(exclusion_check("Excluded", "Provider", "1968-03-22", subject_id="staff-2207"))   # match: HHS-OIG 1128(a)(1)
    print(exclusion_check("Sanctioned", "Provider", None, subject_id="staff-2208"))        # review: state licence action, no DOB on the list
    print(exclusion_check("Clear", "Provider", "1980-01-01", subject_id="staff-2209"))     # clear
```

**JavaScript**
```javascript
// Skilled nursing screening — OffenderSearch reference integration (Node 18+, global fetch).
// Three jobs: registryCheck (residents, visitors), criminalCheck (residents),
// exclusionCheck (staff and contractors, include: ["exclusions"]). Runs against the sandbox.
//   OFFENDERSEARCH_API_KEY  os_test_… (sandbox) or os_live_… (production)
//   OFFENDERSEARCH_BASE     https://sandbox.offendersearch.app (default here)
//   FACILITY_ID             your id for this building — sent as clientId so clearances stay per facility
const API_KEY = process.env.OFFENDERSEARCH_API_KEY;
const BASE = process.env.OFFENDERSEARCH_BASE ?? "https://sandbox.offendersearch.app";
const FACILITY_ID = process.env.FACILITY_ID ?? "facility-07";
const HEADERS = { "X-API-Key": API_KEY, "Content-Type": "application/json" };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function post(path, body) {
  for (let attempt = 0; attempt < 4; attempt++) {
    const r = await fetch(`${BASE}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
    if (r.status === 429) { await sleep(1000 * Number(r.headers.get("Retry-After") ?? 5)); continue; }
    if (r.status >= 500) { await sleep(1000 * 2 ** attempt); continue; }
    return r;
  }
  throw new Error("OffenderSearch unavailable after retries");
}

// outcome: clear | review | match | incomplete | refused | failed
const result = (outcome, detail, extra = {}) => ({ outcome, detail, searchId: null, candidates: [], incompleteSources: [], cleared: 0, ...extra });

/** Sex Offender Search across all 58 US registries — residents at admission, visitors at the desk. */
export async function registryCheck({ firstName, lastName, dob, age, subjectId }) {
  const query = { firstName, lastName, ...(dob ? { dob } : age != null ? { age } : {}) };
  const r = await post("/v1/search", { query, subjectId, clientId: FACILITY_ID });
  if (r.status === 422) return result("refused", `Refused: ${JSON.stringify((await r.json()).detail)}`);
  if (!r.ok) return result("failed", `HTTP ${r.status}`);
  const data = await r.json();
  const incompleteSources = data.sourceStatus.filter((s) => s.incomplete).map((s) => s.source);
  const cleared = (data.cleared ?? []).length;
  if (data.records.length === 0) {
    return data.status === "complete"
      ? result("clear", "No registrant matched in any registry searched.", { searchId: data.searchId, cleared })
      : result("incomplete", "No match, but some registries did not finish — not a clear.", { searchId: data.searchId, incompleteSources, cleared });
  }
  const strong = data.records.filter((x) => x.matchState === "dob_match");   // tier on matchState, not matchConfidence alone
  return result(strong.length ? "match" : "review",
    `${data.records.length} candidate(s); ${strong.length} with the date of birth confirmed. A person decides.`,
    { searchId: data.searchId, candidates: data.records, incompleteSources, cleared });
}

async function criminal(body) {
  const r = await post("/v1/criminal/search", body);
  if (r.status === 402) return result("failed", `Billing: ${(await r.json()).detail}`);
  if (r.status === 422) { const { error } = await r.json(); return result("refused", `Refused (${error.code}): ${error.message}`); }
  if (r.status === 202) {
    // Only a live block resolving to more than two sources goes async. Poll every 2 s:
    // 202 (JobRecord, result null) while pending; 200 with the envelope under result; 422 refused.
    const { poll } = await r.json();
    for (let i = 0; i < 150; i++) {
      await sleep(2000);
      const p = await fetch(`${BASE}${poll}`, { headers: HEADERS });
      if (p.status === 202) continue;
      if (p.status === 422) return result("refused", "The stored query was refused when it ran.");
      if (!p.ok) return result("failed", `Poll HTTP ${p.status}`);
      return (await p.json()).result;
    }
    return result("incomplete", "Live check did not finish within five minutes.");
  }
  if (!r.ok) return result("failed", `HTTP ${r.status}`);
  return r.json();
}

function classifyCriminal(data, what) {
  const incompleteSources = data.sourceStatus.filter((s) => s.incomplete).map((s) => s.code ?? "unsupported_site");
  const cleared = data.counts.clearedSuppressed ?? 0;
  if (data.records.length === 0) {
    return data.status === "complete" && (data.counts.sourcesPending ?? 0) === 0
      ? result("clear", `No ${what} matched.`, { searchId: data.searchId, cleared })
      : result("incomplete", `No ${what} matched, but a source did not finish or is pending — not a clear.`, { searchId: data.searchId, incompleteSources, cleared });
  }
  const strong = data.records.filter((x) => x.matchState === "dob_match");
  return result(strong.length ? "match" : "review",
    `${data.records.length} ${what}(s); ${strong.length} with the date of birth confirmed. Read each record before any decision.`,
    { searchId: data.searchId, candidates: data.records, incompleteSources, cleared });
}

/** Criminal Records, cached search — residents at admission. state scopes to one jurisdiction; run once per state lived in. */
export async function criminalCheck({ firstName, lastName, dob, state, subjectId }) {
  const query = { lastName, firstName, ...(dob ? { dob } : {}), ...(state ? { state } : {}) };
  const data = await criminal({ query, perPage: 50, subjectId, clientId: FACILITY_ID });
  return data.outcome ? data : classifyCriminal(data, "criminal record");
}

/** Exclusion lists (HHS-OIG, SAM.gov, state Medicaid) — staff and contractors. include: ["exclusions"] adds the
 *  administrative tier a default search omits. When the answer decides hiring, keeping or assigning someone,
 *  run it as the criminal search of an FCRA order instead (see "When this is FCRA"). */
export async function exclusionCheck({ firstName, lastName, dob, subjectId }) {
  const query = { lastName, firstName, ...(dob ? { dob } : {}) };
  const data = await criminal({ query, include: ["exclusions"], perPage: 50, subjectId, clientId: FACILITY_ID });
  if (data.outcome) return data;
  if (data.counts.exclusionsIncluded !== true) throw new Error("exclusions tier was not applied");
  data.records = data.records.filter((x) => x.recordType === "healthcare_exclusion");
  const out = classifyCriminal(data, "exclusion-list record");
  for (const x of out.candidates) {                       // what the reviewer sees, from the exclusion block
    const e = x.exclusion;
    console.log(`  ${x.name.first} ${x.name.last}: ${e.program} · ${e.authority} · conviction-based=${e.convictionBased} · excluded ${e.excludedOn} · reinstated ${e.reinstatedOn}`);
  }
  return out;
}

// Sandbox test names — expected outcome in each comment.
console.log(await registryCheck({ firstName: "Exact", lastName: "Match", dob: "1980-01-01", subjectId: "resident-48213" }));   // match (dob_match, OH)
console.log(await registryCheck({ firstName: "Name", lastName: "Only", subjectId: "resident-48214" }));                        // review: 3 candidates
console.log(await registryCheck({ firstName: "Clear", lastName: "Visitor", age: 42, subjectId: "visitor-1001" }));            // clear
console.log(await criminalCheck({ firstName: "Felony", lastName: "Record", dob: "1975-06-15", subjectId: "resident-48213" })); // match: one court case
console.log(await exclusionCheck({ firstName: "Excluded", lastName: "Provider", dob: "1968-03-22", subjectId: "staff-2207" })); // match: HHS-OIG 1128(a)(1)
console.log(await exclusionCheck({ firstName: "Sanctioned", lastName: "Provider", subjectId: "staff-2208" }));                 // review: state licence action
console.log(await exclusionCheck({ firstName: "Clear", lastName: "Provider", dob: "1980-01-01", subjectId: "staff-2209" }));   // clear
```

### The outcome logic

| Outcome | Rule | What the reviewer sees |
| --- | --- | --- |
| `clear` | `records` empty **and** `status: "complete"` (criminal: and `counts.sourcesPending` is 0) | The admission or the monthly run proceeds on its own merits. The `searchId` is filed with the referral or the roster run. |
| `review` | Records exist, none with `matchState: "dob_match"` — the registry or court published no birth date (`no_dob_age_year`, `unverified: true`), or only the year or an age agreed | A card per candidate: `name.full`, `aliases`, `dob`/`age`, `matchBasis` (why it matched), `matchedName.type` (`legal` or `alias`), `registrationState`, `addressStates`, `offense.crime`, the photo, and `sources[].recordUrl` to open the registry's own page. For criminal records: `recordCategory`, `cases[].charges[]` with `severity` and `disposition`, `currentStatus`. |
| `match` | At least one record with `matchState: "dob_match"` | The same card, flagged. Still a candidate — a person confirms identity against the photo and details, then facility policy decides. Never auto-decline on the API answer. |
| `incomplete` | `records` empty but `status: "partial"` (a registry did not finish: `sourceStatus[].incomplete`, `incompleteReason`) or `counts.sourcesPending > 0` | "Could not complete" with the registries named. Retry, or re-check those registries with `live.jurisdictions`. Never record it as clear. |
| `refused` / `failed` | `422` (read `detail` on the registry search, `error.code` + `error.message` on the criminal search), `402` (billing), other non-2xx | Fix the request or the account; nothing was searched and nothing billed on `429` or `5xx`. |

> **Note.** A candidate's `matchConfidence` is a ceiling on the evidence held, not a probability that this is the resident. Tier the queue on `matchState` and `matchDetail.strategies`, explain the match with `matchBasis`, and let a person decide. The [results guide](/docs/guides/sex-offender/understanding-results) and [false positives](/docs/guides/criminal/false-positives) cover the edge cases.

## Ongoing monitoring

An admission check is a snapshot. A resident may be added to a registry after admission; a licensed nurse may be excluded mid-year; a registrant may move into the ZIP code a Virginia facility asked to be notified about. Monitoring turns the census into standing watches: one monitor per person per dataset (`product: "sex-offender"` or `"criminal"`, `type: "person"`), evaluated once a day, alerting by email or webhook with `eventType` `new`, `changed` or `removed`. A criminal person monitor also alerts on exclusion-list records: the alert's `detail.recordType` is `healthcare_exclusion` and `detail.exclusion` carries the list's own facts.

*Create a person monitor for an admitted resident*
```bash
curl -X POST https://sandbox.offendersearch.app/v1/monitors \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "product": "sex-offender",
    "type": "person",
    "person": { "firstName": "Exact", "lastName": "Match", "dob": "1980-01-01" },
    "minConfidence": "year_match",
    "label": "resident-48213 · unit 2B",
    "channels": { "webhookUrl": "https://your-app.example.com/hooks/offendersearch?token=REPLACE_WITH_A_SECRET" }
  }'
```

*201 Created*
```json
{
  "id": "mon_7f2a1c9e0b4d",
  "product": "sex-offender",
  "type": "person",
  "minConfidence": "year_match",
  "label": "resident-48213 · unit 2B",
  "status": "active",
  "channels": { "emails": [], "webhookUrl": "https://your-app.example.com/hooks/offendersearch?token=REPLACE_WITH_A_SECRET" },
  "createdAt": "2026-10-25T14:02:11+00:00",
  "canceledAt": null,
  "person": { "firstName": "Exact", "lastName": "Match", "dob": "1980-01-01" },
  "recentAlerts": [],
  "sandbox": true
}
```

- Create the two monitors when the admission is confirmed, and the criminal monitor for each staff member when they are added to the roster; cancel on discharge or termination with `DELETE /v1/monitors/{monitorId}`.
- Put the resident or employee id in `label` so the alert routes itself; the alert never carries your id otherwise.
- Every delivery is signed with the monitor's `webhookSecret` (`X-Offendersearch-Signature: t=…,v1=…` over `<t>.<raw body>`): verify it, then answer `2xx` quickly. A first evaluation reports every current match as `new`, so expect alerts immediately for anyone who already has a record.
- A location monitor (`type: "location"`, `location.address` or `lat`/`lng` + `radiusMiles`) around the building is the API's version of the ZIP-code notice some states offer facilities.

Details: [person monitoring](/docs/monitoring/person-monitoring), [alerts](/docs/monitoring/alerts), [delivery](/docs/monitoring/delivery), and the [webhooks and lifecycle guide](/docs/guides/monitoring/webhooks-and-lifecycle).

## When this is FCRA

Resident admission and visitor screening are facility-safety processes governed by your state's rules and your policy; they are not consumer reports, and every self-service response carries a `legal.notice` saying so. Staff are different. Deciding whether to hire, keep or assign an employee or an agency nurse is an employment purpose under the Fair Credit Reporting Act, and a report procured from a third party for that purpose is a consumer report. That path is the [FCRA Partner API](/docs/fcra): an end user with a certified permissible purpose (`employment`), a consent attestation on every order, the subject's name, date of birth and addresses, and a criminal search whose `exclusions` mode (`conviction_only` by default, `all`, `none`) returns the same `healthcare_exclusion` records inside a regulated report with disputes, a consumer portal and signed webhooks. It is available to approved partners; other accounts receive `403 fcra_not_enabled`.

> **Warning.** The `exclusion_check` function above shows the data and its fields. Whether a particular exclusion-list screen of your workforce is a consumer-report use is a question for your counsel; where it is, run it as the criminal search of an FCRA order, not through the self-service endpoint.

## Evidence and audit

A surveyor or an Illinois Department of Public Health reviewer will ask what was checked, when, and what it showed. Keep four things with every referral, roster run and desk check: the `searchId`, the `status` with `counts` and `sourceStatus[]`, the outcome your software derived, and the reviewer's decision with their name and note. Searches stay in the account's history for 14 days; `GET /v1/searches/{searchId}/results` re-reads one in that window, and the dashboard's history lists both products.

*Verification report for the chart*
```bash
curl -X POST https://sandbox.offendersearch.app/v1/report \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -d '{ "searchId": "srch_9f2c1a7b", "viewerName": "Admissions desk" }' \
  --output admission-48213.pdf      # application/pdf; the report id is in the X-Report-Id header
```

- **Verification report.** `POST /v1/report` with the `searchId` renders a timestamped PDF of every matching record and every field, with a source citation on each record and the legal notice printed; metered at +$0.02. The criminal twin is `POST /v1/criminal/report`. Render it at decision time, not weeks later. [Reports](/docs/reports).
- **The registry's own page.** Where a state process wants the registry record itself, send `live: { "jurisdictions": ["OH"], "evidence": "pdf" }` on the search: it answers `202` with a `pollUrl`; the finished job carries `evidence[]` with a `downloadUrl` per registry, readable for 30 days, 25¢ per document. [Live evidence](/docs/live-evidence).
- **Clearances as audit trail.** A `not_this_person` verdict must cite the `searchId` that showed the person, carries `reviewer` and `note`, and stays readable after revocation (`includeRevoked=true`). It is the record of who decided what about a look-alike. [Clearances](/docs/clearances).

*Record a reviewer's verdict*
```bash
curl -X POST https://sandbox.offendersearch.app/v1/clearances \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "product": "sex_offender",
    "subjectId": "resident-48214",
    "clientId": "facility-07",
    "personRef": "prs_3c9e1f0a7b2d4e6f8a1b",
    "recordId": "rec_6d066763131ebe8c77ea",
    "searchId": "srch_9f2c1a7b",
    "verdict": "not_this_person",
    "reviewer": "admissions-director",
    "note": "Registry photo and address do not match the resident; age differs by 14 years."
  }'
```

Referral data is protected health information: call the API from your backend over TLS, send only name, date of birth and place, and execute a Business Associate Agreement from [Dashboard → BAA](/dashboard/baa) where your counsel requires one.

## Rollout plan

1. **Sandbox first.** [Create an account](/sign-up), mint an `os_test_` key under [Dashboard → API keys](/dashboard/keys) and run the module above against `https://sandbox.offendersearch.app`. Confirm each test name lands in the expected outcome, including the `review` states — those are the ones your reviewer screen has to handle.
2. **Map the referral.** Decide which packet fields become `firstName`, `lastName`, `dob` and the states for the criminal calls; decide the `subjectId` scheme (your resident id, never the MRN) and the `clientId` per building. Write down what "clear" means in your admissions policy with counsel.
3. **Build the reviewer screen.** Show the candidate card from the table above, the registry's own page via `sources[].recordUrl`, and two buttons: "this person" and "not this person". Wire the second to `POST /v1/clearances`.
4. **Pilot one building.** Switch that building's key to `os_live_`, keep the same code, and run live referrals alongside the current manual process for a few weeks. Compare outcomes, measure review volume, tune nothing in the API — tune the policy and the screen.
5. **Add the monthly exclusion run.** Export the roster, run `exclusion_check` per person (or `POST /v1/criminal/batch` for the list), route every `review`/`match` to compliance, and file the `searchId`s with the run. Decide with counsel which workforce checks belong on the FCRA Partner API.
6. **Turn on monitoring, then roll out.** Create the person monitors for the current census and roster, verify the webhook receives and acknowledges alerts, then add the remaining buildings — one `clientId` each, the same key or one per building where you need separate revocation.

## Related

- [POST /v1/search](https://offendersearch.app/docs/search)
- [POST /v1/criminal/search](https://offendersearch.app/docs/criminal/search)
- [Clearances](https://offendersearch.app/docs/clearances)
- [Person monitoring](https://offendersearch.app/docs/monitoring/person-monitoring)
- [FCRA Partner API](https://offendersearch.app/docs/fcra)
- [Sandbox test names](https://offendersearch.app/docs/sandbox)
