# Visitor management systems

> An ID is scanned, a badge prints or a staff member is quietly notified. The check has to fit inside that second, never accuse a parent on a name coincidence, and leave a record a security director can stand behind.

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

A visitor management system replaces the paper log with a kiosk or a front-desk screen. The visitor presents a government ID; the system reads the barcode to extract name, date of birth and photo; it checks the person against a sex offender registry and the organisation's own watch and banned lists; a cleared visitor gets a printed badge with photo, destination and time, and the host is notified; a flagged visitor is held at the desk while a designated staff member — the front-office lead, a school resource officer, hospital security — reviews the alert out of the visitor's sight. The same product runs in K-12 schools, hospitals and clinics, corporate lobbies, data centres and manufacturing sites; schools add custodial-order and volunteer-approval lists, hospitals add patient-requested restrictions, corporate sites add contractor credential expiry. The check runs on every sign-in, which means the integration is judged on two numbers: how quickly it answers and how rarely it flags the wrong person.

The rules are mostly state and institutional. Texas Education Code § 38.022 lets a district verify whether a visitor is a registered sex offender against the state database or another database it can access, requires its board to adopt a policy for a visitor so identified, and limits any electronic visitor log to security use. Louisiana R.S. 14:91.1 requires a registrant on school property to notify the principal's office on arrival and departure; many states bar registrants from school grounds without permission. Florida's Jessica Lunsford Act (§ 1012.465) requires Level 2 screening of contractors with access to students, which is why contractor credentialing often rides on the same kiosk. Districts that have deployed registry-checking kiosks have also seen the failure mode: at least one Ohio district dropped a program after it misidentified visitors, and vendor guidance now stresses minimal data retention — the name, date of birth and last four digits of the ID, not the ID image. For corporate and healthcare sites the duty is the organisation's own: a reasonable visitor-safety policy, applied consistently, with a review step before anyone is turned away.

Visitor screening is not an employment, housing, credit or insurance decision, so the FCRA does not govern it. State registry-use statutes still do: California Penal Code § 290.46(j), Nevada NRS 179B.270 and New Jersey N.J.S.A. 2C:7-16(c) prohibit using information from the state registry website for listed purposes, and California and Nevada include benefits, privileges or services provided by a business establishment; whether a facility-access policy falls inside those lists is for the organisation's counsel, and the state registry pages publish their own conditions of use.

> **Four desk states, not two.** A name-only hit is not a match. The integration shows **Clear**, **Review**, **Match** or **Incomplete** to staff only; the visitor sees a badge or a wait. A **Match** requires the full date of birth from the ID to agree with the record; a **Review** is a possible match a person compares against the photo; **Incomplete** means some registries did not finish and is never shown as Clear. The policy for each state is the organisation's.

## Where the API fits

| Desk step | Endpoint | Fields that matter |
| --- | --- | --- |
| ID scanned → one check | `POST /v1/search` | `query.firstName`, `query.lastName`, `query.dob` (`YYYY-MM-DD`) from the barcode; `subjectId` = a hash of issuer + ID number; `clientId` = the campus or building; `deadlineMs` for a bounded wait; read `status`, `counts.sourcesIncomplete`, `records[].matchState`, `records[].matchDetail` |
| Same visitor, same day | your cache keyed by `subjectId` | One search per visitor per day is enough; the snapshot is continuously updated and a monitor covers longer gaps |
| Staff review of a hit | `GET /v1/records/{recordId}`, `images[]`, `source.recordUrl` | Photo, `offenses[]`, `registrationState`, `addressStates`, `matchDetail.strategies` |
| A regular visitor who keeps matching someone else | `POST /v1/clearances` | `product: "sex_offender"`, `subjectId`, `clientId`, `personRef`, `recordId`, `searchId` (within 14 days), `verdict: "not_this_person"`, `reviewer`, `note` |
| Badge holders, contractors, frequent visitors | `POST /v1/monitors` (`type: "person"`) | `product: "sex-offender"`, `person.firstName/lastName/dob`, `label` with the site and badge id, `channels.webhookUrl` with a token |
| Awareness around the site | `POST /v1/monitors` (`type: "location"`) | `location.lat`/`lng`, `radiusMiles`; alerts with `matchLabel: "in_radius"` |
| Incident review | `GET /v1/searches/{searchId}/results`, `POST /v1/report` | Stored results for 14 days; a PDF of what the desk saw for 7 days |

