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

# Check Onboarding Documents for Consistency

> Classify an identity document and two proofs of address, extract the relevant fields with citations, and return a deterministic PASS, REVIEW, or FAIL decision.

An onboarding flow often receives several documents that should describe the same person: an identity document, a utility bill, and a bank statement. Reading each file is only half the job. We also need to check that the names and addresses agree, confirm that the identity document hasn't expired, and stop when a critical field can't be read confidently.

This recipe classifies three uploaded documents, applies a type-specific extraction schema to each one, and runs deterministic consistency checks over the results. It returns `PASS`, `REVIEW`, or `FAIL`, with confidence scores and source citations preserved for every extracted field.

<Warning>
  This workflow checks consistency and basic validity, not authenticity. It doesn't detect forged documents, verify security features, perform face matching, or provide liveness checks. A convincing fake with internally consistent details can pass these rules.
</Warning>

## What We'll Build

A script that:

1. Classifies each file as an identity document, utility bill, bank statement, or other document.
2. Extracts the fields required for that document type with citations enabled.
3. Normalizes names and addresses before comparing them across files.
4. Checks the identity document's expiry date.
5. Returns `FAIL` for a mismatch or expired document, `REVIEW` for uncertain data, and `PASS` only when every check clears.

The three documents follow the same pipeline, while extraction preserves the evidence used by the decision rules:

<img src="https://mintcdn.com/unsiloed/H42xfy2eyiJiA0Sh/images/kyc-document-flow.png?fit=max&auto=format&n=H42xfy2eyiJiA0Sh&q=85&s=7a0233ec5f468a96ff89dc04974a8bf4" alt="Three onboarding documents flow through classification, type-specific extraction, and deterministic checks before receiving a PASS, REVIEW, or FAIL decision." width="1520" height="1260" data-path="images/kyc-document-flow.png" />

We'll use a public fictional Luxembourg passport plus two synthetic proof-of-address documents for the same person. The passport number is intentionally obscured, so the verified outcome is `REVIEW`: all cross-document values match, but the workflow refuses to approve a field it can't ground.

