Developer docs · v1.0.0

Test a background check API integration on the sandbox

A free test key, a fixed set of documented test people, and the same response shapes as production: everything you need to write a test suite that proves your integration reads clear, matched, unverified, partial and error answers correctly before a single call is billed.

Updated November 3, 2026

Mint a test key and point the integration at the sandbox

The public API for testing is a separate host, https://sandbox.offendersearch.app, that answers the same endpoints as production with the same response shapes, from a fixed set of documented test people. It needs a key, and the key is free: in the dashboard, create a test key, which begins os_test_ and works only on the sandbox. An account can hold three active test keys and mint ten a day; a revoked one stops working within minutes. Nothing a test key does is billed or counted against usage, and the limits that do apply, 60 calls a minute and 2,000 a day per key, are real 429s with Retry-After, which is useful because your retry code gets tested too.

Put the key in an environment variable and the host in a setting. The whole point of the sandbox is that go-live is two configuration changes, so an integration that hard-codes either is already wrong. Then make one call to confirm the wiring:

The first sandbox call
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"}}'

# "searchId": "srch_sbx_so_exact", "status": "complete", "counts": { "records": 1, … }
# records[0].matchState == "dob_match", records[0].unverified == false

If that answers 401, the header name is wrong or the key is a live one on the sandbox host. If it answers 200 with "status": "complete" and one record, you are connected and everything else on this page is a variation on that call. Python and Node.js SDKs ship with the first registry release; see SDKs. Until then the raw HTTP calls below are the integration.

Sandbox parity: the same shapes as production

The sandbox answers from the same serializers as the production host. Every key the record object documents is in a sandbox record, with the same types, and the envelope is the same: searchId, status, counts, page, perPage, totalPages, warnings, sourceStatus, records. Validation is identical, so a body the sandbox refuses with 422 is one production would refuse too. The criminal envelope additionally carries "sandbox": true, so a test that asserts on it will catch a suite accidentally pointed at production.

What differs is only the data. Any name that is not a documented test person returns a complete answer with zero records, so the sandbox cannot tell you anything about a real subject; that is what the 25 free production calls are for. And the sandbox's state (monitors, async searches) is in memory and may be reset on a redeploy, so a test that depends on something created in an earlier run should create it again. The sandbox reference and the environments page are the authoritative descriptions.

The sandbox test names: registry and criminal scenarios

Each test person exercises one path your code must handle. The date of birth is part of the scenario: the same name with the documented date, with no date, and with a contradicting date are three different answers.

EndpointNamedobWhat comes back
/v1/searchClear Visitorany or nonecomplete, 0 records, every sourceStatus row complete
/v1/searchExact Match1980-01-011 record, matchState: "dob_match", OH, two addresses, a photo, offense "Sexual battery", Tier III
/v1/searchExact MatchnoneThe same person, matchState: null (nothing was sent to verify)
/v1/searchExact Match1990-05-050 records: the published date contradicts yours, so the record is dropped
/v1/searchName Onlyany3 records in 3 states, no published DOB: matchState: "no_dob_age_year", unverified: true
/v1/searchAlias MatchnoneMatched through an alias: matchedName.type: "alias", matchBasis includes alias:exact
/v1/searchOut Statestate: "TX"Registered by OK, lives in TX: registrationState: "OK", addressStates: ["TX"]
/v1/searchMulti StatenoneOne record merged from FL and GA; sources[] lists both
/v1/searchHomeless RegistrantnonehousingStatus: "homeless"
/v1/criminal/searchClear Recordany or nonecomplete, 0 records
/v1/criminal/searchFelony Record1975-06-151 court_case: felony burglary Guilty, misdemeanor theft Dismissed, a sentence, dob_match
/v1/criminal/searchFelony RecordnoneThe same case, matchState: "no_dob_age_year", unverified: true
/v1/criminal/searchFelony Record1990-01-010 records (contradiction)
/v1/criminal/searchMisdemeanor Record1988-11-02A misdemeanor theft conviction with a fine
/v1/criminal/searchArrest Only1992-04-20One jail_booking: a charge at booking, no disposition
/v1/criminal/searchActive Warrant1985-09-09One warrant, currentStatus: "active_warrant"
/v1/criminal/searchIn Custody1979-12-31One doc_inmate: currentlyIncarcerated: true, custodyFacility, a projected release date
/v1/criminal/searchPossible Matchany3 records in 3 states, no DOB on any
/v1/criminal/searchExcluded Provider1968-03-22One healthcare_exclusion (conviction-based)
/v1/criminal/searchSanctioned ProvideranyAn administrative exclusion, returned only with include: ["exclusions"]
/v1/monitorsExact Match1980-01-01A person monitor fires one new alert to the webhook at once

