# Background-check and screening platforms

> One integration serving many clients: per-tenant isolation, batch and asynchronous intake, reviewer verdicts that stick, usage you can reconcile, and a precise hand-off to the FCRA Partner API for regulated products.

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

A background-screening platform sells reports and workflow to many clients at once: employers, property managers, gig marketplaces, care providers, volunteer organisations, dating and sharing-economy apps. The platform receives an order from a client system — an applicant-tracking system, a property-management system, a marketplace back office, or the platform's own portal — collects the subject's identifiers (name, date of birth, addresses, sometimes an alias list), runs the components the client ordered, applies quality review to anything that returns a record, assembles a report, and pushes a status and a result link back to the client system. Two integration shapes dominate: the client system sends the complete order in one call, or it sends the minimum and the platform emails the subject a link to finish the form and give consent. Status flows back through polling or webhooks, and results land in the client's candidate or applicant record.

The regulatory line that shapes the whole industry is whether a given report is a **consumer report** under the Fair Credit Reporting Act. When a report is used for employment, housing, credit or insurance eligibility, the platform (or the consumer reporting agency behind it) owes the FCRA duties: a permissible purpose for each report and a certification from the end user (15 U.S.C. § 1681b and § 1681e(a)); reasonable procedures to assure maximum possible accuracy (§ 1681e(b)); for public-record information reported for employment purposes, either contemporaneous notice to the consumer or strict procedures to keep the information complete and up to date (§ 1681k); a summary of consumer rights with each report (§ 1681g(c)); and a reinvestigation path for disputes (§ 1681i). The user of the report owes the disclosure, authorization and adverse-action steps described in the [FCRA access and end-user pages](/docs/fcra/access) and the [orders reference](/docs/fcra/orders), and in the staffing and property-management articles in this section. Whether a particular product is a consumer report is a question of use, not of data: the same registry record can be furnished in a hiring report or shown to a hospital front desk, and only the first is regulated by the FCRA.

Many platform products are not consumer reports: resident and visitor screening for facilities, vendor and contractor on-site access, trust and safety checks inside a marketplace or dating product, and internal risk review. Those run under the platform's own policies and under state rules on how registry information may be used — notably California Penal Code § 290.46(j), Nevada NRS 179B.270 and New Jersey N.J.S.A. 2C:7-16(c), which list purposes (insurance, loans, credit, education, housing, and in California and Nevada employment) for which information from the state's public registry site may not be used, with exceptions for uses authorised by other law. What each rule requires is stated here; whether it applies to a given client and product is for the client's counsel.

> **Two products, one integration.** The self-service Sex Offender and Criminal Records APIs are not consumer reports and must not be the sole basis for an employment, housing, credit or insurance decision. Products that make those decisions run through the [FCRA Partner API](/docs/fcra), which is a different set of endpoints with an end-user model, consent attestation, signed webhooks and a dispute path. A platform typically runs both: self-service searches for its non-regulated products and FCRA orders for its regulated ones, under one account.

## Where the API fits

