# Evidence and re-verification

> Every live check is captured and hash-pinned; every finished search can be run again. Here is how to read the one and trigger the other.

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

The audit side of an order: list the official-source captures taken for it, fetch one capture’s stored bytes and check them against the published hash, and understand what the evidence record pins. Then the re-verification path: `POST /v1/fcra/searches/{search_id}/reverify` adds a fresh search of the same type, marks the old one `superseded`, and returns the order to `in_progress` — with the consequences for the report, PDF and receipt.

## Before you start

- A completed order. Note: **sandbox searches make no live calls and capture nothing**, so `GET /v1/fcra/orders/{order_id}/evidence` returns an empty `captures[]` on the sandbox; the shapes below are what a production order returns.
- Capture kinds: `source_response` (what the official source returned), `parsed_result` (what was read from it), `service_result`. Each carries `sha256` and `size` of the **full** body; bytes are stored up to a limit and `truncated` says whether the stored copy is cut.
- The **evidence record** (written once per accepted order, and for a purpose refusal) holds the end user, purpose, consent attestation, subject identifiers, what each source answered, the control version and hash, and every record furnished or withheld with its reason. It is never edited.

> **No retention period is published.** Captures are kept for as long as the order’s evidence record. The API does not publish a number of days or years; do not promise one to your end users.

## Step by step

1. **List the captures.** `GET /v1/fcra/orders/{order_id}/evidence` → `{orderId, captures[]}`. This list includes captures of superseded searches; the report’s `evidence[]` lists only current ones.
2. **Fetch one capture.** `GET /v1/fcra/orders/{order_id}/evidence/{capture_id}` returns the stored bytes with the capture’s own content type and `X-Capture-SHA256`, `X-Capture-Kind`, `X-Capture-Source`, `X-Capture-Truncated`.
3. **Check the hash.** SHA-256 the bytes you received; when `X-Capture-Truncated` is `false` it equals `X-Capture-SHA256` and the capture’s `sha256`.
4. **Re-verify when you need a fresh answer.** `POST /v1/fcra/searches/{search_id}/reverify` → `202` with the **new** search at `queued`. Wait for its `search.completed` and the order’s new `order.completed`.

