# Background checks for marketplaces: add a check to provider sign-up

> Where the check sits in onboarding, the two calls that run it, how to route each result, what to store with the decision, how to re-screen the provider base and watch it afterwards, and where the FCRA line falls.

- **HTML:** https://offendersearch.app/docs/learn/background-checks-for-marketplaces
- **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

## Where a background check sits in marketplace sign-up

A marketplace or gig platform has one moment where a background check pays for itself: after the provider has told you who they are, and before anything they do can reach a customer. Run it earlier and you pay to check people who never finish the form; run it later and a provider is live while you wait. The flow is identity capture, then the check, then activation.

The check is two HTTP calls on one API key: the [sex offender API](/sex-offender-api) (`POST /v1/search`, 58 US registries) and the [criminal records API](/criminal-records-api) (`POST /v1/criminal/search`). Both take the same query object, both answer in one request on a cached search, and together they are what the [background check API](/background-check-api) means by "one key". Pricing is $0.15 a call, $0.11 past 2,000 calls in a month, 25 free calls, no card.

Everything below runs against the [sandbox](/sandbox) at `https://sandbox.offendersearch.app` with an `os_test_` key from the dashboard: a test key is free, nothing is billed, and the limits are 60 calls a minute and 2,000 a day per key. The sandbox answers fixed scenarios by name, so the routing code can be tested before a real provider signs up. Production is `https://api.offendersearch.app` with an `os_live_` key and the same shapes. Python and Node.js SDKs ship with the first registry release; see [SDKs](/sdk). Until then the raw HTTP calls below are the integration.

> **Not a consumer report.** Every standard response carries `legal.notice`. If the result decides whether a person may work on your platform, read the FCRA section first.

## The two marketplace background check calls on one key

The API identifies people on name and date of birth, so the sign-up form must collect a legal first name, a legal last name and a full `YYYY-MM-DD` date of birth: the only automatic identification the API makes, `matchState: "dob_match"`, needs your full date to compare against. The registry search is `422` when the query constrains nothing, or only `firstName`, or only `state`; the criminal search requires `lastName` (`422`, code `guard_unbounded_query`). Send your provider id as `subjectId` on both calls.

Both calls take `{ "query": { firstName, lastName, dob } }`. The registry search also takes `match` (`strict`, `balanced` or `broad`; `balanced` is the default) and `jurisdictions` (state codes, or `null` for all 58 US registries). The criminal search takes `perPage` (1–200, at the top level, not inside `query`). Run them in parallel; each answers with its own `searchId`.

**curl**
```bash
# Two calls, one key. The sandbox answers with fixed scenarios; "Exact Match" / 1980-01-01 is a dob_match.
export OFFENDERSEARCH_API_KEY=os_test_...   # minted in the dashboard; nothing is billed on the sandbox

# 1. Sex offender registry search
curl -X POST https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "match": "balanced", "subjectId": "provider-10422"}'

# 2. Criminal records search (lastName is required)
curl -X POST https://sandbox.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "perPage": 50, "subjectId": "provider-10422"}'
```

**Python**
```python
import os, requests

HOST = "https://sandbox.offendersearch.app"          # api.offendersearch.app + an os_live_ key in production
HEADERS = {"X-API-Key": os.environ["OFFENDERSEARCH_API_KEY"], "Content-Type": "application/json"}

def screen_provider(provider_id: str, first: str, last: str, dob: str) -> dict:
    """Run both checks for one sign-up and return a routing decision with its citations."""
    person = {"firstName": first, "lastName": last, "dob": dob}
    registry = requests.post(f"{HOST}/v1/search", headers=HEADERS, timeout=130,
                             json={"query": person, "match": "balanced", "subjectId": provider_id}).json()
    criminal = requests.post(f"{HOST}/v1/criminal/search", headers=HEADERS, timeout=130,
                             json={"query": person, "perPage": 50, "subjectId": provider_id}).json()

    records = registry["records"] + criminal["records"]
    incomplete = registry["counts"]["sourcesIncomplete"] + criminal["counts"]["sourcesIncomplete"]

    if not records and incomplete == 0:
        decision = "clear"                       # a complete answer with zero records
    elif any(r["matchState"] == "dob_match" for r in records):
        decision = "hold"                        # identity confirmed on the full date of birth
    else:
        decision = "review"                      # name-only or year/age leads, or an incomplete source

    citations = [
        {"recordId": r.get("recordId") or r.get("externalId"), "personRef": r["personRef"],
         "matchState": r["matchState"], "urls": [s["recordUrl"] for s in r["sources"]]}
        for r in records
    ]
    return {"decision": decision, "searchIds": [registry["searchId"], criminal["searchId"]],
            "citations": citations, "legalNotice": criminal["legal"]["notice"]}

print(screen_provider("provider-10422", "Exact", "Match", "1980-01-01"))
```

