# Assisted living and memory care

> Fifty state licensing regimes, one integration: move-in screening, workforce and contractor checks, family-visitor practice and per-community policy in code.

- **HTML:** https://offendersearch.app/docs/solutions/assisted-living
- **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 assisted living today

Assisted living is licensed and regulated by the states, not certified by CMS. The National Center for Assisted Living's annual *Assisted Living State Regulatory Review* describes a system in which each state sets its own licensing, staffing, training, move-in and disclosure requirements, and the result is real variation: the same operator can run communities under five different rulebooks. Memory care — a secured unit or a dedicated dementia-care licence — adds its own staffing and training rules and, in practice, a more controlled door. Screening is run by the community's executive director, its move-in or sales coordinator, and a regional or corporate HR and compliance function that holds the policy.

### Move-in

A move-in begins with a nurse assessment, a negotiated service plan and an admission agreement. Whether it also includes a registry or criminal check of the applicant is a matter of state rule and community policy, and most states say nothing about it. Where a state speaks, it tends to speak about disclosure rather than a check: Virginia requires an assisted living facility to inform each resident, with written acknowledgment, that they may wish to consult the sex offender registry and to assist on request (Va. Code § 63.2-1808(A)(19)), and lets the facility ask the State Police for automatic notice when an offender registers nearby (Va. Code § 9.1-914). Minnesota's registrant-notification statute (Minn. Stat. § 243.166 subd. 4b) puts the disclosure duty on the offender and the notice on law enforcement for the health care facilities it covers; whether a given community falls within it is a licensing question. Illinois' Identified Offenders Program, by contrast, reaches nursing homes and hospitals but not communities licensed under the Assisted Living and Shared Housing Act. Operators that screen applicants anyway do so under their own admission policy, usually at the point the deposit is taken, and usually for both the registry and criminal records in the states the applicant has lived.

### Staff and contractors

Here the states are specific, and the specifics differ. Florida requires Level 2 screening — fingerprints through the Care Provider Background Screening Clearinghouse, rescreened every five years — for assisted living personnel who provide personal care or have access to residents' funds, property or living areas (Fla. Stat. §§ 429.174, 408.809(1)(e), 435.04). Washington requires a name-and-date-of-birth check plus a national fingerprint check for the administrator and every caregiver "employed directly or by contract", and the name-and-date-of-birth check for volunteers, students and contractors with unsupervised access (WAC 388-78A-2462); WAC 388-78A-2470 bars employing a registered sex offender or anyone on an abuse, neglect or exploitation registry, and RCW 74.39A.056 requires state and federal checks for all long-term care workers. Oregon makes the licensee responsible for background checks on all "subject individuals", repeated every two years (OAR 411-054-0025). Texas assisted living facilities are "facilities" under Health & Safety Code chapter 250, so the Employee Misconduct Registry and nurse aide registry must be checked before employment, with no direct consumer contact while a criminal history check is pending (§ 250.003). Illinois lists establishments licensed under the Assisted Living and Shared Housing Act as "health care employers" under its Health Care Worker Background Check Act (225 ILCS 46/15), which bars hiring or retaining anyone with a registry finding of abuse, neglect or theft in a direct-care role (225 ILCS 46/25). Communities that bill Medicaid waiver programs also screen staff and contractors against the OIG's LEIE and the state exclusion list on the monthly cadence the OIG recommends (Special Advisory Bulletin, May 2013; 42 CFR 1001.1901).

### Family visitors and the memory care door

Assisted living is a residence, and visiting is informal: a sign-in book or tablet at the front, a code or badge at the memory care door, and staff who know the regular visitors by face. Screening of visitors, where it exists, is targeted — a new visitor to a resident with a guardian, a person a family has asked the community to watch for, a state notice — and sits in the community's written visitation policy rather than in a statute. The memory care unit's real controls are elopement prevention and a staffed door, and the visitor log is as much about knowing who was in the building as about screening.

> **Rules are named, not applied.** The citations above locate each rule; they do not say it applies to your community, your role or your decision. State rules change, several reach contractors and volunteers differently, and whether a result may be used at all is for your compliance officer and counsel. The API supplies records with provenance; it does not make a community compliant.

## Where the API fits

