# Sandbox testing and error handling

> Seven last names cover every outcome; one envelope covers every refusal. Build your test suite on the first and your error handling on the second.

- **HTML:** https://offendersearch.app/docs/guides/fcra/sandbox-and-errors
- **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

## What you’ll build

An integration test suite that places one order per sandbox subject and asserts the outcome, plus an error handler that switches on `detail.code` for every refusal the FCRA API raises itself and on the array shape for field validation. The sandbox runs the full order, search, dispute and webhook flow with deterministic results, no live calls and no charges.

## Before you start

- A test key against `https://sandbox.offendersearch.app`. Every `os_test_` account is an enabled sandbox partner for all four purposes; orders finish right after the `202` — poll once.
- The subject’s **last name** picks the outcome (first name and date of birth are free; use `Pat`, `1980-01-01`). Every sandbox record carries `sandbox: true` and an id beginning `sandbox:`; nothing is metered.
- Sandbox limits: 60 requests a minute and 2,000 a day per test key (`429` with `Retry-After`); state is in memory and may be reset on redeploy. The consumer portal routes are not on the sandbox.
- The same last-name rules apply on the live host for a partner account flagged `sandbox: true`.

> **Two error shapes, not one.** Every refusal the FCRA API raises itself is `{"detail": {"code": "…", "message": "…"}}` — switch on `detail.code`. A request body that fails field validation is the platform’s standard `422` with `detail` as an **array** of `{loc, msg, type}`. Two refusals carry a plain string: the `401` (bad key or session) and the `403` for a non-admin changing the webhook endpoint.

## Step by step

1. **Register one end user.** `POST /v1/fcra/end-users` with any purpose; reuse its `eu_` id for every test order.
2. **Place one order per subject.** Loop over the seven last names below; on `Excluded` and `Sanctioned` vary the criminal search’s `exclusions`.
3. **Assert.** Order `status`, each search’s `status`, `result`, `recordCount` and `withheld` against the table.
4. **Exercise the refusals.** A purpose the end user does not hold → `403`; a suspended end user → `422 end_user_suspended`; a `callbackUrl` without an endpoint → `422 webhook_endpoint_required`; a repeated `Idempotency-Key` with a new body → `409`.

**Python**
```python
import os, requests

BASE = "https://sandbox.offendersearch.app"
H = {"X-API-Key": os.environ["OFFENDERSEARCH_TEST_KEY"]}
END_USER = os.environ["OS_TEST_END_USER"]            # eu_… from POST /v1/fcra/end-users

CASES = [  # (last name, searches, expected per search type: (status, result, recordCount))
    ("Clear",       [{"type": "sex_offender"}, {"type": "criminal"}],      ("complete", "clear", 0)),
    ("Records",     [{"type": "sex_offender"}, {"type": "criminal"}],      ("complete", "records_found", 1)),
    ("Withheld",    [{"type": "criminal"}],                                ("complete", "clear", 0)),
    ("Unavailable", [{"type": "sex_offender"}],                            ("unavailable", "incomplete", 0)),
    ("Dispute",     [{"type": "criminal"}],                                ("complete", "records_found", 1)),
    ("Excluded",    [{"type": "criminal"}],                                ("complete", "records_found", 1)),
    ("Excluded",    [{"type": "criminal", "exclusions": "none"}],          ("complete", "clear", 0)),
    ("Sanctioned",  [{"type": "criminal"}],                                ("complete", "clear", 0)),
    ("Sanctioned",  [{"type": "criminal", "exclusions": "all"}],           ("complete", "records_found", 1)),
]

def place(last, searches):
    res = requests.post(BASE + "/v1/fcra/orders", headers=H, timeout=30, json={
        "endUserId": END_USER, "permissiblePurpose": "employment",
        "consent": {"obtainedAt": "2026-10-25T13:55:00Z", "method": "esign"},
        "subject": {"firstName": "Pat", "lastName": last, "dob": "1980-01-01"},
        "searches": searches,
    })
    if res.status_code != 202:
        d = res.json()["detail"]
        code = d["code"] if isinstance(d, dict) else "validation_array" if isinstance(d, list) else "string"
        raise AssertionError((res.status_code, code, d))
    order_id = res.json()["id"]
    return requests.get(BASE + "/v1/fcra/orders/" + order_id, headers=H, timeout=30).json()

for last, searches, (st, result, n) in CASES:
    order = place(last, searches)
    assert order["status"] == "complete", (last, order["status"])
    for s in order["searches"]:
        assert (s["status"], s["result"], s["recordCount"]) == (st, result, n), (last, s)
    if last == "Withheld":
        assert order["searches"][0]["withheld"] == {"insufficient_identifiers": 2, "not_current_at_source": 1}
    print("ok", last, searches)
```

