FCRA Partner API · v1.0.0

Consumer portal and reports

Every order carries a code the consumer can use, a report you can fetch, and a public receipt anyone can check. This guide wires all three.

What you’ll build

Three things that belong together at adjudication time. The consumer portal: every order carries consumerPortal.url and consumerPortal.accessCode; with the code, their last name and date of birth the consumer sees exactly what was furnished in that order and can dispute any item. The report: GET /v1/fcra/orders/{order_id}/report as JSON or the stored PDF. The receipt: GET /v1/fcra/receipts/{order_id}, public, Ed25519-signed, carrying the hashes and no personal data.

Before you start

  • A completed order. The report and receipt work on the sandbox with a test key (the sandbox signs receipts with its own key, never the production key).
  • The consumer portal routes (/v1/consumer/portal/*) are not on the sandbox server; they are public consumer-side routes on https://api.offendersearch.app, take no API key, and are shown here so you know what the consumer’s session does.
  • Access codes are 12 characters as XXXX-XXXX-XXXX, accepted with or without dashes in any case; the last name is matched in any case; the date of birth must equal the order’s exactly.
Put the code in the consumer’s copy. The access code is shown only to the ordering account. Put consumerPortal.url and accessCode in the consumer’s copy of the report or your pre-adverse notice, so the consumer can see what was furnished and dispute it without contacting you first.

Step by step

  1. Fetch the report. GET /v1/fcra/orders/{order_id}/report (JSON, default) or ?format=pdf. 409 report_not_ready until the order is complete or error.
  2. Store the hashes. JSON: integrity.reportSha256, integrity.pdfSha256, integrity.receiptUrl, integrity.verifyUrl. PDF: headers X-Report-SHA256 and X-PDF-SHA256.
  3. Fetch and verify the receipt. GET /v1/fcra/receipts/{order_id} — no key. Rebuild the signed payload from signedFields, verify signature with publicKeyPem, compare pdfSha256 with your file.
  4. Hand the consumer the code. The consumer opens consumerPortal.url, enters code, last name and date of birth, and gets a 30-minute session for that one order.
The report, both ways
curl https://sandbox.offendersearch.app/v1/fcra/orders/fo_ee0b34c5b52fd35b/report \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"

curl -D - -o report.pdf "https://sandbox.offendersearch.app/v1/fcra/orders/fo_ee0b34c5b52fd35b/report?format=pdf" \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"
# HTTP/2 200
# content-type: application/pdf
# x-report-sha256: 4b1f…9e0c
# x-pdf-sha256: a90d…71b4
200 — the report (trimmed)
{
  "orderId": "fo_ee0b34c5b52fd35b",
  "status": "complete",
  "createdAt": "2026-10-25T14:01:07.318402+00:00",
  "completedAt": "2026-10-25T14:01:07.502118+00:00",
  "permissiblePurpose": "employment",
  "endUser": { "id": "eu_0b44acbeefeaa7d7", "name": "Northwind Staffing LLC" },
  "clientReference": "cra-order-889",
  "subject": { "firstName": "Pat", "middleName": null, "lastName": "Records", "dob": "1980-01-01" },
  "controlVersion": "fcra-controls-2026-10-19.1",
  "sandbox": true,
  "liveScope": "relevant",
  "searches": [ { "id": "fs_452884eecde9a1f2", "type": "sex_offender", "status": "complete", "result": "records_found", "recordCount": 1, "records": [ … ] },
                { "id": "fs_9c03d7e1a4b6f258", "type": "criminal", "status": "complete", "result": "records_found", "recordCount": 1, "records": [ … ] } ],
  "evidence": [],
  "integrity": {
    "reportSha256": "4b1f…9e0c",
    "pdfSha256": "a90d…71b4",
    "receiptUrl": "/v1/fcra/receipts/fo_ee0b34c5b52fd35b",
    "verifyUrl": "https://offendersearch.app/verify/fo_ee0b34c5b52fd35b",
    "note": "reportSha256 is computed over this document without the integrity block."
  }
}

The receipt

curl https://sandbox.offendersearch.app/v1/fcra/receipts/fo_ee0b34c5b52fd35b     # public — no key
200
{
  "orderId": "fo_ee0b34c5b52fd35b",
  "completedAt": "2026-10-25T14:01:07.502118+00:00",
  "controlVersion": "fcra-controls-2026-10-19.1",
  "reportSha256": "4b1f…9e0c",
  "pdfSha256": "a90d…71b4",
  "issuedAt": "2026-10-25T14:01:07.502118+00:00",
  "algorithm": "Ed25519",
  "alg": "Ed25519",
  "keyId": "ed25519:88f16fa019c4132c",
  "signature": "Qk3x…",
  "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n",
  "signedFields": ["completedAt", "controlVersion", "orderId", "pdfSha256", "reportSha256"],
  "canonicalization": "Ed25519 signature (base64url, no padding) over the UTF-8 JSON of exactly the fields listed in signedFields, …"
}
import base64, hashlib, json, requests
from cryptography.hazmat.primitives.serialization import load_pem_public_key

BASE = "https://sandbox.offendersearch.app"

def verify_receipt(order_id, pdf_path):
    r = requests.get(BASE + "/v1/fcra/receipts/" + order_id, timeout=30)
    r.raise_for_status()                              # 404 not_found until complete; 503 receipts_unavailable
    receipt = r.json()
    payload = json.dumps({k: receipt[k] for k in receipt["signedFields"]},
                         sort_keys=True, separators=(",", ":")).encode()
    sig = base64.urlsafe_b64decode(receipt["signature"] + "=" * (-len(receipt["signature"]) % 4))
    load_pem_public_key(receipt["publicKeyPem"].encode()).verify(sig, payload)   # raises on failure
    with open(pdf_path, "rb") as f:
        pdf_ok = hashlib.sha256(f.read()).hexdigest() == receipt["pdfSha256"]
    return {"signatureValid": True, "pdfMatches": pdf_ok, "controlVersion": receipt["controlVersion"]}

What the consumer’s session does (live host, no key)

# 1. Open a 30-minute session for one order
curl -X POST https://api.offendersearch.app/v1/consumer/portal/session \
  -H "Content-Type: application/json" \
  -d '{ "accessCode": "K7QF-2MXP-9RDA", "lastName": "Records", "dob": "1980-01-01" }'
# → { "token": "…", "expiresAt": "2026-10-25T15:01:07+00:00" }
# → 401 {"detail":{"code":"not_matched", …}} on any mismatch; 429 "locked" after five failures in an hour

# 2. Read what was furnished, in plain language, with the full records
curl https://api.offendersearch.app/v1/consumer/portal/report -H "Authorization: Bearer <token>"

# 3. Dispute from the portal — you receive dispute.created with origin consumer_portal
curl -X POST https://api.offendersearch.app/v1/consumer/portal/disputes -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "recordIds": ["crim:IL-SANGAMON:2021CR004417"], "reason": "not_me", "statement": "…" }'

Reading the result

FieldValuesWhat it decides
report statuscomplete, errorThe report exists for both; 409 report_not_ready before.
searches[]current searches with recordsSuperseded searches are left out of the report (the evidence list keeps their captures).
integrity.reportSha256hexSHA-256 of the canonical report JSON without the integrity block; also X-Report-SHA256 on the PDF response.
integrity.pdfSha256hex or nullSHA-256 of the stored PDF bytes, rendered once at completion; also X-PDF-SHA256. Changes only if a re-verify completes the order again.
receipt signedFieldscompletedAt, controlVersion, orderId, pdfSha256, reportSha256Exactly what the Ed25519 signature covers. No name, no date of birth, no record content.
receipt keyIded25519: + 16 hexIdentifies the signing key; pin it if you cache publicKeyPem.
portal sessiontoken, expiresAt30 minutes, one order. A cancelled order cannot be opened.

Edge cases and pitfalls

  • `404 not_found` on the receipt means unknown or not yet complete — the same answer for both, by design.
  • `503 receipts_unavailable` — signing is unavailable; retry later. The report itself is unaffected.
  • The PDF is rendered once. Its bytes and hash are stable until a re-verify completes the order again; then the receipt changes too. Keep the PDF and its receipt from the moment you adjudicated.
  • Sandbox PDFs carry the text "SANDBOX — test data, not a real report." and sandbox receipts are signed with a sandbox key.
  • The portal shows only furnished records — never withheld counts, other orders, or evidence URLs — and never asks for a Social Security number.
  • Five failed portal attempts in an hour for one code or from one connection lock further attempts (429 locked); a mismatch is always the same 401 not_matched.
  • Anyone with the PDF can verify it at https://offendersearch.app/verify/{orderId} (the QR code on the PDF) without uploading it; the browser hashes the file locally and checks the signature.

Production checklist

  • Store the PDF, reportSha256, pdfSha256 and the receipt with each adjudicated order.
  • Verify the receipt signature once on receipt; pin keyId.
  • Include consumerPortal.url and accessCode in the consumer’s copy of the report or your pre-adverse notice.
  • Expect dispute.created with origin: "consumer_portal" and handle it like your own disputes.
  • Pre-adverse and adverse-action notices and the consumer’s copy of the report stay with you; the portal shows the consumer what was furnished.

Product overview