| Platform step | Endpoint | Fields that matter |
| --- | --- | --- |
| A client's user runs one check and waits | `POST /v1/search` | `query.firstName`, `query.lastName`, `query.dob`; `subjectId` (your id for the person) and `clientId` (your id for the client) on every call; read `status`, `counts.sourcesIncomplete`, `records[].matchState`, `records[].matchDetail`, `cleared[]` |
| Roster or file intake, up to 1,000 people | `POST /v1/batch` | JSON `queries[]` or a `text/csv` body; `subjectId` per row, `clientId` once at the envelope; results in input order; rows carry no `searchId` and no `sourceStatus` — read `counts.sourcesIncomplete` and `warnings[]` per row |
| Unattended re-screens, scheduled jobs | `POST /v1/searches` → `GET /v1/searches/{searchId}` | `webhookUrl` (unsigned — put a secret token in the URL); `202` body `resultsUrl`; poll until `status` leaves `pending`/`running` |
| Criminal records component | `POST /v1/criminal/search` | `query.lastName` required; `subjectId`/`clientId` work the same way; `202` with `poll` only when a `live` block fans out; refusals are `{"error": {"code", "message"}}`; `meterEvents[]` on every response |
| A reviewer decides a candidate is or is not the subject | `POST /v1/clearances` | `product` (`sex_offender` or `criminal`), `subjectId`, `clientId`, `personRef`, `searchId` (within 14 days), `verdict`; `GET /v1/clearances?subjectId=…&clientId=…` lists them |
| Keep a client's roster current | `POST /v1/monitors` | `product` + `type`; `label` carrying your tenant id; `channels.webhookUrl` or `emails[]` |
| Regulated products (employment, housing) | `POST /v1/fcra/end-users`, `POST /v1/fcra/orders` | One end user per client with `permissiblePurposes` and a `certification`; one order per consumer with `permissiblePurpose`, `consent`, `subject`, `searches[]` |
| Usage reconciliation per client | `meterEvents[]`, [Dashboard → Usage](/dashboard/usage) | Criminal responses always carry `meterEvents[]`; sex offender responses only when a `live` block ran. Regular sex offender searches reconcile from the dashboard, keyed by the `clientId` you log beside each `searchId` |

Isolation lives in the identifiers, not in keys. Send the same `clientId` for everything one client does and that client's clearances never touch another client's results; both ids are stored only as per-account hashes. Issue separate API keys only where you need separate revocation. The [platform data model](/docs/platform/data-model) and [clearances reference](/docs/clearances) have the full field lists.

## Reference integration

A complete module for the sex offender component: an interactive check, roster intake through batch, an unattended re-screen through the asynchronous endpoint, and the reviewer verdict that makes a false positive disappear for that client from then on. The outcome rule is the one every tenant shares: **clear** is a `complete` answer with no records; **incomplete** is no records but at least one registry that did not finish (never shown as clear); **match** is a record whose full date of birth agreed (`matchState: "dob_match"`) and whose legal name matched exactly on both fields (`matchDetail.strategies`, `matchedNameType: "legal"`); everything else is **review**. Run it against the sandbox with an `os_test_` key — the names are the [sandbox test names](/docs/sandbox).