| Workflow step | Endpoint | Fields that matter |
| --- | --- | --- |
| Move-in — registry check on the applicant (where policy calls for it) | `POST /v1/search`, all 58 US registries | `query.firstName`, `lastName`, `dob`; `subjectId` = resident id, `clientId` = community; `records[].matchState`, `matchedName.type`, `registrationState`, `addressStates`, `sources[].recordUrl` |
| Move-in — criminal records in the states lived in | `POST /v1/criminal/search`, cached, one call per state | `query.state`; `records[].recordCategory`, `cases[].charges[]`, `currentStatus`, `normalized` |
| Workforce and contractor exclusion check (Medicaid waiver communities) | `POST /v1/criminal/search` with `include: ["exclusions"]` | `recordType: "healthcare_exclusion"`, `exclusion.program`, `authority`, `convictionBased`, `excludedOn`, `reinstatedOn`, `profession` |
| Per-community policy | Your configuration, keyed by `clientId` | Which checks run, which states, which roles — set with counsel from the state's rules; the code only reads it |
| Family visitor at the memory care door | `POST /v1/search` with `age`; see [hospitals and visitor management](/docs/solutions/hospitals-visitor-management) for the kiosk flow | `query.age`; `subjectId` = visitor id; `status: "partial"` → "could not complete" |
| Look-alike resolved by the executive director | `POST /v1/clearances` | `product`, `subjectId`, `clientId`, `personRef`, `searchId`, `verdict: "not_this_person"`, `reviewer`, `note` |
| Residents and staff stay watched | `POST /v1/monitors`, `type: "person"`, one per dataset | `person`, `minConfidence`, `label` (community · person id), `channels` |
| The neighbourhood around a community (the API's version of a state's ZIP-code notice) | `POST /v1/monitors`, `type: "location"` | `location.address` or `lat`/`lng`, `radiusMiles` (0–100, default 3) |

## Reference integration

The module is written for an operator with several communities. One API key serves all of them; `clientId` is the community, so a clearance recorded in Austin never hides a record in Tampa; `subjectId` is your resident or worker id. `COMMUNITY_POLICY` holds what each community has decided to run — the code reads it and never decides it. `move_in_packet` returns one `Check` per policy item for the move-in coordinator to attach to the admission agreement; `worker_exclusion_check` runs the exclusion tier for staff and contractors with direct access. Run it against the sandbox with an `os_test_` key.

