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.
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. Everyos_test_account is an enabled sandbox partner for all four purposes; orders finish right after the202— 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 carriessandbox: trueand an id beginningsandbox:; nothing is metered. - Sandbox limits: 60 requests a minute and 2,000 a day per test key (
429withRetry-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
- Register one end user.
POST /v1/fcra/end-userswith any purpose; reuse itseu_id for every test order. - Place one order per subject. Loop over the seven last names below; on
ExcludedandSanctionedvary the criminal search’sexclusions. - Assert. Order
status, each search’sstatus,result,recordCountandwithheldagainst the table. - Exercise the refusals. A purpose the end user does not hold →
403; a suspended end user →422 end_user_suspended; acallbackUrlwithout an endpoint →422 webhook_endpoint_required; a repeatedIdempotency-Keywith a new body →409.
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){
"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."
}
}{
"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 →401or the non-admin403. - `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
incompleteReasonsvalue on a completed search whose relevant jurisdiction could not be searched live. - Capitalisation of the sandbox last name does not matter;
records,RECORDSandRecordsbehave alike. Any other name is Clear. - The sandbox never simulates outages, timeouts or partial answers;
Unavailableis 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.codefirst, array second, string last; never retry403/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.