# 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.

- **HTML:** https://offendersearch.app/docs/learn/test-a-background-check-integration
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## Mint a test key and point the integration at the sandbox

The [public API for testing](/sandbox) 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 `429`s 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*
```bash
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](/sdk). 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](/docs/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](/docs/sandbox) 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.

**Python (pytest)**
```python
# 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
    rec = body["records"][0]
    assert rec["matchState"] == "dob_match" and rec["unverified"] is False
    assert rec["registrationState"] == "OH" and rec["source"]["recordUrl"]

def test_clear_visitor_is_a_whole_answer_with_zero_records():
    body = search("Clear", "Visitor").json()
    assert body["status"] == "complete" and body["counts"]["records"] == 0
    assert all(not row["incomplete"] for row in body["sourceStatus"])

def test_contradicting_dob_drops_the_record():
    body = search("Exact", "Match", "1990-05-05").json()
    assert body["status"] == "complete" and body["counts"]["records"] == 0

def test_name_only_records_are_leads_not_identifications():
    body = search("Name", "Only", "1988-04-12").json()
    assert body["counts"]["records"] == 3
    assert {r["matchState"] for r in body["records"]} == {"no_dob_age_year"}
    assert all(r["unverified"] for r in body["records"])

def test_no_dob_sent_means_matchstate_null():
    rec = search("Exact", "Match").json()["records"][0]
    assert rec["matchState"] is None

def test_only_first_name_is_422():
    r = requests.post(f"{BASE}/v1/search", headers=H, json={"query": {"firstName": "Exact"}}, timeout=60)
    assert r.status_code == 422 and "detail" in r.json()

def test_felony_record_has_a_guilty_felony_charge():
    rec = criminal("Felony", "Record", "1975-06-15").json()["records"][0]
    assert rec["recordType"] == "court_case" and rec["matchState"] == "dob_match"
    charges = rec["cases"][0]["charges"]
    assert any(c["severity"] == "felony" and c["disposition"] == "Guilty" for c in charges)

def test_arrest_only_is_a_booking_without_disposition():
    rec = criminal("Arrest", "Only", "1992-04-20").json()["records"][0]
    assert rec["recordType"] == "jail_booking"
```

**Node (Jest)**
```javascript
// npm i -D jest   (Node 18+: fetch is global; jest.config: testEnvironment "node")
// OFFENDERSEARCH_TEST_KEY=os_test_… npx jest offendersearch.test.js
const BASE = "https://sandbox.offendersearch.app";   // os_test_… keys only; nothing is billed
const KEY = process.env.OFFENDERSEARCH_TEST_KEY;
const H = { "X-API-Key": KEY, "Content-Type": "application/json" };

const post = (path, body) => fetch(`${BASE}${path}`, { method: "POST", headers: H, body: JSON.stringify(body) });
const search = (q, extra = {}) => post("/v1/search", { query: q, ...extra }).then((r) => r.json());
const criminal = (q, extra = {}) => post("/v1/criminal/search", { query: q, ...extra }).then((r) => r.json());

test("Exact Match with the right DOB is identified", async () => {
  const body = await search({ firstName: "Exact", lastName: "Match", dob: "1980-01-01" });
  expect(body.status).toBe("complete");
  expect(body.counts.records).toBe(1);
  expect(body.records[0].matchState).toBe("dob_match");
  expect(body.records[0].unverified).toBe(false);
  expect(body.records[0].source.recordUrl).toBeTruthy();
});

test("Clear Visitor is a complete answer with zero records", async () => {
  const body = await search({ firstName: "Clear", lastName: "Visitor" });
  expect(body.status).toBe("complete");
  expect(body.counts.records).toBe(0);
  expect(body.sourceStatus.every((row) => row.incomplete === false)).toBe(true);
});

test("a contradicting DOB drops the record", async () => {
  const body = await search({ firstName: "Exact", lastName: "Match", dob: "1990-05-05" });
  expect(body.counts.records).toBe(0);
});

test("Name Only returns three unverified leads", async () => {
  const body = await search({ firstName: "Name", lastName: "Only", dob: "1988-04-12" });
  expect(body.counts.records).toBe(3);
  for (const rec of body.records) {
    expect(rec.matchState).toBe("no_dob_age_year");
    expect(rec.unverified).toBe(true);
  }
});

test("a query with only firstName is a 422", async () => {
  const res = await post("/v1/search", { query: { firstName: "Exact" } });
  expect(res.status).toBe(422);
  expect((await res.json()).detail).toBeDefined();
});

test("Felony Record is a court case with a guilty felony charge", async () => {
  const body = await criminal({ firstName: "Felony", lastName: "Record", dob: "1975-06-15" });
  const rec = body.records[0];
  expect(rec.recordType).toBe("court_case");
  expect(rec.matchState).toBe("dob_match");
  expect(rec.cases[0].charges.some((c) => c.severity === "felony" && c.disposition === "Guilty")).toBe(true);
});

test("In Custody is a DOC record with a facility", async () => {
  const body = await criminal({ firstName: "In", lastName: "Custody", dob: "1979-12-31" });
  expect(body.records[0].recordType).toBe("doc_inmate");
  expect(body.records[0].currentlyIncarcerated).toBe(true);
});
```

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](/docs/guides/sex-offender/date-of-birth-matching) 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](/docs/learn/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](/docs/guides/sex-offender/incomplete-results) covers retrying just the incomplete registries with `jurisdictions`.

*fixtures/partial.json (two of the 58 rows shown)*
```json
{
  "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*
```python
# 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](/docs/errors) 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*
```python
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](/docs/learn/rate-limits-retries-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*
```bash
# 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](/tools/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](/docs/learn/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](/postman) 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*
```plain
# .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*
```bash
# 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](/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](/docs/learn/background-check-api-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.

## Related

- [Public API for testing (the sandbox)](https://offendersearch.app/sandbox)
- [Sandbox reference and test people](https://offendersearch.app/docs/sandbox)
- [Criminal sandbox reference](https://offendersearch.app/docs/criminal/sandbox)
- [Postman collection and sandbox environment](https://offendersearch.app/postman)
- [Webhook tester](https://offendersearch.app/tools/webhook-tester)
- [Webhook example: receive, verify and retry](https://offendersearch.app/docs/learn/webhook-example)
- [The response explained field by field](https://offendersearch.app/docs/learn/background-check-api-response-explained)
- [No-match and multiple-match results](https://offendersearch.app/docs/learn/no-match-and-multiple-match-results)
- [Rate limits, retries and idempotency](https://offendersearch.app/docs/learn/rate-limits-retries-idempotency)
- [Errors reference](https://offendersearch.app/docs/errors)
- [Free background check API (25 free calls)](https://offendersearch.app/free-background-check-api)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
