# Staffing agencies and HR platforms

> A hire is an FCRA decision from the first click. This article maps the disclosure-to-adverse-action workflow onto FCRA orders, end users and signed webhooks, and shows where continuous workforce monitoring fits without becoming a decision on its own.

- **HTML:** https://offendersearch.app/docs/solutions/staffing-and-hr
- **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 staffing and HR today

Pre-hire screening is nearly universal: in the Professional Background Screening Association's 2021 industry survey, 95% of employers with U.S. locations reported running one or more types of background check, and 19% reported checking beyond the initial hire — up from 12% the year before. The workflow is set by the Fair Credit Reporting Act and runs inside the applicant-tracking or HR system. A recruiter advances a candidate (after a conditional offer where a fair-chance law requires it); the system presents the FCRA disclosure as a standalone document and captures the candidate's written authorization; it orders the report from a consumer reporting agency; components run — identity, criminal, registry, employment and education verification, driving record, drug screen as ordered — and a report returns with records or a clear result; the employer adjudicates against its criteria; if it intends to decline, it sends a **pre-adverse action** notice with a copy of the report and the Summary of Rights, waits a reasonable period, and then sends the **adverse action** notice. Staffing agencies run this at volume for temporary and contract placements, often with a client's criteria layered on their own; HR platforms embed it as a step in onboarding.

The rules, precisely. Before procuring a report for employment purposes, the user must make a clear and conspicuous written disclosure in a document consisting solely of the disclosure, and obtain the consumer's written authorization (15 U.S.C. § 1681b(b)(2)(A)). Before taking adverse action based in whole or in part on the report, it must provide a copy of the report and the written description of consumer rights prescribed by the Consumer Financial Protection Bureau (§ 1681b(b)(3)(A)). The statute sets no waiting period; FTC staff said in a 1997 opinion letter that five business days "appears reasonable", and courts have held a user to the period its own pre-adverse letter promised. After the decision, the adverse-action notice must identify the agency with its contact details, state that the agency did not make the decision and cannot explain it, and describe the right to a free copy within 60 days and to dispute (§ 1681m(a)). The agency, for its part, must keep reasonable procedures for maximum possible accuracy (§ 1681e(b)) and, for public-record items reported for employment, either notify the consumer contemporaneously or maintain strict procedures to keep them complete and up to date (§ 1681k); non-conviction items older than seven years are excluded except for positions paying $75,000 or more (§ 1681c(a), (b)(3)). The EEOC's 2012 Enforcement Guidance on arrest and conviction records asks employers to use a targeted screen on the nature of the offence, the time elapsed and the nature of the job, followed by an individualized assessment, and warns against excluding on arrests alone. Fair-chance (ban-the-box) laws in roughly 37 states, the District of Columbia and more than 150 localities, and the federal Fair Chance to Compete for Jobs Act for federal agencies and contractors, push the criminal-history inquiry to after a conditional offer. Staffing agencies are users of the report for their own placements and have been sued for sending the pre-adverse notice after the worker had already been removed; a client that supplies the criteria shares the exposure.

Continuous monitoring is the growth edge: a standing watch on the active workforce so that a new conviction or registration between hire and the next re-screen is seen. It raises its own questions — whether the original authorization covers ongoing checks, which state laws limit it — and in practice an alert is a trigger to run a compliant check, not a finding in itself.

> **The API gives the report; the process stays yours.** Every hiring decision informed by a report is an FCRA decision, so this entire article runs on the [FCRA Partner API](/docs/fcra). The disclosure, authorization, adjudication, pre-adverse and adverse-action steps are the user's obligations and are not performed by the API. The standard APIs are not consumer reports and must not be the sole basis for an employment decision. Counsel decides applicability and the waiting period.

## Where the API fits

