# Background check API tutorial: your first check in 10 minutes

> How to run a background check with an API, end to end: a free test key, two POST calls against the sandbox, the order to read the response in, the citation to store, and the two-line change that takes it to production.

- **HTML:** https://offendersearch.app/docs/learn/background-check-api-tutorial
- **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

## What this background check API tutorial builds

By the end of this page you will have made the two calls a background check consists of on this platform, in curl, Python and Node, and you will know what to do with the answer. The first is a sex offender registry search across 58 US registries; the second is a criminal records search. Both are plain HTTPS `POST` requests with a JSON body and an `X-API-Key` header. Python and Node.js SDKs ship with the first registry release; see [SDKs](/sdk). Until then the raw HTTP calls below are the integration.

Every request here runs against the [sandbox](/sandbox), a separate server that answers from fixed sample data. It is free, nothing sent to it is billed, and a test key is limited to 60 calls a minute and 2,000 a day. The sample people have names you cannot mistake for real ones: `Exact Match`, `Felony Record`, `Clear Visitor`. Any other name returns a complete answer with zero records. If you want the reference instead of a tutorial, the [background check API](/background-check-api) page links to both endpoints.

## Step 1: mint a test key for the background check API

[Sign up](/sign-up) with an email address, open the dashboard and create a key. Two kinds exist. A **test key** starts with `os_test_`, is accepted only by `https://sandbox.offendersearch.app`, and costs nothing. A **live key** starts with `os_live_`, is accepted only by `https://api.offendersearch.app`, and is billed per call. Mint the test key first; the live one comes in the last step.

Put the key in an environment variable, not in code; the examples read `OFFENDERSEARCH_TEST_KEY`. Every endpoint authenticates with the same `X-API-Key` header; there is no OAuth flow and no token refresh. A missing or wrong header is `401` with `{ "detail": "Missing or invalid API key. Send it in the X-API-Key header." }`, as the [authentication](/docs/authentication) reference shows.

## Step 2: the first background check API call, a sex offender registry search

Send `POST /v1/search` with a `query` object. The magic name `Exact Match` with the date of birth `1980-01-01` returns one record from the sandbox's Ohio sample registry. The `dob` is the important part: with it, the record comes back with `matchState: "dob_match"`; without it the same record comes back with `matchState: null`, because there was nothing to verify against.

**curl**
```bash
# Sex offender registry search on the sandbox (free; nothing is billed)
curl -X POST https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}}'
```

**Python**
```python
# pip install requests
import os, requests

KEY = os.environ["OFFENDERSEARCH_TEST_KEY"]      # os_test_… minted in the dashboard
HOST = "https://sandbox.offendersearch.app"

r = requests.post(
    f"{HOST}/v1/search",
    headers={"X-API-Key": KEY},
    json={"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}},
    timeout=130,
)
r.raise_for_status()
body = r.json()

print(body["status"], body["counts"])           # read these before records[]
for rec in body["records"]:
    print(rec["matchState"], rec["name"]["full"], rec["registrationState"])
    for src in rec["sources"]:
        print("  cite:", src["jurisdiction"], src["recordUrl"], src["lastCheckedAt"])
```

**Node**
```javascript
// Node 18+: fetch is built in
const KEY = process.env.OFFENDERSEARCH_TEST_KEY;   // os_test_… minted in the dashboard
const HOST = "https://sandbox.offendersearch.app";

const res = await fetch(`${HOST}/v1/search`, {
  method: "POST",
  headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
  body: JSON.stringify({ query: { firstName: "Exact", lastName: "Match", dob: "1980-01-01" } }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const body = await res.json();

console.log(body.status, body.counts);           // read these before records[]
for (const rec of body.records) {
  console.log(rec.matchState, rec.name.full, rec.registrationState);
  for (const src of rec.sources) console.log("  cite:", src.jurisdiction, src.recordUrl, src.lastCheckedAt);
}
```

A query must constrain something. A body with no name, no date of birth, no place and no `jurisdictions`, or one that sends only `firstName` or only `state`, is refused with `422` and a `detail` sentence rather than answered with a lottery of records. Place fields (`city`, `county`, `state`, `zipcode`, `address`, `lat`, `lng`, `radiusMiles`) and the `age`, `ageTolerance`, `dobTolerance` and `q` fields exist too; for a first check, a name and a date of birth are what you want.

