# Front-desk visitor screening integration

> From the ID scan to the staff screen, the reviewer's verdict, and the roster you keep watching. A complete walkthrough against the sandbox.

- **HTML:** https://offendersearch.app/docs/guides/sex-offender/visitor-screening
- **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

## What you will build

A visitor arrives at a school, a care facility or an office; a kiosk scans the ID; before the badge prints, the desk knows whether the name and date of birth match a registrant. You will build the four moving parts: the **search at sign-in** (keyed to your visitor and site ids), the **desk state** it maps to (Clear, Review, Match), the **reviewer's verdict** recorded as a clearance so a false positive is quiet next time, and a **monitor** on the people who visit regularly. The reference tables for this recipe — what to send from the ID, the staff screen, retention — live on [Front-desk visitor screening](/docs/front-desk-visitor-screening); this guide is the build order.

## Before you start

- A test key. Sandbox visitors: **Clear Visitor** (Clear), **Exact Match** with `dob` `1980-01-01` (Match), **Name Only** (Review: three same-name candidates without birth dates).
- Two ids from your own system on every search: `subjectId` (your stable id for the visitor — a badge number, a CRM id) and `clientId` (the site, school or district when one key serves several). They are stored only as per-account hashes and they are what clearances key on.
- The desk-state rule: **Match** = a record with `matchState: "dob_match"` and `matchConfidence ≥ 0.90` (an exact legal name with a confirmed full date scores 0.90 or more); **Clear** = `status: "complete"` and `counts.records: 0`; everything else is **Review**. The thresholds come from the scorer's own arithmetic, not from tuning.

> **The one thing people get wrong.** A kiosk cannot decide. It can only sort into Clear, Review and Match and show the right screen to **staff**, never to the visitor. Review and Match are prompts for a person to compare the photo, age and address against the person standing there and to open `sources[].recordUrl`. Nothing in this flow auto-denies.

## Step by step

1. **Search at sign-in.** Send `firstName`, `lastName` and `dob` from the ID with `subjectId` and `clientId`. One `POST /v1/search`, typically well under a second.
2. **Map to a desk state.** Compute Clear / Review / Match from `status`, `counts.records` and each record's `matchState` + `matchConfidence`. Store `searchId`, the state and the time.
3. **Record the reviewer's verdict.** When a reviewer decides a candidate is not the visitor, `POST /v1/clearances` with the record's `personRef`, the `searchId` that showed it, and `verdict: "not_this_person"`. The next search for that `subjectId` suppresses the person and lists them in `cleared[]`.
4. **Watch the regulars.** For visitors on a standing roster (contractors, volunteers, family members), create a person monitor so a new registration reaches you between visits.

### Steps 1–3 — the desk module

**Python**
```python
"""desk.py — kiosk sign-in → Clear | Review | Match, and the reviewer's clearance."""
import os
from datetime import datetime, timezone
import requests

BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")
H = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}
MATCH_MIN_CONFIDENCE = 0.90

def _post(path, body):
    r = requests.post(BASE + path, headers=H, json=body, timeout=60)
    r.raise_for_status()
    return r.json()

def desk_state(result):
    if result["status"] != "complete":
        return "review"                      # incomplete: a person must see it; never Clear
    if result["counts"]["records"] == 0:
        return "clear"
    strong = any(r["matchState"] == "dob_match" and (r["matchConfidence"] or 0) >= MATCH_MIN_CONFIDENCE
                 for r in result["records"])
    return "match" if strong else "review"

def screen_visitor(visitor_id, site_id, first_name, last_name, dob):
    result = _post("/v1/search", {
        "query": {"firstName": first_name, "lastName": last_name, "dob": dob},   # dob YYYY-MM-DD from the ID
        "subjectId": visitor_id,
        "clientId": site_id,
    })
    return {
        "state": desk_state(result),
        "searchId": result["searchId"],
        "checkedAt": datetime.now(timezone.utc).isoformat(),
        "hidden": len(result.get("cleared", [])),             # people a reviewer already cleared for this visitor
        "candidates": [{
            "personRef": r["personRef"], "recordId": r["recordId"],
            "name": r["name"]["full"], "aliases": r["aliases"],
            "dob": r["dob"] or r["birthYear"], "age": r["age"],
            "photo": r["images"][0]["url"] if r["images"] else None,
            "registrationState": r["registrationState"],
            "matchState": r["matchState"], "matchConfidence": r["matchConfidence"],
            "registryPage": (r.get("source") or {}).get("recordUrl"),
        } for r in result["records"]],
    }

def clear_candidate(visitor_id, site_id, search_id, candidate, reviewer, note):
    """The reviewer decided this returned person is NOT the visitor."""
    return _post("/v1/clearances", {
        "product": "sex_offender",            # underscore here (a monitor's product is sex-offender)
        "subjectId": visitor_id, "clientId": site_id,
        "personRef": candidate["personRef"], "recordId": candidate["recordId"],
        "searchId": search_id, "verdict": "not_this_person",
        "reviewer": reviewer, "note": note,
    })

if __name__ == "__main__":
    print(screen_visitor("visitor-1042", "facility-07", "Exact", "Match", "1980-01-01")["state"])   # match
    print(screen_visitor("visitor-1043", "facility-07", "Clear", "Visitor", "1988-04-12")["state"]) # clear
    check = screen_visitor("visitor-1044", "facility-07", "Name", "Only", "1988-04-12")
    print(check["state"], len(check["candidates"]))                                                  # review 3
    clr = clear_candidate("visitor-1044", "facility-07", check["searchId"], check["candidates"][0],
                          "front-desk", "Photo and age do not match the visitor.")
    again = screen_visitor("visitor-1044", "facility-07", "Name", "Only", "1988-04-12")
    print(clr["id"], again["hidden"], len(again["candidates"]))                                     # clr_... 1 2
```