**Python**
```python
"""Multi-tenant screening — OffenderSearch Sex Offender Search API, Python 3.11+, requests.

Run against the sandbox:
  OFFENDERSEARCH_API_KEY=os_test_...  OFFENDERSEARCH_BASE=https://sandbox.offendersearch.app  python screening.py

Every client of your platform is a clientId; every person a client screens is a subjectId.
Both are stored only as per-account hashes, so one client's clearances never reach another's results.
"""
from __future__ import annotations

import os
import time
from dataclasses import dataclass, field

import requests

API_KEY = os.environ["OFFENDERSEARCH_API_KEY"]                       # os_test_... against the sandbox
BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")
HEADERS = {"X-API-Key": API_KEY, "Content-Type": "application/json"}


@dataclass
class Outcome:
    state: str                      # "clear" | "review" | "match" | "incomplete"
    reason: str
    search_id: str | None = None
    candidates: list[dict] = field(default_factory=list)
    cleared: list[dict] = field(default_factory=list)
    incomplete_sources: list[str] = field(default_factory=list)


def _request(method: str, path: str, **kw) -> requests.Response:
    """429 -> wait Retry-After; 5xx -> back off. Everything else goes back to the caller."""
    for attempt in range(4):
        r = requests.request(method, f"{BASE}{path}", headers=HEADERS, timeout=60, **kw)
        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 classify(envelope: dict) -> Outcome:
    """One rule for every tenant. A match is a candidate for a human reviewer, never a confirmed identity."""
    records = envelope["records"]
    incomplete = [s["source"] for s in envelope.get("sourceStatus", []) if s.get("incomplete")]
    if not records:
        if envelope["status"] == "complete":
            return Outcome("clear", "Complete answer, no record matched.", envelope.get("searchId"),
                           cleared=envelope.get("cleared", []))
        n = envelope["counts"]["sourcesIncomplete"]
        return Outcome("incomplete", f"No match, but {n} source(s) did not finish - not a clear result.",
                       envelope.get("searchId"), incomplete_sources=incomplete)
    # Gate on matchState and matchDetail.strategies, not on matchConfidence alone.
    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"
    ]
    state = "match" if strong else "review"
    reason = f"{len(records)} candidate(s); {len(strong)} with an exact legal name and a confirmed date of birth."
    if incomplete:
        reason += f" {len(incomplete)} source(s) did not finish, so the list is a lower bound."
    return Outcome(state, reason, envelope.get("searchId"), records, envelope.get("cleared", []), incomplete)


def screen(tenant_id: str, subject_id: str, first: str, last: str, dob: str | None) -> Outcome:
    """Interactive check - a user at the tenant is waiting. The tenant's clearances apply automatically."""
    body = {"query": {"firstName": first, "lastName": last}, "subjectId": subject_id, "clientId": tenant_id}
    if dob:
        body["query"]["dob"] = dob                                   # YYYY-MM-DD
    r = _request("POST", "/v1/search", json=body)
    if r.status_code == 422:
        raise ValueError(f"Refused: {r.json()['detail']}")          # nothing ran, nothing billed
    r.raise_for_status()
    return classify(r.json())


def intake_batch(tenant_id: str, rows: list[dict]) -> list[Outcome]:
    """Roster intake: up to 1,000 subjects in one call, results in input order. Each row is one billable search.
    A row carries no searchId and no sourceStatus - run POST /v1/search for a row you need to cite or clear."""
    queries = []
    for x in rows:
        q = {"firstName": x["first"], "lastName": x["last"], "subjectId": x["subject_id"]}
        if x.get("dob"):
            q["dob"] = x["dob"]
        queries.append(q)
    r = _request("POST", "/v1/batch", json={"queries": queries, "clientId": tenant_id})
    if r.status_code == 422:
        raise ValueError(f"A row could not be read: {r.json()['detail']}")   # names the row index and field
    r.raise_for_status()
    out = []
    for row in r.json()["results"]:
        if row["status"] == "error":
            out.append(Outcome("incomplete", f"Row {row['index']} failed: {row['error']}"))
        else:
            out.append(classify({**row, "sourceStatus": []}))
    return out


def submit_unattended(tenant_id: str, subject_id: str, first: str, last: str, dob: str | None,
                      webhook_url: str) -> str:
    """Scheduled re-screen: the finished envelope is POSTed to webhook_url. Those deliveries are not signed,
    so put a secret token in the URL and confirm with GET /v1/searches/{searchId} before acting."""
    body = {"query": {"firstName": first, "lastName": last}, "subjectId": subject_id,
            "clientId": tenant_id, "webhookUrl": webhook_url}
    if dob:
        body["query"]["dob"] = dob
    r = _request("POST", "/v1/searches", json=body)
    r.raise_for_status()                                             # 202 AsyncSearchAccepted
    return r.json()["searchId"]                                      # also: resultsUrl, estimatedCompletionSec


def collect(search_id: str) -> Outcome | None:
    """Poll resultsUrl when no webhook arrives. None while the job is pending or running."""
    r = _request("GET", f"/v1/searches/{search_id}")
    r.raise_for_status()
    data = r.json()
    if data["status"] in ("pending", "running"):
        return None
    if data["status"] == "error":
        return Outcome("incomplete", f"Job failed: {data.get('error')}", search_id)
    return classify(data)


def record_verdict(tenant_id: str, subject_id: str, search_id: str, person_ref: str, record_id: str,
                   reviewer: str, not_this_person: bool, note: str = "") -> dict:
    """A reviewer's verdict. It cites the search that showed the person; it matches by personRef, never by name."""
    body = {"product": "sex_offender", "subjectId": subject_id, "clientId": tenant_id,
            "personRef": person_ref, "recordId": record_id, "searchId": search_id,
            "verdict": "not_this_person" if not_this_person else "this_person",
            "reviewer": reviewer, "note": note}
    r = _request("POST", "/v1/clearances", json=body)
    if r.status_code in (404, 409):              # search_not_found / person_not_in_search
        raise ValueError(r.json()["detail"]["message"])
    r.raise_for_status()
    return r.json()                               # 201 Clearance: id clr_..., supersedes


if __name__ == "__main__":
    tenant = "tenant-acme-care"
    o = screen(tenant, "applicant-1001", "Exact", "Match", "1980-01-01")
    print(o.state, "-", o.reason, "| searchId:", o.search_id)        # match

    o = screen(tenant, "applicant-1002", "Name", "Only", None)
    print(o.state, "-", o.reason)                                    # review: three people, no DOB on file
    first = o.candidates[0]
    c = record_verdict(tenant, "applicant-1002", o.search_id, first["personRef"], first["recordId"],
                       reviewer="qa-desk", not_this_person=True, note="Photo and address do not match the applicant.")
    o = screen(tenant, "applicant-1002", "Name", "Only", None)
    print(o.state, "- now", len(o.candidates), "candidate(s),", len(o.cleared), "hidden by", c["id"])

    for row in intake_batch(tenant, [{"subject_id": "r-1", "first": "Clear", "last": "Visitor"},
                                     {"subject_id": "r-2", "first": "Multi", "last": "State", "dob": "1972-03-14"}]):
        print("batch:", row.state, "-", row.reason)
```

