# Schools, camps and youth-serving organisations

> Coaches, counsellors, chaperones and the parent at the gate on game day: a registry check that answers in under a second, a criminal-records check a safety director can read, and a record of who was screened, when, and by whom.

- **HTML:** https://offendersearch.app/docs/solutions/youth-organizations
- **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 youth-serving organisations today

Youth-serving organisations screen on a season or a year, not on a hire. A volunteer coordinator, athletic director, camp director or safety officer collects an application and a consent form, orders a check through a screening provider or a state program, reviews anything that comes back, and records an approval before the person is placed with children. The rule-makers are the organisations themselves and the states, and the cadences are explicit. Little League requires a national criminal background check that includes a sex offender registry component on every volunteer and hired worker with repetitive access to children, every year, because appointments last one year; a registry hit must be referred to the national office before any appointment. The American Camp Association's accreditation standards call for a criminal background check on seasonal staff annually and on year-round staff at least every five years, plus an annual registry check and a voluntary disclosure statement. National governing bodies under the U.S. Center for SafeSport and the U.S. Olympic & Paralympic Committee generally require adults in positions of authority over athletes to pass a background screen every two years, with SafeSport training on its own cycle. Pennsylvania's Child Protective Services Law (Act 153 of 2014 as amended by Act 15 of 2015) requires volunteers with direct contact with children to hold three clearances — the state police criminal history, the child-abuse history clearance and, unless the volunteer has been a continuous Pennsylvania resident for ten years, an FBI fingerprint check — renewed every 60 months. The federal National Child Protection Act of 1993, as amended, is the framework through which qualified youth-serving entities obtain fingerprint-based national criminal history checks via their state.