**JavaScript**
```javascript
// desk.mjs — kiosk sign-in → Clear | Review | Match, and the reviewer's clearance. Node 18+.
const BASE = process.env.OFFENDERSEARCH_BASE ?? 'https://sandbox.offendersearch.app';
const H = { 'X-API-Key': process.env.OFFENDERSEARCH_KEY, 'Content-Type': 'application/json' };
const MATCH_MIN_CONFIDENCE = 0.9;

async function post(path, body) {
  const res = await fetch(BASE + path, { method: 'POST', headers: H, body: JSON.stringify(body) });
  if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + (await res.text()));
  return res.json();
}

export function deskState(result) {
  if (result.status !== 'complete') return 'review';            // incomplete: never Clear
  if (result.counts.records === 0) return 'clear';
  const strong = result.records.some((r) => r.matchState === 'dob_match' && (r.matchConfidence ?? 0) >= MATCH_MIN_CONFIDENCE);
  return strong ? 'match' : 'review';
}

export async function screenVisitor({ visitorId, siteId, firstName, lastName, dob }) {
  const result = await post('/v1/search', {
    query: { firstName, lastName, dob },          // dob YYYY-MM-DD from the ID
    subjectId: visitorId,
    clientId: siteId,
  });
  return {
    state: deskState(result),
    searchId: result.searchId,
    checkedAt: new Date().toISOString(),
    hidden: (result.cleared ?? []).length,         // people a reviewer already cleared for this visitor
    candidates: result.records.map((r) => ({
      personRef: r.personRef, recordId: r.recordId,
      name: r.name.full, aliases: r.aliases,
      dob: r.dob ?? r.birthYear ?? null, age: r.age,
      photo: r.images[0]?.url ?? null,
      registrationState: r.registrationState,
      matchState: r.matchState, matchConfidence: r.matchConfidence,
      registryPage: r.source?.recordUrl ?? null,
    })),
  };
}

/** The reviewer decided this returned person is NOT the visitor. */
export function clearCandidate({ visitorId, siteId, searchId, candidate, reviewer, note }) {
  return post('/v1/clearances', {
    product: 'sex_offender',                       // underscore here (a monitor's product is sex-offender)
    subjectId: visitorId, clientId: siteId,
    personRef: candidate.personRef, recordId: candidate.recordId,
    searchId, verdict: 'not_this_person', reviewer, note,
  });
}

const a = await screenVisitor({ visitorId: 'visitor-1042', siteId: 'facility-07', firstName: 'Exact', lastName: 'Match', dob: '1980-01-01' });
const b = await screenVisitor({ visitorId: 'visitor-1044', siteId: 'facility-07', firstName: 'Name', lastName: 'Only', dob: '1988-04-12' });
console.log(a.state, b.state, b.candidates.length);                  // match review 3
const clr = await clearCandidate({ visitorId: 'visitor-1044', siteId: 'facility-07', searchId: b.searchId, candidate: b.candidates[0], reviewer: 'front-desk', note: 'Photo and age do not match the visitor.' });
const c = await screenVisitor({ visitorId: 'visitor-1044', siteId: 'facility-07', firstName: 'Name', lastName: 'Only', dob: '1988-04-12' });
console.log(clr.id, c.hidden, c.candidates.length);                   // clr_... 1 2
```

*Step 3 — POST /v1/clearances → 201*
```json
{
  "id": "clr_9c54032c347c4f1e",
  "product": "sex_offender",
  "verdict": "not_this_person",
  "personRef": "prs_fec5ef71d9deef5bc8c4",
  "recordId": "rec_fec5ef71d9deef5bc8c4",
  "searchId": "srch_e891a43dfb5b8648131fde66",
  "subjectId": "visitor-1044",
  "clientId": "facility-07",
  "reviewer": "front-desk",
  "note": "Photo and age do not match the visitor.",
  "createdAt": "2026-10-25T14:18:55.563989+00:00",
  "revokedAt": null,
  "supersedes": null,
  "sandbox": true
}
```