**JavaScript**
```javascript
// Multi-tenant screening — OffenderSearch Sex Offender Search API, Node 18+ (global fetch).
// Sandbox: OFFENDERSEARCH_API_KEY=os_test_...  OFFENDERSEARCH_BASE=https://sandbox.offendersearch.app
// Every client of your platform is a clientId; every person a client screens is a subjectId.
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));

async function request(method, path, body) {
  for (let attempt = 0; attempt < 4; attempt++) {
    const r = await fetch(BASE + path, { method, headers: HEADERS, body: body ? JSON.stringify(body) : undefined });
    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");
}

/** One rule for every tenant: clear | review | match | incomplete. A match is a candidate for a reviewer. */
export function classify(env) {
  const incompleteSources = (env.sourceStatus ?? []).filter((s) => s.incomplete).map((s) => s.source);
  if (env.records.length === 0) {
    if (env.status === "complete") return { state: "clear", reason: "Complete answer, no record matched.", searchId: env.searchId, cleared: env.cleared ?? [] };
    return { state: "incomplete", reason: "No match, but " + env.counts.sourcesIncomplete + " source(s) did not finish.", searchId: env.searchId, incompleteSources };
  }
  // Gate on matchState and matchDetail.strategies, not on matchConfidence alone.
  const strong = env.records.filter((r) =>
    r.matchState === "dob_match" && r.matchDetail.matchedNameType === "legal" &&
    r.matchDetail.strategies.lastName === "exact" && r.matchDetail.strategies.firstName === "exact");
  return {
    state: strong.length ? "match" : "review",
    reason: env.records.length + " candidate(s); " + strong.length + " with an exact legal name and a confirmed date of birth." +
      (incompleteSources.length ? " Some sources did not finish, so the list is a lower bound." : ""),
    searchId: env.searchId, candidates: env.records, cleared: env.cleared ?? [], incompleteSources,
  };
}

/** Interactive check: a user at the tenant is waiting. The tenant's clearances apply automatically. */
export async function screen(tenantId, subjectId, { firstName, lastName, dob }) {
  const body = { query: { firstName, lastName, ...(dob ? { dob } : {}) }, subjectId, clientId: tenantId };
  const r = await request("POST", "/v1/search", body);
  if (r.status === 422) throw new Error("Refused: " + JSON.stringify((await r.json()).detail));
  if (!r.ok) throw new Error("HTTP " + r.status);
  return classify(await r.json());
}

/** Roster intake: up to 1,000 rows, results in input order, each row one billable search. Rows carry no searchId. */
export async function intakeBatch(tenantId, rows) {
  const queries = rows.map((x) => ({ firstName: x.firstName, lastName: x.lastName, ...(x.dob ? { dob: x.dob } : {}), subjectId: x.subjectId }));
  const r = await request("POST", "/v1/batch", { queries, clientId: tenantId });
  if (r.status === 422) throw new Error("A row could not be read: " + JSON.stringify((await r.json()).detail));
  if (!r.ok) throw new Error("HTTP " + r.status);
  return (await r.json()).results.map((row) =>
    row.status === "error" ? { state: "incomplete", reason: "Row " + row.index + " failed: " + row.error } : classify({ ...row, sourceStatus: [] }));
}

/** Scheduled re-screen: the finished envelope is POSTed to webhookUrl (unsigned — put a secret token in the URL). */
export async function submitUnattended(tenantId, subjectId, { firstName, lastName, dob }, webhookUrl) {
  const body = { query: { firstName, lastName, ...(dob ? { dob } : {}) }, subjectId, clientId: tenantId, webhookUrl };
  const r = await request("POST", "/v1/searches", body);
  if (r.status !== 202) throw new Error("HTTP " + r.status);
  return (await r.json()).searchId;                                   // also: resultsUrl, estimatedCompletionSec
}

/** Poll resultsUrl when no webhook arrives. Returns null while pending or running. */
export async function collect(searchId) {
  const r = await request("GET", "/v1/searches/" + searchId);
  if (!r.ok) throw new Error("HTTP " + r.status);
  const data = await r.json();
  if (data.status === "pending" || data.status === "running") return null;
  if (data.status === "error") return { state: "incomplete", reason: "Job failed: " + data.error, searchId };
  return classify(data);
}

/** A reviewer's verdict: cites the search that showed the person, matches by personRef only. */
export async function recordVerdict(tenantId, subjectId, searchId, personRef, recordId, reviewer, notThisPerson, note = "") {
  const r = await request("POST", "/v1/clearances", {
    product: "sex_offender", subjectId, clientId: tenantId, personRef, recordId, searchId,
    verdict: notThisPerson ? "not_this_person" : "this_person", reviewer, note,
  });
  if (r.status === 404 || r.status === 409) throw new Error((await r.json()).detail.message); // search_not_found / person_not_in_search
  if (r.status !== 201) throw new Error("HTTP " + r.status);
  return r.json();                                                     // Clearance: id clr_..., supersedes
}

// Demo against the sandbox test names.
const tenant = "tenant-acme-care";
let o = await screen(tenant, "applicant-1001", { firstName: "Exact", lastName: "Match", dob: "1980-01-01" });
console.log(o.state, "-", o.reason, "| searchId:", o.searchId);       // match
o = await screen(tenant, "applicant-1002", { firstName: "Name", lastName: "Only" });
console.log(o.state, "-", o.reason);                                   // review: three people, no DOB on file
const first = o.candidates[0];
const c = await recordVerdict(tenant, "applicant-1002", o.searchId, first.personRef, first.recordId, "qa-desk", true, "Photo and address do not match.");
o = await screen(tenant, "applicant-1002", { firstName: "Name", lastName: "Only" });
console.log(o.state, "- now", o.candidates.length, "candidate(s),", o.cleared.length, "hidden by", c.id);
for (const row of await intakeBatch(tenant, [{ subjectId: "r-1", firstName: "Clear", lastName: "Visitor" }, { subjectId: "r-2", firstName: "Multi", lastName: "State", dob: "1972-03-14" }])) {
  console.log("batch:", row.state, "-", row.reason);
}
```

