FCRA Partner API · v1.0.0

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. 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.
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)
A coded refusal
{
  "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
{
  "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 nameSearchesOutcome
Clear (or any unlisted name)either or bothOrder complete; every search complete, result: "clear", recordCount: 0, withheld: {}.
Recordseither or bothrecords_found, one sandbox record per search: the sex offender record carries registry{}, the criminal record cases[] with a misdemeanor conviction.
Withheldcriminalcomplete, result: "clear", recordCount: 0, withheld: {"insufficient_identifiers": 2, "not_current_at_source": 1}.
Unavailablesex offenderSearch unavailable, result: "incomplete", incompleteReasons: ["live_unavailable"]; the order still completes.
DisputecriminalOne furnished record; a dispute filed on it resolves deleted 60 seconds later (read on the next GET), emitting dispute.created and dispute.updated.
ExcludedcriminalOne exclusion-list record (recordType: "healthcare_exclusion", dispositionStatus: "conviction", authority 1128(a)(1)) under the default conviction_only; clear under exclusions: "none".
Sanctionedcriminalclear under the default; one record with dispositionStatus: "sanction", convictionBased: false under exclusions: "all".

Error codes by endpoint

EndpointCodes
Every partner route401 (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/orders403 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}/reverify404 not_found · 409 search_not_finished · 409 order_cancelled
POST …/orders/{order_id}/cancel404 not_found · 409 not_cancellable
GET …/orders/{order_id}/report404 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}/disputes404 not_found · 422 unknown_record · 422 validation · 422 array
POST /v1/fcra/disputes/{dispute_id}/information404 not_found · 409 dispute_not_extendable
PUT /v1/fcra/webhook-endpoint403 (string: admin only) · 422 array (url not https on a public host)
POST /v1/fcra/events/{event_id}/resend404 not_found
POST /v1/consumer/portal/session (public, live only)401 not_matched · 429 locked · 429 rate_limited · 422 array
Portal routes with a token401 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.

Product overview