*The next sign-in for the same visitor (trimmed)*
```json
{
  "status": "complete",
  "counts": { "records": 2, "recordsReturned": 3, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0, "clearedSuppressed": 1 },
  "records": [
    { "recordId": "rec_ca91307a7124dca36777", "personRef": "prs_ca91307a7124dca36777", "registrationState": "GA", "matchState": "no_dob_age_year" },
    { "recordId": "rec_84881c51922112e393d2", "personRef": "prs_84881c51922112e393d2", "registrationState": "MI", "matchState": "no_dob_age_year" }
  ],
  "cleared": [
    { "personRef": "prs_fec5ef71d9deef5bc8c4", "clearanceId": "clr_9c54032c347c4f1e", "verdict": "not_this_person",
      "clearedAt": "2026-10-25T14:18:55.563989+00:00", "recordId": "rec_fec5ef71d9deef5bc8c4" }
  ]
}
```

The cleared person has left `records[]`, `counts.records` is 2, and `cleared[]` lets the staff screen say "1 previously cleared person hidden". Send `clearanceMode: "annotate"` instead to keep the record with a `clearance` object for a greyed-out row. A clearance matches by `personRef` only — never by name — because the person being cleared shares the visitor's name. If that registrant's record changes materially, the clearance goes `stale` and the person comes back for review.

### Step 4 — monitor the roster

*POST /v1/monitors*
```json
{
  "product": "sex-offender",
  "type": "person",
  "label": "facility-07 · volunteer v-2201",
  "person": { "firstName": "Jordan", "lastName": "Rivera", "dob": "1988-04-12" },
  "channels": { "webhookUrl": "https://example.com/hooks/monitors?token=YOUR_SECRET" }
}
```

One monitor per person per dataset (`product` is `sex-offender` with a hyphen here; a clearance's `product` is `sex_offender` with an underscore). Monitors are evaluated daily, alerts arrive by email or webhook, and a cancelled monitor stops at once. Billing is flat per active monitor ([Monitoring billing](/docs/monitoring/billing)). The build details are on [Person monitoring](/docs/monitoring/person-monitoring).

## Reading the result

| Desk state | Rule | What staff see | What is stored |
| --- | --- | --- | --- |
| **Clear** | `status: "complete"` and `counts.records: 0` | Nothing — the badge prints. | `searchId`, state, `checkedAt`. |
| **Review** | Any records, none `dob_match` at ≥ 0.90; or `status: "partial"` | Candidate cards: name, aliases, photo, age, registration state, `matchState`, a link to the registry page. "Not this person" and "This is the person" buttons. | `searchId`, each candidate's `personRef` and `recordId`, the verdict, the reviewer. |
| **Match** | A record with `matchState: "dob_match"` and `matchConfidence ≥ 0.90` | The same card, flagged, with your escalation procedure. | The above, plus a [verification report](/docs/guides/sex-offender/verification-reports) requested within 7 days. |
| `cleared[]` | Present when `subjectId` was sent | "N previously cleared people hidden", each linking to its clearance. | `clearanceId`s for the audit. |

## Edge cases and pitfalls

- **No `dob` from the ID means no Match state is reachable.** A record found on name alone has `matchState: null` even at 0.90. Treat name-only sign-ins as Review and ask for the date of birth.
- **`partial` is Review, not Clear.** The sandbox never produces it; test the branch with a fixture. See [Working with incomplete results](/docs/guides/sex-offender/incomplete-results).
- **Multi-site platforms must send `clientId`.** Without it, one site's clearances apply at every site the key serves. A verdict recorded with `clientId` is listed only with the same `clientId`.
- **A clearance must cite the search that showed the person** within the 14-day window. Citing another search answers `409 person_not_in_search`; citing an erased one answers `404 search_not_found`. Clear from the result on screen, at the time.
- **`this_person` is also a verdict.** Recording it annotates the record on later searches rather than hiding it, so the next desk sees that the match was confirmed before.
- **Keep the kiosk honest about time.** `checkedAt` is yours; `sources[].lastCheckedAt` is when the registry was last read. Both belong in the log.
- **This is not a consumer report.** Visitor screening is not an employment, housing, credit or insurance decision, and the API must not be used as the sole basis for one; that path is the [FCRA Partner API](/fcra).

## Production checklist

- Staff-only display; the visitor never sees a result.
- `subjectId` and `clientId` on every search; the same ids used for clearances and monitors.
- Review and Match screens link `sources[].recordUrl` and record the reviewer's verdict as a clearance.
- Match triggers a verification report within 7 days; `searchId`, `recordId`, `personRef`, `clearanceId` and `X-Report-Id` are stored together.
- Standing visitors monitored; alerts routed to the same review queue.
- The remaining checks in the [Go-live checklist](/docs/guides/sex-offender/go-live).

## Related

- [Front-desk visitor screening (reference)](https://offendersearch.app/docs/front-desk-visitor-screening)
- [Clearances](https://offendersearch.app/docs/clearances)
- [Person monitoring](https://offendersearch.app/docs/monitoring/person-monitoring)
- [Solutions: visitor management](https://offendersearch.app/docs/solutions/visitor-management)
- [Matching and confidence](https://offendersearch.app/docs/guides/sex-offender/matching-and-confidence)