**Python**
```python
"""Assisted living and memory care — move-in and workforce screening for a multi-community operator.
Python 3.11+, requests. Runs against the sandbox with its fixed test names.

  OFFENDERSEARCH_API_KEY   os_test_… (sandbox) or os_live_… (production)
  OFFENDERSEARCH_BASE      https://sandbox.offendersearch.app (default here)

One API key serves every community; clientId (the community id) keeps each community's
clearances to itself, and subjectId (your resident or worker id) ties every search to one person.
"""
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")
HEADERS = {"X-API-Key": API_KEY, "Content-Type": "application/json"}

# Per-community policy is YOUR configuration, set with counsel from the state's rules — not a statement
# of what any state requires. The code only reads it.
COMMUNITY_POLICY = {
    "community-austin-01": {"registry": True, "criminal": True, "criminal_states": ["TX"], "worker_exclusions": True},
    "community-tampa-02":  {"registry": True, "criminal": False, "criminal_states": [],   "worker_exclusions": True},
}


@dataclass
class Check:
    kind: str                      # registry | criminal | exclusions
    outcome: str                   # clear | review | match | incomplete | refused | failed | skipped
    detail: str
    search_id: str | None = None
    reviewer_cards: list[dict] = field(default_factory=list)   # what the move-in coordinator sees


def _post(path: str, body: dict) -> requests.Response:
    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


def _tier(records: list[dict]) -> str:
    """Candidates are tiered by how the birth date agreed, never by matchConfidence alone."""
    return "match" if any(x["matchState"] == "dob_match" for x in records) else "review"


def _registry_card(x: dict) -> dict:
    """The reviewer's card for one Sex Offender record: enough to compare against the applicant."""
    return {
        "name": x["name"]["full"], "aliases": x["aliases"], "dob": x["dob"], "age": x["age"],
        "matchState": x["matchState"], "matchBasis": x["matchBasis"], "matchedName": x["matchedName"],
        "registeredBy": x["registrationState"], "livesIn": x["addressStates"],
        "offense": x["offense"]["crime"], "photo": (x["images"][0]["url"] if x["images"] else None),
        "registryPages": [s["recordUrl"] for s in x["sources"]], "recordId": x["recordId"], "personRef": x["personRef"],
    }


def _criminal_card(x: dict) -> dict:
    """The reviewer's card for one Criminal record: the case facts, not a verdict."""
    charges = [c for case in x["cases"] for c in case["charges"]]
    return {
        "name": f"{x['name']['first']} {x['name']['last']}", "dob": x["dob"]["date"], "matchState": x["matchState"],
        "recordType": x["recordType"], "recordCategory": x["recordCategory"], "currentStatus": x["currentStatus"],
        "where": f"{x['county'] or ''} {x['state'] or ''}".strip(),
        "charges": [f"{c['description']} · {c['severity']} · {c['disposition'] or 'no disposition'}" for c in charges],
        "exclusion": x["exclusion"], "sourcePages": [s["recordUrl"] for s in x["sources"]], "personRef": x["personRef"],
    }


def registry_screen(person: dict, *, subject_id: str, community_id: str) -> Check:
    """All 58 US registries, never scoped to the community's state — an applicant may be registered elsewhere."""
    query = {"firstName": person["first"], "lastName": person["last"]}
    if person.get("dob"):
        query["dob"] = person["dob"]
    r = _post("/v1/search", {"query": query, "subjectId": subject_id, "clientId": community_id})
    if r.status_code == 422:
        return Check("registry", "refused", f"Refused: {r.json()['detail']}")
    if r.status_code != 200:
        return Check("registry", "failed", f"HTTP {r.status_code}")
    d = r.json()
    if not d["records"]:
        if d["status"] == "complete":
            return Check("registry", "clear", "No registrant matched.", d["searchId"])
        unfinished = [s["source"] for s in d["sourceStatus"] if s["incomplete"]]
        return Check("registry", "incomplete", f"Registries did not finish: {unfinished}", d["searchId"])
    return Check("registry", _tier(d["records"]), f"{len(d['records'])} candidate(s) for review", d["searchId"],
                 [_registry_card(x) for x in d["records"]])


def criminal_screen(person: dict, *, subject_id: str, community_id: str, state: str | None = None,
                    exclusions: bool = False) -> Check:
    """Cached criminal search. state scopes to one jurisdiction. exclusions=True adds include: ["exclusions"]
    (the administrative exclusion tier) for worker and contractor checks."""
    query = {"lastName": person["last"], "firstName": person["first"]}
    if person.get("dob"):
        query["dob"] = person["dob"]
    if state:
        query["state"] = state
    body: dict = {"query": query, "perPage": 50, "subjectId": subject_id, "clientId": community_id}
    if exclusions:
        body["include"] = ["exclusions"]
    r = _post("/v1/criminal/search", body)
    kind = "exclusions" if exclusions else "criminal"
    if r.status_code == 402:
        return Check(kind, "failed", f"Billing: {r.json()['detail']}")
    if r.status_code == 422:
        e = r.json()["error"]
        return Check(kind, "refused", f"Refused ({e['code']}): {e['message']}")
    if r.status_code != 200:                      # 202 happens only with a live block (none sent here)
        return Check(kind, "failed", f"HTTP {r.status_code}")
    d = r.json()
    records = d["records"]
    if exclusions:
        records = [x for x in records if x["recordType"] == "healthcare_exclusion"]
    if not records:
        if d["status"] == "complete" and d["counts"].get("sourcesPending", 0) == 0:
            return Check(kind, "clear", "No record matched.", d["searchId"])
        return Check(kind, "incomplete", "A source did not finish or is pending — not a clear.", d["searchId"])
    return Check(kind, _tier(records), f"{len(records)} record(s) for review", d["searchId"],
                 [_criminal_card(x) for x in records])


def move_in_packet(applicant: dict, *, resident_id: str, community_id: str) -> list[Check]:
    """Everything the move-in coordinator attaches to the admission agreement, per the community's policy."""
    policy = COMMUNITY_POLICY[community_id]
    checks: list[Check] = []
    if policy["registry"]:
        checks.append(registry_screen(applicant, subject_id=resident_id, community_id=community_id))
    if policy["criminal"]:
        for st in policy["criminal_states"] or [None]:
            checks.append(criminal_screen(applicant, subject_id=resident_id, community_id=community_id, state=st))
    else:
        checks.append(Check("criminal", "skipped", "Not part of this community's move-in policy."))
    return checks


def worker_exclusion_check(worker: dict, *, worker_id: str, community_id: str) -> Check:
    """Exclusion lists for staff and contractors with direct access. When the result decides hiring,
    retention or assignment, run it as the criminal search of an FCRA order instead."""
    if not COMMUNITY_POLICY[community_id]["worker_exclusions"]:
        return Check("exclusions", "skipped", "Not part of this community's workforce policy.")
    return criminal_screen(worker, subject_id=worker_id, community_id=community_id, exclusions=True)


if __name__ == "__main__":
    # Sandbox test names. Expected outcomes in the comments.
    for c in move_in_packet({"first": "Exact", "last": "Match", "dob": "1980-01-01"},
                            resident_id="resident-3101", community_id="community-austin-01"):
        print(c.kind, c.outcome, "-", c.detail)          # registry match (OH); criminal clear for TX
    for c in move_in_packet({"first": "Alias", "last": "Match"},
                            resident_id="resident-3102", community_id="community-tampa-02"):
        print(c.kind, c.outcome, "-", c.detail)          # registry review: matched through an alias (NC); criminal skipped
    w = worker_exclusion_check({"first": "Sanctioned", "last": "Provider"}, worker_id="worker-880", community_id="community-tampa-02")
    print(w.kind, w.outcome, "-", w.detail, w.reviewer_cards[0]["exclusion"] if w.reviewer_cards else "")   # review: administrative exclusion
    w = worker_exclusion_check({"first": "Clear", "last": "Provider", "dob": "1980-01-01"}, worker_id="worker-881", community_id="community-tampa-02")
    print(w.kind, w.outcome, "-", w.detail)              # clear
```