| Hiring step | Endpoint | Fields that matter |
| --- | --- | --- |
| Confirm partner access and purposes | `GET /v1/fcra/partner` | `enabled`, `allowedPurposes`, `webhookConfigured`; never a 403 — `enabled: false` is the state to show |
| Register each employer the reports are for | `POST /v1/fcra/end-users` | `name`, `externalId` (your client id), `permissiblePurposes: ["employment"]`, `certification`; `PATCH …/{end_user_id}` to suspend or change purposes |
| Disclosure and written authorization | your ATS / HR system | Produce the standalone disclosure and capture the authorization; keep its reference for `consent.documentRef` |
| Place the order after authorization | `POST /v1/fcra/orders` + `Idempotency-Key` | `endUserId`, `permissiblePurpose: "employment"`, `consent` (`obtainedAt`, `method`, `documentRef`), `subject` (`firstName`, `lastName`, full `dob`, `aliases[]`, `addresses[]`), `useLocation.state` (the job), `annualSalary`, `searches[]`, `clientReference`, optional `callbackUrl` |
| Learn the result | signed webhook, `GET /v1/fcra/orders/{order_id}` | `search.completed`, `order.completed`; `searches[].result`, `recordCount`, `withheld{}`, `incompleteReasons[]`; `POST /v1/fcra/searches/{search_id}/reverify` for an `unavailable` source |
| Adjudicate | `GET /v1/fcra/orders/{order_id}/searches/{search_id}` | Furnished records with `matchBasis.state`, `dispositionStatus`, per-charge `fcraDisposition`, `retrievedAt`/`verifiedAt`, `captureId` |
| Pre-adverse and adverse action | `GET /v1/fcra/orders/{order_id}/report?format=pdf` | The copy of the report for the candidate, with `consumerPortal.url` and `accessCode` from the order; the notices themselves are yours |
| Dispute from the candidate | `POST /v1/fcra/orders/{order_id}/disputes` | `recordIds[]`, `reason`, `consumerStatement`, `receivedAt` (the 30-day clock); `dispute.updated` and `record.corrected` events |
| Watch the active workforce | `POST /v1/monitors` | `product: "criminal"` or `"sex-offender"`, `type: "person"`, `label` with client and worker ids; an alert triggers a new order, never a decision |

## Reference integration

The module registers an employer as an end user, places an employment order under an idempotency key after the authorization is on file, waits for completion (production code reads `order.completed` from the webhook; polling is the fallback), and maps the order to the next step: **clear** (nothing furnished), **review** (records furnished — adjudication, then the pre-adverse and adverse-action sequence if declining) or **incomplete** (a source could not be reached — reverify first). It also includes the signature check for the webhook. On the sandbox every `os_test_` key is an enabled partner and the subject's last name selects the scenario (`Clear`, `Records`, `Withheld`, `Unavailable`, `Dispute`).