## Step 3: the second background check API call, a criminal records search

Send `POST /v1/criminal/search`. The body has the same `query` object, with two differences you will hit on day one. First, `query.lastName` is required: a body without a surname is `422` with the coded error `guard_unbounded_query`, because an unbounded query cannot be answered. Second, `page` and `perPage` sit at the **top level** of the body, not inside `query`; `perPage` runs from 1 to 200 and defaults to 50.

**curl**
```bash
# Criminal records search on the sandbox: lastName is required, page/perPage sit at the top level
curl -X POST https://sandbox.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"}, "page": 1, "perPage": 50}'
```

**Python**
```python
r = requests.post(
    f"{HOST}/v1/criminal/search",
    headers={"X-API-Key": KEY},
    json={"query": {"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"},
          "page": 1, "perPage": 50},
    timeout=130,
)
r.raise_for_status()
body = r.json()

print(body["status"], body["counts"], body["legal"]["notice"])
for rec in body["records"]:
    print(rec["recordType"], rec["matchState"], rec["unverified"])
    for case in rec["cases"]:
        for ch in case["charges"]:
            print("  ", ch["severity"], ch["description"], "->", ch["disposition"])
    for src in rec["sources"]:
        print("  cite:", src["code"], src["recordUrl"], src["lastCheckedAt"])
```

**Node**
```javascript
const res2 = await fetch(`${HOST}/v1/criminal/search`, {
  method: "POST",
  headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
  body: JSON.stringify({
    query: { firstName: "Felony", lastName: "Record", dob: "1975-06-15" },
    page: 1, perPage: 50,
  }),
});
if (!res2.ok) throw new Error(`${res2.status} ${await res2.text()}`);
const crim = await res2.json();

console.log(crim.status, crim.counts, crim.legal.notice);
for (const rec of crim.records) {
  console.log(rec.recordType, rec.matchState, rec.unverified);
  for (const c of rec.cases) for (const ch of c.charges) console.log("  ", ch.severity, ch.description, "->", ch.disposition);
  for (const src of rec.sources) console.log("  cite:", src.code, src.recordUrl, src.lastCheckedAt);
}
```

The magic name `Felony Record` with `1975-06-15` returns one `court_case` with two charges in the same case: a felony burglary with the disposition `Guilty` and a misdemeanor theft with the disposition `Dismissed`. That pairing is deliberate: a case is not a conviction, a charge with a disposition is, and `cases[].charges[].disposition` is how you tell them apart. A criminal search is answered from the continuously updated dataset by default, which is why `counts.sourcesQueried` is `0` in the sample. Adding a `live` block re-verifies against named sources; the [cached vs live guide](/docs/guides/criminal/cached-vs-live) covers it, and the [criminal records API](/criminal-records-api) page describes the full record.

## Reading the background check API response: status, counts, sourceStatus, then records

The most common integration mistake is to jump to `records[]` and treat an empty array as "clear". Read the envelope first. The top of the registry answer for `Exact Match`, as the sandbox returned it:

*POST /v1/search — envelope*
```json
{
  "searchId": "srch_sbx_so_exact",
  "status": "complete",
  "elapsedMs": 236,
  "counts": {
    "records": 1,
    "sourcesQueried": 58,
    "sourcesIncomplete": 0,
    "sourcesComplete": 58,
    "sourcesSkippedByScope": 0,
    "recordsReturned": 1
  },
  "capped": false,
  "page": 1, "perPage": 1, "totalPages": 1,
  "warnings": [],
  "sourceStatus": [
    { "source": "AK", "status": "complete", "matched": 0, "fromCache": true,
      "note": null, "incomplete": false, "incompleteReason": null },
    { "source": "OH", "status": "complete", "matched": 1, "fromCache": true,
      "note": null, "incomplete": false, "incompleteReason": null },
    /* … 56 more rows, every one "status": "complete", "matched": 0 */
  ],
  "records": [ /* one record, below */ ]
}
```

