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.
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.
# 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"}}'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.
# 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}'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 covers it, and the 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:
{
"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 */ ]
}- `status`.
completemeans every source in scope answered.partialmeans at least one did not, so a record could exist in a source that did not answer. Apartialanswer with zero records is not a clear result. - `counts`.
sourcesQueriedis how many registries were in scope (58 here);sourcesIncompleteis how many failed to answer;recordsis how many matched;recordsReturnedis how many are on this page. - `sourceStatus[]`. One row per source, with
status,matched,fromCache,incompleteandincompleteReason: which registry held the match, and which, if any, did not answer. Store it with the search. - `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.
{
"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:
{
"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 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.
- Mint a test key. Sign up, open the dashboard and create a test key. It starts with
os_test_, works only againsthttps://sandbox.offendersearch.app, and is never billed. Put it in an environment variable. - Run the registry search.
POST /v1/searchwithquery.firstName"Exact",query.lastName"Match" andquery.dob"1980-01-01". The sandbox answers with onesex_offenderrecord whosematchStateisdob_match. - Run the criminal search.
POST /v1/criminal/searchwithquery.firstName"Felony",query.lastName"Record",query.dob"1975-06-15", andpageandperPageat the top level. The sandbox answers with onecourt_caserecord. - Read status, counts and sourceStatus before records. Check
statusis "complete", comparecounts.sourcesIncompletewithcounts.sourcesQueried, and scansourceStatus[]forincomplete: true. Only then readrecords[]. - Read matchState and store the citation. Treat
dob_matchas identification and anything else as a lead for a reviewer. StoresearchId,recordId,personRefand everysources[].recordUrlwith itslastCheckedAt. - Go live. Mint an
os_live_key, change the host tohttps://api.offendersearch.appand 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 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.
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,locationScopedand match modes: the check a registered sex offender tutorial. - Live re-verification of criminal records (the
liveblock, the202async flow and polling): search criminal records by name and date of birth. - Ongoing checks after the first one: monitor a person with webhooks.
- FCRA orders, consent and adverse action: the FCRA adverse action API tutorial.