The Sex Offender Search API is the product for the desk. Criminal records are rarely part of a visitor policy and introduce review work the sign-in line cannot absorb; where a site screens contractors for work eligibility, that is an employment decision and belongs on the [FCRA Partner API](/docs/fcra), not the kiosk.

## Reference integration

The module is the whole desk: a stable `subjectId` derived from the ID without storing its number, a same-day cache, a bounded search, the four-state rule, and the clearance a reviewer records for a regular visitor who keeps matching someone else. **Match** requires `matchState: "dob_match"` and an exact legal-name match on both fields in `matchDetail.strategies`; a weaker name strategy (`nickname`, `prefix`, `fuzzy`), an alias hit or a missing date of birth is **Review**. Run it against the sandbox with an `os_test_` key; the [test names](/docs/sandbox) produce each state.

**Python**
```python
"""Kiosk visitor screening — OffenderSearch Sex Offender Search API, Python 3.11+, requests.

The ID scan gives you a name and a date of birth; the search answers in well under a second from the
current snapshot. The desk sees one of four states; the visitor sees nothing. Cache by subjectId so a
person who signs in twice today is searched once.

  OFFENDERSEARCH_API_KEY=os_test_...  OFFENDERSEARCH_BASE=https://sandbox.offendersearch.app  python kiosk.py
"""
from __future__ import annotations

import hashlib
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"}
SITE = "site-lincoln-elementary"               # your clientId: the campus, building or facility
CACHE_TTL_S = 12 * 3600                         # one working day; a Monitoring watch covers anything longer
_cache: dict[str, tuple[float, "DeskResult"]] = {}


@dataclass
class DeskResult:
    state: str                     # "clear" | "review" | "match" | "incomplete"
    staff_note: str
    search_id: str | None = None
    candidates: list[dict] = field(default_factory=list)   # what the reviewer sees; never shown at the kiosk
    elapsed_ms: int | None = None


def subject_id_for(id_number: str, issuer: str) -> str:
    """A stable visitor id that never stores the document number itself."""
    return "visitor-" + hashlib.sha256(f"{issuer}:{id_number}".encode()).hexdigest()[:24]


def _post(path: str, body: dict) -> requests.Response:
    for attempt in range(3):
        r = requests.post(f"{BASE}{path}", headers=HEADERS, json=body, timeout=15)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "2")))
            continue
        if r.status_code >= 500:
            time.sleep(1 + attempt)
            continue
        return r
    return r


def desk_state(env: dict) -> DeskResult:
    records = env["records"]
    incomplete = env["counts"]["sourcesIncomplete"]
    if not records:
        if env["status"] == "complete":
            return DeskResult("clear", "No registrant matched. Print the badge.", env["searchId"], elapsed_ms=env.get("elapsedMs"))
        return DeskResult("incomplete", f"{incomplete} of {env['counts']['sourcesQueried']} registries did not finish - "
                          "not a clear result. Follow the could-not-complete procedure.", env["searchId"])
    # Match = full date of birth confirmed AND the legal name matched exactly. Everything else is Review.
    strong = [r for r in records
              if r.get("matchState") == "dob_match"
              and r["matchDetail"].get("matchedNameType") == "legal"
              and r["matchDetail"]["strategies"].get("lastName") == "exact"
              and r["matchDetail"]["strategies"].get("firstName") == "exact"]
    if strong:
        return DeskResult("match", "A registrant with this name and date of birth. Notify the designated staff member; "
                          "follow the facility's registrant-visitor policy.", env["searchId"], strong)
    return DeskResult("review", f"{len(records)} possible match(es) on name; the date of birth did not confirm them. "
                      "A staff member compares the photo and details before the visit proceeds.", env["searchId"], records)


def screen_visitor(id_number: str, issuer: str, first: str, last: str, dob: str | None) -> DeskResult:
    sid = subject_id_for(id_number, issuer)
    hit = _cache.get(sid)
    if hit and time.time() - hit[0] < CACHE_TTL_S:
        return hit[1]
    body = {"query": {"firstName": first, "lastName": last}, "subjectId": sid, "clientId": SITE,
            "deadlineMs": 5000, "onDeadline": "partial"}      # a bounded wait at the kiosk; partial -> Incomplete, never Clear
    if dob:
        body["query"]["dob"] = dob                            # YYYY-MM-DD from the ID. Without it, every hit is Review.
    r = _post("/v1/search", body)
    if r.status_code == 422:
        return DeskResult("incomplete", f"Refused: {r.json()['detail']}")
    if r.status_code != 200:
        return DeskResult("incomplete", f"HTTP {r.status_code} - follow the could-not-complete procedure.")
    result = desk_state(r.json())
    _cache[sid] = (time.time(), result)
    return result


def confirm_not_this_person(id_number: str, issuer: str, result: DeskResult, candidate: dict, reviewer: str, note: str) -> dict:
    """A regular visitor who keeps matching someone else: record it once and the person stops appearing at this site."""
    body = {"product": "sex_offender", "subjectId": subject_id_for(id_number, issuer), "clientId": SITE,
            "personRef": candidate["personRef"], "recordId": candidate["recordId"], "searchId": result.search_id,
            "verdict": "not_this_person", "reviewer": reviewer, "note": note}
    r = _post("/v1/clearances", body)
    if r.status_code in (404, 409):
        raise ValueError(r.json()["detail"]["message"])      # search_not_found / person_not_in_search
    r.raise_for_status()
    _cache.pop(subject_id_for(id_number, issuer), None)      # the next sign-in re-runs with the clearance applied
    return r.json()                                           # 201 Clearance


if __name__ == "__main__":
    print(screen_visitor("D123-4567", "OH", "Clear", "Visitor", "1985-03-03").state)            # clear
    print(screen_visitor("D223-4567", "OH", "Exact", "Match", "1980-01-01").state)              # match
    rv = screen_visitor("D323-4567", "GA", "Name", "Only", None)
    print(rv.state, "-", rv.staff_note)                                                          # review (3 candidates)
    c = confirm_not_this_person("D323-4567", "GA", rv, rv.candidates[0], "front-office-lead",
                                "Registry photo and city do not match the parent on file.")
    rv = screen_visitor("D323-4567", "GA", "Name", "Only", None)
    print(rv.state, "- now", len(rv.candidates), "candidate(s); clearance", c["id"])            # review (2 candidates)
```

