# Managing disputes

> A dispute is an endpoint. Open it with the records and the date it reached you, follow it by webhook, and apply the outcome everywhere you hold the record.

- **HTML:** https://offendersearch.app/docs/guides/fcra/disputes
- **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 dispute flow from your side: when a consumer disputes a record you received from us, open a reinvestigation on the order naming the records, the reason and when the dispute reached you; add the consumer’s new information if it arrives; receive the outcome by `dispute.updated`; and react to `record.corrected`, which reaches **every** partner that received the record. You will run it on the sandbox subject **Dispute**, whose dispute resolves `deleted` sixty seconds after you file it.

## Before you start

- A completed sandbox order for **Pat Dispute** (1980-01-01) with a criminal search; it furnishes one record with id `sandbox:criminal:dispute-1980-01-01`.
- Required: `recordIds[]` (1–100, each furnished in this order — including by a superseded search) and `reason` ∈ `not_me`, `inaccurate`, `incomplete`, `outdated`, `other`. Optional: `consumerStatement` (≤ 8,000), `receivedAt` (a past date-time).
- Statuses: `received` → `investigating` → `verified` | `modified` | `deleted` | `unable_to_verify`. `overdue` is true for an open dispute past `dueAt`.

> **Send `receivedAt`.** The 30-day clock runs from when the dispute reached you (15 U.S.C. § 1681i(a)(1)); without `receivedAt` it runs from the call. `dueAt` is `receivedAt` + 30 days. If the consumer sends new information during the investigation, `POST /v1/fcra/disputes/{dispute_id}/information` moves `dueAt` by 15 days — once (15 U.S.C. § 1681i(a)(1)(B)).

## Step by step

1. **Open the dispute.** `POST /v1/fcra/orders/{order_id}/disputes` → `201` at `received` with `dueAt`. You also receive `dispute.created`.
2. **Add information if it comes.** `POST /v1/fcra/disputes/{dispute_id}/information` with `{ "statement": "…" }` → `200` with `dueAt` moved and `extendedAt` set; `409 dispute_not_extendable` if resolved, already extended, or past due.
3. **Follow it.** `dispute.updated` on each change; `GET /v1/fcra/disputes/{dispute_id}` for the current state; `GET /v1/fcra/disputes?status=&orderId=` lists every dispute on your orders, earliest due first.
4. **Apply the outcome.** `verified`: nothing changes. `modified`: `changes` says what; the record carries `corrections[]` in later orders. `deleted` and `unable_to_verify`: the record is suppressed from every later FCRA order. All three send `record.corrected` to every partner that received the record.

**cURL**
```bash
curl -X POST https://sandbox.offendersearch.app/v1/fcra/orders/fo_ee0b34c5b52fd35b/disputes \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recordIds": ["sandbox:criminal:dispute-1980-01-01"],
    "reason": "not_me",
    "consumerStatement": "I have never lived in that county.",
    "receivedAt": "2026-10-25T09:12:00Z"
  }'
```

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

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

res = requests.post(BASE + "/v1/fcra/orders/fo_ee0b34c5b52fd35b/disputes", headers=H, timeout=30, json={
    "recordIds": ["sandbox:criminal:dispute-1980-01-01"],
    "reason": "not_me",
    "consumerStatement": "I have never lived in that county.",
    "receivedAt": "2026-10-25T09:12:00Z",
})
if res.status_code == 422:
    raise RuntimeError(res.json()["detail"])        # unknown_record, validation, or a field list
res.raise_for_status()                                # 201
dispute = res.json()
print(dispute["id"], dispute["status"], "due", dispute["dueAt"])

# the sandbox subject Dispute resolves 60 seconds later; production resolves by webhook
time.sleep(61)
dispute = requests.get(BASE + "/v1/fcra/disputes/" + dispute["id"], headers=H, timeout=30).json()
print(dispute["status"], dispute["resolvedAt"], dispute["changes"])
```

**JavaScript**
```javascript
const BASE = "https://sandbox.offendersearch.app";
const headers = { "X-API-Key": process.env.OFFENDERSEARCH_TEST_KEY, "Content-Type": "application/json" };

const res = await fetch(BASE + "/v1/fcra/orders/fo_ee0b34c5b52fd35b/disputes", {
  method: "POST", headers,
  body: JSON.stringify({
    recordIds: ["sandbox:criminal:dispute-1980-01-01"],
    reason: "not_me",
    consumerStatement: "I have never lived in that county.",
    receivedAt: "2026-10-25T09:12:00Z",
  }),
});
let dispute = await res.json();
if (res.status !== 201) throw new Error(JSON.stringify(dispute.detail));   // unknown_record, validation, or a field list
console.log(dispute.id, dispute.status, "due", dispute.dueAt);