**Python**
```python
"""Pre-hire screening as an FCRA order — OffenderSearch FCRA Partner API, Python 3.11+, requests.

The agency (or the HR platform acting for an employer) is the partner; each employer the report is for
is an end user. One order = one candidate, one end user, one permissible purpose, one consent attestation.

Sandbox: every os_test_ key is an enabled sandbox partner and orders finish right after the 202.
  OFFENDERSEARCH_API_KEY=os_test_...  OFFENDERSEARCH_BASE=https://sandbox.offendersearch.app  python hire_order.py
"""
from __future__ import annotations

import hashlib
import hmac
import os
import time
from datetime import datetime, timezone

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"}


def _now() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _err(r: requests.Response) -> str:
    d = r.json().get("detail")
    return f"{d['code']}: {d['message']}" if isinstance(d, dict) else str(d)


def register_employer(name: str, external_id: str) -> str:
    """One end user per employer. Its certification is your record of the purposes it may use reports for."""
    body = {"name": name, "externalId": external_id, "permissiblePurposes": ["employment"],
            "certification": {"text": "The employer certifies that it will use consumer reports solely for employment "
                                      "purposes, has provided the required disclosure and obtained the candidate's written "
                                      "authorization, and will follow the FCRA adverse-action process.",
                              "version": "v2", "certifiedAt": _now(), "certifiedBy": "compliance@your-agency.example"}}
    r = requests.post(f"{BASE}/v1/fcra/end-users", headers=HEADERS, json=body, timeout=30)
    if r.status_code == 403:
        raise PermissionError(_err(r))                 # fcra_not_enabled / fcra_purpose_not_allowed
    r.raise_for_status()
    return r.json()["id"]


def order_hire_screen(end_user_id: str, candidate: dict, job_state: str, consent: dict, req_id: str,
                      annual_salary: int | None = None, callback_url: str | None = None) -> dict:
    """Place the order after the disclosure and written authorization are on file (consent cites that document)."""
    body = {"endUserId": end_user_id, "permissiblePurpose": "employment", "consent": consent,
            "subject": candidate, "useLocation": {"state": job_state},
            "searches": [{"type": "sex_offender"}, {"type": "criminal"}],
            "clientReference": req_id, "liveScope": "relevant"}
    if annual_salary is not None:
        body["annualSalary"] = annual_salary             # lifts the seven-year rule at $75,000+, 15 U.S.C. § 1681c(b)(3)
    if callback_url:
        body["callbackUrl"] = callback_url               # per-order override of your webhook endpoint (same secret)
    headers = {**HEADERS, "Idempotency-Key": f"hire-{req_id}"}   # same key + same body within 24 h = the same order
    r = requests.post(f"{BASE}/v1/fcra/orders", headers=headers, json=body, timeout=60)
    if r.status_code in (403, 404, 409, 422):
        raise ValueError(_err(r))
    r.raise_for_status()
    return r.json()                                      # 202, status "received"; eta.p50/p90 while pending


def wait_for_order(order_id: str) -> dict:
    """Fallback when no webhook is configured. In production read order.completed from the webhook instead."""
    for _ in range(120):
        r = requests.get(f"{BASE}/v1/fcra/orders/{order_id}", headers=HEADERS, timeout=30)
        r.raise_for_status()
        order = r.json()
        if order["status"] in ("complete", "error", "cancelled"):
            return order
        time.sleep(5)
    raise TimeoutError(order_id)


def next_step(order: dict) -> tuple[str, str]:
    """clear / review / incomplete. 'review' means adjudication by the employer under its criteria - never an API verdict."""
    searches = order["searches"]
    if any(s["status"] in ("unavailable", "error") or s.get("result") == "incomplete" for s in searches):
        return "incomplete", "A source could not be reached. Reverify (POST /v1/fcra/searches/{id}/reverify) before deciding."
    if any(s.get("result") == "records_found" for s in searches):
        return "review", ("Records furnished. If the employer intends to decline: pre-adverse notice with a copy of the "
                          "report and the Summary of Rights, a reasonable wait, then the adverse-action notice.")
    return "clear", "Nothing furnished. Proceed on the employer's other criteria."


def verify_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_s: int = 300) -> dict | None:
    """X-Offendersearch-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, '<t>.' + body)>. Returns the event or None."""
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1) or abs(time.time() - int(t)) > tolerance_s:
        return None
    import json
    return json.loads(raw_body)                          # {"id": "fev_...", "type": "order.completed", "createdAt", "data": {...}}


if __name__ == "__main__":
    employer = register_employer("Northwind Logistics Inc.", "client-7731")
    consent = {"obtainedAt": "2026-10-24T16:40:00Z", "method": "esign", "documentRef": "auth-req-4410"}
    for last in ("Clear", "Records", "Withheld"):
        order = order_hire_screen(employer, {"firstName": "Pat", "lastName": last, "dob": "1980-01-01",
                                             "addresses": [{"state": "IL", "city": "Chicago", "from": "2019-01", "to": None}]},
                                  job_state="IL", consent=consent, req_id=f"4410-{last.lower()}", annual_salary=58000)
        done = wait_for_order(order["id"])
        step, why = next_step(done)
        print(done["id"], done["status"], "->", step, "-", why)
        for s in done["searches"]:
            print(f"   {s['type']}: {s['status']} result={s['result']} records={s['recordCount']} withheld={s['withheld']}")
```