**JavaScript**
```javascript
// Kiosk visitor screening — OffenderSearch Sex Offender Search API, Node 18+ (global fetch).
// Sandbox: OFFENDERSEARCH_API_KEY=os_test_...  OFFENDERSEARCH_BASE=https://sandbox.offendersearch.app
import crypto from "node:crypto";

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 SITE = "site-lincoln-elementary";                   // your clientId: the campus, building or facility
const CACHE_TTL_MS = 12 * 3600 * 1000;                     // one working day; a Monitoring watch covers anything longer
const cache = new Map();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

/** A stable visitor id that never stores the document number itself. */
export const subjectIdFor = (idNumber, issuer) =>
  "visitor-" + crypto.createHash("sha256").update(issuer + ":" + idNumber).digest("hex").slice(0, 24);

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

/** clear | review | match | incomplete. The candidates are for the reviewer; the kiosk shows nothing. */
export function deskState(env) {
  if (env.records.length === 0) {
    if (env.status === "complete") return { state: "clear", staffNote: "No registrant matched. Print the badge.", searchId: env.searchId, elapsedMs: env.elapsedMs };
    return { state: "incomplete", searchId: env.searchId,
      staffNote: env.counts.sourcesIncomplete + " of " + env.counts.sourcesQueried + " registries did not finish - not a clear result. Follow the could-not-complete procedure." };
  }
  // Match = full date of birth confirmed AND the legal name matched exactly. Everything else is Review.
  const strong = env.records.filter((r) => r.matchState === "dob_match" && r.matchDetail.matchedNameType === "legal" &&
    r.matchDetail.strategies.lastName === "exact" && r.matchDetail.strategies.firstName === "exact");
  if (strong.length) return { state: "match", searchId: env.searchId, candidates: strong,
    staffNote: "A registrant with this name and date of birth. Notify the designated staff member; follow the registrant-visitor policy." };
  return { state: "review", searchId: env.searchId, candidates: env.records,
    staffNote: env.records.length + " possible match(es) on name; the date of birth did not confirm them. A staff member compares the photo and details before the visit proceeds." };
}

export async function screenVisitor({ idNumber, issuer, firstName, lastName, dob }) {
  const sid = subjectIdFor(idNumber, issuer);
  const hit = cache.get(sid);
  if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.result;
  const body = { query: { firstName, lastName, ...(dob ? { dob } : {}) }, subjectId: sid, clientId: SITE,
    deadlineMs: 5000, onDeadline: "partial" };               // bounded wait at the kiosk; partial -> Incomplete, never Clear
  const r = await post("/v1/search", body);
  if (r.status === 422) return { state: "incomplete", staffNote: "Refused: " + JSON.stringify((await r.json()).detail) };
  if (r.status !== 200) return { state: "incomplete", staffNote: "HTTP " + r.status + " - follow the could-not-complete procedure." };
  const result = deskState(await r.json());
  cache.set(sid, { at: Date.now(), result });
  return result;
}

/** A regular visitor who keeps matching someone else: record it once and the person stops appearing at this site. */
export async function confirmNotThisPerson({ idNumber, issuer }, result, candidate, reviewer, note) {
  const sid = subjectIdFor(idNumber, issuer);
  const r = await post("/v1/clearances", { product: "sex_offender", subjectId: sid, clientId: SITE,
    personRef: candidate.personRef, recordId: candidate.recordId, searchId: result.searchId,
    verdict: "not_this_person", reviewer, note });
  if (r.status === 404 || r.status === 409) throw new Error((await r.json()).detail.message);
  if (r.status !== 201) throw new Error("HTTP " + r.status);
  cache.delete(sid);                                          // the next sign-in re-runs with the clearance applied
  return r.json();
}

// Demo against the sandbox test names.
console.log((await screenVisitor({ idNumber: "D123-4567", issuer: "OH", firstName: "Clear", lastName: "Visitor", dob: "1985-03-03" })).state);   // clear
console.log((await screenVisitor({ idNumber: "D223-4567", issuer: "OH", firstName: "Exact", lastName: "Match", dob: "1980-01-01" })).state);     // match
let rv = await screenVisitor({ idNumber: "D323-4567", issuer: "GA", firstName: "Name", lastName: "Only" });
console.log(rv.state, "-", rv.staffNote);                                                                                                        // review (3 candidates)
const c = await confirmNotThisPerson({ idNumber: "D323-4567", issuer: "GA" }, rv, rv.candidates[0], "front-office-lead", "Registry photo and city do not match the parent on file.");
rv = await screenVisitor({ idNumber: "D323-4567", issuer: "GA", firstName: "Name", lastName: "Only" });
console.log(rv.state, "- now", rv.candidates.length, "candidate(s); clearance", c.id);                                                           // review (2 candidates)
```