```bash
curl https://api.offendersearch.app/v1/fcra/orders/fo_ee0b34c5b52fd35b/evidence \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

*200 — capture metadata (production)*
```json
{
  "orderId": "fo_ee0b34c5b52fd35b",
  "captures": [
    { "captureId": "fc_31f0a9c2d4e6b801", "searchId": "fs_452884eecde9a1f2", "source": "WI",
      "kind": "source_response", "url": null,
      "retrievedAt": "2026-10-02T14:01:31.004812+00:00", "httpStatus": null,
      "contentType": "text/html; charset=utf-8",
      "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "size": 48213, "truncated": false },
    { "captureId": "fc_31f0b2e5a7c9d013", "searchId": "fs_452884eecde9a1f2", "source": "WI",
      "kind": "parsed_result", "url": null,
      "retrievedAt": "2026-10-02T14:01:31.117320+00:00", "httpStatus": null,
      "contentType": "application/json",
      "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "size": 2210, "truncated": false }
  ]
}
```

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

BASE = "https://api.offendersearch.app"
H = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}

def fetch_capture(order_id, capture_id):
    res = requests.get(BASE + "/v1/fcra/orders/" + order_id + "/evidence/" + capture_id,
                       headers=H, timeout=60)
    res.raise_for_status()
    digest = hashlib.sha256(res.content).hexdigest()
    intact = (res.headers["X-Capture-Truncated"] == "false"
              and digest == res.headers["X-Capture-SHA256"])
    return {
        "kind": res.headers["X-Capture-Kind"],
        "source": res.headers["X-Capture-Source"],
        "contentType": res.headers.get("Content-Type"),
        "bytes": res.content,
        "hashMatches": intact,
    }

def reverify(search_id):
    res = requests.post(BASE + "/v1/fcra/searches/" + search_id + "/reverify", headers=H, timeout=30)
    if res.status_code == 409:
        raise RuntimeError(res.json()["detail"]["code"])      # search_not_finished | order_cancelled
    res.raise_for_status()                                     # 202
    return res.json()["id"]                                    # the NEW fs_ id, status queued
```

**JavaScript**
```javascript
import crypto from "node:crypto";

const BASE = "https://api.offendersearch.app";
const headers = { "X-API-Key": process.env.OFFENDERSEARCH_KEY };

async function fetchCapture(orderId, captureId) {
  const res = await fetch(BASE + "/v1/fcra/orders/" + orderId + "/evidence/" + captureId, { headers });
  if (!res.ok) throw new Error(await res.text());
  const bytes = Buffer.from(await res.arrayBuffer());
  const digest = crypto.createHash("sha256").update(bytes).digest("hex");
  return {
    kind: res.headers.get("x-capture-kind"),
    source: res.headers.get("x-capture-source"),
    contentType: res.headers.get("content-type"),
    bytes,
    hashMatches: res.headers.get("x-capture-truncated") === "false" && digest === res.headers.get("x-capture-sha256"),
  };
}

async function reverify(searchId) {
  const res = await fetch(BASE + "/v1/fcra/searches/" + searchId + "/reverify", { method: "POST", headers });
  const body = await res.json();
  if (res.status === 409) throw new Error(body.detail.code);   // search_not_finished | order_cancelled
  if (res.status !== 202) throw new Error(JSON.stringify(body));
  return body.id;                                              // the NEW fs_ id, status queued
}
```

*202 — the new search returned by reverify*
```json
{
  "id": "fs_b71e4d2c9a05f368",
  "type": "criminal",
  "status": "queued",
  "result": null,
  "recordCount": 0,
  "withheld": {},
  "createdAt": "2026-10-25T16:40:12.771004+00:00",
  "startedAt": null,
  "completedAt": null,
  "supersededBy": null,
  "coverage": [],
  "incompleteReasons": []
}
```

Afterwards `GET /v1/fcra/orders/{order_id}` shows the old search with `status: "superseded"` and `supersededBy: "fs_b71e4d2c9a05f368"`, and the order back at `in_progress` with `completedAt: null`. When the new search finishes, the order completes again — with a new `reportSha256`, a newly rendered PDF, a new `pdfSha256` and a new receipt.

## Reading the result

| Field | Values | What it decides |
| --- | --- | --- |
| `captures[].kind` | `source_response`, `parsed_result`, `service_result` | What the bytes are: the source’s answer, what was read from it, or the service result. |
| `captures[].sha256`, `size` | hex, bytes | Of the **full** body as retrieved. Compare only when `truncated` is `false`. |
| `captures[].truncated` | boolean | The stored copy was cut at the storage limit; the hash still describes the full body. |
| `coverage[].captureIds[]` (on the search) | `fc_…` | Which captures back which coverage row — the join between result and evidence. |
| reverify `202` → `status` | `queued` | A new `fs_` id. The old search becomes `superseded`; the order becomes `in_progress`. |
| reverify `409` | `search_not_finished`, `order_cancelled` | Only a `complete`, `unavailable` or `error` search on a live order can be re-run. |

## Edge cases and pitfalls

- **Empty `captures[]` on the sandbox** is correct, not a bug; test your hash check against a production order.
- **The capture’s media type varies** (`text/html`, `application/json`, …); read `Content-Type`, do not assume `application/octet-stream`.
- **A re-verify emits no event of its own.** You learn it ran from the later `search.completed` (new `searchId`) and `order.completed`. Record the new id when you call reverify.
- **Hashes change after a re-verify completes.** Anyone holding the previous PDF will find its `pdfSha256` no longer matches the current receipt; keep the old file and receipt together if you need them.
- **A re-run is a new search on your account** of the same type and the same `exclusions` setting; it does not create a new order.
- **The evidence list keeps superseded searches’ captures**; the report’s `evidence[]` does not. Use the list for a full audit trail.

## Production checklist

- Store `captureId`s with each adjudicated order; fetch bytes on demand, verify the hash.
- Record the new `fs_` id returned by every reverify; expect a new receipt afterwards.
- Do not state a retention period to end users; none is published.
- Keep the report PDF and its receipt together at the time of adjudication.

## Related

- [Evidence & verification (reference)](https://offendersearch.app/docs/fcra/evidence)
- [Results & records](https://offendersearch.app/docs/fcra/results)
- [Controls & reason codes](https://offendersearch.app/docs/fcra/controls)
- [FCRA API reference](https://offendersearch.app/docs/fcra/api-reference)