**JavaScript**
```javascript
// Pre-hire screening as an FCRA order — OffenderSearch FCRA Partner API, Node 18+ (global fetch).
// The agency (or HR platform acting for an employer) is the partner; each employer is an end user.
// Sandbox: every os_test_ key is an enabled sandbox partner and orders finish right after the 202.
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const now = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
const err = async (r) => { const d = (await r.json()).detail; return d && d.code ? d.code + ": " + d.message : JSON.stringify(d); };

/** One end user per employer. Its certification is your record of the purposes it may use reports for. */
export async function registerEmployer(name, externalId) {
  const r = await fetch(BASE + "/v1/fcra/end-users", { method: "POST", headers: HEADERS, body: JSON.stringify({
    name, externalId, permissiblePurposes: ["employment"],
    certification: { text: "The employer certifies that it will use consumer reports solely for employment purposes, has provided the required disclosure and obtained the candidate's written authorization, and will follow the FCRA adverse-action process.",
      version: "v2", certifiedAt: now(), certifiedBy: "compliance@your-agency.example" },
  }) });
  if (r.status === 403) throw new Error(await err(r));                // fcra_not_enabled / fcra_purpose_not_allowed
  if (r.status !== 201) throw new Error("HTTP " + r.status);
  return (await r.json()).id;
}

/** Place the order after the disclosure and written authorization are on file (consent cites that document). */
export async function orderHireScreen(endUserId, candidate, jobState, consent, reqId, { annualSalary, callbackUrl } = {}) {
  const body = { endUserId, permissiblePurpose: "employment", consent, subject: candidate, useLocation: { state: jobState },
    searches: [{ type: "sex_offender" }, { type: "criminal" }], clientReference: reqId, liveScope: "relevant",
    ...(annualSalary != null ? { annualSalary } : {}),                  // lifts the seven-year rule at $75,000+, 15 U.S.C. § 1681c(b)(3)
    ...(callbackUrl ? { callbackUrl } : {}) };                          // per-order override of your webhook endpoint
  const r = await fetch(BASE + "/v1/fcra/orders", { method: "POST", headers: { ...HEADERS, "Idempotency-Key": "hire-" + reqId }, body: JSON.stringify(body) });
  if ([403, 404, 409, 422].includes(r.status)) throw new Error(await err(r));
  if (r.status !== 202) throw new Error("HTTP " + r.status);
  return r.json();                                                      // status "received"; eta.p50/p90 while pending
}

/** Fallback when no webhook is configured. In production read order.completed from the webhook instead. */
export async function waitForOrder(orderId) {
  for (let i = 0; i < 120; i++) {
    const r = await fetch(BASE + "/v1/fcra/orders/" + orderId, { headers: HEADERS });
    if (!r.ok) throw new Error("HTTP " + r.status);
    const order = await r.json();
    if (["complete", "error", "cancelled"].includes(order.status)) return order;
    await sleep(5000);
  }
  throw new Error("timed out: " + orderId);
}

/** clear / review / incomplete. "review" means adjudication by the employer under its criteria - never an API verdict. */
export function nextStep(order) {
  const s = order.searches;
  if (s.some((x) => ["unavailable", "error"].includes(x.status) || x.result === "incomplete"))
    return ["incomplete", "A source could not be reached. Reverify (POST /v1/fcra/searches/{id}/reverify) before deciding."];
  if (s.some((x) => x.result === "records_found"))
    return ["review", "Records furnished. If the employer intends to decline: pre-adverse notice with a copy of the report and the Summary of Rights, a reasonable wait, then the adverse-action notice."];
  return ["clear", "Nothing furnished. Proceed on the employer's other criteria."];
}

/** X-Offendersearch-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>." + rawBody)>. Returns the event or null. */
export function verifyWebhook(rawBody, signatureHeader, secret, toleranceS = 300) {
  const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", secret).update(parts.t + "." + rawBody).digest("hex");
  const ok = expected.length === (parts.v1 ?? "").length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
  if (!ok || Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceS) return null;
  return JSON.parse(rawBody);                                           // { id: "fev_...", type: "order.completed", createdAt, data }
}

// Demo against the sandbox subjects (chosen by last name).
const employer = await registerEmployer("Northwind Logistics Inc.", "client-7731");
const consent = { obtainedAt: "2026-10-24T16:40:00Z", method: "esign", documentRef: "auth-req-4410" };
for (const last of ["Clear", "Records", "Withheld"]) {
  const order = await orderHireScreen(employer,
    { firstName: "Pat", lastName: last, dob: "1980-01-01", addresses: [{ state: "IL", city: "Chicago", from: "2019-01", to: null }] },
    "IL", consent, "4410-" + last.toLowerCase(), { annualSalary: 58000 });
  const done = await waitForOrder(order.id);
  const [step, why] = nextStep(done);
  console.log(done.id, done.status, "->", step, "-", why);
  for (const s of done.searches) console.log("  ", s.type + ":", s.status, "result=" + s.result, "records=" + s.recordCount, "withheld=" + JSON.stringify(s.withheld));
}
```