**Node**
```javascript
// Node 18+: fetch is built in
const HOST = "https://sandbox.offendersearch.app";   // api.offendersearch.app + an os_live_ key in production
const headers = { "X-API-Key": process.env.OFFENDERSEARCH_API_KEY, "Content-Type": "application/json" };

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

export async function screenProvider(providerId, first, last, dob) {
  const person = { firstName: first, lastName: last, dob };
  const [registry, criminal] = await Promise.all([
    post("/v1/search", { query: person, match: "balanced", subjectId: providerId }),
    post("/v1/criminal/search", { query: person, perPage: 50, subjectId: providerId }),
  ]);

  const records = [...registry.records, ...criminal.records];
  const incomplete = registry.counts.sourcesIncomplete + criminal.counts.sourcesIncomplete;

  let decision = "review";                                  // name-only / year / age leads, or an incomplete source
  if (records.length === 0 && incomplete === 0) decision = "clear";
  else if (records.some((r) => r.matchState === "dob_match")) decision = "hold";

  const citations = records.map((r) => ({
    recordId: r.recordId ?? r.externalId, personRef: r.personRef, matchState: r.matchState,
    urls: r.sources.map((s) => s.recordUrl),
  }));
  return { decision, searchIds: [registry.searchId, criminal.searchId], citations, legalNotice: criminal.legal.notice };
}

console.log(await screenProvider("provider-10422", "Exact", "Match", "1980-01-01"));
```

The sandbox name "Exact Match" with `dob` `1980-01-01` returns one registry record at `matchState: "dob_match"`: registered in OH, two addresses, a photo, "Sexual battery", Tier III; on the criminal side the same name is a complete answer with zero records. "Clear Visitor" and "Clear Record" are clear; "Name Only" (registry) and "Possible Match" (criminal) are three name-only leads in three states with no date of birth published. A name that is not a scenario, such as Jordan Rivera, 1988-04-12, is a complete answer with zero records.

## Reading the result: dob_match is automatic, weaker matches go to trust and safety

A gig platform wants an automatic answer wherever the API can give one and a human everywhere else. `matchState` on each record is exactly that split:

| matchState | What it means | Marketplace routing |
| --- | --- | --- |
| `dob_match` | The full date of birth you sent agrees with the record. The only automatic identification. | Automatic hold. |
| `year_match` | The registry publishes a birth year only, and it agrees. | Trust-and-safety queue. |
| `age_match` | Only an age is published; it agrees within `ageTolerance`. | Trust-and-safety queue. |
| `no_dob_age_year` | Nothing to verify against; the name matched. `unverified: true`. | Trust-and-safety queue; never an automatic hold. |
| `null` | No `dob` or `age` was sent. | Fix the form; re-run with a date of birth. |

A record whose date of birth contradicts yours is dropped, not returned: "Exact Match" with `dob` `1990-05-05` comes back with zero records. So "zero records" already has the contradictions removed, and the routing rule is short. Zero records across both calls with `counts.sourcesIncomplete` at `0` on both is **clear**. Any `dob_match` record is an automatic **hold** pending your policy. Anything else, including a complete answer with an incomplete source, is **review**. The [matching reference](/docs/matching) explains how names and aliases are scored.

> **Never auto-decline on a name-only match.** `matchConfidence` is a ceiling on the evidence, not a probability: a `0.95` on a `no_dob_age_year` record is still a name match.