**JavaScript**
```javascript
const BASE = "https://sandbox.offendersearch.app";
const headers = { "X-API-Key": process.env.OFFENDERSEARCH_TEST_KEY, "Content-Type": "application/json" };
const END_USER = process.env.OS_TEST_END_USER;        // eu_… from POST /v1/fcra/end-users

const CASES = [ // [last name, searches, expected per search: [status, result, recordCount]]
  ["Clear",       [{ type: "sex_offender" }, { type: "criminal" }],     ["complete", "clear", 0]],
  ["Records",     [{ type: "sex_offender" }, { type: "criminal" }],     ["complete", "records_found", 1]],
  ["Withheld",    [{ type: "criminal" }],                               ["complete", "clear", 0]],
  ["Unavailable", [{ type: "sex_offender" }],                           ["unavailable", "incomplete", 0]],
  ["Dispute",     [{ type: "criminal" }],                               ["complete", "records_found", 1]],
  ["Excluded",    [{ type: "criminal" }],                               ["complete", "records_found", 1]],
  ["Excluded",    [{ type: "criminal", exclusions: "none" }],           ["complete", "clear", 0]],
  ["Sanctioned",  [{ type: "criminal" }],                               ["complete", "clear", 0]],
  ["Sanctioned",  [{ type: "criminal", exclusions: "all" }],            ["complete", "records_found", 1]],
];

function classify(status, body) {
  const d = body.detail;
  if (Array.isArray(d)) return { kind: "validation", problems: d };      // 422 field list
  if (d && typeof d === "object") return { kind: d.code, message: d.message };
  return { kind: "string", message: String(d) };                           // 401, non-admin 403
}

async function place(last, searches) {
  const res = await fetch(BASE + "/v1/fcra/orders", {
    method: "POST", headers,
    body: JSON.stringify({
      endUserId: END_USER, permissiblePurpose: "employment",
      consent: { obtainedAt: "2026-10-25T13:55:00Z", method: "esign" },
      subject: { firstName: "Pat", lastName: last, dob: "1980-01-01" },
      searches,
    }),
  });
  const body = await res.json();
  if (res.status !== 202) throw new Error(JSON.stringify(classify(res.status, body)));
  return (await fetch(BASE + "/v1/fcra/orders/" + body.id, { headers })).json();
}

for (const [last, searches, [st, result, n]] of CASES) {
  const order = await place(last, searches);
  if (order.status !== "complete") throw new Error(last + " " + order.status);
  for (const s of order.searches) {
    if (s.status !== st || s.result !== result || s.recordCount !== n) throw new Error(last + " " + JSON.stringify(s));
  }
  console.log("ok", last, JSON.stringify(searches));
}
```

*A coded refusal*
```json
{
  "detail": {
    "code": "fcra_purpose_not_allowed",
    "message": "The purpose 'tenant_screening' is not certified for this end user or not covered by your partner agreement."
  }
}
```

*A field-validation 422*
```json
{
  "detail": [
    { "loc": ["body", "subject", "dob"], "msg": "Value error, dob must be a full date, YYYY-MM-DD", "type": "value_error" },
    { "loc": ["body", "searches"], "msg": "each search type at most once per order", "type": "value_error" }
  ]
}
```

## Reading the result

| Last name | Searches | Outcome |
| --- | --- | --- |
| **Clear** (or any unlisted name) | either or both | Order `complete`; every search `complete`, `result: "clear"`, `recordCount: 0`, `withheld: {}`. |
| **Records** | either or both | `records_found`, one sandbox record per search: the sex offender record carries `registry{}`, the criminal record `cases[]` with a misdemeanor conviction. |
| **Withheld** | criminal | `complete`, `result: "clear"`, `recordCount: 0`, `withheld: {"insufficient_identifiers": 2, "not_current_at_source": 1}`. |
| **Unavailable** | sex offender | Search `unavailable`, `result: "incomplete"`, `incompleteReasons: ["live_unavailable"]`; the order still completes. |
| **Dispute** | criminal | One furnished record; a dispute filed on it resolves `deleted` 60 seconds later (read on the next GET), emitting `dispute.created` and `dispute.updated`. |
| **Excluded** | criminal | One exclusion-list record (`recordType: "healthcare_exclusion"`, `dispositionStatus: "conviction"`, authority `1128(a)(1)`) under the default `conviction_only`; `clear` under `exclusions: "none"`. |
| **Sanctioned** | criminal | `clear` under the default; one record with `dispositionStatus: "sanction"`, `convictionBased: false` under `exclusions: "all"`. |

