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:
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 == falseIf 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.
| Endpoint | Name | dob | What comes back |
|---|---|---|---|
/v1/search | Clear Visitor | any or none | complete, 0 records, every sourceStatus row complete |
/v1/search | Exact Match | 1980-01-01 | 1 record, matchState: "dob_match", OH, two addresses, a photo, offense "Sexual battery", Tier III |
/v1/search | Exact Match | none | The same person, matchState: null (nothing was sent to verify) |
/v1/search | Exact Match | 1990-05-05 | 0 records: the published date contradicts yours, so the record is dropped |
/v1/search | Name Only | any | 3 records in 3 states, no published DOB: matchState: "no_dob_age_year", unverified: true |
/v1/search | Alias Match | none | Matched through an alias: matchedName.type: "alias", matchBasis includes alias:exact |
/v1/search | Out State | state: "TX" | Registered by OK, lives in TX: registrationState: "OK", addressStates: ["TX"] |
/v1/search | Multi State | none | One record merged from FL and GA; sources[] lists both |
/v1/search | Homeless Registrant | none | housingStatus: "homeless" |
/v1/criminal/search | Clear Record | any or none | complete, 0 records |
/v1/criminal/search | Felony Record | 1975-06-15 | 1 court_case: felony burglary Guilty, misdemeanor theft Dismissed, a sentence, dob_match |
/v1/criminal/search | Felony Record | none | The same case, matchState: "no_dob_age_year", unverified: true |
/v1/criminal/search | Felony Record | 1990-01-01 | 0 records (contradiction) |
/v1/criminal/search | Misdemeanor Record | 1988-11-02 | A misdemeanor theft conviction with a fine |
/v1/criminal/search | Arrest Only | 1992-04-20 | One jail_booking: a charge at booking, no disposition |
/v1/criminal/search | Active Warrant | 1985-09-09 | One warrant, currentStatus: "active_warrant" |
/v1/criminal/search | In Custody | 1979-12-31 | One doc_inmate: currentlyIncarcerated: true, custodyFacility, a projected release date |
/v1/criminal/search | Possible Match | any | 3 records in 3 states, no DOB on any |
/v1/criminal/search | Excluded Provider | 1968-03-22 | One healthcare_exclusion (conviction-based) |
/v1/criminal/search | Sanctioned Provider | any | An administrative exclusion, returned only with include: ["exclusions"] |
/v1/monitors | Exact Match | 1980-01-01 | A 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"] == 0Eight 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 nosourceStatusrow withincomplete: true. Only this combination is a clearance. Your test should assert all three, because apartialwith zero records (below) must not pass the same check. - Contradiction (Exact Match with
1990-05-05; Felony Record with1990-01-01): alsocompletewith 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"andunverified: 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 withmatchState: null.nullmeans *you sent nothing to verify*;no_dob_age_yearmeans *you did, and the source had nothing*. A test that asserts the difference catches a client that drops thedobfield 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.
{
"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": []
}# 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 long422 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:
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.
# 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/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-Afterbackoff above to the test client. Eight to twenty calls a run is comfortable. - Assert on
sandbox: truein a criminal response so a suite that is misconfigured to the production host fails loudly rather than spending the free calls. - Treat
warningsandnoteas 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.
# 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
- 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. - Point your client at the sandbox. Make the base URL a setting:
https://sandbox.offendersearch.appnow,https://api.offendersearch.applater. Send the key inX-API-Key. Confirm with one call for Exact Match1980-01-01and readstatus,counts.recordsandrecords[0].matchState. - 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. - 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 asourceStatusrow withincomplete: true, and a429withRetry-After, and assert it retries rather than clears. - 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.
- 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.