*The order.completed delivery: verify v1 over "<t>." + raw body before reading data*
```http
POST https://your-agency.example/hooks/offendersearch
Content-Type: application/json
X-Offendersearch-Event: order.completed
X-Offendersearch-Event-Id: fev_5f1e3340306f1d02
X-Offendersearch-Signature: t=1792000252,v1=5d41402abc4b2a76b9719d911017c592…

{"createdAt":"2026-10-25T14:04:12Z","data":{"orderId":"fo_ee0b34c5b52fd35b","status":"complete"},"id":"fev_5f1e3340306f1d02","type":"order.completed"}
```

*GET /v1/fcra/orders/{order_id}/searches/{search_id} — what the adjudicator reads (trimmed)*
```json
{
  "id": "fs_9c03d7e1a4b6f258",
  "type": "criminal",
  "status": "complete",
  "result": "records_found",
  "recordCount": 1,
  "withheld": { "insufficient_identifiers": 1 },
  "incompleteReasons": [],
  "coverage": [
    { "source": "IL-SANGAMON", "name": "Sangamon County Circuit Court", "jurisdiction": "IL", "mode": "live",
      "status": "answered", "relevant": true, "restricted": false, "candidates": 2, "furnished": 1, "withheld": 1,
      "captureIds": ["fc_41a2c7e09b3d5a18"] }
  ],
  "records": [
    { "recordId": "crim:IL-SANGAMON:2019CF000412", "type": "criminal",
      "matchBasis": { "state": "dob_match", "identifiers": ["name", "dob"] },
      "dispositionStatus": "conviction", "retrievedAt": "2026-10-25T14:02:38Z", "verifiedAt": "2026-10-25T14:02:38Z",
      "cases": [ { "caseNumber": "2019CF000412", "charges": [ { "description": "Retail theft", "severity": "misdemeanor",
                   "disposition": "Guilty", "dispositionDate": "2019-11-04", "fcraDisposition": "conviction" } ] } ] }
  ]
}
```

What the adjudicator sees: each furnished record's `matchBasis.state` and `identifiers`; for criminal records `dispositionStatus` across the charges and each charge's `fcraDisposition` (so a dismissed count is never read as a conviction); `fcraRedactions` naming what the controls removed and why; `coverage[]` saying which sources answered live, which from a snapshot, and how many candidates were withheld (`insufficient_identifiers`, `not_current_at_source`, `obsolete_non_conviction`). That last list is how an empty report explains itself. [Results & records](/docs/fcra/results), [Controls & reason codes](/docs/fcra/controls).

## Ongoing monitoring

For a placed worker or an active employee, a person monitor is a standing watch on one dataset: `product: "criminal"` for new court, jail, prison or warrant records, `product: "sex-offender"` for a new registration; two monitors to watch both. Put the client and worker ids in `label`, set `minConfidence: "dob_match"` to alert only on a confirmed date of birth, and deliver to a webhook with a secret token in the URL (monitoring deliveries are not signed). An alert is a fact about a record, not a consumer report: the compliant response is to place a new FCRA order — with authorization that covers it — and adjudicate that. Cancel the monitor when the placement ends.

*Watch one placed worker for new criminal records*
```bash
curl -X POST https://sandbox.offendersearch.app/v1/monitors \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "product": "criminal",
    "type": "person",
    "person": { "firstName": "Felony", "lastName": "Record", "dob": "1975-06-15" },
    "minConfidence": "dob_match",
    "label": "client-7731 / worker-5520 (active placement)",
    "channels": { "webhookUrl": "https://your-agency.example/hooks/offendersearch-monitors?token=7d2f41c0" }
  }'
```