Any other name, on either endpoint, is a complete answer with zero records. Jordan Rivera, 1988-04-12, is the conventional one for that.

A test suite for the background check API: pytest and Jest

Assert on the three things a decision depends on, status, counts and matchState, and on the citation. Do not assert on elapsedMs, on warnings text or on sourceStatus[].note; those are informational and may change wording. Both suites below hit the sandbox directly and need only the test key in the environment.

# pip install pytest requests
# OFFENDERSEARCH_TEST_KEY=os_test_… pytest -q test_offendersearch.py
import os, requests, pytest

BASE = "https://sandbox.offendersearch.app"          # os_test_… keys only; nothing is billed
KEY = os.environ["OFFENDERSEARCH_TEST_KEY"]
H = {"X-API-Key": KEY, "Content-Type": "application/json"}

def search(first, last, dob=None, **extra):
    q = {"firstName": first, "lastName": last}
    if dob:
        q["dob"] = dob
    return requests.post(f"{BASE}/v1/search", headers=H, json={"query": q, **extra}, timeout=60)

def criminal(first, last, dob=None, **extra):
    q = {"firstName": first, "lastName": last}
    if dob:
        q["dob"] = dob
    return requests.post(f"{BASE}/v1/criminal/search", headers=H, json={"query": q, **extra}, timeout=60)

def test_exact_match_is_identified():
    body = search("Exact", "Match", "1980-01-01").json()
    assert body["status"] == "complete"
    assert body["counts"]["records"] == 1 and body["counts"]["sourcesIncomplete"] == 0

Eight tests, eight sandbox calls, well inside the per-minute limit. Add one per record kind your product consumes (Active Warrant, Misdemeanor Record, Excluded Provider) and one per decision rule in your own code; the sandbox people are the inputs, your classifier is what is under test. The record types guide explains what each criminal recordType carries so the assertions are the right ones.

Testing the clear, contradiction and name-only paths

Three zero-or-weak answers look alike to careless code and mean different things. The suite above covers each; this is why.

  • Clear (Clear Visitor, Clear Record): status: "complete", counts.records: 0, and no sourceStatus row with incomplete: true. Only this combination is a clearance. Your test should assert all three, because a partial with zero records (below) must not pass the same check.
  • Contradiction (Exact Match with 1990-05-05; Felony Record with 1990-01-01): also complete with zero records. The person exists in the dataset, but the published date of birth contradicts yours, so the record is dropped rather than returned with a low score. From the outside it is indistinguishable from clear, and that is the design: a wrong-date hit is not your applicant. The date of birth matching guide has the rules.
  • Name-only (Name Only, Possible Match): records come back, each with matchState: "no_dob_age_year" and unverified: true, because the source publishes nothing to check against. These are leads for a reviewer, never identifications. Assert that your code routes them to review rather than treating them as hits or as clear.
  • No date sent (Exact Match without dob): the record comes back with matchState: null. null means *you sent nothing to verify*; no_dob_age_year means *you did, and the source had nothing*. A test that asserts the difference catches a client that drops the dob field by mistake.

The sibling tutorial on no-match and multiple-match results walks the decision table these tests pin down.

Testing partial results, 422 validation and 429 limits on the sandbox

Partial results. The sandbox's fixed people all answer complete, so a partial answer is tested as a shape: build a fixture from the documented envelope with status: "partial", counts.sourcesIncomplete above zero and a sourceStatus row with incomplete: true and a closed-enum incompleteReason (deadline, truncated, excluded, not_searched, unavailable, error), and assert that your code treats it as *retry those registries*, not as clear. In production the same answer is reproduced on demand with a short deadlineMs and onDeadline: "partial"; the incomplete results guide covers retrying just the incomplete registries with jurisdictions.

fixtures/partial.json (two of the 58 rows shown)
{
  "searchId": "srch_fixture_partial",
  "status": "partial",
  "counts": { "records": 0, "sourcesQueried": 58, "sourcesIncomplete": 2, "sourcesComplete": 56,
              "sourcesSkippedByScope": 0, "recordsReturned": 0 },
  "warnings": [],
  "sourceStatus": [
    { "source": "AK", "status": "complete", "matched": 0, "fromCache": true, "note": null,
      "incomplete": false, "incompleteReason": null, "cappedOmitted": false },
    { "source": "OH", "status": "complete", "matched": 0, "fromCache": false, "note": null,
      "incomplete": true, "incompleteReason": "deadline", "cappedOmitted": false }
  ],
  "records": []
}
The shape test
# Your parser must refuse to call this "clear". Feed it the fixture above.
def test_partial_with_zero_records_is_not_a_clearance():
    body = json.load(open("fixtures/partial.json"))
    verdict = my_app.classify(body)                       # your code under test
    assert verdict == "retry"                             # not "clear"
    assert my_app.registries_to_retry(body) == ["OH"]      # the rows with incomplete: true