What a reviewer sees for a **review** or **match**: each candidate's `name`, `aliases`, `dob` or `birthYear` or `age`, `registrationState` and `addressStates`, `offenses[]`, `images[]`, `matchDetail` and `source.recordUrl` — enough to compare against the subject's own documents. The reviewer's verdict goes back as a clearance citing the `searchId` that showed the person; the next search for that subject and client lists the person in `cleared[]` with `counts.clearedSuppressed` incremented, or keeps and annotates them in `clearanceMode: "annotate"` for an interface that greys cleared people out. A cleared person who re-surfaces with a stronger match or a changed record comes back with `clearance.stale: true` and a `staleReason`, and stays until the reviewer decides again.

> **Batch rows cannot be cited or cleared.** A `POST /v1/batch` row has no `searchId`, so it cannot be opened with `GET /v1/searches/{searchId}/results`, rendered with `POST /v1/report`, or cited by a clearance. Use batch for the sweep; run `POST /v1/search` for any person a reviewer will look at. The criminal component has its own [batch endpoint](/docs/criminal/batch) with the same shape.

## Ongoing monitoring

Clients who hold a roster — a care provider's staff, a marketplace's active providers, a league's coaches — want to know when something changes between screens. A person monitor is one standing watch on one dataset: `product: "sex-offender"` or `product: "criminal"`, so watching both is two monitors. Put your tenant and subject ids in `label` so an alert can be routed without a lookup, and deliver to a per-tenant or per-platform `webhookUrl`. Monitoring deliveries are not signed today; authenticate them with a secret token in the URL you register and confirm anything consequential by reading `GET /v1/monitors/{monitorId}/alerts`.

