# 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.

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

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*
```bash
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)*
```json
{
  "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

```bash
curl https://sandbox.offendersearch.app/v1/fcra/receipts/fo_ee0b34c5b52fd35b     # public — no key
```

*200*
```json
{
  "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, …"
}
```

**Python**
```python
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"]}
```

**JavaScript**
```javascript
import crypto from "node:crypto";
import { readFile } from "node:fs/promises";

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

async function verifyReceipt(orderId, pdfPath) {
  const res = await fetch(BASE + "/v1/fcra/receipts/" + orderId);   // public
  if (!res.ok) throw new Error(res.status + " " + (await res.text())); // 404 until complete; 503 receipts_unavailable
  const receipt = await res.json();
  const payload = JSON.stringify(
    Object.fromEntries(receipt.signedFields.slice().sort().map((k) => [k, receipt[k]])),
  );                                                                  // sorted keys, compact
  const sig = Buffer.from(receipt.signature, "base64url");
  const key = crypto.createPublicKey(receipt.publicKeyPem);
  const signatureValid = crypto.verify(null, Buffer.from(payload), key, sig);
  const pdfMatches = crypto.createHash("sha256").update(await readFile(pdfPath)).digest("hex") === receipt.pdfSha256;
  return { signatureValid, pdfMatches, controlVersion: receipt.controlVersion };
}
```

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

```bash
# 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

| Field | Values | What it decides |
| --- | --- | --- |
| report `status` | `complete`, `error` | The report exists for both; `409 report_not_ready` before. |
| `searches[]` | current searches with `records` | Superseded searches are left out of the report (the evidence list keeps their captures). |
| `integrity.reportSha256` | hex | SHA-256 of the canonical report JSON without the `integrity` block; also `X-Report-SHA256` on the PDF response. |
| `integrity.pdfSha256` | hex or `null` | SHA-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 `signedFields` | `completedAt`, `controlVersion`, `orderId`, `pdfSha256`, `reportSha256` | Exactly what the Ed25519 signature covers. No name, no date of birth, no record content. |
| receipt `keyId` | `ed25519:` + 16 hex | Identifies the signing key; pin it if you cache `publicKeyPem`. |
| portal session | `token`, `expiresAt` | 30 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.

## Related

- [Consumer portal (reference)](https://offendersearch.app/docs/fcra/consumer-portal)
- [Evidence & verification](https://offendersearch.app/docs/fcra/evidence)
- [Disputes](https://offendersearch.app/docs/fcra/disputes)
- [Consumer file requests](https://offendersearch.app/consumer)
