Developer docs · v1.0.0

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.

Updated November 3, 2026

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. Until then the raw HTTP calls below are the integration.

Every request here runs against the 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 page links to both endpoints.

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

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 reference shows.

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
{
  "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 page defines complete and partial precisely.

POST /v1/criminal/search — envelope and record
{
  "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
{
  "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
}
matchStateMeaningWhat to do
dob_matchThe 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_matchThe source publishes a birth year only, and it agrees.Strong lead; a reviewer confirms.
age_matchThe source publishes an age only, and it agrees within ageTolerance.Lead; a reviewer confirms.
no_dob_age_yearThe source publishes no date, year or age. Name only.Lead for a reviewer. Never an automatic hit.
dob_near_matchOnly with dobTolerance: the record's date is a likely typo of yours. Always unverified: true.Show the reviewer both dates.
nullYou 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 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
# 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 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 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, 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