**JavaScript**
```javascript
// Assisted living and memory care — move-in and workforce screening for a multi-community operator.
// Node 18+ (global fetch). Runs against the sandbox with its fixed test names.
//   OFFENDERSEARCH_API_KEY  os_test_… (sandbox) or os_live_… (production)
//   OFFENDERSEARCH_BASE     https://sandbox.offendersearch.app (default here)
// One key serves every community; clientId (community id) keeps clearances per community,
// subjectId (your resident or worker id) ties every search to one person.
const API_KEY = process.env.OFFENDERSEARCH_API_KEY;
const BASE = process.env.OFFENDERSEARCH_BASE ?? "https://sandbox.offendersearch.app";
const HEADERS = { "X-API-Key": API_KEY, "Content-Type": "application/json" };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// YOUR configuration, set with counsel from each state's rules — not a statement of what a state requires.
const COMMUNITY_POLICY = {
  "community-austin-01": { registry: true, criminal: true, criminalStates: ["TX"], workerExclusions: true },
  "community-tampa-02": { registry: true, criminal: false, criminalStates: [], workerExclusions: true },
};

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");
}

const check = (kind, outcome, detail, searchId = null, reviewerCards = []) => ({ kind, outcome, detail, searchId, reviewerCards });
const tier = (records) => (records.some((x) => x.matchState === "dob_match") ? "match" : "review");   // never matchConfidence alone

const registryCard = (x) => ({
  name: x.name.full, aliases: x.aliases, dob: x.dob, age: x.age, matchState: x.matchState, matchBasis: x.matchBasis,
  matchedName: x.matchedName, registeredBy: x.registrationState, livesIn: x.addressStates, offense: x.offense.crime,
  photo: x.images[0]?.url ?? null, registryPages: x.sources.map((s) => s.recordUrl), recordId: x.recordId, personRef: x.personRef,
});
const criminalCard = (x) => ({
  name: `${x.name.first} ${x.name.last}`, dob: x.dob.date, matchState: x.matchState, recordType: x.recordType,
  recordCategory: x.recordCategory, currentStatus: x.currentStatus, where: `${x.county ?? ""} ${x.state ?? ""}`.trim(),
  charges: x.cases.flatMap((c) => c.charges).map((c) => `${c.description} · ${c.severity} · ${c.disposition || "no disposition"}`),
  exclusion: x.exclusion, sourcePages: x.sources.map((s) => s.recordUrl), personRef: x.personRef,
});

/** All 58 US registries, never scoped to the community's state — an applicant may be registered elsewhere. */
export async function registryScreen(person, { subjectId, communityId }) {
  const query = { firstName: person.first, lastName: person.last, ...(person.dob ? { dob: person.dob } : {}) };
  const r = await post("/v1/search", { query, subjectId, clientId: communityId });
  if (r.status === 422) return check("registry", "refused", `Refused: ${JSON.stringify((await r.json()).detail)}`);
  if (!r.ok) return check("registry", "failed", `HTTP ${r.status}`);
  const d = await r.json();
  if (d.records.length === 0) {
    if (d.status === "complete") return check("registry", "clear", "No registrant matched.", d.searchId);
    const unfinished = d.sourceStatus.filter((s) => s.incomplete).map((s) => s.source);
    return check("registry", "incomplete", `Registries did not finish: ${unfinished.join(", ")}`, d.searchId);
  }
  return check("registry", tier(d.records), `${d.records.length} candidate(s) for review`, d.searchId, d.records.map(registryCard));
}

/** Cached criminal search; state scopes to one jurisdiction; exclusions adds include: ["exclusions"] for worker checks. */
export async function criminalScreen(person, { subjectId, communityId, state, exclusions = false }) {
  const query = { lastName: person.last, firstName: person.first, ...(person.dob ? { dob: person.dob } : {}), ...(state ? { state } : {}) };
  const body = { query, perPage: 50, subjectId, clientId: communityId, ...(exclusions ? { include: ["exclusions"] } : {}) };
  const kind = exclusions ? "exclusions" : "criminal";
  const r = await post("/v1/criminal/search", body);
  if (r.status === 402) return check(kind, "failed", `Billing: ${(await r.json()).detail}`);
  if (r.status === 422) { const { error } = await r.json(); return check(kind, "refused", `Refused (${error.code}): ${error.message}`); }
  if (!r.ok || r.status === 202) return check(kind, "failed", `HTTP ${r.status}`);   // 202 only with a live block (none sent)
  const d = await r.json();
  const records = exclusions ? d.records.filter((x) => x.recordType === "healthcare_exclusion") : d.records;
  if (records.length === 0) {
    return d.status === "complete" && (d.counts.sourcesPending ?? 0) === 0
      ? check(kind, "clear", "No record matched.", d.searchId)
      : check(kind, "incomplete", "A source did not finish or is pending — not a clear.", d.searchId);
  }
  return check(kind, tier(records), `${records.length} record(s) for review`, d.searchId, records.map(criminalCard));
}

/** Everything the move-in coordinator attaches to the admission agreement, per the community's policy. */
export async function moveInPacket(applicant, { residentId, communityId }) {
  const policy = COMMUNITY_POLICY[communityId];
  const checks = [];
  if (policy.registry) checks.push(await registryScreen(applicant, { subjectId: residentId, communityId }));
  if (policy.criminal) {
    for (const state of policy.criminalStates.length ? policy.criminalStates : [undefined]) {
      checks.push(await criminalScreen(applicant, { subjectId: residentId, communityId, state }));
    }
  } else checks.push(check("criminal", "skipped", "Not part of this community's move-in policy."));
  return checks;
}

/** Exclusion lists for staff and contractors. When the result decides hiring, retention or assignment,
 *  run it as the criminal search of an FCRA order instead. */
export async function workerExclusionCheck(worker, { workerId, communityId }) {
  if (!COMMUNITY_POLICY[communityId].workerExclusions) return check("exclusions", "skipped", "Not part of this community's workforce policy.");
  return criminalScreen(worker, { subjectId: workerId, communityId, exclusions: true });
}

// Sandbox test names; expected outcomes in the comments.
for (const c of await moveInPacket({ first: "Exact", last: "Match", dob: "1980-01-01" }, { residentId: "resident-3101", communityId: "community-austin-01" }))
  console.log(c.kind, c.outcome, "-", c.detail);          // registry match (OH); criminal clear for TX
for (const c of await moveInPacket({ first: "Alias", last: "Match" }, { residentId: "resident-3102", communityId: "community-tampa-02" }))
  console.log(c.kind, c.outcome, "-", c.detail);          // registry review: matched through an alias (NC); criminal skipped
let w = await workerExclusionCheck({ first: "Sanctioned", last: "Provider" }, { workerId: "worker-880", communityId: "community-tampa-02" });
console.log(w.kind, w.outcome, "-", w.detail, w.reviewerCards[0]?.exclusion);   // review: administrative exclusion
w = await workerExclusionCheck({ first: "Clear", last: "Provider", dob: "1980-01-01" }, { workerId: "worker-881", communityId: "community-tampa-02" });
console.log(w.kind, w.outcome, "-", w.detail);            // clear
```