*Create a person monitor for one tenant's subject*
```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": "Exact", "lastName": "Match", "dob": "1980-01-01" },
    "minConfidence": "year_match",
    "label": "tenant-acme-care / applicant-1001",
    "channels": { "webhookUrl": "https://your-app.example.com/hooks/offendersearch?token=9f2c1a7b" }
  }'
```

*The monitor.alert delivery (sandbox: posted once, right after the 201)*
```json
{
  "type": "monitor.alert",
  "event": "new",
  "alertId": "hit_7fa176c8889c7569",
  "monitorId": "mon_b8f40d7afb9dc0d8",
  "monitorLabel": "tenant-acme-care / applicant-1001",
  "product": "sex-offender",
  "detail": {
    "name": "EXACT SAMPLE MATCH",
    "jurisdiction": "OH",
    "locality": "Sampleville",
    "matchLabel": "dob_match",
    "matchConfidence": 1.0,
    "personRef": "prs_3c9e1f0a7b2d4e6f8a1b",
    "recordId": "rec_6d066763131ebe8c77ea"
  },
  "occurredAt": "2026-10-25T12:00:41+00:00",
  "sentAt": "2026-10-25T12:01:03+00:00"
}
```

Alerts carry `event` (`new`, `changed`, `removed`), the `personRef` the alert is keyed on, and for sex offender monitors the `recordId` to open with `GET /v1/records/{recordId}`. Treat an alert as a trigger for a fresh search or an FCRA order, not as a result. Cancel with `DELETE /v1/monitors/{monitorId}` when the client offboards a person: evaluation stops at once and the monitor is not charged on any invoice prepared after the cancel. [Alerts](/docs/monitoring/alerts), [Delivery](/docs/monitoring/delivery), [Billing](/docs/monitoring/billing).

## When this is FCRA

If a product's result will help a client decide whether to hire someone or rent to them, the product is a consumer report and runs on the [FCRA Partner API](/docs/fcra). Access is granted to approved partners under a written agreement; without it every FCRA route answers `403 fcra_not_enabled`, and `GET /v1/fcra/partner` reports `enabled: false` without a 403 so you can show the state in your admin UI. What changes in the integration:

- **Your client becomes an end user.** `POST /v1/fcra/end-users` with the client's `name`, its `permissiblePurposes` (`employment`, `tenant_screening`, `consumer_written_instructions`, `legitimate_business_need`) and your `certification` of it. A purpose outside your partner agreement is `403 fcra_purpose_not_allowed`.
- **Each search becomes an order.** `POST /v1/fcra/orders` names the `endUserId`, one `permissiblePurpose`, a `consent` attestation (`obtainedAt`, `method`, `documentRef`), the `subject` with a full date of birth, `useLocation` (the state the job or housing is in) and one or two `searches[]`. It answers `202` with status `received`; searches run in the background under the accuracy controls, and records that fail them are withheld with a reason rather than furnished.
- **Results come by signed webhook.** `PUT /v1/fcra/webhook-endpoint` returns a `secret` once; every `search.completed`, `order.completed`, `dispute.created` and `record.corrected` delivery carries `X-Offendersearch-Signature: t=…,v1=…`, an HMAC-SHA256 over `"<t>." + body`. Verify before acting; `GET /v1/fcra/events` is the replayable log.
- **The report is the deliverable.** `GET /v1/fcra/orders/{order_id}/report` as JSON or the stored PDF, with `integrity.reportSha256`, and a public Ed25519 receipt at `GET /v1/fcra/receipts/{order_id}` that anyone can verify without a key. Every order also carries `consumerPortal.url` and `accessCode` for the consumer's copy and dispute path.
- **Disputes have a clock.** `POST /v1/fcra/orders/{order_id}/disputes` opens a reinvestigation due 30 days from `receivedAt`, with one 15-day extension through `/information`.