<Accordion title="Show the Full Script">
  Set `UNSILOED_API_KEY` in your environment and save the three sample files in the same directory as the script before running it.

  <Tabs>
    <Tab title="Python">
      ```python check_kyc.py theme={null}
      import concurrent.futures
      import datetime
      import itertools
      import json
      import mimetypes
      import os
      import re
      import time
      import unicodedata
      from pathlib import Path

      import requests

      API_KEY = os.environ["UNSILOED_API_KEY"]
      BASE_URL = "https://prod.visionapi.unsiloed.ai"
      CONFIDENCE_THRESHOLD = 0.85

      FILES = [
          Path("passport_specimen.jpg"),
          Path("utility_bill.png"),
          Path("bank_statement.png"),
      ]

      CATEGORIES = [
          {
              "name": "Identity Document",
              "description": "A passport, national identity card, or driving licence that identifies a person",
          },
          {
              "name": "Utility Bill",
              "description": "An electricity, gas, water, internet, or municipal service bill showing a customer and service address",
          },
          {
              "name": "Bank Statement",
              "description": "An account statement from a bank showing an account holder, statement period, and transactions",
          },
          {
              "name": "Other",
              "description": "A document that does not match any of the other categories",
          },
      ]

      SCHEMAS = {
          "Identity Document": {
              "type": "object",
              "properties": {
                  "full_name": {
                      "type": "string",
                      "description": "Full name as printed, combining given names and surname in natural reading order",
                  },
                  "document_number": {
                      "type": "string",
                      "description": "Passport or identity document number. Return null if the complete number is obscured or illegible. Do not substitute a CAN number",
                  },
                  "date_of_birth": {
                      "type": "string",
                      "description": "Date of birth normalized to ISO 8601 YYYY-MM-DD",
                  },
                  "date_of_expiry": {
                      "type": "string",
                      "description": "Date of expiry normalized to ISO 8601 YYYY-MM-DD",
                  },
                  "nationality": {
                      "type": "string",
                      "description": "Nationality as printed on the identity document",
                  },
              },
              "required": ["full_name", "date_of_birth", "date_of_expiry"],
              "additionalProperties": False,
          },
          "Utility Bill": {
              "type": "object",
              "properties": {
                  "account_holder_name": {
                      "type": "string",
                      "description": "Name of the account holder or customer",
                  },
                  "service_address": {
                      "type": "string",
                      "description": "Complete service or billing address",
                  },
                  "provider": {
                      "type": "string",
                      "description": "Utility provider or company name",
                  },
                  "account_number": {
                      "type": "string",
                      "description": "Utility account number",
                  },
                  "amount_due": {
                      "type": "string",
                      "description": "Total amount due, including the printed currency symbol",
                  },
                  "billing_date": {
                      "type": "string",
                      "description": "Billing date normalized to ISO 8601 YYYY-MM-DD",
                  },
              },
              "required": ["account_holder_name", "service_address"],
              "additionalProperties": False,
          },
          "Bank Statement": {
              "type": "object",
              "properties": {
                  "account_holder_name": {
                      "type": "string",
                      "description": "Name of the account holder",
                  },
                  "address": {
                      "type": "string",
                      "description": "Complete mailing address of the account holder",
                  },
                  "bank_name": {
                      "type": "string",
                      "description": "Bank or financial institution name",
                  },
                  "account_number": {
                      "type": "string",
                      "description": "Account number or IBAN",
                  },
                  "statement_period": {
                      "type": "string",
                      "description": "Statement period exactly as printed",
                  },
                  "closing_balance": {
                      "type": "string",
                      "description": "Closing or ending balance exactly as printed",
                  },
              },
              "required": ["account_holder_name", "address"],
              "additionalProperties": False,
          },
      }

      NAME_FIELDS = {
          "Identity Document": "full_name",
          "Utility Bill": "account_holder_name",
          "Bank Statement": "account_holder_name",
      }
      ADDRESS_FIELDS = {
          "Utility Bill": "service_address",
          "Bank Statement": "address",
      }
      CRITICAL_FIELDS = {
          "Identity Document": {"full_name", "document_number", "date_of_birth", "date_of_expiry"},
          "Utility Bill": {"account_holder_name", "service_address"},
          "Bank Statement": {"account_holder_name", "address"},
      }


      def wait_for(job_type, job_id):
          path = "classify" if job_type == "classify" else "extract"
          for _ in range(120):
              response = requests.get(
                  f"{BASE_URL}/{path}/{job_id}",
                  headers={"api-key": API_KEY},
                  timeout=60,
              )
              response.raise_for_status()
              job = response.json()
              if job.get("status") in ("completed", "review"):
                  return job
              if job.get("status") == "failed":
                  raise RuntimeError(job.get("error") or job.get("message") or f"{job_type} failed")
              time.sleep(3)
          raise TimeoutError(f"{job_type} job {job_id} did not finish within six minutes")


      def upload(path, endpoint, file_field, data):
          mime_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
          with path.open("rb") as document:
              response = requests.post(
                  f"{BASE_URL}/{endpoint}",
                  headers={"api-key": API_KEY},
                  files={file_field: (path.name, document, mime_type)},
                  data=data,
                  timeout=90,
              )
          response.raise_for_status()
          body = response.json()
          if not body.get("job_id"):
              raise RuntimeError(f"{endpoint} returned no job_id: {body}")
          return body["job_id"]


      def confidence(field):
          score = field.get("score") or {}
          if isinstance(score, (int, float)):
              return float(score)
          available = [score.get("grounding_score"), score.get("extraction_score")]
          available = [float(value) for value in available if value is not None]
          return min(available) if available else 0.0


      def process_document(path):
          classify_id = upload(
              path,
              "classify",
              "pdf_file",
              {"categories": json.dumps(CATEGORIES)},
          )
          classification_job = wait_for("classify", classify_id)
          classification = classification_job["result"]
          document_type = classification["classification"]
          if document_type not in SCHEMAS:
              raise RuntimeError(f"{path.name} classified as {document_type}; no extraction schema applies")

          extract_id = upload(
              path,
              "v2/extract",
              "pdf_file",
              {
                  "schema_data": json.dumps(SCHEMAS[document_type]),
                  "schema_name": f"kyc-{document_type.lower().replace(' ', '-')}-v1",
                  "model": "gamma",
                  "enable_citations": "true",
              },
          )
          extraction_job = wait_for("extract", extract_id)

          fields = {}
          for name, field in extraction_job["result"].items():
              citation = field.get("citation") or {}
              fields[name] = {
                  "value": field.get("value"),
                  "confidence": confidence(field),
                  "page": citation.get("page"),
              }

          return {
              "file": path.name,
              "type": document_type,
              "classification_confidence": classification["confidence"],
              "fields": fields,
              "raw": {
                  "classification": classification_job,
                  "extraction": extraction_job,
              },
          }


      def normalized_tokens(value):
          if not value:
              return set()
          text = unicodedata.normalize("NFKD", str(value))
          text = "".join(character for character in text if not unicodedata.combining(character))
          text = re.sub(r"[^a-z0-9]+", " ", text.lower())
          return {token for token in text.split() if token}


      def token_match(left, right, threshold=0.8):
          left_tokens = normalized_tokens(left)
          right_tokens = normalized_tokens(right)
          if not left_tokens or not right_tokens:
              return None
          overlap = len(left_tokens & right_tokens) / min(len(left_tokens), len(right_tokens))
          return overlap >= threshold


      processed = []
      errors = []
      with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
          futures = {pool.submit(process_document, path): path for path in FILES}
          for future in concurrent.futures.as_completed(futures):
              path = futures[future]
              try:
                  processed.append(future.result())
              except Exception as error:
                  errors.append(f"{path.name}: {error}")

      with open("kyc-raw.json", "w") as output:
          json.dump({document["file"]: document["raw"] for document in processed}, output, indent=2)

      documents = {document["type"]: document for document in processed}
      checks = []

      named_documents = [doc_type for doc_type in NAME_FIELDS if doc_type in documents]
      for left_type, right_type in itertools.combinations(named_documents, 2):
          left = documents[left_type]["fields"][NAME_FIELDS[left_type]]["value"]
          right = documents[right_type]["fields"][NAME_FIELDS[right_type]]["value"]
          checks.append({
              "type": "Name",
              "documents": [left_type, right_type],
              "values": [left, right],
              "match": token_match(left, right),
          })

      addressed_documents = [doc_type for doc_type in ADDRESS_FIELDS if doc_type in documents]
      for left_type, right_type in itertools.combinations(addressed_documents, 2):
          left = documents[left_type]["fields"][ADDRESS_FIELDS[left_type]]["value"]
          right = documents[right_type]["fields"][ADDRESS_FIELDS[right_type]]["value"]
          checks.append({
              "type": "Address",
              "documents": [left_type, right_type],
              "values": [left, right],
              "match": token_match(left, right, threshold=0.7),
          })

      flags = list(errors)
      for document_type, document in documents.items():
          if document["classification_confidence"] < CONFIDENCE_THRESHOLD:
              flags.append(f"{document['file']}: low classification confidence")
          for field_name in CRITICAL_FIELDS[document_type]:
              field = document["fields"].get(field_name, {})
              if field.get("value") in (None, "", "None") or field.get("confidence", 0) == 0:
                  flags.append(f"{document_type}.{field_name}: unreadable or not grounded")
              elif field["confidence"] < CONFIDENCE_THRESHOLD:
                  flags.append(
                      f"{document_type}.{field_name}: confidence {field['confidence']:.3f} is below {CONFIDENCE_THRESHOLD}"
                  )

      expired = False
      identity = documents.get("Identity Document")
      if identity:
          expiry = identity["fields"].get("date_of_expiry", {}).get("value")
          try:
              expired = datetime.date.fromisoformat(expiry) < datetime.date.today()
          except (TypeError, ValueError):
              flags.append("Identity Document.date_of_expiry: missing or not valid ISO 8601")

      mismatch = any(check["match"] is False for check in checks)
      inconclusive = any(check["match"] is None for check in checks)
      if inconclusive:
          flags.append("At least one cross-document comparison was inconclusive")

      if expired or mismatch:
          decision = "FAIL"
          reason = "identity document expired" if expired else "name or address mismatch"
      elif flags:
          decision = "REVIEW"
          reason = "uncertain or unreadable data needs manual review"
      else:
          decision = "PASS"
          reason = "all documents are consistent and clearly read"

      result = {
          "decision": decision,
          "reason": reason,
          "checks": checks,
          "flags": flags,
          "documents": {
              document_type: {
                  "file": document["file"],
                  "classification_confidence": document["classification_confidence"],
                  "fields": document["fields"],
              }
              for document_type, document in documents.items()
          },
      }

      with open("kyc-result.json", "w") as output:
          json.dump(result, output, indent=2)

      print(json.dumps({
          "decision": result["decision"],
          "reason": result["reason"],
          "checks": result["checks"],
          "flags": result["flags"],
      }, indent=2))
      ```
    </Tab>

    <Tab title="JavaScript">
      Save this as `check-kyc.mjs`. It requires Node.js 18 or newer for the global `fetch`, `FormData`, and `Blob` APIs.

      ```javascript check-kyc.mjs theme={null}
      import fs from "node:fs";
      import path from "node:path";

      const API_KEY = process.env.UNSILOED_API_KEY;
      const BASE_URL = "https://prod.visionapi.unsiloed.ai";
      const CONFIDENCE_THRESHOLD = 0.85;

      if (!API_KEY) throw new Error("Set UNSILOED_API_KEY before running the script");

      const FILES = ["passport_specimen.jpg", "utility_bill.png", "bank_statement.png"];
      const CATEGORIES = [
        {
          name: "Identity Document",
          description: "A passport, national identity card, or driving licence that identifies a person",
        },
        {
          name: "Utility Bill",
          description: "An electricity, gas, water, internet, or municipal service bill showing a customer and service address",
        },
        {
          name: "Bank Statement",
          description: "An account statement from a bank showing an account holder, statement period, and transactions",
        },
        { name: "Other", description: "A document that does not match any of the other categories" },
      ];

      const SCHEMAS = {
        "Identity Document": {
          type: "object",
          properties: {
            full_name: {
              type: "string",
              description: "Full name as printed, combining given names and surname in natural reading order",
            },
            document_number: {
              type: "string",
              description: "Passport or identity document number. Return null if the complete number is obscured or illegible. Do not substitute a CAN number",
            },
            date_of_birth: {
              type: "string",
              description: "Date of birth normalized to ISO 8601 YYYY-MM-DD",
            },
            date_of_expiry: {
              type: "string",
              description: "Date of expiry normalized to ISO 8601 YYYY-MM-DD",
            },
            nationality: { type: "string", description: "Nationality as printed on the identity document" },
          },
          required: ["full_name", "date_of_birth", "date_of_expiry"],
          additionalProperties: false,
        },
        "Utility Bill": {
          type: "object",
          properties: {
            account_holder_name: { type: "string", description: "Name of the account holder or customer" },
            service_address: { type: "string", description: "Complete service or billing address" },
            provider: { type: "string", description: "Utility provider or company name" },
            account_number: { type: "string", description: "Utility account number" },
            amount_due: { type: "string", description: "Total amount due, including the printed currency symbol" },
            billing_date: { type: "string", description: "Billing date normalized to ISO 8601 YYYY-MM-DD" },
          },
          required: ["account_holder_name", "service_address"],
          additionalProperties: false,
        },
        "Bank Statement": {
          type: "object",
          properties: {
            account_holder_name: { type: "string", description: "Name of the account holder" },
            address: { type: "string", description: "Complete mailing address of the account holder" },
            bank_name: { type: "string", description: "Bank or financial institution name" },
            account_number: { type: "string", description: "Account number or IBAN" },
            statement_period: { type: "string", description: "Statement period exactly as printed" },
            closing_balance: { type: "string", description: "Closing or ending balance exactly as printed" },
          },
          required: ["account_holder_name", "address"],
          additionalProperties: false,
        },
      };

      const NAME_FIELDS = {
        "Identity Document": "full_name",
        "Utility Bill": "account_holder_name",
        "Bank Statement": "account_holder_name",
      };
      const ADDRESS_FIELDS = { "Utility Bill": "service_address", "Bank Statement": "address" };
      const CRITICAL_FIELDS = {
        "Identity Document": ["full_name", "document_number", "date_of_birth", "date_of_expiry"],
        "Utility Bill": ["account_holder_name", "service_address"],
        "Bank Statement": ["account_holder_name", "address"],
      };

      const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));

      async function waitFor(jobType, jobId) {
        const endpoint = jobType === "classify" ? "classify" : "extract";
        for (let attempt = 0; attempt < 120; attempt++) {
          const response = await fetch(`${BASE_URL}/${endpoint}/${jobId}`, {
            headers: { "api-key": API_KEY },
          });
          if (!response.ok) throw new Error(`${jobType} poll failed: HTTP ${response.status} ${await response.text()}`);
          const job = await response.json();
          if (["completed", "review"].includes(job.status)) return job;
          if (job.status === "failed") throw new Error(job.error || job.message || `${jobType} failed`);
          await sleep(3000);
        }
        throw new Error(`${jobType} job ${jobId} did not finish within six minutes`);
      }

      function mimeType(fileName) {
        const extension = path.extname(fileName).toLowerCase();
        if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
        if (extension === ".png") return "image/png";
        if (extension === ".pdf") return "application/pdf";
        return "application/octet-stream";
      }

      async function upload(fileName, endpoint, fileField, data) {
        const form = new FormData();
        form.append(
          fileField,
          new Blob([fs.readFileSync(fileName)], { type: mimeType(fileName) }),
          path.basename(fileName),
        );
        for (const [key, value] of Object.entries(data)) form.append(key, value);

        const response = await fetch(`${BASE_URL}/${endpoint}`, {
          method: "POST",
          headers: { "api-key": API_KEY },
          body: form,
        });
        if (!response.ok) throw new Error(`${endpoint} failed: HTTP ${response.status} ${await response.text()}`);
        const body = await response.json();
        if (!body.job_id) throw new Error(`${endpoint} returned no job_id: ${JSON.stringify(body)}`);
        return body.job_id;
      }

      function confidence(field) {
        const score = field.score || {};
        if (typeof score === "number") return score;
        const available = [score.grounding_score, score.extraction_score]
          .filter((value) => value != null)
          .map(Number);
        return available.length ? Math.min(...available) : 0;
      }

      async function processDocument(fileName) {
        const classifyId = await upload(fileName, "classify", "pdf_file", {
          categories: JSON.stringify(CATEGORIES),
        });
        const classificationJob = await waitFor("classify", classifyId);
        const classification = classificationJob.result;
        const documentType = classification.classification;
        if (!SCHEMAS[documentType]) {
          throw new Error(`${fileName} classified as ${documentType}; no extraction schema applies`);
        }

        const extractId = await upload(fileName, "v2/extract", "pdf_file", {
          schema_data: JSON.stringify(SCHEMAS[documentType]),
          schema_name: `kyc-${documentType.toLowerCase().replaceAll(" ", "-")}-v1`,
          model: "gamma",
          enable_citations: "true",
        });
        const extractionJob = await waitFor("extract", extractId);

        const fields = Object.fromEntries(
          Object.entries(extractionJob.result).map(([name, field]) => [name, {
            value: field.value,
            confidence: confidence(field),
            page: field.citation?.page ?? null,
          }]),
        );

        return {
          file: fileName,
          type: documentType,
          classification_confidence: classification.confidence,
          fields,
          raw: { classification: classificationJob, extraction: extractionJob },
        };
      }

      function normalizedTokens(value) {
        if (!value) return new Set();
        const normalized = String(value)
          .normalize("NFKD")
          .replace(/\p{Diacritic}/gu, "")
          .toLowerCase()
          .replace(/[^a-z0-9]+/g, " ")
          .trim();
        return new Set(normalized ? normalized.split(/\s+/) : []);
      }

      function tokenMatch(left, right, threshold = 0.8) {
        const leftTokens = normalizedTokens(left);
        const rightTokens = normalizedTokens(right);
        if (!leftTokens.size || !rightTokens.size) return null;
        const overlap = [...leftTokens].filter((token) => rightTokens.has(token)).length;
        return overlap / Math.min(leftTokens.size, rightTokens.size) >= threshold;
      }

      function pairs(values) {
        const output = [];
        for (let left = 0; left < values.length; left++) {
          for (let right = left + 1; right < values.length; right++) {
            output.push([values[left], values[right]]);
          }
        }
        return output;
      }

      const processed = await Promise.all(FILES.map(async (fileName) => {
        try {
          return await processDocument(fileName);
        } catch (error) {
          return { file: fileName, error: error.message };
        }
      }));

      const completed = processed.filter((document) => !document.error);
      fs.writeFileSync(
        "kyc-raw.json",
        JSON.stringify(Object.fromEntries(completed.map((document) => [document.file, document.raw])), null, 2),
      );

      const documents = Object.fromEntries(completed.map((document) => [document.type, document]));
      const checks = [];

      const namedDocuments = Object.keys(NAME_FIELDS).filter((documentType) => documents[documentType]);
      for (const [leftType, rightType] of pairs(namedDocuments)) {
        const left = documents[leftType].fields[NAME_FIELDS[leftType]].value;
        const right = documents[rightType].fields[NAME_FIELDS[rightType]].value;
        checks.push({
          type: "Name",
          documents: [leftType, rightType],
          values: [left, right],
          match: tokenMatch(left, right),
        });
      }

      const addressedDocuments = Object.keys(ADDRESS_FIELDS).filter((documentType) => documents[documentType]);
      for (const [leftType, rightType] of pairs(addressedDocuments)) {
        const left = documents[leftType].fields[ADDRESS_FIELDS[leftType]].value;
        const right = documents[rightType].fields[ADDRESS_FIELDS[rightType]].value;
        checks.push({
          type: "Address",
          documents: [leftType, rightType],
          values: [left, right],
          match: tokenMatch(left, right, 0.7),
        });
      }

      const flags = processed.filter((document) => document.error).map((document) => `${document.file}: ${document.error}`);
      for (const [documentType, document] of Object.entries(documents)) {
        if (document.classification_confidence < CONFIDENCE_THRESHOLD) {
          flags.push(`${document.file}: low classification confidence`);
        }
        for (const fieldName of CRITICAL_FIELDS[documentType]) {
          const field = document.fields[fieldName] || {};
          if (field.value == null || field.value === "" || field.value === "None" || !field.confidence) {
            flags.push(`${documentType}.${fieldName}: unreadable or not grounded`);
          } else if (field.confidence < CONFIDENCE_THRESHOLD) {
            flags.push(
              `${documentType}.${fieldName}: confidence ${field.confidence.toFixed(3)} is below ${CONFIDENCE_THRESHOLD}`,
            );
          }
        }
      }

      let expired = false;
      const expiry = documents["Identity Document"]?.fields.date_of_expiry?.value;
      if (expiry) {
        const expiryDate = new Date(`${expiry}T00:00:00Z`);
        if (Number.isNaN(expiryDate.getTime())) {
          flags.push("Identity Document.date_of_expiry: missing or not valid ISO 8601");
        } else {
          expired = expiryDate < new Date();
        }
      } else if (documents["Identity Document"]) {
        flags.push("Identity Document.date_of_expiry: missing or not valid ISO 8601");
      }

      const mismatch = checks.some((check) => check.match === false);
      const inconclusive = checks.some((check) => check.match == null);
      if (inconclusive) flags.push("At least one cross-document comparison was inconclusive");

      let decision;
      let reason;
      if (expired || mismatch) {
        decision = "FAIL";
        reason = expired ? "identity document expired" : "name or address mismatch";
      } else if (flags.length) {
        decision = "REVIEW";
        reason = "uncertain or unreadable data needs manual review";
      } else {
        decision = "PASS";
        reason = "all documents are consistent and clearly read";
      }

      const result = {
        decision,
        reason,
        checks,
        flags,
        documents: Object.fromEntries(Object.entries(documents).map(([documentType, document]) => [
          documentType,
          {
            file: document.file,
            classification_confidence: document.classification_confidence,
            fields: document.fields,
          },
        ])),
      };

      fs.writeFileSync("kyc-result.json", JSON.stringify(result, null, 2));
      console.log(JSON.stringify({ decision, reason, checks, flags }, null, 2));
      ```
    </Tab>
  </Tabs>
