# Front-desk visitor screening

> Add a sex offender registry check to front-desk visitor sign-in: what to send from the ID, mapping results to Clear, Review or Match, and the audit trail.

- **HTML:** https://offendersearch.app/docs/front-desk-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; see https://offendersearch.app/docs/sandbox.md
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **All documentation as markdown:** https://offendersearch.app/docs.md

## Front-desk visitor screening: what to send from the ID

This guide is for teams building a sex offender registry check into a front-desk sign-in: a visitor management product, a school platform, or a district’s own app. The product side of the same workflow is on https://offendersearch.app/visitor-screening-api and https://offendersearch.app/school-visitor-screening-api.

Send three fields from the visitor’s ID: `firstName`, `lastName` and `dob` as `YYYY-MM-DD`. Add your own ids for the visitor (`subjectId`) and for the school or district (`clientId`). Omit `jurisdictions`: the default search covers 58 registries, so a visitor registered in another state is found at the desk as readily as a local one.

```json
{
  "query": { "firstName": "James", "lastName": "Smith", "dob": "1984-03-09" },
  "subjectId": "visitor-1042",
  "clientId": "district-17/school-3"
}
```

## Why date of birth matters in visitor screening

A name alone is not an identity. A common name matches many registrants, and none of them can be told apart from the visitor without more information. With the date of birth, every record whose full date of birth disagrees with the ID is left out, and the records that remain say how their date of birth was checked in `matchState`. With a date of birth, a common name usually returns zero or one person. A record from a registry that publishes only a birth year, an age, or no birth information stays in the answer, labelled `year_match`, `age_match` or `no_dob_age_year`, because it cannot be ruled out. Those are the records your desk reviews.

## Reading a visitor screening result: Clear, Review, Match

Map every response to one of three states. Take the strongest state any record reaches: one Match record makes the whole check a Match.

| State | Rule | What it means at the desk |
| --- | --- | --- |
| Clear | `status` is `"complete"` and `counts.records` is `0`. | No registrant matches the visitor. Continue the sign-in and print the badge. |
| Review | At least one record, and none meets the Match rule: `year_match`, `age_match`, `no_dob_age_year`, a `dob_match` below 0.90, and every record from a search sent without a `dob` (`matchState: null`). | A possible match. A staff member compares the photo and details with the ID before the visitor goes further. |
| Match | At least one record has `matchState: "dob_match"` and `matchConfidence` of 0.90 or higher. | A strong match: the visitor’s full date of birth matches a registrant with the same name. Alert an administrator quietly and follow the site’s protocol. |

`matchConfidence` measures how much identity evidence the record carries: a full date of birth and both names on file score 0.90, and 1.00 when more than one registry holds the person. A `dob_match` record is never capped, so it scores 0.90 or higher whenever both names are on file. But an exact-name record with a date of birth on file also scores 0.90 when you sent no `dob`, and nothing about the visitor was checked. Only `matchState` says the visitor’s date of birth was compared, so the rule reads both.

| matchState on the record | matchConfidence | Desk state |
| --- | --- | --- |
| dob_match, first and last name on file | 0.90 – 1.00 | Match |
| dob_match, a name missing from the record | 0.70 – 0.80 | Review |
| year_match | up to 0.85 | Review |
| age_match | up to 0.70 | Review |
| no_dob_age_year | up to 0.70 | Review |
| null (no dob sent) | up to 1.00 | Review |

Every state is a statement about the registry search, not about the person. A staff member confirms every Review and Match against the ID and the registry photo before anyone acts on it.

## Visitor screening desk UI: what staff see

1. Scan the ID: first name, last name and date of birth.
2. Search: `POST /v1/search` with `subjectId` and `clientId`.
3. Map the answer to Clear, Review or Match. The visitor’s screen shows none of it.
   - **Clear:** print the badge; log the searchId, time and state.
   - **Review:** the visitor waits; staff compare the photo, date of birth and details with the ID. Not this person: record a clearance and print the badge. Cannot rule it out: escalate to an administrator.
   - **Match:** no badge prints; a quiet alert goes to the designated administrator with the photo; the administrator confirms against the ID and follows the written protocol.

Never show a result on a screen the visitor or the line can see. For each candidate the staff screen shows the photo (`images[0].url`) beside the ID photo; name and `dob` (or `birthYear` or `age`); `registrationState` and addresses; how the record matched, in plain words; and the registry’s own page, `source.recordUrl`. Do not deny entry on a Review alone. State laws differ on when a registrant may be on school grounds, so the response to a confirmed match belongs to district policy and counsel.