A `status: "partial"` answer means a registry could not be searched to the end; `sourceStatus[]` names it with `incompleteReason`. Do not mark that provider clear; queue a re-run.

## Store the marketplace decision with its citations

A decision you cannot explain six months later is a decision you cannot defend. Store, keyed by provider: both `searchId` values, the decision and who made it, and for every record its `recordId` (criminal records carry `externalId` instead), `personRef`, `matchState` and each `sources[].recordUrl`. Keep the `legal.notice` text with the row, because it states what the search was and was not.

*A stored decision row*
```json
{
  "providerId": "provider-10422",
  "decision": "hold",
  "decidedBy": "auto",
  "decidedAt": "2026-11-03T15:12:40Z",
  "searchIds": ["srch_sbx_so_exact", "srch_…"],
  "citations": [
    {
      "recordId": "rec_28db9639d712c4c61e8e",
      "personRef": "prs_28db9639d712c4c61e8e",
      "matchState": "dob_match",
      "urls": ["https://offendersearch.app/docs/sandbox"]
    }
  ],
  "legalNotice": "Not a consumer report. This information may not be used for any purpose under the Fair Credit Reporting Act (15 U.S.C. § 1681 et seq.)."
}
```

`personRef` is the stable identity reference: the same person seen again, in a re-screen or a monitor alert, carries the same `personRef`, so a reviewer can tell "the record we already assessed" from "a new record". `recordId` re-fetches the record with `GET /v1/records/{recordId}` or `GET /v1/criminal/records/{recordId}`; `sources[].recordUrl` is the registry's or court's own page, which a reviewer reads before any adverse step. Do not store the record body as your source of truth: records change, and the identifiers and the `searchId` are what let you cite the answer you acted on.

## Re-screen the provider base with the batch endpoints

`POST /v1/batch` re-runs the base without one call per provider: a JSON array of query objects, an envelope `{ "queries": [...], "match": ... }` whose keys apply to every row, or a `text/csv` body with the header `firstName,lastName,state,dob,city,zipcode,address,age,q`. The limit is 1,000 rows a call; every row is billed as one search; results come back in input order; a row that fails validation is reported in place with `status: "error"` and never aborts the batch.

*Registry and criminal batch*
```bash
# Re-screen the active provider base. One row per provider, up to 1,000 rows a call, billed per row,
# results in input order. A row that fails validation comes back in place with status "error".
curl -X POST https://sandbox.offendersearch.app/v1/batch \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"match": "balanced",
       "queries": [
         {"firstName": "Exact",  "lastName": "Match",   "dob": "1980-01-01"},
         {"firstName": "Clear",  "lastName": "Visitor"},
         {"firstName": "Jordan", "lastName": "Rivera",  "dob": "1988-04-12"}
       ]}'

# The same shape for criminal records: POST /v1/criminal/batch (cached rows only; lastName required per row)
curl -X POST https://sandbox.offendersearch.app/v1/criminal/batch \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"queries": [
         {"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"},
         {"firstName": "Clear",  "lastName": "Record"}
       ]}'

# Both answer a wrapper, not the single-search envelope:
# { "count": 2, "results": [ { "status": "complete", "counts": {...}, "warnings": [], "records": [...] },
#                            { "status": "error", "error": "..." } ] }
# Walk results[] with the same index as your input and apply the routing rule from the sign-up check.
```

`POST /v1/criminal/batch` has the same row-in, row-out contract: rows are cached lookups only, `lastName` is required per row, and a 1,000-row batch is 1,000 billable calls. Both answer `{ count, results: [...] }` rather than the single-search envelope; a batch row carries no `searchId` of its own, so run `POST /v1/search` for a row a reviewer needs to cite. Persist each response before sending the next chunk: there is no idempotency header, so a retried request is a second billed batch. A `429` means nothing was searched or billed; honour `Retry-After` and resend. The [batch reference](/docs/batch) covers CSV handling and envelope defaults.

## Monitor each active provider with a person monitor and signed webhooks

