# Document checks in an intake workflow

> Add document checks to an intake flow: upload, receive the webhook, route by risk level, queue human review, store the report ID and verify it later.

Source: https://fileverity.com/en/docs/intake-workflow

This recipe puts document checks in front of a process that receives documents from outside: loan applications, insurance claims, vendor onboarding or expense reports. Each document is checked once, routed by its risk level, and its report ID is kept as the audit record. The API returns a level and a recommended action. Your policy and your reviewers make the decision.

It uses the model `fvdoc-3` with the calls from [Files](https://fileverity.com/en/docs/files.md), [Analyses](https://fileverity.com/en/docs/analyses.md) and [Webhooks](https://fileverity.com/en/docs/webhooks.md). With a test key the upload and the check return an instant sample, but no webhook is sent and there is no sample PDF. Use **Send test** on a webhook endpoint in the Console to try your receiver.

## The flow

1. Upload the file with `POST /files` and start the check with `POST /analyses`. Put your case ID in `metadata` and send an `Idempotency-Key`.
2. Answer your user at once. Documents can be queued, so do not hold their request open; the webhook tells you when the check is done.
3. Receive `analysis.completed` or `analysis.failed`, verify the signature and queue a job.
4. In the job, fetch the analysis, route it by its summary, and save the result and the PDF report with the case.
5. Send cases that need a person to a review queue, with the PDF attached.
6. Later, verify the report against the stored file.

## 1. Upload and start the check

Files up to 4 MB go up as multipart. For larger files, up to 50 MB, use a pre-signed upload as described in [Files](https://fileverity.com/en/docs/files.md#presigned). Creating the analysis returns `202` with `status: processing` (a test key returns `200` with a finished sample), and your `metadata` comes back on the analysis and in every webhook for it.

**Submit a document: cURL**

```bash
FILE_ID=$(curl -s https://api.fileverity.com/v1/files \
  -H "Authorization: Bearer $FILEVERITY_API_KEY" \
  -H "Idempotency-Key: intake-A-10492-file" \
  -F file=@application.pdf | jq -r .id)

curl https://api.fileverity.com/v1/analyses \
  -H "Authorization: Bearer $FILEVERITY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: intake-A-10492-check" \
  -d "{\"model\": \"fvdoc-3\", \"file\": \"$FILE_ID\", \"metadata\": {\"case_id\": \"A-10492\"}}"
```

**Submit a document: JavaScript**

```javascript
import { readFile } from "node:fs/promises";
import { basename } from "node:path";

const base = "https://api.fileverity.com/v1";
const auth = { Authorization: "Bearer " + process.env.FILEVERITY_API_KEY };

async function call(path, init) {
  const response = await fetch(base + path, init);
  const body = await response.json();
  if (!response.ok) throw new Error(body.error.code + " (" + body.error.request_id + ")");
  return body;
}

export async function submit(caseId, path) {
  const form = new FormData();
  form.append("file", new Blob([await readFile(path)]), basename(path));
  const file = await call("/files", {
    method: "POST",
    headers: { ...auth, "Idempotency-Key": `intake-${caseId}-file` },
    body: form,
  });
  return call("/analyses", {
    method: "POST",
    headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": `intake-${caseId}-check` },
    body: JSON.stringify({ model: "fvdoc-3", file: file.id, metadata: { case_id: caseId } }),
  });
}
```

**Submit a document: Python**

```python
import os
import requests

BASE = "https://api.fileverity.com/v1"
HEADERS = {"Authorization": "Bearer " + os.environ["FILEVERITY_API_KEY"]}

def call(method, path, **kwargs):
    headers = {**HEADERS, **kwargs.pop("headers", {})}
    response = requests.request(method, BASE + path, headers=headers, timeout=90, **kwargs)
    body = response.json()
    if not response.ok:
        raise RuntimeError(body["error"]["code"] + " (" + body["error"]["request_id"] + ")")
    return body

def submit(case_id, path):
    with open(path, "rb") as source:
        file = call("POST", "/files", headers={"Idempotency-Key": f"intake-{case_id}-file"}, files={"file": source})
    return call(
        "POST",
        "/analyses",
        headers={"Idempotency-Key": f"intake-{case_id}-check"},
        json={"model": "fvdoc-3", "file": file["id"], "metadata": {"case_id": case_id}},
    )
```

**202 Accepted**

```json
{
  "id": "an_8Hk2pQ4vN9sL1xR7cW3mY6zB",
  "object": "analysis",
  "created": 1793700120,
  "model": "fvdoc-3-2026-11-03",
  "livemode": true,
  "status": "processing",
  "file": {
    "name": "application.pdf",
    "bytes": 18874368,
    "format": "pdf"
  },
  "report_id": null,
  "report_url": null,
  "summary": null,
  "result": null,
  "error": null,
  "progress": {
    "stage": "queued",
    "done": 0,
    "total": 0,
    "position": 1
  },
  "usage": {
    "amount": 599,
    "currency": "usd",
    "funding": "balance"
  },
  "metadata": {
    "case_id": "A-10492"
  },
  "completed_at": null,
  "expires_at": null
}
```

Save `id` with the case. Because the keys are derived from the case ID, running `submit` again after a timeout returns the first responses instead of uploading a second file or billing a second check. Keys are kept for 24 hours; see [Idempotency](https://fileverity.com/en/docs/idempotency.md).

## 2. Receive the result

Verify the signature with the `verify` function from [Webhooks](https://fileverity.com/en/docs/webhooks.md#signature), saved as `verify.js` or `verify.py`. Answer `2xx` quickly, and do the work in a job. `enqueue` stands for your own job queue. Store each job under the event ID, so a duplicate delivery does not create a second job.

**Webhook receiver: JavaScript**

```javascript
import express from "express";
import { verify } from "./verify.js";

const app = express();

app.post("/webhooks/fileverity", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = verify(process.env.FILEVERITY_WEBHOOK_SECRET, req.headers, req.body.toString("utf8"));
  } catch {
    return res.sendStatus(400);
  }
  if (event.type === "analysis.completed" || event.type === "analysis.failed") {
    await enqueue({ eventId: event.id, analysisId: event.data.id });
  }
  res.sendStatus(200);
});
```

**Webhook receiver: Python**

```python
import os

from fastapi import FastAPI, HTTPException, Request

from verify import verify

app = FastAPI()

@app.post("/webhooks/fileverity")
async def receive(request: Request):
    body = await request.body()
    try:
        event = verify(os.environ["FILEVERITY_WEBHOOK_SECRET"], request.headers, body)
    except (KeyError, ValueError):
        raise HTTPException(status_code=400)
    if event["type"] in ("analysis.completed", "analysis.failed"):
        enqueue(event["id"], event["data"]["id"])
    return {"ok": True}
```

## 3. Route by risk level

`summary` is the stable, machine-readable verdict of a document check. `level` runs from `L1` to `L5` and `action` is the recommended next step. Route on both, and treat any value you do not recognize as a case for a person.

| Summary | What it means | Example route |
| --- | --- | --- |
| `L1` or `L2`, `accept` | No signs of tampering were found. `L1` means the source was verified. | Continue automatically. Store the report ID. |
| `L1` or `L2`, `note` | No signs of tampering, but AI-written text or an AI label is marked in the report. | Continue, and flag the case if your policy limits AI-written content. |
| `L3`, `review` | Needs manual review. | Standard review queue. |
| `L4`, `review` | High risk. | Priority review queue. |
| `L5`, `reject` | Problems are established in the report. | Priority review queue. A person confirms before anything is declined. |
| `status` is `failed` or `canceled` | The check did not finish and you were not charged. | Upload the stored original again, or send the case to a person. |

The mapping is your policy. Move `L3` into the priority queue, or let `note` go to review, by changing the function. The action set also contains `resubmit`, which the function treats as a request for another copy of the file. Every summary field is described in [Analyses](https://fileverity.com/en/docs/analyses.md#summary-document).

**Route a finished check: JavaScript**

```javascript
export function route(analysis) {
  if (analysis.status !== "succeeded") return "manual";
  const { action, level } = analysis.summary;
  if (action === "accept") return "accept";
  if (action === "note") return "accept_with_note";
  if (action === "resubmit") return "request_new_copy";
  if (action === "reject" || level === "L4" || level === "L5") return "priority_review";
  return "review";
}
```

**Route a finished check: Python**

```python
def route(analysis):
    if analysis["status"] != "succeeded":
        return "manual"
    action = analysis["summary"]["action"]
    level = analysis["summary"]["level"]
    if action == "accept":
        return "accept"
    if action == "note":
        return "accept_with_note"
    if action == "resubmit":
        return "request_new_copy"
    if action == "reject" or level in ("L4", "L5"):
        return "priority_review"
    return "review"
```

## 4. Store the report ID and the PDF

The job fetches the analysis, routes it and saves what you will need later. It reuses `call`, `route` and the credentials from the earlier steps, and `saveCase` and `saveReport` stand for your own storage. Download the PDF in the same job: the analysis result is kept for 7 days, the page images and text excerpts of a document for 3 hours, and Fileverity keeps only the verification record afterwards, not the full report.

**Process a finished check: JavaScript**

```javascript
export async function handleResult(analysisId) {
  const analysis = await call(`/analyses/${analysisId}`, { headers: auth });
  const decision = route(analysis);
  await saveCase(analysis.metadata.case_id, {
    analysisId: analysis.id,
    reportId: analysis.report_id,
    reportUrl: analysis.report_url,
    summary: analysis.summary,
    model: analysis.model,
    decision,
  });
  if (analysis.status === "succeeded") {
    const pdf = await fetch(`${base}/analyses/${analysis.id}/report.pdf`, { headers: auth });
    await saveReport(analysis.report_id, Buffer.from(await pdf.arrayBuffer()));
  }
  return decision;
}
```

**Process a finished check: Python**

```python
def handle_result(analysis_id):
    analysis = call("GET", f"/analyses/{analysis_id}")
    decision = route(analysis)
    save_case(analysis["metadata"]["case_id"], {
        "analysis_id": analysis["id"],
        "report_id": analysis["report_id"],
        "report_url": analysis["report_url"],
        "summary": analysis["summary"],
        "model": analysis["model"],
        "decision": decision,
    })
    if analysis["status"] == "succeeded":
        pdf = requests.get(f"{BASE}/analyses/{analysis['id']}/report.pdf", headers=HEADERS, timeout=90)
        pdf.raise_for_status()
        save_report(analysis["report_id"], pdf.content)
    return decision
```

- `report_id` (`FV-XXXX-XXXX-XXXX`) is the key to the audit record. Save it with the case, in your database and on any decision letter.
- `model` is the dated snapshot that produced the result, so you can say later exactly which version checked the document.
- Keep the original file in your own storage. Fileverity deletes an uploaded file when the analysis starts, and a failed check needs a new upload. See [Data retention and security](https://fileverity.com/en/docs/security.md).

## 5. Run the human review queue

Create one task per routed case. Give the reviewer what they need to decide without calling the API again:

- The case ID, `level`, `action`, `reason` and the finding IDs in `findings`, with the PDF report attached. The PDF lists each finding with its location and evidence.
- The original file you stored.
- `report_url`, the public verification page. It confirms the report record and the file fingerprint, but it is not a substitute for reading the report.

Record the reviewer, the decision and the time in your own system, next to `report_id`.

> **Note.** A clean check is not proof that a document is genuine, and a finding is not proof of fraud. Do not decline a case on the API result alone. For income, identity or ownership documents, confirm with the issuer through a contact you found yourself. The [guide to document fraud detection](https://fileverity.com/en/guides/ai-document-fraud-detection) covers what to check by document type.

## 6. Verify the report later

Months later, an auditor or a customer can check the record without a Fileverity account. Open the stored `report_url`. It shows the report type, the recorded finding and next step, the SHA-256 fingerprint of the file that was examined and the RFC 3161 timestamp. Drop your stored original on the page and your browser computes its SHA-256 and compares it with the record; nothing is uploaded. Any re-saved, converted or edited copy gives a different fingerprint.

You can run the same comparison in your own system:

**Fingerprint the stored original: cURL**

```bash
sha256sum application.pdf
```

**Fingerprint the stored original: JavaScript**

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

const digest = createHash("sha256").update(await readFile("application.pdf")).digest("hex");
console.log(digest);
```

**Fingerprint the stored original: Python**

```python
import hashlib

print(hashlib.sha256(open("application.pdf", "rb").read()).hexdigest())
```

Verification does not analyze the file again, and it does not certify that a document is authentic. It records which file was examined, what the report said and when. The full report exists only in the PDF you stored. The fields of the report are described in [the report object](https://fileverity.com/en/docs/analyses.md#report).

## Before you go live

- Build against a test key. It returns an instant sample, level `L2` with action `accept`, without running the model. Webhooks and the PDF download need a live key, which you can try on your free start. Pass the other summaries from [Analyses](https://fileverity.com/en/docs/analyses.md#summary-document) through `route` to test each branch.
- Handle `503 service_unavailable`: wait the `retry-after` seconds and retry with the same `Idempotency-Key`. See [When the service is busy](https://fileverity.com/en/docs/analyses.md#busy).
- Set a monthly spend limit on the key, and add a `balance.low` webhook, so intake does not stop with `insufficient_balance`. See [Pricing and billing](https://fileverity.com/en/docs/pricing.md#alerts).
- Call the API from your server and keep keys in a secrets manager, never in browser code.
- Quote the `x-request-id` header when you [contact support](https://fileverity.com/en/contact).