## Repeat visitors: clearances for visitor screening

Send your stable id for the visitor as `subjectId` on every search. When a reviewer decides a returned person is not that visitor, record it with `POST /v1/clearances` (`verdict: "not_this_person"`, `reviewer`, `note`, and the `searchId`, `personRef` and `recordId`). Later searches with the same `subjectId` and `clientId` remove that person from `records[]`, list them in `cleared[]` and count them in `counts.clearedSuppressed`. A clearance names one `personRef`, never a name or a date of birth; a change to the person brings them back with `clearance.stale: true`. `clearanceMode` is `"suppress"` by default, or `"annotate"` / `"ignore"`. No extra charge, no expiry until revoked. See [Clearances](https://offendersearch.app/docs/clearances.md).

## Visitor screening for platforms serving many schools and districts

- **One account, one key per environment.** Every key on the account can search; separate keys can be rotated or revoked on their own.
- **`clientId` per school or district**, up to 128 printable characters, on every search and every clearance. Clearances are scoped to it, so one district’s decisions never change another’s results. It takes effect alongside a `subjectId`.
- **`subjectId` unique across your platform.** Both ids are stored only as per-account hashes.
- **Usage per school or district.** Usage and the invoice are per account. To report or bill each district, count the `searchId`s you stored against each `clientId`.
- Pre-registered visitors can be screened before the day with [Batch & CSV](https://offendersearch.app/docs/batch.md), which takes the same three fields per row.

## Visitor screening audit trail and data retention

Store one row per check, Clear ones included: the `searchId`; the timestamp from your own clock; the desk state; the `clientId` and `subjectId`; and for Review and Match, each candidate’s `personRef` and `recordId`, who reviewed it, the decision and any clearance `id`.

We keep each search and its results for 14 days. In that window it appears in the dashboard’s search history, a clearance can cite it, and [POST /v1/report](https://offendersearch.app/docs/reports.md) can turn it into a timestamped PDF. After 14 days the stored results are erased, so keep in your own records whatever your retention policy requires.

## Visitor screening speed and registry coverage

A search is designed to be sub-second, and every response carries `elapsedMs` so you can measure it in your own flow. Each search covers 58 registries: the 50 states, DC and the US territories.

## Visitor screening and the FCRA

A visitor check decides who is escorted today. Results from `POST /v1/search` are not a consumer report and may not be used for FCRA-covered decisions. When a result will help decide whether to hire or engage someone, such as a substitute teacher, a coach or a contracted caregiver, that check is a consumer report and runs as an FCRA order through FCRA Partner Access. See the [FCRA API](https://offendersearch.app/docs/fcra.md).

## Visitor screening integration code in Node and Python

A complete minimal integration: search at sign-in, map the answer to Clear, Review or Match, and record a reviewer’s decision as a clearance. It points at the sandbox; set `OFFENDERSEARCH_BASE` and `OFFENDERSEARCH_KEY` to go live.

```javascript
// desk-screening.mjs — Node 18+ (fetch is built in)
const BASE = process.env.OFFENDERSEARCH_BASE ?? 'https://sandbox.offendersearch.app';
const KEY = process.env.OFFENDERSEARCH_KEY;

// A Match is a record whose full date of birth matched the visitor's ID and
// whose first and last name are both on file: matchState "dob_match" scores 0.90+.
const MATCH_MIN_CONFIDENCE = 0.90;

async function call(path, body) {
  const res = await fetch(BASE + path, {
    method: 'POST',
    headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  return res.json();
}

/** Clear | Review | Match for one search response. */
export function deskState(result) {
  if (result.status === 'complete' && result.counts.records === 0) return 'clear';
  const strong = result.records.some(
    (r) => r.matchState === 'dob_match' && r.matchConfidence >= MATCH_MIN_CONFIDENCE,
  );
  return strong ? 'match' : 'review';
}

/** Run at sign-in, after the ID is scanned. Show the result to staff only. */
export async function screenVisitor({ visitorId, schoolId, firstName, lastName, dob }) {
  const result = await call('/v1/search', {
    query: { firstName, lastName, dob },  // dob as YYYY-MM-DD, from the ID
    subjectId: visitorId,                 // your stable id for this visitor
    clientId: schoolId,                   // your id for the school or district
  });
  return {
    state: deskState(result),
    searchId: result.searchId,
    checkedAt: new Date().toISOString(),
    // What the staff screen shows for Review and Match.
    candidates: result.records.map((r) => ({
      personRef: r.personRef,
      recordId: r.recordId,
      name: r.name.full,
      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,
    })),
  };
}

/** A reviewer decided a returned person is NOT the visitor at the desk. */
export async function clearCandidate({ visitorId, schoolId, searchId, candidate, reviewer, note }) {
  return call('/v1/clearances', {
    product: 'sex_offender',
    subjectId: visitorId,
    clientId: schoolId,
    personRef: candidate.personRef,
    recordId: candidate.recordId,
    searchId,
    verdict: 'not_this_person',
    reviewer,
    note,
  });
}

// Try it against the sandbox: prints "match".
const check = await screenVisitor({
  visitorId: 'visitor-1042', schoolId: 'district-17/school-3',
  firstName: 'Exact', lastName: 'Match', dob: '1980-01-01',
});
console.log(check.state, check.searchId);
```

```python
"""desk_screening.py — Python 3.9+, requests."""
import os
from datetime import datetime, timezone

import requests

BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")
KEY = os.environ["OFFENDERSEARCH_KEY"]

# A Match is a record whose full date of birth matched the visitor's ID and
# whose first and last name are both on file: matchState "dob_match" scores 0.90+.
MATCH_MIN_CONFIDENCE = 0.90


def _call(path, body):
    r = requests.post(BASE + path, headers={"X-API-Key": KEY}, json=body, timeout=30)
    return r.json()


def desk_state(result):
    """Clear | Review | Match for one search response."""
    if result["status"] == "complete" and result["counts"]["records"] == 0:
        return "clear"
    strong = any(
        r["matchState"] == "dob_match" and r["matchConfidence"] >= MATCH_MIN_CONFIDENCE
        for r in result["records"]
    )
    return "match" if strong else "review"


def screen_visitor(visitor_id, school_id, first_name, last_name, dob):
    """Run at sign-in, after the ID is scanned. Show the result to staff only."""
    result = _call("/v1/search", {
        "query": {"firstName": first_name, "lastName": last_name, "dob": dob},
        "subjectId": visitor_id,   # your stable id for this visitor
        "clientId": school_id,     # your id for the school or district
    })
    return {
        "state": desk_state(result),
        "searchId": result["searchId"],
        "checkedAt": datetime.now(timezone.utc).isoformat(),
        "candidates": [
            {
                "personRef": r["personRef"],
                "recordId": r["recordId"],
                "name": r["name"]["full"],
                "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, school_id, search_id, candidate, reviewer, note):
    """A reviewer decided a returned person is NOT the visitor at the desk."""
    return _call("/v1/clearances", {
        "product": "sex_offender",
        "subjectId": visitor_id,
        "clientId": school_id,
        "personRef": candidate["personRef"],
        "recordId": candidate["recordId"],
        "searchId": search_id,
        "verdict": "not_this_person",
        "reviewer": reviewer,
        "note": note,
    })


if __name__ == "__main__":
    # Try it against the sandbox: prints "match".
    check = screen_visitor("visitor-1042", "district-17/school-3",
                           "Exact", "Match", "1980-01-01")
    print(check["state"], check["searchId"])
```

## Test visitor screening in the API sandbox

Run each of these against `https://sandbox.offendersearch.app` with a test key and check that your app reaches the desk state shown. See [Sandbox & test keys](https://offendersearch.app/docs/sandbox.md) and the Postman collection at https://offendersearch.app/downloads/offendersearch-sandbox.postman_collection.json.

| Send | What comes back | Desk state |
| --- | --- | --- |
| `firstName: Clear · lastName: Visitor` | Clear visitor | Clear |
| `firstName: Exact · lastName: Match · dob: 1980-01-01` | Exact match, date of birth verified | Match |
| `firstName: Exact · lastName: Match` | Exact match, no date of birth sent | Review |
| `firstName: Exact · lastName: Match · dob: 1990-05-05` | Exact match name, different date of birth | Clear |
| `firstName: Name · lastName: Only` | Name only — possible matches | Review |
| `firstName: Alias · lastName: Match` | Matched through an alias | Review |
| `firstName: Out · lastName: State · state: TX` | Registered in another state | Review |
| `firstName: Multi · lastName: State` | One person on two registries | Review |
| `firstName: Homeless · lastName: Registrant` | Homeless registrant | Review |

---

## Related

- Previous: [Batch & CSV search](https://offendersearch.app/docs/batch.md)
- Next: [Sex offender API reference](https://offendersearch.app/docs/api-reference.md)
- Index: [OffenderSearch API documentation](https://offendersearch.app/docs.md)