*The Match response for "Exact Match", 1980-01-01 (trimmed)*
```json
{
  "searchId": "srch_9f2c1a7b",
  "status": "complete",
  "elapsedMs": 412,
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 58, "sourcesComplete": 58,
              "sourcesIncomplete": 0, "sourcesSkippedByScope": 0, "clearedSuppressed": 0 },
  "warnings": [],
  "cleared": [],
  "records": [
    { "recordId": "rec_6d066763131ebe8c77ea", "personRef": "prs_3c9e1f0a7b2d4e6f8a1b",
      "name": { "first": "Exact", "middle": "Sample", "last": "Match", "full": "Exact Sample Match" },
      "dob": "1980-01-01", "registrationState": "OH", "addressStates": ["OH"],
      "matchConfidence": 1.0, "matchState": "dob_match",
      "matchBasis": ["lastName:exact", "firstName:exact", "dob"],
      "matchDetail": { "strategies": { "lastName": "exact", "firstName": "exact" },
                       "fieldsPresent": ["lastName", "firstName", "dob"], "matchedNameType": "legal",
                       "signals": ["matchedLegalName"], "verification": "dob_match" },
      "offenses": [ { "crime": "Sexual battery", "tier": "Tier III", "convictionDate": "2004-05-12" } ],
      "images": [ { "url": "https://sandbox.offendersearch.app/v1/sandbox/photos/1.svg" } ] }
  ]
}
```

What staff see for **Review** or **Match**: the registry photo beside the ID photo, the record's `name` and `aliases`, `dob` or `age`, `registrationState` and `addressStates`, `offenses[]`, and how the name matched. A Review that a reviewer resolves as the wrong person becomes a clearance; the next sign-in by the same ID lists that registrant in `cleared[]` and the desk state recomputes without them. A cleared person who re-surfaces more strongly comes back with `clearance.stale: true` for a fresh look. `elapsedMs` on every response is the server time to watch in your kiosk metrics; healthy searches complete in well under a second.

> **Partial is Incomplete, never Clear.** With `onDeadline: "partial"` a search that cannot finish inside the kiosk's bound returns what completed and says so in `counts.sourcesIncomplete` and `warnings[]`. An empty `records[]` on such a response is a lower bound, not a clearance. Show "could not complete" and follow that procedure; the default behaviour keeps working past the deadline target precisely so that this is rare.

## Ongoing monitoring