Integrating the FCRA Partner API does not make a platform or its clients compliant: the disclosure, authorization, adjudication, pre-adverse and adverse-action steps remain the user's, and the platform's own § 1681e procedures remain its own. The API gives those steps a report whose provenance can be shown. Counsel decides which products are consumer reports.

## Evidence and audit

- **Persist every `searchId`** with the `clientId`, `subjectId`, `status`, `counts`, `sourceStatus[]` and your outcome. That row is the proof of what was searched and when; it is also what a clearance must cite.
- **Re-open stored results** with `GET /v1/searches/{searchId}/results` for the account's retention window (`retentionDays`, 14 days today); after that the endpoint answers `410` and the search must be run again.
- **Render a verification report** with `POST /v1/report` for a search up to 7 days old: one consolidated PDF of every matching record and every field on file, with a citation per record and the use-restriction notice printed on it. The `X-Report-Id` header is the document id to store.
- **Capture the registry's own page** when a client's policy requires primary evidence: `live.evidence: "pdf"` with `live.jurisdictions` answers `202` with `pollUrl`; the finished search carries `evidence[]` with a `downloadUrl` per registry, readable for 30 days. [Live evidence](/docs/live-evidence).
- **Clearances are an audit trail.** `GET /v1/clearances?subjectId=…&clientId=…&includeRevoked=true` returns every verdict with `reviewer`, `note`, `createdAt`, `revokedAt` and `supersedes`.
- **FCRA orders write their own evidence**: `GET /v1/fcra/orders/{order_id}/evidence` lists every official-source capture with a SHA-256; the receipt binds the report hash to the order. No retention period is published for FCRA evidence; keep your own copies to your policy.

## Rollout plan

1. **Sandbox.** [Create an account](/sign-up), mint a test key under [Dashboard → API keys](/dashboard/keys), and run the module above against `https://sandbox.offendersearch.app`. Exercise clear, review, match and incomplete, and a clearance round-trip, on the [sandbox names](/docs/sandbox).
2. **Tenant model.** Decide the `clientId` grain (one per billing client) and the `subjectId` grain (one per person per client). Log both beside every `searchId` for reconciliation.
3. **Reviewer UI.** Show candidates with photo, offenses, registry and `matchDetail`; record verdicts as clearances; surface `clearance.stale` for re-review.
4. **Volume paths.** Wire `POST /v1/batch` for intake and `POST /v1/searches` with a tokenised `webhookUrl` for scheduled re-screens; handle `429` with `Retry-After` and treat `partial` as incomplete. [Operations](/docs/platform/operations).
5. **Monitoring.** Create person monitors per tenant subject with `label` routing; cancel on offboard.
6. **FCRA products.** Apply for [FCRA Partner Access](/fcra). Build end-user registration, orders, signed-webhook verification and the report hand-off against the sandbox, where every `os_test_` key is an enabled partner for every purpose.
7. **Go live.** Switch the base URL and key. Confirm the first production `searchId` resolves on `GET /v1/searches/{searchId}/results`, the first `meterEvents[]` reconcile with [Dashboard → Usage](/dashboard/usage), and the first monitor alert reaches your webhook. [Go-live checklist](/docs/guides/sex-offender/go-live).

## Related

- [Clearances](https://offendersearch.app/docs/clearances): verdicts by personRef, per subject and client
- [Batch & CSV](https://offendersearch.app/docs/batch)
- [Async & webhooks](https://offendersearch.app/docs/platform/async-and-webhooks)
- [FCRA Partner API](https://offendersearch.app/docs/fcra)
- [Monitoring API](https://offendersearch.app/docs/monitoring)