// the sandbox subject Dispute resolves 60 seconds later; production resolves by webhook
await new Promise((r) => setTimeout(r, 61000));
dispute = await (await fetch(BASE + "/v1/fcra/disputes/" + dispute.id, { headers })).json();
console.log(dispute.status, dispute.resolvedAt, dispute.changes);
```

*201 Created*
```json
{
  "id": "fd_acae08b7d57ccc18",
  "orderId": "fo_ee0b34c5b52fd35b",
  "origin": "partner",
  "recordIds": ["sandbox:criminal:dispute-1980-01-01"],
  "reason": "not_me",
  "consumerStatement": "I have never lived in that county.",
  "receivedAt": "2026-10-25T09:12:00+00:00",
  "dueAt": "2026-11-24T09:12:00+00:00",
  "extendedAt": null,
  "status": "received",
  "notes": null,
  "changes": null,
  "resolvedAt": null,
  "overdue": false
}
```

*GET /v1/fcra/disputes/{dispute_id} a minute later (sandbox)*
```json
{
  "id": "fd_acae08b7d57ccc18",
  "orderId": "fo_ee0b34c5b52fd35b",
  "origin": "partner",
  "recordIds": ["sandbox:criminal:dispute-1980-01-01"],
  "reason": "not_me",
  "status": "deleted",
  "receivedAt": "2026-10-25T09:12:00+00:00",
  "dueAt": "2026-11-24T09:12:00+00:00",
  "extendedAt": null,
  "notes": null,
  "changes": null,
  "resolvedAt": "2026-10-25T14:31:19.004118+00:00",
  "overdue": false
}
```

*The record.corrected event every furnished partner receives*
```json
{
  "id": "fev_9c1d3e5f7a2b4068",
  "type": "record.corrected",
  "createdAt": "2026-10-25T14:31:19.021776+00:00",
  "data": { "recordId": "sandbox:criminal:dispute-1980-01-01", "disputeId": "fd_acae08b7d57ccc18", "outcome": "deleted" }
}
```

## Reading the result

| Field | Values | What it decides |
| --- | --- | --- |
| `status` | `received`, `investigating` | Open. The clock is running; `overdue` flips when `dueAt` passes. |
| `status` | `verified` | Confirmed as furnished. Nothing changes. |
| `status` | `modified` | Corrected. `changes` lists what; later orders carry `corrections[]` on the record. |
| `status` | `deleted` | Removed, or found not to be the consumer’s. Suppressed from every later FCRA order (`disputed_suppressed`). |
| `status` | `unable_to_verify` | Could not be verified. Treated as deleted: suppressed the same way. |
| `dueAt`, `extendedAt` | date-times | `receivedAt` + 30 days; + 15 days once when information is added, with `extendedAt` recording when. |
| `origin` | `partner`, `consumer_portal` | Who opened it. Portal disputes are the same object and reach you by `dispute.created`. |

## Edge cases and pitfalls

- **`422 unknown_record`** — a `recordIds` entry was not furnished in this order (the message names it). Use the `recordId` exactly as the search returned it.
- **`receivedAt` in the future** is a `422`; a `receivedAt` long in the past makes the dispute `overdue` at once. Send the real date.
- **`409 dispute_not_extendable`** carries one of three messages: already resolved, already extended once, or the period has ended.
- **Disputes without an access code** reach us through the consumer request form, are reinvestigated by our team and are not listed among your disputes; when one deletes or corrects a record you received, you still get `record.corrected`.
- **`record.corrected` can arrive for an old order** and from another partner’s dispute. Key your handling on `recordId`, not on your open disputes.
- **The list is unpaginated**, ordered earliest due first; filter with `status` and `orderId`.
- **Sandbox auto-resolution** applies only to the subject **Dispute** and only to `deleted`; other outcomes need a production dispute.

## Production checklist

- Open disputes with `receivedAt`; store `fd_` ids against the consumer’s case.
- Consume `dispute.created`, `dispute.updated` and `record.corrected`; suppress or correct the record in your own copies.
- Watch `overdue` on the list; escalate before `dueAt`.
- Notices to the consumer about the outcome, and any adverse-action steps, stay with you; the API reinvestigates and corrects the record.

## Related

- [Disputes (reference)](https://offendersearch.app/docs/fcra/disputes)
- [Consumer portal](https://offendersearch.app/docs/fcra/consumer-portal)
- [Webhooks & events](https://offendersearch.app/docs/fcra/webhooks)
- [Error codes](https://offendersearch.app/docs/fcra/errors)