Alerts arrive as `monitor.alert` with `event` (`new`, `changed`, `removed`), the `personRef`, and for criminal monitors the matched record's `recordType`; `GET /v1/monitors/{monitorId}/alerts` is the history. [Person monitoring](/docs/monitoring/person-monitoring), [Webhooks, duplicates and cancelling](/docs/guides/monitoring/webhooks-and-lifecycle).

## When this is FCRA

All of it. Hiring, placing, retaining and promoting are employment purposes, and a report that informs them is a consumer report. The staffing agency or HR platform reaches the [FCRA Partner API](/docs/fcra) as an approved partner under a written agreement, or through its screening partner; without access every FCRA route answers `403 fcra_not_enabled`. The **end-user model** is the hinge: the agency itself is an end user for its own placements, and each client employer whose criteria drive a decision is registered as its own end user with its own certification, so the report names who procured it. The **adverse-action hand-off** is a sequence the API supports but does not perform: the order's `consumerPortal` gives the candidate their copy and a dispute path; `GET …/report?format=pdf` is the copy that accompanies the pre-adverse notice; the Summary of Rights, the wait, and the final notice are the user's to send and time. If the candidate disputes, `POST …/disputes` starts the 30-day reinvestigation, `dispute.updated` reports its outcome, and `record.corrected` reaches every partner that received the record. Nothing in this flow makes an employer compliant on its own; it makes each step provable.

## Evidence and audit

- **Order id per requisition.** Store the `fo_` id with the candidate and the `clientReference`; `GET /v1/fcra/orders?clientReference=…` finds it, with `status` filters for queue views.
- **Consent provenance.** `consent.documentRef` on the order should point at the stored authorization; the order carries the attestation and the `controlVersion` it ran under.
- **Report hashes and receipt.** Keep the PDF with `X-PDF-SHA256` and `X-Report-SHA256`; `GET /v1/fcra/receipts/{order_id}` is the public, Ed25519-signed receipt binding order, time, control version and hashes — verifiable without a key.
- **Evidence captures.** `GET /v1/fcra/orders/{order_id}/evidence` lists every official-source capture (`source`, `kind`, `sha256`, `retrievedAt`), including from superseded searches; the bytes are at `…/evidence/{capture_id}`. No retention period is published; copy what your policy requires.
- **Event log.** `GET /v1/fcra/events?after=…` pages every event with its delivery status and attempts; `POST /v1/fcra/events/{event_id}/resend` replays one.
- **Disputes.** Each dispute's `receivedAt`, `dueAt`, `extendedAt`, `status` and outcome are on `GET /v1/fcra/disputes/{dispute_id}` — the record of the 30-day clock.

## Rollout plan

1. **Access.** Apply for [FCRA Partner Access](/fcra) with the purposes you need; until approval, `GET /v1/fcra/partner` returns `enabled: false` on the live host. Build in the sandbox meanwhile, where every `os_test_` key is an enabled partner.
2. **Consent flow.** Produce the standalone disclosure and capture written authorization in your ATS or HR system before any order; store a reference you can place in `consent.documentRef`.
3. **End users.** Register the agency and each client employer with `certification` text your compliance team approves; suspend end users whose agreements lapse.
4. **Orders and webhooks.** `PUT /v1/fcra/webhook-endpoint`, store the one-time `secret`, verify signatures on the raw body, and drive your pipeline from `search.completed` and `order.completed`. Use `Idempotency-Key` on every create.
5. **Adjudication UI.** Show `dispositionStatus`, per-charge `fcraDisposition`, `withheld{}` and `coverage[]`; generate the pre-adverse packet with the PDF and the consumer portal access code; enforce your waiting period before the adverse-action notice.
6. **Workforce monitors.** Create person monitors on placement, route alerts to a new-order queue, and cancel on end of assignment.
7. **Go live.** Place the first live order with a real consent record; confirm the receipt verifies and the webhook signature checks. [Sandbox](/docs/fcra/sandbox), [Errors](/docs/fcra/errors).

## Related

- [FCRA Partner API](https://offendersearch.app/docs/fcra)
- [Access & end users](https://offendersearch.app/docs/fcra/access)
- [Orders](https://offendersearch.app/docs/fcra/orders)
- [Webhooks & events](https://offendersearch.app/docs/fcra/webhooks)
- [Disputes](https://offendersearch.app/docs/fcra/disputes)