People who hold a standing badge — contractors, vendors, volunteers, regular family visitors on a school's approved list — are checked once at credentialing and then walk in on the badge. A person monitor keeps that credential honest: `product: "sex-offender"`, `type: "person"`, the name and date of birth from the credentialing scan, a `label` the security office can route on, and a `webhookUrl` carrying a secret token (monitoring deliveries are not signed). An alert suspends the badge for review; it is not itself a denial. Cancel the monitor when the credential expires.

*Watch a credentialed contractor*
```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": "Jordan", "lastName": "Rivera", "dob": "1988-04-12" },
    "minConfidence": "year_match",
    "label": "site-lincoln-elementary / badge-0417 (HVAC contractor)",
    "channels": { "webhookUrl": "https://your-vms.example/hooks/offendersearch?token=3c9e1f0a" }
  }'
```

A location monitor around the site (`type: "location"`, the campus coordinates, a radius) gives the office the awareness many district policies call for, and the [Offender Map API](/docs/map) can render it. One dataset per monitor; alerts and history at `GET /v1/monitors/{monitorId}/alerts`. [Person monitoring](/docs/monitoring/person-monitoring), [Delivery](/docs/monitoring/delivery).

## When this is FCRA

Deciding whether a visitor may enter a building is not an employment, housing, credit or insurance decision, so visitor screening does not run on the FCRA Partner API and the standard API is the right product. Two adjacent jobs are different. If the same system decides whether a contractor or vendor employee is eligible to **work** on site — a credentialing decision an employer or the contractor's employer will act on — that is an employment purpose and belongs in an [FCRA order](/docs/fcra/orders) placed by an approved partner, with the disclosure, authorization and adverse-action steps that go with it. And if a school uses the kiosk to screen **volunteers** for placement with students, the youth-organisations article in [this section](/docs/solutions) covers that flow and the counsel question around volunteers. In every case the standard API is not a consumer report and must not be the sole basis for such a decision.

## Evidence and audit

- **Per sign-in**: the `searchId`, the desk state, `status`, `counts.sourcesComplete` of `counts.sourcesQueried`, the staff member who handled a Review or Match, and the outcome under policy. Never the ID image or document number; the hashed `subjectId` recognises a return visit.
- **Re-open what the desk saw** with `GET /v1/searches/{searchId}/results` for 14 days (`410` afterwards), and render it with `POST /v1/report` within 7 days — a PDF of every matching record with a citation per record and the use-restriction notice, identified by `X-Report-Id`.
- **Clearances** carry `reviewer`, `note`, `createdAt`, `revokedAt` and `supersedes`; `GET /v1/clearances?subjectId=…&clientId=…&includeRevoked=true` reproduces the history for one visitor.
- **Open the record** a reviewer looked at with `GET /v1/records/{recordId}`; a registry identifier that was later superseded still resolves to the person's current record, so the id you logged keeps working.
- **Monitor history** at `GET /v1/monitors/{monitorId}/alerts` ties a badge suspension to the alert that caused it.
- **Data minimisation**: the Texas statute and vendor practice agree — keep what the check needs and the result, and nothing else from the ID.

## Rollout plan

1. **Policy.** Write the procedure for each of the four desk states, who is notified, and what the visitor is told. Confirm with counsel how registry-use statutes in your states treat facility access.
2. **Sandbox.** With an `os_test_` key, run the module above through `Clear Visitor`, `Exact Match` with and without the date of birth, `Name Only`, and `Alias Match`; record a clearance and sign the same ID in again.
3. **Kiosk flow.** Derive `subjectId` from the ID without storing the number; set `deadlineMs` to your line's tolerance; route Review and Match to a staff screen, never the kiosk display.
4. **Review screen.** Registry photo beside the ID photo, `offenses[]`, `matchDetail`, `source.recordUrl`; one-click `not_this_person` for regular visitors.
5. **Credentials and monitors.** Create a person monitor on every standing badge; suspend on alert; cancel on expiry. Add a location monitor per site if policy wants it.
6. **Go live.** Switch host and key; watch `elapsedMs` and the Incomplete rate for the first week; confirm the first production `searchId` opens on `GET /v1/searches/{searchId}/results`. [Go-live checklist](/docs/guides/sex-offender/go-live).

## Related

- [Front-desk visitor screening](https://offendersearch.app/docs/front-desk-visitor-screening): the desk-state rule and sandbox table
- [Clearances](https://offendersearch.app/docs/clearances)
- [Completeness & source status](https://offendersearch.app/docs/result-completeness)
- [Person monitoring](https://offendersearch.app/docs/monitoring/person-monitoring)
- [Sex Offender Search API](https://offendersearch.app/docs/sex-offender)