1. **`status`.** `complete` means every source in scope answered. `partial` means at least one did not, so a record could exist in a source that did not answer. A `partial` answer with zero records is **not** a clear result.
2. **`counts`.** `sourcesQueried` is how many registries were in scope (58 here); `sourcesIncomplete` is how many failed to answer; `records` is how many matched; `recordsReturned` is how many are on this page.
3. **`sourceStatus[]`.** One row per source, with `status`, `matched`, `fromCache`, `incomplete` and `incompleteReason`: which registry held the match, and which, if any, did not answer. Store it with the search.
4. **`records[]`.** Only now. Each record is one person in one registry.

The criminal envelope has the same shape with a few extra keys: `counts.sourcesOnFile` and `counts.sourcesPending` describe the dataset behind a cached answer, `legal.notice` is the use restriction discussed below, and `sandbox: true` says which server answered. The [result completeness](/docs/result-completeness) page defines `complete` and `partial` precisely.

*POST /v1/criminal/search — envelope and record*
```json
{
  "status": "complete",
  "counts": {
    "records": 1, "recordsReturned": 1,
    "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0,
    "candidatesCapped": false, "exclusionsIncluded": false,
    "sourcesOnFile": 1567, "sourcesPending": 347
  },
  "warnings": [],
  "page": 1, "perPage": 50, "totalPages": 1,
  "sourceStatus": [],
  "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.)."
  },
  "searchId": "srch_sbx_cr_felony",
  "sandbox": true,
  "records": [
    {
      "recordType": "court_case",
      "externalId": "SBX-CR-2015-000123",
      "name": { "first": "Felony", "middle": "Sample", "last": "Record", "suffix": "" },
      "dob": { "date": "1975-06-15", "birthYear": 1975, "birthMonth": 6, "age": null, "ageAsOf": null, "dobPrecision": "full" },
      "state": "OH", "county": "Sample",
      "matchConfidence": 0.99,
      "matchState": "dob_match",
      "unverified": false,
      "matchBasis": "lastName:exact firstName:exact dob:match",
      "cases": [ { "caseNumber": "SBX-CR-2015-000123", "court": "Sample County Court of Common Pleas",
                   "filedDate": "2015-03-02", "status": "Closed",
                   "charges": [
                     { "description": "Burglary", "severity": "felony", "disposition": "Guilty", "dispositionDate": "2015-09-14" },
                     { "description": "Theft", "severity": "misdemeanor", "disposition": "Dismissed", "dispositionDate": "2015-09-14" }
                   ] } ],
      "sources": [ { "code": "OH-SAMPLE", "level": "county", "recordUrl": "https://offendersearch.app/docs/sandbox",
                     "lastCheckedAt": "2026-10-01T00:00:00+00:00", "live": false } ]
      /* … every other record key is always present ("" / null / [] when the source publishes nothing) */
    }
  ]
}
```

## matchState and citations: what the background check API says about identity and sources

A record that came back is a record whose name matched. Whether it is **the same person** is a separate question, and `matchState` answers it. The registry record for `Exact Match`, with the physical description and addresses trimmed:

*records[0], trimmed*
```json
{
  "recordId": "rec_28db9639d712c4c61e8e",
  "personRef": "prs_28db9639d712c4c61e8e",
  "recordType": "sex_offender",
  "matchConfidence": 0.9,
  "matchBasis": ["lastName", "firstName", "dob", "lastName:exact", "firstName:exact", "name_match"],
  "matchedName": { "value": "Exact Sample Match", "type": "legal" },
  "name": { "first": "Exact", "middle": "Sample", "last": "Match", "suffix": "", "full": "Exact Sample Match" },
  "dob": "1980-01-01",
  "dobPrecision": "exact",
  "registrationState": "OH",
  "addressStates": ["OH"],
  "offenses": [ { "crime": "Sexual battery", "statute": "SAMPLE 2907.03", "tier": "Tier III",
                  "convictionDate": "2004-05-12", "caseNumber": "SBX-2003-000101" } ],
  "images": [ { "url": "https://sandbox.offendersearch.app/v1/sandbox/photos/1.svg" } ],
  "sources": [ { "jurisdiction": "OH", "registryName": "Sandbox sample registry (OH)",
                 "recordUrl": "https://offendersearch.app/docs/sandbox",
                 "recordedAt": "2025-01-10T00:00:00+00:00",
                 "lastCheckedAt": "2026-10-01T00:00:00+00:00",
                 "sourceUpdatedAt": "2026-10-01T00:00:00+00:00" } ],
  "dobVerification": "dob_match",
  "matchState": "dob_match",
  "unverified": false
}
```