Between re-screens, a person monitor watches one provider continuously and sends a signed webhook when a record appears, changes or is removed. `POST /v1/monitors` takes `product` (`sex-offender` or `criminal`), `type: "person"`, `person { firstName, lastName, dob }`, a `minConfidence` floor (`dob_match`, `year_match`, `age_match`, `name_match` or `any`), a `label` and `channels { email, emails[], webhookUrl }`, and answers `201` with the monitor and `channels.webhookSecret` (`whsec_…`) shown once. The price is $3 a month per person monitor, $10 per location monitor.

*Create a monitor; the alert that arrives*
```bash
# One person monitor per active provider. $3 a month per person monitor. The webhook secret is shown once.
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",
       "label": "provider-10422",
       "person": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "minConfidence": "dob_match",
       "channels": {"webhookUrl": "https://hooks.example.com/hooks/offendersearch"}}'

# 201 … "channels": { "webhookUrl": "…", "webhookSecret": "whsec_…" }   ← store it with the provider row
# A second monitor with "product": "criminal" watches the criminal side for the same person.
# Delete when the provider leaves the platform: DELETE /v1/monitors/{monitorId}

# What arrives (signed with X-Offendersearch-Signature: t=<unix>,v1=<hex>):
{
  "type": "monitor.alert",
  "event": "new",
  "alertId": "hit_7fa176c8889c7569",
  "monitorId": "mon_b8f40d7afb9dc0d8",
  "monitorLabel": "provider-10422",
  "product": "sex-offender",
  "detail": {
    "name": "EXACT SAMPLE MATCH",
    "jurisdiction": "OH",
    "locality": "Sampleville",
    "matchLabel": "dob_match",
    "matchConfidence": 0.9,
    "personRef": "prs_28db9639d712c4c61e8e",
    "recordId": "rec_28db9639d712c4c61e8e"
  },
  "occurredAt": "2026-11-03T15:20:11+00:00",
  "sentAt": "2026-11-03T15:20:12+00:00"
}
```

Set `minConfidence` to `dob_match` when the monitor should fire only on a confirmed identity, or `any` when the queue should see name-only leads too. The alert carries `event` (`new`, `changed`, `removed`), `alertId`, `monitorId`, `monitorLabel` and a `detail` with `personRef` and `recordId`; use `monitorLabel` to find the provider and `personRef` to see whether the person was already assessed. On the sandbox, a monitor for "Exact Match" with `dob` `1980-01-01` fires one `new` alert at once.

Verify `X-Offendersearch-Signature` (`t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>." + raw body)>`) on the raw body, in constant time, inside the five-minute replay window, before parsing. The [webhook example](/docs/learn/webhook-example) has the receivers; the [person monitoring reference](/docs/monitoring/person-monitoring) covers lifecycle and alerts. Delete the monitor when the provider leaves. Monitoring is not a consumer report.

## The FCRA line: when a gig platform decision is a consumer report

The standard endpoints answer with `legal.notice`: "Not a consumer report. This information may not be used for any purpose under the Fair Credit Reporting Act (15 U.S.C. § 1681 et seq.)." Deciding whether a person may work on your platform can be a consumer-report use. If that is what the check decides, do not build the decision on the standard endpoints.

The platform has a separate path: [FCRA Partner Access](/fcra). An approved partner registers the end user with `POST /v1/fcra/end-users` and its `permissiblePurposes`, then creates an order with `POST /v1/fcra/orders` carrying an `Idempotency-Key`, `endUserId`, `permissiblePurpose` (`employment` for a hiring-style decision), `consent { obtainedAt, method, documentRef }`, the `subject`, `useLocation { state }` and `searches: [{ type: "sex_offender" }, { type: "criminal", exclusions: "all" }]`. The order answers `202` at `received`, reaches `complete`, and the report, adverse-action and dispute flows hang off it. The [FCRA orders reference](/docs/fcra/orders) documents the body.

Which of your decisions fall on which side of that line is a question for counsel. This page can only say what each endpoint does: the standard API answers a search and says it is not a consumer report; the FCRA order endpoints run the same searches inside a consent, permissible-purpose, dispute and adverse-action process. The [background check API for gig platforms and marketplaces](/background-check-api) page describes how platforms typically split the two.

