# Sex Offender API sandbox & test keys

> Test the Sex Offender API in the sandbox with an os_test_ key: fixed test names for search, batch, compat, records, reports and clearances. Never billed.

- **HTML:** https://offendersearch.app/docs/sandbox
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed; see https://offendersearch.app/docs/sandbox.md
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **All documentation as markdown:** https://offendersearch.app/docs.md

## API sandbox test keys: create one in the dashboard

The sandbox is a separate server with the same endpoints as the live API. This page covers the Sex Offender API: search, batch, stored searches, the offenders.io-compatible endpoint, records, reports and clearances. It answers from a fixed set of made-up people, so every request returns the same result every time, and nothing you send is billed. Build and test your whole integration against it, then switch two settings to go live.

1. In the dashboard, open [Developers → API keys](https://offendersearch.app/dashboard/keys).
2. Choose **Create test key**. Test keys are for business accounts and need no subscription.
3. Copy the key. Test keys begin `os_test_`. Like a live key, the full secret is shown once, so put it in your secrets store straight away.

One test key works on every API’s sandbox. Each key in the list is labelled **Test · Sandbox** or **Live**, with the base URL it works on. Send it in the same `X-API-Key` header the live API uses. An account holds up to 3 active test keys and can create 10 a day. A revoked test key stops working on the sandbox within a few minutes.

## Sandbox base URL and test keys vs live keys

|  | Sandbox | Production |
| --- | --- | --- |
| Base URL | `https://sandbox.offendersearch.app` | `https://api.offendersearch.app` |
| Key prefix | `os_test_` | `os_live_` |
| Data | The fixed test people listed below | 58 sex offender registries |
| Billing | Never billed, never recorded to usage | Your plan’s prices |
| `sandbox` field | `true` on every response | Absent |
| `X-Offendersearch-Environment` | `sandbox` on every response | Absent |

Each key works on its own server only, so a mixed-up configuration is caught on the first request rather than after a release. A test key sent to the live API, or a live key sent to the sandbox, is refused with `401` and a `detail` message that says so:

401 — a test key sent to the live API

```json
{
  "detail": "This is a test key (os_test_…). Test keys work only on the sandbox at https://sandbox.offendersearch.app, which answers with sample data. To search real records, send a live key (os_live_…) to https://api.offendersearch.app."
}
```

401 — a live key sent to the sandbox

```json
{
  "detail": "This is a live key. The sandbox at https://sandbox.offendersearch.app accepts test keys (os_test_…) only — create one in the dashboard under Developers → API keys. Send live keys to https://api.offendersearch.app."
}
```

A revoked test key gets `401` with: “This test key has been revoked. Create a new test key in the dashboard under Developers → API keys.”

## Sandbox response format: identical to live, never billed

Every sandbox endpoint takes the same request body and returns the same response shape as its live counterpart, produced by the same serializers: the same envelope, the same [Record object](https://offendersearch.app/docs/record-object.md), the same `matchState`, `matchConfidence`, `matchBasis` and `matchDetail`. Code that parses a sandbox response parses a live one. Two things are added so a sandbox answer is never mistaken for a real screening: `"sandbox": true` in the body and the `X-Offendersearch-Environment: sandbox` header.

POST /v1/search — sandbox (abridged)

```json
HTTP/1.1 200 OK
X-Offendersearch-Environment: sandbox

{
  "sandbox": true,
  "searchId": "srch_…",
  "status": "complete",
  "counts": { "records": 1, … },
  "records": [
    { "recordId": "rec_…",
      "personRef": "prs_…",
      "name": { "first": "Exact", "middle": "Sample", "last": "Match", … },
      "dob": "1980-01-01",
      "matchState": "dob_match",
      "matchConfidence": 0.9,
      "registrationState": "OH",
      "images": [ { "url": "https://…" } ],
      … }
  ]
}
```

- **Never billed.** Nothing on the sandbox is billed or recorded to your usage.
- **Names match as a pair.** `firstName` and `lastName` are matched together, ignoring case. Any other name returns a complete answer with zero records.
- **Dates of birth behave as in production.** Send a test person’s `dob` exactly as listed; a different `dob` contradicts the record and returns no match, and omitting it returns the person at name-only confidence.
- **Filters behave as in production.** A `state`, `city` or `zipcode` returns a test person only when it matches their registry state or one of their addresses, and a malformed request gets the same `422` it would in production.
- **Test data is temporary.** Searches, stored results, clearances, monitors and FCRA orders you create in the sandbox are kept for testing and may be reset.

The test people are invented, with sample addresses and placeholder photos marked SAMPLE. They are not real registrants, and their records must never be shown to anyone as real.

## Sandbox rate limits

Rate limits keep the sandbox fast for everyone. They apply only to the sandbox, and to every API on it together.

| Limit | Value |
| --- | --- |
| Requests per test key, per minute | 60 |
| Requests per test key, per day | 2,000 |
| Requests per IP address, per minute | 120 |
| Active test keys per account | 3 |
| Test keys created per account, per day | 10 |

Past a request limit the sandbox answers `429` with a `Retry-After` header giving the seconds until the window resets. Wait that long and send the request again.

429 — sandbox

```json
HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-Offendersearch-Environment: sandbox

{
  "detail": "Sandbox rate limit reached: 60 requests a minute for this test key. Retry after 23s. The sandbox allows 60 requests a minute and 2000 a day per test key, and 120 a minute per IP address.",
  "sandbox": true
}
```

## Sex offender search test names

Send these to `POST /v1/search` on the sandbox. Each one returns the same answer every time. The **Desk state** column is what the mapping in the [front-desk visitor screening guide](https://offendersearch.app/docs/front-desk-visitor-screening.md) produces for that answer. The same names work on `POST /v1/batch`, `POST /v1/searches` and the offenders.io-compatible endpoint, and a returned `recordId` opens with `GET /v1/records/{recordId}`.

| Send | What comes back | Desk state |
| --- | --- | --- |
| `firstName: Clear`, `lastName: Visitor` | **Clear visitor.** No match: a complete answer with zero records. | Clear |
| `firstName: Exact`, `lastName: Match`, `dob: 1980-01-01` | **Exact match, date of birth verified.** One strong match: DOB-verified (matchState dob_match), high confidence, photo, offense, registry state and two addresses. | Match |
| `firstName: Exact`, `lastName: Match` | **Exact match, no date of birth sent.** The same person, matched on name alone: matchState is null because no date of birth was sent to verify. | Review |
| `firstName: Exact`, `lastName: Match`, `dob: 1990-05-05` | **Exact match name, different date of birth.** No match: the registry's date of birth contradicts the one sent. | Clear |
| `firstName: Name`, `lastName: Only` | **Name only — possible matches.** Three possible matches in three states. None of the registries publishes a date of birth, so the name is the only evidence (medium confidence). Sending a dob returns the same three with matchState no_dob_age_year and unverified true. | Review |
| `firstName: Alias`, `lastName: Match` | **Matched through an alias.** One match whose legal name is different (Jordan Sample Placeholder): matchedName.type is alias, matchBasis carries alias:exact. | Review |
| `firstName: Out`, `lastName: State`, `state: TX` | **Registered in another state.** One match living in Texas but registered by Oklahoma's registry: registrationState OK, addressStates [TX]. | Review |
| `firstName: Multi`, `lastName: State` | **One person on two registries.** One record merged from two registries (Florida and Georgia): sources[] lists both. | Review |
| `firstName: Homeless`, `lastName: Registrant` | **Homeless registrant.** One match with housingStatus homeless. | Review |

POST /v1/search — sandbox

```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"
      }
    }'
```

In a batch, each row gets the same answer it gets from `POST /v1/search`, in input order.

POST /v1/batch — sandbox

```bash
curl -X POST "https://sandbox.offendersearch.app/v1/batch" \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '[
      {
        "firstName": "Clear",
        "lastName": "Visitor"
      },
      {
        "firstName": "Exact",
        "lastName": "Match",
        "dob": "1980-01-01"
      },
      {
        "firstName": "Exact",
        "lastName": "Match"
      }
    ]'
```

## Clearances in the API sandbox

All four [clearance](https://offendersearch.app/docs/clearances.md) endpoints run on the sandbox against your sandbox searches. Search a test name with a `subjectId`, take the `searchId` and a returned `personRef`, and `POST /v1/clearances` with `verdict: "not_this_person"`. The next sandbox search with the same `subjectId` and `clientId` lists that person in `cleared[]` instead of `records[]`, exactly as live. `Name Only` is a good subject: it returns three people, so you can clear one and keep two.

POST /v1/search with a subjectId — sandbox

```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": "Name",
        "lastName": "Only"
      },
      "subjectId": "visitor-1042",
      "clientId": "school-3"
    }'
```

## Other sandbox endpoints: reports, live evidence and catalogues

The rest of the Sex Offender API runs on the sandbox too, with the same paths and shapes as live. The test names above apply wherever a name is searched.

| Send | What comes back |
| --- | --- |
| `POST /v1/report {"searchId": "<a sandbox search id>"} (or an inline query)` | **Verification report.** The production verification PDF (application/pdf, X-Report-Id header) for the sample people that search returned, without photographs. Never billed. |
| `POST /v1/search with "proof": {"registries": ["OH"]}, or POST /v1/searches/{id}/proof` | **Proof documents.** A ProofBundle: status ready, one document per registry at /v1/proof-docs/{token}, rendered by the production renderer. billedDocuments is 0: the sandbox bills nothing. |
| `POST /v1/search with "live": {"jurisdictions": ["OH"], "evidence": "pdf"}` | **Live evidence documents.** 202 {searchId: evs_…, status: pending, mode: async, registries, pollUrl}. The first poll of GET /v1/searches/{evs_…} is complete: the search plus evidence[] with one complete SAMPLE PDF per registry (inline pdfBase64 and a downloadUrl). Nothing billed: billed false, chargeUsd 0. |
| `GET /v1/sources (no key)` | **Registry catalogue.** The production catalogue of 58 registries. health is null: the sandbox has no ingest. |
| `GET /v1/status, GET /v1/sandbox/fixtures (no key)` | **Sandbox status.** Environment, base URLs, key prefix, rate limits; and this file's scenarios. |

## Sandbox API examples in cURL, Node and Python

The date-of-birth-verified test person, called three ways. Export your test key as `OFFENDERSEARCH_TEST_KEY` first.

POST /v1/search — sandbox — cURL

```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"
      }
    }'
```

POST /v1/search — sandbox — Node

```javascript
// Node 18+ — fetch is built in.
const BASE = process.env.OFFENDERSEARCH_BASE ?? 'https://sandbox.offendersearch.app';

const res = await fetch(`${BASE}/v1/search`, {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.OFFENDERSEARCH_TEST_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ query: { firstName: 'Exact', lastName: 'Match', dob: '1980-01-01' } }),
});
const body = await res.json();

console.log(res.headers.get('X-Offendersearch-Environment')); // "sandbox"
console.log(body.sandbox);                // true
console.log(body.records[0].matchState);  // "dob_match"
```

POST /v1/search — sandbox — Python

```python
import os
import requests

BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")

r = requests.post(
    f"{BASE}/v1/search",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_TEST_KEY"]},
    json={"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}},
    timeout=30,
)
body = r.json()

print(r.headers["X-Offendersearch-Environment"])  # sandbox
print(body["sandbox"])                     # True
print(body["records"][0]["matchState"])    # dob_match
```

## Postman collection for the OffenderSearch sandbox

One collection covers every API’s sandbox, with a folder per API, ready to import into Postman or Insomnia: https://offendersearch.app/downloads/offendersearch-sandbox.postman_collection.json. The **Sex Offender API** folder has one request for every scenario on this page. Set `{{apiKey}}` to your test key; `{{baseUrl}}` is preset to the sandbox. Ids from earlier responses go in `{{searchId}}`.

## Sandbox test keys for the criminal, monitoring, Map and FCRA APIs

Each API’s docs have their own sandbox page, with that API’s test data, calls and Postman folder. The same `os_test_` key works on all of them.

- [Criminal Search API sandbox](https://offendersearch.app/docs/criminal/sandbox.md)
- [Monitoring API sandbox](https://offendersearch.app/docs/monitoring/sandbox.md)
- [Map API sandbox server](https://offendersearch.app/docs/map/sandbox.md)
- [FCRA API sandbox](https://offendersearch.app/docs/fcra/sandbox.md)

## Move from the API sandbox to production

The sandbox and the live API differ only in the base URL and the key. Keep both in configuration, not in code, and going live is a configuration change:

.env

```bash
# Sandbox
OFFENDERSEARCH_BASE=https://sandbox.offendersearch.app
OFFENDERSEARCH_KEY=os_test_…

# Production — change these two values and nothing else
OFFENDERSEARCH_BASE=https://api.offendersearch.app
OFFENDERSEARCH_KEY=os_live_…
```

- Create a live key on the same [Developers → API keys](https://offendersearch.app/dashboard/keys) page.
- Remove any branch that reads `sandbox`. The field does not exist on live.
- Live answers are real people. Read [Matching & confidence](https://offendersearch.app/docs/matching.md) and [Date of birth](https://offendersearch.app/docs/date-of-birth.md) before you decide what your interface shows for each `matchState`.
- Live calls are billed; see [pricing](https://offendersearch.app/pricing).

---

## Related

- Previous: [Authentication, keys & security](https://offendersearch.app/docs/authentication.md)
- Next: [Errors, status codes & rate limits](https://offendersearch.app/docs/errors.md)
- Index: [OffenderSearch API documentation](https://offendersearch.app/docs.md)
