Offendersearch
Tutorial

Using a background check API in Python.

A working tutorial for calling a background check API from Python with requests: the first search, reading match states, criminal records, batch, async with webhooks, and error handling you can paste into a service.

25 free calls · No card · $0.15 a call, $0.11 past 2,000 a month

import os, requests

r = requests.post(
    "https://api.offendersearch.app/v1/search",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
    json={"query": {
        "firstName": "John",
        "lastName": "Doe",
        "dob": "1980-04-12",
    }},
    timeout=60,
)
records = r.json()["records"]
strong = [
    x for x in records if x["matchState"] == "dob_match"
]
Before you start

What you need to call a background check API from Python

Python 3.8 or newer, the requests library, and an API key. Sign up to create one; it works immediately on 25 free calls with no card. There is no SDK to install — the API is plain HTTPS with JSON, so requests is the whole dependency list.

python -m venv .venv && source .venv/bin/activate
pip install requests
export OFFENDERSEARCH_KEY="your_key_here"
Step 1

Make your first background check API call in Python

Authentication is one header, X-API-Key. A requests.Session sets it once and reuses the connection. The search itself is POST /v1/search with a query object; a last name is the minimum, and a full date of birth is what makes the result usable.

import os
import requests

BASE = "https://api.offendersearch.app"