# Production reproduces it on demand: a short deadline with onDeadline "partial".
# {"query": {...}, "deadlineMs": 1000, "onDeadline": "partial"}  → status "partial" when a registry runs long

422 validation. A query with only firstName, only state, or nothing at all is refused before anything runs, with a detail sentence; a malformed field is refused with a detail list of { loc, msg, type }; on the criminal endpoint a missing lastName is a coded guard_unbounded_query. The sandbox returns the same validation as production, so one test per body you might accidentally send is cheap insurance. Never retry a 422: it will fail the same way. The errors reference lists every shape.

429 at the limits. The sandbox enforces 60 calls a minute and 2,000 a day per test key. When you hit one, nothing was searched or billed, the Retry-After header carries the seconds to wait (1–120) and detail.retryAfterSeconds repeats it. A suite that runs in parallel will see this, which makes it the natural place to test the backoff you will need in production for 429, 502, 503 and 504:

Honour Retry-After
import time, requests

def post_with_backoff(url, headers, body, attempts=5):
    for n in range(attempts):
        r = requests.post(url, headers=headers, json=body, timeout=60)
        if r.status_code == 429:                       # sandbox: 60/min and 2,000/day per test key
            wait = int(r.headers.get("Retry-After", "0")) or r.json()["detail"]["retryAfterSeconds"]
            time.sleep(wait)                           # nothing ran and nothing was billed
            continue
        if r.status_code in (502, 503, 504):
            time.sleep(2 ** n)
            continue
        return r                                       # 2xx, or a 4xx you must not retry
    raise RuntimeError("gave up after repeated 429/5xx")

There is no idempotency header on the search endpoints: a repeated POST after a timeout is a second search, billed in production. Persist searchId from the first answer and poll instead of resubmitting. The sibling tutorial on rate limits, retries and idempotency goes deeper.

Testing monitors and signed webhooks in the sandbox

The sandbox runs the monitoring flow with test keys. Create a person monitor for Exact Match with dob 1980-01-01 and a channels.webhookUrl, and the sandbox fires one monitor.alert with event: "new" to that URL at once, signed exactly as production signs: X-Offendersearch-Signature: t=<unix>,v1=<hex> where v1 is HMAC-SHA256 over "<t>." + raw body with the webhookSecret returned once on the 201.

Create the monitor
# A sandbox person monitor for Exact Match fires one "new" alert to your webhook at once.
curl -X POST https://sandbox.offendersearch.app/v1/monitors \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"product": "sex-offender", "type": "person", "label": "sandbox smoke test",
       "person": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "channels": {"webhookUrl": "https://hooks.example.com/hooks/offendersearch"}}'

# 201 … "channels": {"webhookUrl": "…", "webhookSecret": "whsec_…"}   ← store it; shown once
# Then: GET /v1/monitors/{monitorId}/alerts lists the same alert your webhook received.
# Header on the delivery:  X-Offendersearch-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>." + raw body)>

That gives you an end-to-end test of your receiver: the raw-body read, the constant-time comparison, the five-minute replay window and the dedupe on alertId. If the receiver is not yet reachable from the internet, paste the header and body it would have received into the webhook tester to check your signature arithmetic, or point webhookUrl at a tunnel to your laptop. GET /v1/monitors/{monitorId}/alerts lists the same alert, so a test can assert the webhook delivery against the API's own record of it. Delete the monitor with DELETE /v1/monitors/{monitorId} when the test is done; sandbox state is in memory, but a tidy suite leaves nothing behind. The webhook example has receivers in Express and FastAPI, and the monitoring quickstart the full monitor object.

Postman and CI for a background check API integration

For exploring by hand, the Postman collection has every endpoint with bodies pre-filled with the sandbox test names, and a sandbox environment whose base_url is already the sandbox host; paste your test key into the api_key secret variable and send. It is the quickest way to see a scenario's full response before you write the assertion for it, and the Postman documentation page explains the collection layout.

For automation, the suite above is a contract test: it proves that your client still sends what the API expects and still reads what it returns. Run it on every deploy of your own service.