### The outcome logic and the reviewer card

| Outcome | Rule | The card the coordinator sees |
| --- | --- | --- |
| `clear` | `records` empty and `status: "complete"`; for criminal, `counts.sourcesPending` also 0 | The check line reads "clear" with its `searchId`; the packet proceeds. |
| `review` | Candidates exist, none with the birth date confirmed (`matchState` is `year_match`, `age_match` or `no_dob_age_year`) | Registry: `name`, `aliases`, `dob`/`age`, `matchState`, `matchBasis`, `matchedName`, `registeredBy`, `livesIn`, `offense`, `photo`, `registryPages`. Criminal: `recordType`, `recordCategory`, `currentStatus`, `where`, one line per charge (`description · severity · disposition`), the `exclusion` block when it is a list record, `sourcePages`. |
| `match` | A candidate with `matchState: "dob_match"` | The same card, flagged as the strongest tier. A person compares photo, aliases and addresses against the applicant before anything is decided. |
| `incomplete` | `status: "partial"`, or `counts.sourcesPending > 0` on a criminal search | "A source did not finish" with the names; the check is rerun, not recorded as clear. |
| `skipped` | The community's policy does not include this check | Shown as skipped, with the policy as the reason, so the packet says what was and was not run. |
| `refused` / `failed` | `422` (`detail` on the registry search; `error.code` and `error.message` on criminal), `402`, other errors | An operator message; nothing was billed on `429` or `5xx`. |