session = requests.Session()
session.headers.update({"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]})


def search_registry(first_name, last_name, dob=None, state=None):
    query = {"firstName": first_name, "lastName": last_name}
    if dob:
        query["dob"] = dob          # ISO date: "1980-04-12"
    if state:
        query["state"] = state
    r = session.post(f"{BASE}/v1/search", json={"query": query}, timeout=60)
    r.raise_for_status()
    return r.json()
Step 2

Read the response: match states and source status

Two fields decide what you do with a response. matchState on each record says how it matched, and sourceStatus says which jurisdictions answered. Filter on the match state, not on the confidence score alone.

def triage(response):
    """Split records into identifications and leads for a human."""
    confirmed, review = [], []
    for rec in response["records"]:
        if rec["matchState"] == "dob_match":
            confirmed.append(rec)
        else:                       # year_match, age_match, no date on file …
            review.append(rec)

    # A clear result only counts if every source answered.
    unanswered = [s["source"] for s in response["sourceStatus"] if not s["ok"]]
    return confirmed, review, unanswered


data = search_registry("John", "Doe", dob="1980-04-12")
confirmed, review, unanswered = triage(data)
print(len(confirmed), "confirmed,", len(review), "to review,", len(unanswered), "sources unanswered")
matchStateMeaningTypical handling
dob_matchThe record’s full date of birth equals the one you sentTreat as an identification
year_matchThe record publishes a birth year only, and it matchesSend to a reviewer
age_matchNo date on file; the published age fits your date within a yearSend to a reviewer
Anything elseNo date or age to check againstTreat as a name-only lead

Every state is defined in the matching reference.

Step 3

Search criminal records from Python

Criminal records live at POST /v1/criminal/search on the same key. A last name is required. Results are paginated with page and perPage, and each record says what type it is — a jail booking, a custody record, a court case or a warrant — so your code never treats an arrest as a conviction. Fields are in the criminal record reference.

import os, requests

r = requests.post(
    "https://api.offendersearch.app/v1/criminal/search",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
    json={
        "query": {
            "firstName": "Morgan",
            "lastName": "Okafor",
            "dob": "1990-06-18",
            "state": "OK",
        },
        "page": 1,
        "perPage": 50,
    },
    timeout=30,
)
for rec in r.json()["records"]:
    source = rec["sources"][0]["code"]
    print(rec["matchState"], rec["matchConfidence"], source)
Step 4

Run a batch of background checks in one request

POST /v1/batch takes up to 1,000 people per call as a JSON queries array (or a CSV body). Each result carries the index of the row it answers, so you can join results back to your own list without matching on names. A batch is billed per row.

people = [
    {"firstName": "John", "lastName": "Smith", "state": "FL", "dob": "1980-04-12"},
    {"firstName": "Jane", "lastName": "Doe", "state": "TX"},
]

r = session.post(f"{BASE}/v1/batch", json={"queries": people}, timeout=300)
r.raise_for_status()

for result in r.json()["results"]:
    person = people[result["index"]]          # zip results back by index
    print(person["lastName"], result["status"], result["counts"]["records"])
Get an API key25 free calls, no card.
Step 5

Run a long search asynchronously with a webhook

When nobody is waiting on the answer, POST /v1/searches accepts the search and returns a searchId. Supply a webhookUrl to be notified when it finishes. The notification is sent once and is not signed, so always confirm by fetching GET /v1/searches/{searchId} with your key. An Idempotency-Key header makes a retried request return the original job rather than start a second one.

import time, uuid

job = session.post(
    f"{BASE}/v1/searches",
    headers={"Idempotency-Key": str(uuid.uuid4())},   # reuse the key when retrying
    json={
        "query": {"lastName": "Doe", "firstName": "John", "dob": "1980-04-12"},
        "webhookUrl": "https://example.com/hooks/offendersearch",
    },
    timeout=30,
)
job.raise_for_status()
search_id = job.json()["searchId"]

# The webhook is a single unsigned notification, so the GET is the source of truth.
while True:
    result = session.get(f"{BASE}/v1/searches/{search_id}", timeout=30).json()
    if result.get("status") in ("complete", "partial"):
        break
    time.sleep(2)
Step 6

Handle errors, rate limits and retries

Every non-2xx response has the same JSON body: a stable error.code to switch on and an error.message you can show a user. One wrapper covers the whole API.

import time


class ApiError(Exception):
    def __init__(self, status, code, message):
        super().__init__(f"{status} {code}: {message}")
        self.status, self.code = status, code


def post(path, payload, attempts=4):
    for attempt in range(attempts):
        r = session.post(f"{BASE}{path}", json=payload, timeout=60)
        if r.status_code == 429:                       # rate limited: wait and retry
            time.sleep(int(r.headers.get("Retry-After", 2 ** attempt)))
            continue
        if r.status_code >= 500 and attempt < attempts - 1:
            time.sleep(2 ** attempt)
            continue
        if not r.ok:
            err = r.json().get("error", {})
            raise ApiError(r.status_code, err.get("code"), err.get("message"))
        return r.json()
    raise ApiError(429, "rate_limited", "gave up after retries")
StatusWhat it meansWhat to do
400A required parameter is missing or invalidFix the request; do not retry
401Missing or invalid X-API-KeyCheck the key and the header name
402A payment method is needed to continueAdd a card in the dashboard
422The request was understood and declined, with the reason in the messageNarrow the query as the message says
429Rate limit or quota reachedWait for Retry-After, then retry
504A deadline you set was exceededRetry, or use the async endpoint

The full list is in the errors reference.

Production

A short checklist before you ship

  • Keep the key in an environment variable or a secret manager, never in the repository.
  • Always send a full date of birth when you have one.
  • Store the record citations with each result so a reviewer can open the official record.
  • Treat an unanswered source as “not clear”, not as “no record”.
  • Set timeouts on every request and retry only on 429 and 5xx.
  • Do not use standard API results for a hiring or tenant decision — those run as FCRA workflows through FCRA Partner Access.
Related

More on the background check API

The product is described on the background check API page, and the language-neutral build guide is background check API integration. Prefer JavaScript? See the background check API in Node.js tutorial. Costs are on the pay-as-you-go background check API page, and the 25 free calls are explained on the free background check API page.

FAQ

Background check API Python questions

Is there a Python SDK for the background check API?

There is no SDK to install. The API is JSON over HTTPS, so the requests library is all you need, and the wrapper on this page is about forty lines.

Which Python versions work?

Python 3.8 or newer. The only dependency is requests.

Can I use asyncio or httpx instead of requests?

Yes. The API is plain HTTPS, so httpx or aiohttp work with the same URLs, header and JSON bodies shown here.

How do I run background checks on a pandas DataFrame?

Convert the rows to a list of dicts with firstName, lastName and dob, send them to POST /v1/batch in chunks of up to 1,000, and join the results back using the index field.

How do I avoid false matches on common names?

Send a full date of birth and act automatically only on dob_match. Route year_match, age_match and name-only records to a person.

Is there a free background check API I can use from Python?

Every new account gets 25 free calls with no card, on the same key and endpoints used in this tutorial.

What is the rate limit?

When you exceed it the API returns 429 with a Retry-After header in seconds. Back off for that long and retry, as the wrapper above does.

Can I use this in Django or FastAPI?

Yes. Call it from a view or a background task. For anything that should not block a request, use the async endpoint with a webhook.

Run the first search from your terminal

Create a key, export it, and paste the first example. 25 calls are free.

The Offendersearch APIs

One account, one key, one bill — the sex offender registry and criminal records, side by side.

Sex offender registry

Criminal records

Build with it

Screening and compliance