> ## Documentation Index
> Fetch the complete documentation index at: https://docs.watchdoc.sphinxhq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Result model

> The document_check object returned by the API, webhooks, and app.

This is the public contract. The models, serializers, API docs, and app all build against it.

The response carries what you need to make a decision: the verdict, the reasoning behind it, and
enough of the document's identity to match it against your own records. Rendered revisions,
highlighted diffs, and the raw structural analysis stay in the dashboard, where they can be
displayed properly.

## `document_check`

```json theme={null}
{
  "id": "dc_01jq8x...",
  "object": "document_check",
  "created_at": "2026-07-28T14:03:11Z",
  "status": "completed",
  "decision": "fraudulent",
  "risk_level": "HIGH",
  "risk_score": 92,
  "thresholds": {
    "pending_below": 40,
    "suspicious_below": 60,
    "fraudulent_below": 80
  },
  "summary": "The account balance appears to have been edited after the statement was issued...",
  "flags": [
    {
      "code": "incremental_edit",
      "category": "structure",
      "severity": "medium",
      "title": "Content changed after the document was created",
      "description": "2 separate findings support this flag.",
      "pages": [1],
      "reasons": [
        {
          "code": "incremental_edit.multiple_revisions",
          "summary": "The document contains 2 saved revisions; content was added or changed after the original was written.",
          "pages": [1],
          "evidence": { "version_count": 2 }
        },
        {
          "code": "incremental_edit.finding",
          "summary": "The closing balance was added 4 days after the original was written.",
          "pages": [1],
          "evidence": {}
        }
      ]
    }
  ],
  "rules": [
    {
      "id": "rule_01jq8x...",
      "title": "Employer must match the application",
      "outcome_guidance": "REJECTED",
      "score_delta": 25,
      "reasoning": "The payslip lists Acme Ltd; the application lists Beta Inc."
    }
  ],
  "document": {
    "filename": "statement.pdf",
    "mime_type": "application/pdf",
    "pages": 3,
    "size_bytes": 184320,
    "sha256": "...",
    "format": "digital_pdf"
  },
  "extracted": {
    "doc_type": "Bank Statement",
    "issuer": "Chase",
    "name": "Jane Doe",
    "address": "...",
    "document_date": "2026-05-31",
    "account_number": "...",
    "currency": "USD"
  },
  "engine": {
    "version": "1.4.0",
    "mode": "normal"
  },
  "error": null
}
```

## Flags and reasons

A **flag** is a category of finding. The set of flag codes is closed and stable. Branch on
`flag.code` in your integration and it will keep meaning the same thing.

A **reason** is one concrete observation that produced the flag. A flag always has at least one,
and often several: two independent routes to the same conclusion is stronger evidence than one,
so we return both rather than collapsing them.

```
flag: font_inconsistency          ← closed set, safe to switch on
├── reason: font_inconsistency.minority_font_ratio
└── reason: font_inconsistency.retyped_span
```

<Warning>
  `reason.code` is an **open set**. New reasons appear as detectors improve, without a version
  bump and without a new flag code. Display `reason.summary`; branch on `flag.code`.
</Warning>

`flag.description` is a one-line headline. `flag.pages` is the union of the pages named by its
reasons.

## `status`

| Value        | Meaning                               |
| ------------ | ------------------------------------- |
| `processing` | Analysis still running                |
| `completed`  | Finished successfully                 |
| `failed`     | Engine or worker failure. See `error` |

While `processing`, verdict fields (`decision`, `risk_score`, `summary`, `flags`, …) are
null/empty but the object shape is the same, so pollers never branch on which keys exist. That
holds for nested objects too: every `extracted` field is present and `null` before extraction has
run, rather than the block being empty.

## `decision`

Assigned by the analysis engine (with score-band realignment after rule deltas):

| `decision`   | Meaning                                    |
| ------------ | ------------------------------------------ |
| `clear`      | No fraud signal strong enough to act on    |
| `pending`    | Ambiguous signals. Hold for human review   |
| `suspicious` | Fraud signals present. Route to a human    |
| `fraudulent` | High-confidence fraud. Safe to auto-reject |

## `risk_score`

An integer from 0 to 100. **Higher means riskier**, in the same direction as `risk_level`. It is
null until the check completes.

`decision` is cut from that number using the workspace's outcome thresholds (snapshotted onto
each check). Defaults preserve the historical bands:

| `decision`   | `risk_score` (defaults) |
| ------------ | ----------------------- |
| `clear`      | 0-39                    |
| `pending`    | 40-59                   |
| `suspicious` | 60-79                   |
| `fraudulent` | 80-100                  |

Because the bands are contiguous, `decision` is always recoverable from `risk_score` + the
snapshotted thresholds. The two can never tell you different things.

### Setting your own boundaries

Tune the three cut lines via `GET`/`PATCH /api/v1/thresholds/` or the Rules page:
`pending_below` / `suspicious_below` / `fraudulent_below` (defaults 40 / 60 / 80). Those values
are snapshotted onto each check so later edits do not rewrite history.

```python theme={null}
check = client.document_checks.retrieve("dc_01jq8x...")

if check.decision == "fraudulent":
    reject(check)
elif check.decision in ("pending", "suspicious"):
    queue_for_review(check)
else:
    approve(check)

# Or cut on the score directly (same dial, your own numbers):
if check.risk_score >= check.thresholds.fraudulent_below:
    reject(check)
elif check.risk_score >= check.thresholds.pending_below:
    queue_for_review(check)
else:
    approve(check)
```

Within a band, the score reflects how much evidence there was. Findings combine by saturation
rather than addition, so ten weak signals never outrank one decisive one, and a finding
supported by several independent observations sits higher than the same finding seen once.

A single HIGH finding on a fraudulent document scores near the top of the fraudulent band.

Triggered workspace rules also move the dial by their configured `score_delta`
(positive = riskier). A binding `outcome_guidance` (`REJECTED` / `PENDING` / `ACCEPTED`) floors
or caps the score into the matching decision band. After that, `decision` is re-aligned to the
score band so the two stay consistent. Triggered rules are shown on the document viewer.

<Note>
  A `clear` document is still scored. Signals that were not strong enough to publish as flags (a
  rebuilt PDF, a named editing tool) still move it within 0-39, which is what gives you something
  to tune against across the documents that pass. This is why a `clear` result can score 18 while
  showing an empty `flags` array.
</Note>

<Warning>
  The score is comparable within an `engine.version`, not across versions. Retuning is what a
  version bump is for, so store `engine.version` alongside any threshold you set, and re-check
  your line when it changes.

  If a reviewer overrides a verdict in the dashboard, `decision` reflects the override while
  `risk_score` continues to report what the engine concluded. That is the only case where the two
  diverge, and it is deliberate.
</Warning>

## What the API does not return

The original file, each stored PDF revision, the highlighted diff between revisions, and the raw
structural dump are not part of the response. They are material for a human to look at, and that
review happens in the dashboard, where every finding is rendered against the page it came from.

You always keep the file you uploaded, and `document.sha256` lets you tie a result back to it.
