Developer docs · v1.0.0

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.

Updated November 3, 2026

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 (POST /v1/search, 58 US registries) and the 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 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 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. 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.

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

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:

matchStateWhat it meansMarketplace routing
dob_matchThe full date of birth you sent agrees with the record. The only automatic identification.Automatic hold.
year_matchThe registry publishes a birth year only, and it agrees.Trust-and-safety queue.
age_matchOnly an age is published; it agrees within ageTolerance.Trust-and-safety queue.
no_dob_age_yearNothing to verify against; the name matched. unverified: true.Trust-and-safety queue; never an automatic hold.
nullNo 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 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
{
  "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
# 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 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
# 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 has the receivers; the person monitoring reference 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. 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 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 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.