</Accordion>

## Step 1: Set Up the Sample Documents

Before writing code, we'll download the tested sample set and configure the runtime.

### 1.1 Get an Unsiloed API Key

Get an API key from the [Unsiloed dashboard](https://app.unsiloed.ai), then export it as an environment variable:

```bash theme={null}
export UNSILOED_API_KEY="your-api-key"
```

### 1.2 Download the Fictional Onboarding Set

Create a new directory, then download the three files from the Unsiloed cookbook repository:

```bash theme={null}
curl -LO https://raw.githubusercontent.com/Unsiloed-AI/cookbook/b1d9c38fa5125eb148e14849d4c26d9845b31a97/kyc-app/samples/passport_specimen.jpg
curl -LO https://raw.githubusercontent.com/Unsiloed-AI/cookbook/b1d9c38fa5125eb148e14849d4c26d9845b31a97/kyc-app/samples/utility_bill.png
curl -LO https://raw.githubusercontent.com/Unsiloed-AI/cookbook/b1d9c38fa5125eb148e14849d4c26d9845b31a97/kyc-app/samples/bank_statement.png
```

The passport is a fictional Luxembourg government specimen for Ketty Maus. Some secure fields, including the complete passport number, are intentionally obscured. The utility bill and bank statement are synthetic documents created for this example with the same name and address.

### 1.3 Install the Python Dependency

The JavaScript version uses only Node.js built-ins. For Python, install `requests`:

```bash theme={null}
pip install requests
```

## Step 2: Classify Each Document Before Extraction

An intake folder doesn't reliably tell us which file is an ID or proof of address. We classify first, then use the result to select the right extraction schema.

Define mutually exclusive categories, including an `Other` fallback:

```json theme={null}
[
  {
    "name": "Identity Document",
    "description": "A passport, national identity card, or driving licence that identifies a person"
  },
  {
    "name": "Utility Bill",
    "description": "A utility bill showing a customer and service address"
  },
  {
    "name": "Bank Statement",
    "description": "A bank statement showing an account holder, period, and transactions"
  },
  {
    "name": "Other",
    "description": "A document that does not match any other category"
  }
]
```

The scripts submit all three files concurrently. In a verified run, the API classified every sample correctly with confidence above `0.9999999`.

## Step 3: Extract Fields With a Schema per Type

Each document answers different questions. A passport has a date of birth and expiry date; a utility bill has a service address; a bank statement has an account holder and statement period. A single catch-all schema would ask documents for fields they can't contain.

The scripts keep one schema per classification result and enable citations on every extraction:

```text theme={null}
Identity Document -> name, document number, birth date, expiry date, nationality
Utility Bill      -> account holder, service address, provider, account, amount, billing date
Bank Statement    -> account holder, address, bank, account, period, closing balance
```

Date descriptions request ISO 8601 output. The passport prints `16 02 2036`; extraction returns `2036-02-16`, which Python and JavaScript can compare without a list of locale-specific date formats.

We leave `document_number` out of the schema's `required` array. The sample's complete passport number isn't visible, so requiring it could pressure the extraction toward a guess. It remains a critical field in the decision rules, where a `null` value correctly triggers review.

## Step 4: Compare Confidence, Names, Addresses, and Expiry

The decision engine uses only deterministic code. The model reads each document, but it doesn't decide whether the person passes onboarding.

### 4.1 Gate Critical Fields by Confidence

Each extracted field has a grounding score and an extraction score. The scripts use the lower available value:

```text theme={null}
confidence = min(grounding_score, extraction_score)
```

This prevents a value with strong extraction confidence but weak source grounding from passing automatically. A critical field becomes a review item when it is missing, ungrounded, or below the example threshold of `0.85`.

<Note>
  Start with `0.85` for this example, then calibrate the threshold against documents your team has already reviewed. It isn't a universal compliance threshold.
</Note>

### 4.2 Normalize Before Comparing

Names and addresses rarely use identical punctuation and spacing. The scripts lowercase the values, remove punctuation and diacritics, split them into tokens, and compare token overlap.

For the sample, these two addresses normalize to the same token set:

```text theme={null}
12, Rue de la Gare
L-1611 Luxembourg

12, Rue de la Gare, L-1611 Luxembourg
```

This is intentionally conservative. Production identity matching may need locale-specific address parsing, transliteration, aliases, and approved name-change handling.

### 4.3 Check the Expiry Date in Code

The ISO date lets us compare the identity document against today's date directly. An expired identity document returns `FAIL`; a missing or malformed expiry date returns `REVIEW`.

## Step 5: Return a Review Decision With Evidence

Run the completed script for your runtime:

<Tabs>
  <Tab title="Python">
    Save the Python script from the full-script accordion as `check_kyc.py`, then run:

    ```bash theme={null}
    python check_kyc.py
    ```
  </Tab>

  <Tab title="JavaScript">
    Save the JavaScript script from the full-script accordion as `check-kyc.mjs`, then run:

    ```bash theme={null}
    node check-kyc.mjs
    ```
  </Tab>
</Tabs>

Both implementations write two files:

* `kyc-raw.json` preserves the complete classification and extraction responses, including job IDs and citations.
* `kyc-result.json` contains the normalized decision payload for the onboarding system.

### Sample Output

The live API classified every sample correctly and extracted all visible critical values. Every name comparison passed, and the utility-bill address matched the bank-statement address. The complete passport number is obscured, so the result stops for review:

```json theme={null}
{
  "decision": "REVIEW",
  "reason": "uncertain or unreadable data needs manual review",
  "checks": [
    {
      "type": "Name",
      "documents": ["Identity Document", "Utility Bill"],
      "values": ["Ketty Maus", "Ketty Maus"],
      "match": true
    },
    {
      "type": "Name",
      "documents": ["Identity Document", "Bank Statement"],
      "values": ["Ketty Maus", "Ketty Maus"],
      "match": true
    },
    {
      "type": "Name",
      "documents": ["Utility Bill", "Bank Statement"],
      "values": ["Ketty Maus", "Ketty Maus"],
      "match": true
    },
    {
      "type": "Address",
      "documents": ["Utility Bill", "Bank Statement"],
      "values": [
        "12, Rue de la Gare\nL-1611 Luxembourg",
        "12, Rue de la Gare\nL-1611 Luxembourg"
      ],
      "match": true
    }
  ],
  "flags": [
    "Identity Document.document_number: unreadable or not grounded"
  ]
}
```

The passport-number extraction itself carries the evidence for that decision:

```json theme={null}
{
  "value": null,
  "score": {
    "grounding_score": 0.0,
    "extraction_score": 0.0
  },
  "citation": null
}
```

The workflow doesn't turn missing evidence into a guessed value. A reviewer can request a clearer image or a different identity document before the onboarding process continues.

## Where to Take This Next

For a production onboarding service, add your organization's policy checks around this core pipeline.

Useful extensions include:

* Checking whether proof-of-address documents are recent enough for your policy.
* Comparing a selfie against the identity-document portrait with an approved identity provider.
* Sending review items to an evidence viewer that draws each citation box on the source document.
* Recording reviewer corrections separately from the raw API response.
* Encrypting documents and deleting stored artifacts according to your retention policy.

<CardGroup cols={2}>
  <Card title="Classification" icon="tags" href="/docs/document-processing/classification/classification">
    Define document categories and route each file to the right workflow.
  </Card>

  <Card title="Extraction Schemas" icon="brackets-curly" href="/docs/document-processing/extraction/schemas">
    Add or refine fields for the identity documents your onboarding policy accepts.
  </Card>

  <Card title="Extraction Response Format" icon="code" href="/docs/document-processing/extraction/response-format">
    Inspect value, confidence, citation, and job metadata fields.
  </Card>
</CardGroup>