| matchState | Meaning | What to do |
| --- | --- | --- |
| `dob_match` | The full date of birth on the record agrees with the one you sent. | The only automatic identification. Safe to act on as a hit. |
| `year_match` | The source publishes a birth year only, and it agrees. | Strong lead; a reviewer confirms. |
| `age_match` | The source publishes an age only, and it agrees within `ageTolerance`. | Lead; a reviewer confirms. |
| `no_dob_age_year` | The source publishes no date, year or age. Name only. | Lead for a reviewer. Never an automatic hit. |
| `dob_near_match` | Only with `dobTolerance`: the record's date is a likely typo of yours. Always `unverified: true`. | Show the reviewer both dates. |
| `null` | You sent no `dob` and no `age`, so nothing could be checked. | Send a date of birth. |

A record whose date of birth **contradicts** yours is dropped, not returned: `Exact Match` with `1990-05-05` gives zero records, because the sample person was born in 1980. The criminal endpoint behaves the same way and sets `unverified: true` on anything that is not `dob_match`. The [matching](/docs/matching) reference explains the strategies behind the values.

Then store the citation. Every record carries `sources[]`, one entry per source that holds it: on a registry record `jurisdiction`, `registryName`, `recordUrl`, `recordedAt`, `lastCheckedAt` and `sourceUpdatedAt`; on a criminal record `code` (such as `OH-SAMPLE`), `level`, `recordUrl`, `lastCheckedAt` and `live`. Persist `searchId`, `recordId` (registry) or `externalId` (criminal), `personRef`, and every `recordUrl` with its `lastCheckedAt`. That is what lets a reviewer open the source record months later, and what `GET /v1/records/{recordId}` looks up.

## The background check API tutorial in six steps

The whole procedure, in order.

1. **Mint a test key.** Sign up, open the dashboard and create a test key. It starts with `os_test_`, works only against `https://sandbox.offendersearch.app`, and is never billed. Put it in an environment variable.
2. **Run the registry search.** `POST /v1/search` with `query.firstName` "Exact", `query.lastName` "Match" and `query.dob` "1980-01-01". The sandbox answers with one `sex_offender` record whose `matchState` is `dob_match`.
3. **Run the criminal search.** `POST /v1/criminal/search` with `query.firstName` "Felony", `query.lastName` "Record", `query.dob` "1975-06-15", and `page` and `perPage` at the top level. The sandbox answers with one `court_case` record.
4. **Read status, counts and sourceStatus before records.** Check `status` is "complete", compare `counts.sourcesIncomplete` with `counts.sourcesQueried`, and scan `sourceStatus[]` for `incomplete: true`. Only then read `records[]`.
5. **Read matchState and store the citation.** Treat `dob_match` as identification and anything else as a lead for a reviewer. Store `searchId`, `recordId`, `personRef` and every `sources[].recordUrl` with its `lastCheckedAt`.
6. **Go live.** Mint an `os_live_` key, change the host to `https://api.offendersearch.app` and the environment variable to the live key. Paths, bodies and response keys do not change.

## Go live with the background check API: host, key, free calls and the legal notice

Production is the same API on a different host with a different key prefix. The paths, request bodies, response keys and error shapes are identical, because the sandbox runs the same code against fixed data.

*The diff*
```plain
# The only two things that change between the sandbox and production
- HOST = "https://sandbox.offendersearch.app"     KEY = os.environ["OFFENDERSEARCH_TEST_KEY"]   # os_test_…
+ HOST = "https://api.offendersearch.app"         KEY = os.environ["OFFENDERSEARCH_API_KEY"]    # os_live_…

# Same paths, same bodies, same response keys:
#   POST /v1/search            POST /v1/criminal/search
#   GET  /v1/records/{recordId}   GET  /v1/criminal/records/{recordId}
```

Three things to have in place before you flip the host. A retry policy: `429` means the platform was at capacity, nothing was searched or billed, and the `Retry-After` header says when to try again; `502`, `503` and `504` are retried with backoff; a `4xx` validation error is never retried. No blind resubmits: the search endpoints have no idempotency header, so a repeated `POST` is a second billed search; keep the `searchId` you have and poll rather than resend. And a plan for `partial`: decide in code whether your product waits, retries or shows the reviewer the incomplete sources. The [rate limits, retries and idempotency](/docs/learn/rate-limits-retries-idempotency) tutorial works through all three with code.