## Add background checks to a gig platform in eight steps

1. **Mint a test key.** Create an os_test_ key in the dashboard and call https://sandbox.offendersearch.app: 25 free calls, no card, nothing billed.
2. **Capture identity first.** Collect legal first name, last name and full date of birth. Run the check after identity capture and before activation.
3. **Run the two calls.** POST /v1/search and POST /v1/criminal/search with the same query and the provider id as subjectId.
4. **Route by matchState.** Zero records with sourcesIncomplete 0 is clear; a dob_match record is an automatic hold; anything weaker goes to a trust-and-safety reviewer.
5. **Store the citations.** Persist both searchIds, each recordId or externalId, personRef, matchState, sources[].recordUrl and the legal.notice text with the decision.
6. **Re-screen in batch.** Send up to 1,000 providers a call to POST /v1/batch or POST /v1/criminal/batch and apply the same routing rule to results[].
7. **Monitor each active provider.** POST /v1/monitors with type person and a webhookUrl; verify the signature on alerts; delete the monitor when the provider leaves.
8. **Run eligibility decisions as FCRA orders.** If the result decides whether someone may work on the platform, use FCRA Partner Access and consult counsel.

## Background check API questions gig platforms ask

### Is one call enough, or do I need both?

They answer different questions. `POST /v1/search` is the sex offender registry layer; `POST /v1/criminal/search` is the court, jail, prison and warrant layer. Running only one is a product decision about which your platform cares about.

### How do I test the trust-and-safety queue without a real hit?

Use the sandbox names. "Exact Match" with `1980-01-01` is a `dob_match` hold; "Name Only" is three name-only leads; "Felony Record" with `1975-06-15` is a felony conviction on the criminal side, and without a date of birth it is the same case at `no_dob_age_year` with `unverified: true`.

### What do I do with a partial answer?

Do not treat it as clear. `status: "partial"` or `counts.sourcesIncomplete > 0` means a source was not searched to the end. Queue a re-run, or send the registry search with `onDeadline: "error"` so a timeout is a `504` rather than a shorter answer.

### Does a monitor alert mean the provider should be removed?

No. An alert is a record appearing, changing or being removed for a watched person at or above your `minConfidence`. It goes to the same queue as a sign-up lead, with `personRef` and `recordId` to fetch the record. Monitoring is not a consumer report.

### Which side of the FCRA line is my platform on?

Ask counsel. This page describes what the endpoints do: the standard API is not a consumer report and says so in `legal.notice`; the FCRA order endpoints run the same searches inside consent, permissible purpose, dispute and adverse-action controls.

## What this page does not cover

- Identity verification itself: the check assumes the name and date of birth you captured are the provider's.
- Location monitors, which watch places rather than people.
- The full FCRA order lifecycle, hosted consent, adverse action and the consumer portal.
- Rate limits and retry schedules in detail.

## Related

- [Background check API tutorial](https://offendersearch.app/docs/learn/background-check-api-tutorial)
- [Monitor a person with webhooks](https://offendersearch.app/docs/learn/monitor-a-person-with-webhooks)
- [No-match and multiple-match results](https://offendersearch.app/docs/learn/no-match-and-multiple-match-results)
- [Background check API for HR and ATS](https://offendersearch.app/docs/learn/background-check-api-for-hr-ats)
- [FCRA adverse action API tutorial](https://offendersearch.app/docs/learn/fcra-adverse-action-api)
- [Rate limits, retries and idempotency](https://offendersearch.app/docs/learn/rate-limits-retries-idempotency)
- [Batch searches (criminal)](https://offendersearch.app/docs/criminal/batch)
- [Criminal record object](https://offendersearch.app/docs/criminal/record-object)
- [Incomplete results (registry)](https://offendersearch.app/docs/guides/sex-offender/incomplete-results)
- [Which API do I need?](https://offendersearch.app/docs/products/which-api)
- [Bulk sex offender search](https://offendersearch.app/bulk-sex-offender-search)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