GitHub Actions
# .github/workflows/sandbox.yml — run the suite on every deploy, never with a live key
name: sandbox-contract
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install pytest requests
      - run: pytest -q test_offendersearch.py
        env:
          OFFENDERSEARCH_TEST_KEY: ${{ secrets.OFFENDERSEARCH_TEST_KEY }}   # an os_test_ key, stored as a secret
  • Store the test key as a CI secret. Never a live key: a live key in CI bills real searches and a leaked one bills someone else's.
  • Keep the suite under the per-minute limit, or add the Retry-After backoff above to the test client. Eight to twenty calls a run is comfortable.
  • Assert on sandbox: true in a criminal response so a suite that is misconfigured to the production host fails loudly rather than spending the free calls.
  • Treat warnings and note as logs, not assertions; their wording may change.
  • Pin the shapes you depend on in fixtures (like partial.json) and run those unit tests even without network, so a sandbox outage does not block your deploy.

Switching the integration from sandbox to production

Two settings change: the host becomes https://api.offendersearch.app and the key becomes an os_live_ one from the same dashboard page. Request bodies, headers, response shapes and error shapes are the same. The first 25 production calls are free with no card; after that it is $0.15 a call, $0.11 past 2,000 calls in a month.

Go-live is configuration
# Two settings change. Nothing else does.
# Sandbox was: OFFENDERSEARCH_BASE=https://sandbox.offendersearch.app  OFFENDERSEARCH_API_KEY=os_test_…
export OFFENDERSEARCH_BASE=https://api.offendersearch.app
export OFFENDERSEARCH_API_KEY=os_live_…

# The first 25 production calls are free, no card. Jordan Rivera (1988-04-12) returns a
# complete answer with zero records in production, so it is a safe first live call.
curl -X POST "$OFFENDERSEARCH_BASE/v1/search" \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Jordan", "lastName": "Rivera", "dob": "1988-04-12"}}'

Keep the sandbox suite running against the sandbox after go-live; it remains the cheapest proof that a change on your side did not break the integration. The go-live guide has the production checklist, and the free background check API page describes the 25-call trial.

Test a background check API integration: six steps

  1. Mint a test key. In the dashboard, create an os_test_ key. Up to three can be active per account, ten can be minted a day, and none is ever billed. Store it in an environment variable, never in code.
  2. Point your client at the sandbox. Make the base URL a setting: https://sandbox.offendersearch.app now, https://api.offendersearch.app later. Send the key in X-API-Key. Confirm with one call for Exact Match 1980-01-01 and read status, counts.records and records[0].matchState.
  3. Write the scenario tests. One test per path your code handles: identified (dob_match), clear (complete, zero records), contradiction (zero records), name-only (no_dob_age_year, unverified: true), no date sent (matchState: null), and each criminal record kind you consume.
  4. Test the paths the sandbox cannot produce on demand. Partial results and overload are shape tests: feed your parser a fixture with status: "partial" and a sourceStatus row with incomplete: true, and a 429 with Retry-After, and assert it retries rather than clears.
  5. Run it in CI on every deploy. Store the test key as a CI secret and run the suite on each build. A sandbox call is free and the shapes are production shapes, so the suite is a contract test for your integration.
  6. Switch to production. Change the host and the key to an os_live_ one. The first 25 calls are free with no card. Make the first live call with a name you expect to be clear, and keep the sandbox suite running against the sandbox.

Background check API sandbox testing: questions developers ask

Is the sandbox free?

Yes. A test key is minted in the dashboard in seconds, nothing it does is billed, and the only limits are 60 calls a minute and 2,000 a day per key. Three test keys can be active per account and ten can be minted a day.

Can I test with a real person's name on the sandbox?

You can send any name, but the sandbox only knows its documented test people; anything else returns a complete answer with zero records. Real subjects are what the 25 free production calls are for.

Why does Exact Match return nothing with a different date of birth?

Because a record whose published date contradicts the one you sent is dropped, not returned with a low score. That is the contradiction path, and it is one of the three zero-record answers your suite should tell apart.

How do I test a partial result if the sandbox always answers complete?

As a shape test: a fixture with status: "partial" and a sourceStatus row with incomplete: true, fed to your classifier. In production a short deadlineMs with onDeadline: "partial" reproduces it.

Does the sandbox send real webhooks?

Yes. A person monitor for Exact Match fires one signed monitor.alert to your webhookUrl at once, with the same X-Offendersearch-Signature header production sends. The webhook tester checks the arithmetic if your receiver is not yet public.

Do I need to change my code to go live?

No. The host and the key change; bodies, headers and response shapes do not. That is why the suite you wrote against the sandbox stays valid after go-live.

What this page does not cover

  • Reading every key of a response: see the response explained.
  • Async searches and batch files on the sandbox, which use the same test names: see async searches and batch searches.
  • FCRA order flows on the sandbox: see FCRA sandbox and errors.
  • Load or burst testing: the sandbox limits are a protection of the sandbox, not a model of production capacity.