> **Note.** The alias scenario matters in senior living: a married name, a maiden name, a shortened first name. The registry search matches aliases and nickname variants itself and tells you when it did (`matchedName.type: "alias"`, `matchBasis` carrying `alias:exact`); run a second search only for a prior legal name the applicant gives you, and merge by `personRef`. See [matching](/docs/matching) and [date of birth](/docs/date-of-birth).

## Ongoing monitoring

Residents stay for years and staff turn over quickly, so a move-in or hire-date snapshot ages fast. A person monitor per resident and per worker — one for each dataset you watch — is evaluated once a day and alerts on `new`, `changed` and `removed` events. For a multi-community operator the `label` is the routing key: put the community id and the person id in it, and the alert reaches the right executive director.

*Criminal person monitor for a 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": "criminal",
    "type": "person",
    "person": { "firstName": "Felony", "lastName": "Record", "dob": "1975-06-15" },
    "label": "community-austin-01 · resident-3101",
    "channels": { "emails": ["moveins@your-operator.example.com"] }
  }'
```

*Webhook delivery (type monitor.alert)*
```json
{
  "type": "monitor.alert",
  "event": "new",
  "alertId": "hit_2b7e151628ae",
  "monitorId": "mon_7f2a1c9e0b4d",
  "monitorLabel": "community-austin-01 · resident-3101",
  "product": "criminal",
  "detail": {
    "name": "Felony Sample Record",
    "jurisdiction": "OH",
    "locality": "Sampleville",
    "matchLabel": "dob_match",
    "matchConfidence": 0.95,
    "personRef": "prs_1f3a9c0e7b2d4e6f8a1b",
    "recordType": "court_case",
    "externalId": "SBX-CR-2015-000123"
  },
  "occurredAt": "2026-10-25T08:00:00+00:00",
  "sentAt": "2026-10-25T08:04:12+00:00"
}
```

- A criminal person monitor alerts on exclusion-list records too (`detail.recordType: "healthcare_exclusion"` with a `detail.exclusion` block), which is how a Medicaid waiver community learns about a mid-year exclusion between monthly runs.
- A location monitor around each community (`type: "location"`, `radiusMiles`) alerts when a registrant's published address enters or leaves the radius — useful where a state offers a ZIP-code notice and where it does not.
- Deliveries are signed with the monitor's `webhookSecret`; verify the header before acting. Cancel monitors at move-out and separation; cancelled monitors and their alerts stay readable.

See [person monitoring](/docs/monitoring/person-monitoring), [location monitoring](/docs/monitoring/location-monitoring) and [delivery](/docs/monitoring/delivery).

## When this is FCRA

Screening an applicant for move-in is a residential admission under your state's assisted living rules and your policy — not a tenant-screening or credit decision in the FCRA's sense for most communities, though counsel should confirm that for a community whose agreement is structured as a lease. Screening workers is different: deciding whether to hire, retain or assign an employee, agency caregiver or contractor is an employment purpose, and a report procured for it is a consumer report. The self-service API's `legal.notice` says it may not be used for any FCRA purpose. That workflow belongs on the [FCRA Partner API](/docs/fcra) — end users with certified purposes, consent attestations, a criminal search whose `exclusions` mode furnishes the same exclusion-list records, disputes and signed webhooks — available to approved partners; others receive `403 fcra_not_enabled`. `worker_exclusion_check` above shows the records and fields; where your counsel treats a given workforce screen as a consumer-report use, run it as an FCRA order instead.

## Evidence and audit

- **The move-in packet.** File each `Check` with its `searchId`, `outcome`, the `status`/`counts`/`sourceStatus[]` of the response and the coordinator's decision. A state surveyor reviewing a resident file then sees what was checked and what it showed.
- **Verification report.** `POST /v1/report` (registry) or `POST /v1/criminal/report` (criminal) with the `searchId` renders a timestamped PDF with every record, every field and a source citation per record, +$0.02; render it when the decision is made. [Reports](/docs/reports), [criminal reports](/docs/criminal/verification-reports).
- **Registry page as evidence.** `live: { "jurisdictions": [...], "evidence": "pdf" }` prints the named registries' own pages; the `202` carries a `pollUrl`, the finished search carries `evidence[].downloadUrl`, readable for 30 days. [Live evidence](/docs/live-evidence).
- **Clearances.** Every `not_this_person` verdict names the `searchId`, `reviewer` and `note`, and survives revocation for the audit trail. `GET /v1/clearances?subjectId=…&clientId=…` lists a resident's. [Clearances](/docs/clearances).
- **Retention.** Searches are kept in the account history for 14 days; store what you need to keep on your side, under your records-retention policy. Resident data is protected health information — backend calls over TLS, minimum fields, a BAA from [Dashboard → BAA](/dashboard/baa) where counsel requires one.

## Rollout plan

1. **Write the policy table first.** For each community, decide with counsel which checks run at move-in, for which worker roles, in which states — and record it as the `COMMUNITY_POLICY` configuration. The code must be able to say "skipped by policy" as clearly as it says "clear".
2. **Run the sandbox module.** [Create an account](/sign-up), mint an `os_test_` key in [Dashboard → API keys](/dashboard/keys), run the module against `https://sandbox.offendersearch.app` and check each test name against the expected outcome, especially the alias and `review` cases.
3. **Build the card and the two buttons.** The reviewer card from the table above, the registry's own page via `registryPages`, and "this person" / "not this person" wired to `POST /v1/clearances` with the community as `clientId`.
4. **Pilot one community.** Swap to an `os_live_` key for one community, run move-ins and the workforce check alongside the current process, and review every `review` outcome with the executive director to calibrate what the policy says about each tier.
5. **Add monitoring and the other communities.** Create monitors for current residents and staff with `label` = community · person id, verify the webhook, then add communities one `clientId` at a time. Confirm with counsel which worker screens move to the FCRA Partner API before a hiring decision depends on one.

## Related

- [Skilled nursing facilities](https://offendersearch.app/docs/solutions/skilled-nursing)
- [POST /v1/search](https://offendersearch.app/docs/search)
- [Criminal record object](https://offendersearch.app/docs/criminal/record-object)
- [Clearances](https://offendersearch.app/docs/clearances)
- [Monitoring API](https://offendersearch.app/docs/monitoring)