Pricing in one line: $0.15 a call, $0.11 past 2,000 calls in a month, 25 free calls, no card. The free calls are ordinary production searches with a live key; a registry search or a criminal search each count as one, and a batch row counts as one per row. Use them for the first real names you check after the sandbox, to confirm that your parsing holds up against a real answer with more varied records than the fixtures. The [free background check API](/free-background-check-api) page describes the trial; the sandbox stays free after it.

Every criminal response carries `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.)."* The sex offender endpoint is informational in the same way. The two calls on this page serve use cases such as visitor screening, marketplace trust and safety, and internal research, where no employment, tenant, credit or insurance decision is made about the person. A hiring or tenant decision is an **FCRA order**: `POST /v1/fcra/orders` on the [FCRA Partner API](/fcra), which registers the end user, records consent, produces a report and handles adverse action. Whether your use is a permissible purpose is a question for your counsel, not for this page.

> **Store the notice with the result.** If you persist search results, persist `legal.notice` beside them. A result read six months later by someone who never saw the API should still carry its own use restriction.

## Background check API tutorial FAQ

### How do I run a background check with an API?

Two `POST` requests with a JSON `query` containing a name and a date of birth: `/v1/search` for sex offender registries and `/v1/criminal/search` for criminal records. Read `status` and `counts` first, then `records[]`, then `matchState`. The six-step procedure above is the whole flow.

### Do I need the SDK to follow this tutorial?

No. The Python and Node.js SDKs are not published yet; they ship with the first registry release. The examples here use `requests` and the built-in `fetch`, and the Postman collection linked below has the same calls ready to send.

### Why does the criminal search reject my request with guard_unbounded_query?

Because `query.lastName` is missing. The criminal endpoint requires a surname so that a search is bounded. The registry endpoint has the equivalent rule: a query that constrains nothing, or only `firstName`, or only `state`, is `422` with a `detail` sentence.

### Does a test key work on the production host?

No. An `os_test_` key is accepted only by `sandbox.offendersearch.app`, and an `os_live_` key only by `api.offendersearch.app`. Sending either to the other host is a `401`.

### Is an empty records array a clear result?

Only when `status` is `complete` and `counts.sourcesIncomplete` is `0`. If any source is incomplete, the answer is "not fully checked", and your product should say so. `Clear Visitor` and `Clear Record` on the sandbox show what a genuinely clear answer looks like.

## What this background check API tutorial does not cover

- Place-based queries, `jurisdictions`, `locationScoped` and match modes: the [check a registered sex offender](/docs/learn/check-registered-sex-offender-api) tutorial.
- Live re-verification of criminal records (the `live` block, the `202` async flow and polling): [search criminal records by name and date of birth](/docs/learn/search-criminal-records-by-name-and-dob).
- Ongoing checks after the first one: [monitor a person with webhooks](/docs/learn/monitor-a-person-with-webhooks).
- FCRA orders, consent and adverse action: the [FCRA adverse action API](/docs/learn/fcra-adverse-action-api) tutorial.

## Related

- [Check if someone is a registered sex offender with the API](https://offendersearch.app/docs/learn/check-registered-sex-offender-api)
- [Search criminal records by name and date of birth](https://offendersearch.app/docs/learn/search-criminal-records-by-name-and-dob)
- [Background check API response explained](https://offendersearch.app/docs/learn/background-check-api-response-explained)
- [Test a background check integration](https://offendersearch.app/docs/learn/test-a-background-check-integration)
- [Rate limits, retries and idempotency](https://offendersearch.app/docs/learn/rate-limits-retries-idempotency)
- [Quickstart (sex offender API)](https://offendersearch.app/docs/quickstart)
- [Criminal quickstart](https://offendersearch.app/docs/criminal/quickstart)
- [Public API for testing (the sandbox)](https://offendersearch.app/sandbox)
- [Background check API](https://offendersearch.app/background-check-api)
- [Free background check API](https://offendersearch.app/free-background-check-api)
- [Postman collection](https://offendersearch.app/postman)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