Event day is a different rhythm. A school may verify whether a campus visitor is a registered sex offender against the state database or any other database it can access, and its board must adopt a policy for what happens when one is identified (Texas Education Code § 38.022 is the model many districts follow); several states make it an offence for a registrant to be on school property without permission or without reporting to the office (Louisiana R.S. 14:91.1 requires a registrant to notify the principal's office on arrival and departure). The organisation's need is a check that fits in the sign-in line: name and date of birth from an ID, an answer in a second, a review path that does not accuse a parent on a name coincidence.

Whether the FCRA applies to a volunteer check is a question for counsel. The statute governs consumer reports used for employment purposes; FTC staff have treated reports on volunteers as within that purpose in informal opinion letters, and many organisations run volunteer checks under the full FCRA process for that reason. Paid staff and coaches are employees, and their checks are consumer reports under the FCRA when a consumer reporting agency furnishes them. The records that matter are the registry match first — the strongest, most specific signal, verified by a full date of birth — and then convictions for offences against children, violence and, for drivers, serious traffic offences; an arrest or a dismissed charge is not a conviction and most organisational policies say so.

> **What the API does and does not decide.** The API returns candidates with the evidence for each match. A person reviews them and the organisation's policy decides. The standard API is not a consumer report and must not be the sole basis for an employment decision about paid staff; that path is the [FCRA Partner API](/docs/fcra). Counsel decides whether a given volunteer program must run the same way.

## Where the API fits

| Workflow step | Endpoint | Fields that matter |
| --- | --- | --- |
| Screen one volunteer or coach at application | `POST /v1/search` and `POST /v1/criminal/search` | `query.firstName`, `query.lastName`, `query.dob` from the application; `subjectId` = your volunteer id, `clientId` = the league, camp, school or chapter; registry: `matchState`, `matchDetail`; criminal: `cases[].charges[].disposition`, `matchState`, `recordCategory` |
| Re-screen the whole roster each season | `POST /v1/batch` (registry) and `POST /v1/criminal/batch` | Up to 1,000 rows; `subjectId` per row, `clientId` once; rows carry `counts.sourcesIncomplete` and `warnings[]` but no `searchId` |
| Check a visitor at the gate or the front office | `POST /v1/search` with `deadlineMs` | A bounded wait; `status: "partial"` is Incomplete, never Clear; show the state to staff only |
| A coordinator confirms a name match is someone else | `POST /v1/clearances` | `product: "sex_offender"`, `subjectId`, `clientId`, `personRef`, `recordId`, `searchId`, `verdict: "not_this_person"`, `reviewer` |
| Know about changes between seasons | `POST /v1/monitors` (`type: "person"`) | One monitor per dataset; `label` with the role; email to the safety officer; cancel when the person leaves |
| Paid staff and coaches (employment decisions) | `POST /v1/fcra/orders` | Through an approved partner: `permissiblePurpose: "employment"`, `consent`, `subject`, `useLocation` |
| A document for the volunteer file | `POST /v1/report`, `POST /v1/criminal/report` | `searchId` of the search (within 7 days); PDF with a citation per record and the use-restriction notice |

## Reference integration

One module, three jobs. `screen_volunteer` runs the registry and criminal checks for one applicant; `rescreen_roster` sweeps the active roster through batch at the start of a season; `gate_check` is the event-day call with a bounded wait. The outcome rule for the registry: **clear** is `complete` with no records; **incomplete** is no records with unfinished registries; **match** is `matchState: "dob_match"` on an exact legal surname; otherwise **review**. For criminal records every hit is **review** — the module counts convictions by reading each charge's `disposition` and counts DOB-verified records, and the safety director reads the rest. Run it against the sandbox with the [test names](/docs/sandbox).

**Python**
```python
"""Volunteer and coach screening — OffenderSearch, Python 3.11+, requests.

Three jobs in one module: screen one volunteer (registry + criminal records), re-screen the whole
roster each season (batch), and check a visitor at the gate on event day (registry, sub-second).

  OFFENDERSEARCH_API_KEY=os_test_...  OFFENDERSEARCH_BASE=https://sandbox.offendersearch.app  python volunteers.py
"""
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"}
ORG = "org-riverside-youth-soccer"           # your clientId: the league, camp, school or chapter


@dataclass
class Check:
    product: str                      # "registry" | "criminal"
    state: str                        # "clear" | "review" | "match" | "incomplete"
    reason: str
    search_id: str | None = None
    candidates: list[dict] = field(default_factory=list)


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 _registry_outcome(env: dict) -> Check:
    records = env["records"]
    if not records:
        ok = env["status"] == "complete"
        return Check("registry", "clear" if ok else "incomplete",
                     "No registrant matched." if ok else f"No match, {env['counts']['sourcesIncomplete']} source(s) unfinished.",
                     env.get("searchId"))
    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"]
    return Check("registry", "match" if strong else "review",
                 f"{len(records)} candidate(s), {len(strong)} with a confirmed date of birth. A coordinator reviews before any decision.",
                 env.get("searchId"), records)


def registry_check(volunteer_id: str, first: str, last: str, dob: str | None) -> Check:
    """Sex offender registry check across all 58 US registries. dob from the volunteer's ID when you hold it."""
    body = {"query": {"firstName": first, "lastName": last}, "subjectId": volunteer_id, "clientId": ORG}
    if dob:
        body["query"]["dob"] = dob
    r = _post("/v1/search", body)
    if r.status_code == 422:
        return Check("registry", "incomplete", f"Refused: {r.json()['detail']}")
    r.raise_for_status()
    return _registry_outcome(r.json())


def criminal_check(volunteer_id: str, first: str, last: str, dob: str | None, state: str | None) -> Check:
    """Criminal records check. There is no entitlement and no 403; 402 means billing, 422 is {error:{code,message}}."""
    query = {"lastName": last, "firstName": first}
    if dob:
        query["dob"] = dob
    if state:
        query["state"] = state
    r = _post("/v1/criminal/search", {"query": query, "perPage": 50, "subjectId": volunteer_id, "clientId": ORG})
    if r.status_code == 402:
        return Check("criminal", "incomplete", f"Billing: {r.json()['detail']}")
    if r.status_code == 422:
        return Check("criminal", "incomplete", f"Refused: {r.json()['error']['message']}")
    r.raise_for_status()
    env = r.json()                      # a cached search answers 200; 202 happens only with a live block that fans out
    records = env["records"]
    if not records:
        ok = env["status"] == "complete"
        return Check("criminal", "clear" if ok else "incomplete",
                     "No criminal record matched." if ok else "No match, but a source did not finish.", env["searchId"])
    convictions = [x for x in records
                   if any(c.get("disposition", "").lower() in ("guilty", "convicted")
                          for case in x.get("cases", []) for c in case.get("charges", []))]
    return Check("criminal", "review",
                 f"{len(records)} record(s) ({len(convictions)} with a conviction, "
                 f"{len([x for x in records if x.get('matchState') == 'dob_match'])} DOB-verified). "
                 "A booking or a dismissed charge is not a conviction - read each charge's disposition.",
                 env["searchId"], records)


def screen_volunteer(volunteer_id: str, first: str, last: str, dob: str | None, state: str | None) -> list[Check]:
    return [registry_check(volunteer_id, first, last, dob), criminal_check(volunteer_id, first, last, dob, state)]


def rescreen_roster(roster: list[dict]) -> list[tuple[str, Check]]:
    """Season re-screen: one POST /v1/batch for up to 1,000 volunteers. Clearances recorded for the org apply per row.
    Rows have no searchId - re-run POST /v1/search for anyone you need to cite or clear."""
    queries = [{"firstName": v["first"], "lastName": v["last"], "subjectId": v["id"],
                **({"dob": v["dob"]} if v.get("dob") else {})} for v in roster]
    r = _post("/v1/batch", {"queries": queries, "clientId": ORG})
    r.raise_for_status()
    out = []
    for v, row in zip(roster, r.json()["results"]):
        if row["status"] == "error":
            out.append((v["id"], Check("registry", "incomplete", row["error"])))
        else:
            out.append((v["id"], _registry_outcome({**row, "searchId": None})))
    return out


def gate_check(visitor_id: str, first: str, last: str, dob: str | None) -> Check:
    """Event day: the person is standing at the table. Same search, bounded wait; show the state to staff only."""
    body = {"query": {"firstName": first, "lastName": last}, "subjectId": visitor_id, "clientId": ORG,
            "deadlineMs": 8000}
    if dob:
        body["query"]["dob"] = dob
    r = _post("/v1/search", body)
    r.raise_for_status()
    return _registry_outcome(r.json())


if __name__ == "__main__":
    for c in screen_volunteer("vol-2088", "Exact", "Match", "1980-01-01", "OH"):
        print(f"{c.product:9} {c.state:10} {c.reason}")
    for c in screen_volunteer("vol-2089", "Felony", "Record", "1975-06-15", None):
        print(f"{c.product:9} {c.state:10} {c.reason}")
    for vid, c in rescreen_roster([{"id": "vol-2090", "first": "Clear", "last": "Visitor"},
                                   {"id": "vol-2091", "first": "Name", "last": "Only"}]):
        print(f"roster {vid}: {c.state} - {c.reason}")
    g = gate_check("gate-20261025-017", "Alias", "Match", None)
    print("gate:", g.state, "-", g.reason)
```

**JavaScript**
```javascript
// Volunteer and coach screening — OffenderSearch, Node 18+ (global fetch).
// Sandbox: OFFENDERSEARCH_API_KEY=os_test_...  OFFENDERSEARCH_BASE=https://sandbox.offendersearch.app
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 ORG = "org-riverside-youth-soccer";                 // your clientId: the league, camp, school or chapter
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");
}

function registryOutcome(env) {
  if (env.records.length === 0) {
    const ok = env.status === "complete";
    return { product: "registry", state: ok ? "clear" : "incomplete", searchId: env.searchId,
      reason: ok ? "No registrant matched." : "No match, " + env.counts.sourcesIncomplete + " source(s) unfinished." };
  }
  const strong = env.records.filter((r) => r.matchState === "dob_match" &&
    r.matchDetail.matchedNameType === "legal" && r.matchDetail.strategies.lastName === "exact");
  return { product: "registry", state: strong.length ? "match" : "review", searchId: env.searchId, candidates: env.records,
    reason: env.records.length + " candidate(s), " + strong.length + " with a confirmed date of birth. A coordinator reviews before any decision." };
}

/** Sex offender registry check across all 58 US registries. dob from the volunteer's ID when you hold it. */
export async function registryCheck(volunteerId, { firstName, lastName, dob }) {
  const r = await post("/v1/search", { query: { firstName, lastName, ...(dob ? { dob } : {}) }, subjectId: volunteerId, clientId: ORG });
  if (r.status === 422) return { product: "registry", state: "incomplete", reason: "Refused: " + JSON.stringify((await r.json()).detail) };
  if (!r.ok) throw new Error("HTTP " + r.status);
  return registryOutcome(await r.json());
}

/** Criminal records check. No entitlement and no 403; 402 is billing, 422 is {error:{code,message}}. */
export async function criminalCheck(volunteerId, { firstName, lastName, dob, state }) {
  const query = { lastName, firstName, ...(dob ? { dob } : {}), ...(state ? { state } : {}) };
  const r = await post("/v1/criminal/search", { query, perPage: 50, subjectId: volunteerId, clientId: ORG });
  if (r.status === 402) return { product: "criminal", state: "incomplete", reason: "Billing: " + (await r.json()).detail };
  if (r.status === 422) return { product: "criminal", state: "incomplete", reason: "Refused: " + (await r.json()).error.message };
  if (!r.ok) throw new Error("HTTP " + r.status);
  const env = await r.json();                               // cached search: 200. 202 only with a live block that fans out.
  if (env.records.length === 0) {
    const ok = env.status === "complete";
    return { product: "criminal", state: ok ? "clear" : "incomplete", searchId: env.searchId,
      reason: ok ? "No criminal record matched." : "No match, but a source did not finish." };
  }
  const isConviction = (c) => ["guilty", "convicted"].includes((c.disposition ?? "").toLowerCase());
  const convictions = env.records.filter((x) => (x.cases ?? []).some((k) => (k.charges ?? []).some(isConviction)));
  const verified = env.records.filter((x) => x.matchState === "dob_match");
  return { product: "criminal", state: "review", searchId: env.searchId, candidates: env.records,
    reason: env.records.length + " record(s) (" + convictions.length + " with a conviction, " + verified.length +
      " DOB-verified). A booking or a dismissed charge is not a conviction - read each charge's disposition." };
}

export async function screenVolunteer(volunteerId, person) {
  return [await registryCheck(volunteerId, person), await criminalCheck(volunteerId, person)];
}

/** Season re-screen: one POST /v1/batch for up to 1,000 volunteers. Rows have no searchId. */
export async function rescreenRoster(roster) {
  const queries = roster.map((v) => ({ firstName: v.firstName, lastName: v.lastName, subjectId: v.id, ...(v.dob ? { dob: v.dob } : {}) }));
  const r = await post("/v1/batch", { queries, clientId: ORG });
  if (!r.ok) throw new Error("HTTP " + r.status);
  const { results } = await r.json();
  return roster.map((v, i) => [v.id, results[i].status === "error"
    ? { product: "registry", state: "incomplete", reason: results[i].error }
    : registryOutcome({ ...results[i], searchId: null })]);
}

/** Event day: the person is at the table. Same search, bounded wait; show the state to staff only. */
export async function gateCheck(visitorId, { firstName, lastName, dob }) {
  const r = await post("/v1/search", { query: { firstName, lastName, ...(dob ? { dob } : {}) }, subjectId: visitorId, clientId: ORG, deadlineMs: 8000 });
  if (!r.ok) throw new Error("HTTP " + r.status);
  return registryOutcome(await r.json());
}

// Demo against the sandbox test names.
for (const c of await screenVolunteer("vol-2088", { firstName: "Exact", lastName: "Match", dob: "1980-01-01", state: "OH" })) console.log(c.product, c.state, "-", c.reason);
for (const c of await screenVolunteer("vol-2089", { firstName: "Felony", lastName: "Record", dob: "1975-06-15" })) console.log(c.product, c.state, "-", c.reason);
for (const [id, c] of await rescreenRoster([{ id: "vol-2090", firstName: "Clear", lastName: "Visitor" }, { id: "vol-2091", firstName: "Name", lastName: "Only" }])) console.log("roster", id + ":", c.state, "-", c.reason);
const g = await gateCheck("gate-20261025-017", { firstName: "Alias", lastName: "Match" });
console.log("gate:", g.state, "-", g.reason);
```

What the safety director sees for **review**: the registry candidate's photo (`images[]`), `name` and `aliases`, `dob` or `age`, `registrationState` and `addressStates`, `offenses[]` with tier and dates, and `matchDetail` saying how the name matched (`nickname`, `prefix`, `fuzzy`, an alias). For a criminal record: `recordType` (a `court_case`, `jail_booking`, `doc_inmate`, `warrant`), each case's `charges[]` with `statute`, `description`, `severity`, `disposition` and `dispositionDate`, the record's `matchState` and `unverified` flag, and `recordCategory` for a closed vocabulary. The [criminal guides](/docs/guides/criminal/arrests-charges-convictions) explain how to read a booking against a conviction. A confirmed false positive goes back as a clearance so the same parent-coach is not flagged again next season.

> **Send the date of birth.** Every organisational standard above screens on a full date of birth. Without `query.dob`, a common name returns possible matches with `matchState: null` and every hit is Review; with it, a record whose published birth date contradicts the applicant's is excluded and a confirmed one is labelled `dob_match`. [Searching by date of birth](/docs/date-of-birth).

## Ongoing monitoring

Annual and biennial cycles leave long gaps. A person monitor on each active volunteer closes them: `product: "sex-offender"`, `type: "person"`, the name and date of birth from the approved application, `minConfidence` left unset (which means `year_match` when a `dob` is sent), and the safety officer's address in `channels.email`. A `new` alert is a reason to re-run the check and review, not a decision. Create a second monitor with `product: "criminal"` for the records side. Cancel both when the volunteer leaves the roster; evaluation stops at once and nothing is charged on an invoice prepared after the cancel.

*Watch one coach between seasons*
```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" },
    "label": "org-riverside-youth-soccer / vol-2088 (U12 coach)",
    "channels": { "email": "safety@riverside-youth.example" }
  }'
```

For a campus or camp property, a location monitor (`type: "location"`, a radius around the site) tells the office when a registrant is newly listed nearby, which many districts fold into their § 38.022-style visitor policy. [Person monitoring](/docs/monitoring/person-monitoring), [Location monitoring](/docs/monitoring/location-monitoring).

## When this is FCRA

A paid coach, counsellor, bus driver or teacher is an employee; a report used to decide whether to hire or keep them is a consumer report, and the path is the [FCRA Partner API](/docs/fcra) through an approved partner: an end user for the organisation with `permissiblePurposes: ["employment"]`, one order per candidate with a `consent` attestation and `useLocation`, results by signed webhook, a report with a verifiable receipt, and a dispute path for the candidate. The organisation still owes the standalone disclosure and written authorization before the order and the pre-adverse and adverse-action notices after it (15 U.S.C. § 1681b(b), § 1681m). For unpaid volunteers, whether the same process is required is for counsel; organisations that choose it run volunteers through the same orders with the same purpose. The standard Sex Offender and Criminal Records APIs used in the module above are not consumer reports and must not be the sole basis for an employment decision; they serve the organisation's own safety screening and its event-day checks.

## Evidence and audit

Accreditors, insurers and parents ask the same question: who was screened, when, against what, and who decided. Record this per person per season:

*A volunteer screening record your system keeps*
```json
{
  "volunteerId": "vol-2088",
  "season": "2026-27",
  "screenedAt": "2026-10-25T14:02:11Z",
  "registry":  { "searchId": "srch_9f2c1a7b", "status": "complete", "sourcesComplete": 58, "sourcesQueried": 58, "state": "clear" },
  "criminal":  { "searchId": "crs_7f2a1c9e0b4d6a8f1e23", "status": "complete", "state": "review",
                 "recordsReviewed": 1, "reviewer": "safety-director", "decision": "approved", "decidedAt": "2026-10-26T09:15:00Z" },
  "clearances": ["clr_7d2f41c0a9b3e8f6"],
  "monitorId": "mon_b8f40d7afb9dc0d8",
  "reportPdf": "rpt_9f2c1a7b3e4d"
}
```

- `searchId` and `status` for each product, with `counts.sourcesComplete` against `counts.sourcesQueried` so a partial sweep is visible later.
- The reviewer, the decision and its time for anything that was `review` or `match`; the clearance ids for confirmed false positives (`GET /v1/clearances?subjectId=…&clientId=…&includeRevoked=true` reproduces them).
- The report id from `POST /v1/report` or `POST /v1/criminal/report` (the `X-Report-Id` header) when policy wants a PDF in the file; both must be requested within 7 days of the search.
- Stored results remain readable at `GET /v1/searches/{searchId}/results` for 14 days (`410` afterwards); your record outlives them, so keep the fields above rather than the raw envelope.
- The monitor id, so a later alert can be tied to the person and the season it concerned.
- For event-day checks, the `searchId`, the desk state and the staff member who handled a `review` or `match`; never the ID image or document number — a hash of it as `subjectId` is enough to recognise a return visit.

## Rollout plan

1. **Write the policy first.** Which roles are screened, how often, which outcomes block placement, who reviews, and what the event-day procedure is for Review and Match. Map it to the standards that bind you (your league or NGB, your accreditor, your state).
2. **Sandbox.** With an `os_test_` key, run the module above through a clear volunteer, a DOB-verified registry match, a name-only review, a criminal conviction and a booking without disposition; record a clearance and re-run.
3. **Application form.** Collect legal name, prior names, full date of birth and states lived in; send prior names as a second search and merge by `personRef`.
4. **Reviewer screen.** Photo, offenses, registry state, `matchDetail`; criminal charges with dispositions; a one-click `not_this_person` that posts the clearance.
5. **Season sweep and monitors.** Schedule the batch re-screen on your season boundary; create person monitors on approval and cancel on departure.
6. **Paid staff.** Route employee checks through your FCRA partner; keep volunteer and employee flows visibly separate in the UI.
7. **Go live.** Switch host and key; confirm the first `searchId` opens on `GET /v1/searches/{searchId}/results` and the first report renders. [Go-live checklist](/docs/guides/sex-offender/go-live).

## Related

- [Front-desk visitor screening](https://offendersearch.app/docs/front-desk-visitor-screening): the event-day check in detail
- [Searching by date of birth](https://offendersearch.app/docs/date-of-birth)
- [Arrests vs. charges vs. convictions](https://offendersearch.app/docs/guides/criminal/arrests-charges-convictions)
- [Person monitoring](https://offendersearch.app/docs/monitoring/person-monitoring)
- [Verification reports](https://offendersearch.app/docs/reports)