### Error codes by endpoint

| Endpoint | Codes |
| --- | --- |
| Every partner route | `401` (string) · `403 fcra_not_enabled` · `403 fcra_not_on_trial` |
| `POST /v1/fcra/end-users`, `PATCH …/{end_user_id}` | `403 fcra_purpose_not_allowed` · `404 not_found` (PATCH) · `422` array |
| `POST /v1/fcra/orders` | `403 fcra_purpose_not_allowed` · `404 unknown_end_user` · `409 idempotency_conflict` · `422 end_user_suspended` · `422 webhook_endpoint_required` · `422 validation` (Idempotency-Key over 200) · `422` array |
| `GET …/orders/{order_id}`, `…/searches/{search_id}`, `…/evidence`, `…/evidence/{capture_id}` | `404 not_found` |
| `POST /v1/fcra/searches/{search_id}/reverify` | `404 not_found` · `409 search_not_finished` · `409 order_cancelled` |
| `POST …/orders/{order_id}/cancel` | `404 not_found` · `409 not_cancellable` |
| `GET …/orders/{order_id}/report` | `404 not_found` · `409 report_not_ready` |
| `GET /v1/fcra/receipts/{order_id}` (public) | `404 not_found` (unknown or not complete) · `503 receipts_unavailable` |
| `POST …/orders/{order_id}/disputes` | `404 not_found` · `422 unknown_record` · `422 validation` · `422` array |
| `POST /v1/fcra/disputes/{dispute_id}/information` | `404 not_found` · `409 dispute_not_extendable` |
| `PUT /v1/fcra/webhook-endpoint` | `403` (string: admin only) · `422` array (`url` not `https` on a public host) |
| `POST /v1/fcra/events/{event_id}/resend` | `404 not_found` |
| `POST /v1/consumer/portal/session` (public, live only) | `401 not_matched` · `429 locked` · `429 rate_limited` · `422` array |
| Portal routes with a token | `401 no_session` · `401 session_expired` · `422 unknown_record` · `422 validation` |

## Edge cases and pitfalls

- **`detail` is sometimes a string.** Guard your handler: object → `code`; array → field problems; string → `401` or the non-admin `403`.
- **`fcra_purpose_not_allowed` means two different things**: on an end user, a purpose outside your agreement; on an order, a purpose the end user does not hold or your agreement no longer covers. Only the order refusal is written to the evidence record.
- **`live_unavailable` is not an error.** It is an `incompleteReasons` value on a completed search whose relevant jurisdiction could not be searched live.
- **Capitalisation of the sandbox last name does not matter**; `records`, `RECORDS` and `Records` behave alike. Any other name is Clear.
- **The sandbox never simulates outages, timeouts or partial answers**; `Unavailable` is a documented sandbox outcome, not a sandbox fault.
- **Sandbox evidence is empty** and sandbox receipts are signed with a sandbox key — test hash checks against production.
- **Sandbox `429`** — 60 requests a minute per key; back off on `Retry-After`. Production FCRA routes have no such limit, but the portal and consumer request routes do.

## Production checklist

- Keep the nine-case suite running against the sandbox in CI.
- One error handler: `detail.code` first, array second, string last; never retry `403`/`404`/`409`/`422`.
- Switch host and key together; re-run the partner check (`GET /v1/fcra/partner`) on the live host before the first order.
- Verify receipts and evidence hashes against a production order before go-live.

## Related

- [FCRA sandbox (reference)](https://offendersearch.app/docs/fcra/sandbox)
- [Error codes](https://offendersearch.app/docs/fcra/errors)
- [Errors & retries (platform)](https://offendersearch.app/docs/platform/errors-and-retries)
- [Sandbox & test keys](https://offendersearch.app/docs/sandbox)
