LLM Guides

How to Parse Bank Checks Using JavaScript

Jul 21, 2026

Bank checks look simple until you try to parse them with code.

At first, it feels like a normal OCR task. Take an image, read the text, return JSON. Done.

Then the check shows up slightly tilted. The handwriting is messy. The amount box says $1,250.00, but the written line says “One thousand two hundred fifty and 00/100.” The MICR line has special characters. The memo is half cut off. The photo has a shadow. The bank logo gets picked up as random text. Someone uploads a mobile screenshot instead of a clean scan.

So yes, JavaScript can parse bank checks.

But a good check parser needs more than raw OCR. It needs field extraction, validation, confidence scores, and review logic.

In this guide, we’ll build a practical JavaScript workflow for parsing bank checks. We’ll cover what data to extract, how to upload a check image to a parser API, how to validate the result, how to handle low-confidence fields, and how to use LLMAPI after parsing for summaries, review notes, and workflow automation.

What does bank check parsing mean?

Bank check parsing means converting a scanned or photographed check into structured data.

Input:

check_front.jpg

Output:

{
  "check_number": "1042",
  "date": "2026-07-29",
  "payee": "Acme Supplies LLC",
  "amount_numeric": 1250.00,
  "amount_written": "One thousand two hundred fifty and 00/100",
  "payer_name": "Jordan Lee",
  "bank_name": "Example Bank",
  "memo": "July invoice",
  "routing_number": "071000013",
  "account_number": "123456789",
  "micr_line": "⑆071000013⑆ 123456789⑈ 1042"
}

That structured output can feed finance apps, accounting tools, deposit workflows, audit systems, payment review queues, and internal automation.

A proper parser should also return confidence and warnings:

{
  "amount_numeric": {
    "value": 1250.00,
    "confidence": 0.98
  },
  "amount_written": {
    "value": "One thousand two hundred fifty and 00/100",
    "confidence": 0.74
  },
  "warnings": [
    "Written amount confidence is below review threshold."
  ]
}

That review layer matters because checks involve money.

Why we can write this guide

We’ve spent around 6 years working with AI APIs, OCR, document parsing, finance workflows, extraction pipelines, and developer tutorials. We also checked current check extraction docs and document AI references while preparing this guide.

The practical lesson is simple: check parsing should be schema-first.

A check parser should produce predictable fields, validate the money fields, flag uncertain values, and preserve the original image for review. Raw OCR text is useful, but structured JSON is what your app can actually use.

Microsoft’s current Document Intelligence bank check model describes check extraction as OCR plus deep learning for US bank checks, returning structured JSON. DocuClipper’s Check OCR API docs describe check extraction fields like check number, date, amount, payee, memo, MICR routing, and bank. Those are the kinds of fields we want our JavaScript workflow to handle.

What fields should a check parser extract?

A bank check parser usually needs these fields.

FieldWhy it matters
Check numberIdentifies the check
DatePayment date
PayeeWho receives the money
Numeric amountAmount box value
Written amountLegal amount line
Payer nameAccount holder or sender
Bank nameIssuing bank
Bank addressSometimes needed for review
MemoOptional payment note
Signature presenceUseful for review
Routing numberBank routing identifier
Account numberPayer account identifier
MICR lineMachine-readable check line
Confidence scoresReview routing
Bounding boxesUI highlighting and audit review

The two most important validation fields are usually:

  1. Numeric amount.
  2. Written amount.

If they do not match, your app should flag the check.

The MICR line also needs careful handling because routing and account numbers are sensitive financial data.

The safest architecture

For most products, use a server-side parser workflow.

Do not send sensitive check images directly from the browser to a random third-party API if your security model does not allow it.

A safer architecture looks like this:

browser upload
→ your backend
→ secure file validation
→ check parser API
→ normalized JSON
→ validation rules
→ review queue or app workflow

Why backend-first?

  1. API keys stay private.
  2. File size limits are easier to control.
  3. You can scan and validate uploads.
  4. You can redact logs.
  5. You can store audit metadata.
  6. You can apply business validation before returning data.
  7. You can control retention and deletion.

Checks contain financial information, so treat them like sensitive documents.

Step 1: Create a Node.js project

Start with a simple Node.js setup.

mkdir check-parser-js
cd check-parser-js
npm init -y

Install the packages:

npm install express multer axios dotenv

We’ll use:

PackageWhy
expressSmall backend API
multerFile uploads
axiosHTTP requests to parser APIs
dotenvEnvironment variables

Create a .env file:

CHECK_PARSER_API_KEY=your_parser_api_key_here
CHECK_PARSER_ENDPOINT=https://example-parser-api.com/v1/checks/parse
LLMAPI_API_KEY=your_llmapi_key_here
LLMAPI_BASE_URL=https://api.llmapi.ai/v1

Replace the parser endpoint with the provider you use.

For example, you may use Azure Document Intelligence, DocuClipper, or another check OCR provider. The wrapper pattern stays similar even when the endpoint changes.

Step 2: Build a file upload endpoint

Create server.js:

import express from "express";
import multer from "multer";
import dotenv from "dotenv";
import fs from "fs/promises";

dotenv.config();

const app = express();
const upload = multer({
  dest: "uploads/",
  limits: {
    fileSize: 10 * 1024 * 1024
  }
});

app.post("/parse-check", upload.single("check"), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({
        error: "Check image is required."
      });
    }

    return res.json({
      message: "File uploaded successfully.",
      file: {
        originalName: req.file.originalname,
        path: req.file.path,
        mimetype: req.file.mimetype,
        size: req.file.size
      }
    });
  } catch (error) {
    return res.status(500).json({
      error: "Upload failed.",
      details: error.message
    });
  }
});

app.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

Because we are using ES modules, add this to package.json:

{
  "type": "module"
}

Run:

node server.js

Test with cURL:

curl -X POST http://localhost:3000/parse-check \
  -F "[email protected]"

Now we can receive a check image.

Step 3: Validate uploaded files

Before sending the file to a parser API, validate it.

Checks may arrive as JPG, PNG, PDF, TIFF, or WEBP depending on your app. Start narrow and expand when needed.

const allowedMimeTypes = new Set([
  "image/jpeg",
  "image/png",
  "image/webp",
  "application/pdf"
]);

function validateUploadedFile(file) {
  const errors = [];

  if (!file) {
    errors.push("File is missing.");
    return errors;
  }

  if (!allowedMimeTypes.has(file.mimetype)) {
    errors.push(`Unsupported file type: ${file.mimetype}`);
  }

  if (file.size > 10 * 1024 * 1024) {
    errors.push("File is larger than 10MB.");
  }

  return errors;
}

Use it inside the route:

const validationErrors = validateUploadedFile(req.file);

if (validationErrors.length > 0) {
  return res.status(400).json({
    error: "Invalid file.",
    details: validationErrors
  });
}

File validation is boring, but it saves pain later.

Step 4: Send the check to a parser API

Now let’s create a reusable parser function.

This example uses axios and multipart form data. Different providers may require different request shapes, but the idea is the same.

Install FormData support:

npm install form-data

Create checkParser.js:

import axios from "axios";
import fs from "fs";
import FormData from "form-data";

export async function parseCheckWithApi(filePath, originalName) {
  const form = new FormData();

  form.append("file", fs.createReadStream(filePath), originalName);

  const response = await axios.post(
    process.env.CHECK_PARSER_ENDPOINT,
    form,
    {
      headers: {
        ...form.getHeaders(),
        Authorization: `Bearer ${process.env.CHECK_PARSER_API_KEY}`
      },
      timeout: 60_000
    }
  );

  return response.data;
}

Then update server.js:

import express from "express";
import multer from "multer";
import dotenv from "dotenv";
import fs from "fs/promises";
import { parseCheckWithApi } from "./checkParser.js";

dotenv.config();

const app = express();

const upload = multer({
  dest: "uploads/",
  limits: {
    fileSize: 10 * 1024 * 1024
  }
});

const allowedMimeTypes = new Set([
  "image/jpeg",
  "image/png",
  "image/webp",
  "application/pdf"
]);

function validateUploadedFile(file) {
  const errors = [];

  if (!file) {
    errors.push("File is missing.");
    return errors;
  }

  if (!allowedMimeTypes.has(file.mimetype)) {
    errors.push(`Unsupported file type: ${file.mimetype}`);
  }

  if (file.size > 10 * 1024 * 1024) {
    errors.push("File is larger than 10MB.");
  }

  return errors;
}

app.post("/parse-check", upload.single("check"), async (req, res) => {
  try {
    const validationErrors = validateUploadedFile(req.file);

    if (validationErrors.length > 0) {
      return res.status(400).json({
        error: "Invalid file.",
        details: validationErrors
      });
    }

    const rawParserResult = await parseCheckWithApi(
      req.file.path,
      req.file.originalname
    );

    await fs.unlink(req.file.path);

    return res.json({
      raw: rawParserResult
    });
  } catch (error) {
    if (req.file?.path) {
      await fs.unlink(req.file.path).catch(() => {});
    }

    return res.status(500).json({
      error: "Check parsing failed.",
      details: error.message
    });
  }
});

app.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

Now your backend can upload a check, send it to a parser API, and return the result.

Step 5: Normalize parser output

Every provider returns a different JSON shape.

Your app should convert provider output into one internal schema.

That internal schema might look like this:

{
  "check_number": null,
  "date": null,
  "payee": null,
  "amount_numeric": null,
  "amount_written": null,
  "payer_name": null,
  "bank_name": null,
  "memo": null,
  "routing_number": null,
  "account_number": null,
  "micr_line": null,
  "confidence": {},
  "warnings": []
}

Create normalizeCheck.js:

export function normalizeCheckResult(raw) {
  return {
    check_number: pickValue(raw, [
      "check_number",
      "checkNumber",
      "fields.checkNumber.value",
      "documents.0.fields.CheckNumber.valueString"
    ]),
    date: pickValue(raw, [
      "date",
      "fields.date.value",
      "documents.0.fields.Date.valueDate"
    ]),
    payee: pickValue(raw, [
      "payee",
      "payee_name",
      "fields.payee.value",
      "documents.0.fields.Payee.valueString"
    ]),
    amount_numeric: pickValue(raw, [
      "amount",
      "amount_numeric",
      "fields.amount.value",
      "documents.0.fields.Amount.valueNumber"
    ]),
    amount_written: pickValue(raw, [
      "amount_written",
      "written_amount",
      "fields.writtenAmount.value",
      "documents.0.fields.WrittenAmount.valueString"
    ]),
    payer_name: pickValue(raw, [
      "payer",
      "payer_name",
      "fields.payer.value",
      "documents.0.fields.Payer.valueString"
    ]),
    bank_name: pickValue(raw, [
      "bank",
      "bank_name",
      "fields.bankName.value",
      "documents.0.fields.BankName.valueString"
    ]),
    memo: pickValue(raw, [
      "memo",
      "fields.memo.value",
      "documents.0.fields.Memo.valueString"
    ]),
    routing_number: pickValue(raw, [
      "routing_number",
      "routingNumber",
      "micr.routingNumber",
      "documents.0.fields.RoutingNumber.valueString"
    ]),
    account_number: pickValue(raw, [
      "account_number",
      "accountNumber",
      "micr.accountNumber",
      "documents.0.fields.AccountNumber.valueString"
    ]),
    micr_line: pickValue(raw, [
      "micr_line",
      "micr.raw",
      "fields.micr.value",
      "documents.0.fields.MICR.valueString"
    ]),
    confidence: extractConfidence(raw),
    warnings: []
  };
}

function pickValue(object, paths) {
  for (const path of paths) {
    const value = getPath(object, path);

    if (value !== undefined && value !== null && value !== "") {
      return value;
    }
  }

  return null;
}

function getPath(object, path) {
  return path.split(".").reduce((current, key) => {
    if (current === undefined || current === null) {
      return undefined;
    }

    return current[key];
  }, object);
}

function extractConfidence(raw) {
  return {
    check_number: pickValue(raw, [
      "confidence.check_number",
      "fields.checkNumber.confidence",
      "documents.0.fields.CheckNumber.confidence"
    ]),
    date: pickValue(raw, [
      "confidence.date",
      "fields.date.confidence",
      "documents.0.fields.Date.confidence"
    ]),
    payee: pickValue(raw, [
      "confidence.payee",
      "fields.payee.confidence",
      "documents.0.fields.Payee.confidence"
    ]),
    amount_numeric: pickValue(raw, [
      "confidence.amount",
      "fields.amount.confidence",
      "documents.0.fields.Amount.confidence"
    ]),
    amount_written: pickValue(raw, [
      "confidence.amount_written",
      "fields.writtenAmount.confidence",
      "documents.0.fields.WrittenAmount.confidence"
    ])
  };
}

This is intentionally flexible because provider schemas vary.

Once you choose one provider, simplify the mapping to match that provider exactly.

Step 6: Validate the parsed check

Parsing gives you fields.

Validation tells you whether those fields are usable.

Create validateCheck.js:

export function validateParsedCheck(check) {
  const warnings = [];
  const errors = [];

  if (!check.payee) {
    warnings.push("Payee was not detected.");
  }

  if (!check.date) {
    warnings.push("Date was not detected.");
  }

  if (!check.amount_numeric) {
    errors.push("Numeric amount was not detected.");
  }

  if (!check.routing_number) {
    warnings.push("Routing number was not detected.");
  } else if (!isValidRoutingNumber(check.routing_number)) {
    errors.push("Routing number failed checksum validation.");
  }

  if (!check.account_number) {
    warnings.push("Account number was not detected.");
  }

  if (check.confidence?.amount_numeric < 0.8) {
    warnings.push("Numeric amount confidence is below 0.80.");
  }

  if (check.confidence?.amount_written < 0.8) {
    warnings.push("Written amount confidence is below 0.80.");
  }

  return {
    valid: errors.length === 0,
    review_required: errors.length > 0 || warnings.length > 0,
    errors,
    warnings
  };
}

export function isValidRoutingNumber(routingNumber) {
  const digits = String(routingNumber).replace(/\D/g, "");

  if (!/^\d{9}$/.test(digits)) {
    return false;
  }

  const weights = [3, 7, 1, 3, 7, 1, 3, 7, 1];

  const sum = digits
    .split("")
    .map(Number)
    .reduce((total, digit, index) => {
      return total + digit * weights[index];
    }, 0);

  return sum % 10 === 0;
}

Routing number checksum validation is a simple but useful check.

It helps catch OCR mistakes like:

0 read as 8
1 read as 7
3 read as 8

Use validation in server.js:

import { normalizeCheckResult } from "./normalizeCheck.js";
import { validateParsedCheck } from "./validateCheck.js";

Then inside the route:

const rawParserResult = await parseCheckWithApi(
  req.file.path,
  req.file.originalname
);

const normalizedCheck = normalizeCheckResult(rawParserResult);
const validation = validateParsedCheck(normalizedCheck);

return res.json({
  check: {
    ...normalizedCheck,
    warnings: validation.warnings
  },
  validation,
  raw: rawParserResult
});

Now your API returns normalized fields plus validation.

Step 7: Compare numeric and written amounts

For checks, the amount is especially important.

A serious parser should compare:

$1,250.00

with:

One thousand two hundred fifty and 00/100

Full written-number parsing can get complicated, but we can start with a basic helper or use an npm package.

Install:

npm install words-to-numbers

Create amountValidation.js:

import wordsToNumbers from "words-to-numbers";

export function parseMoneyAmount(value) {
  if (value === null || value === undefined) {
    return null;
  }

  const cleaned = String(value)
    .replace(/[$,]/g, "")
    .trim();

  const number = Number.parseFloat(cleaned);

  if (Number.isNaN(number)) {
    return null;
  }

  return Number(number.toFixed(2));
}

export function parseWrittenAmount(text) {
  if (!text) {
    return null;
  }

  const normalized = String(text)
    .toLowerCase()
    .replace(/dollars?/g, "")
    .replace(/and/g, " ")
    .replace(/only/g, " ")
    .trim();

  const fractionMatch = normalized.match(/(\d{1,2})\s*\/\s*100/);
  const cents = fractionMatch ? Number(fractionMatch[1]) / 100 : 0;

  const withoutFraction = normalized.replace(/\d{1,2}\s*\/\s*100/, "").trim();
  const dollars = wordsToNumbers(withoutFraction);

  if (typeof dollars !== "number" || Number.isNaN(dollars)) {
    return null;
  }

  return Number((dollars + cents).toFixed(2));
}

export function compareAmounts(numericAmount, writtenAmount) {
  const numeric = parseMoneyAmount(numericAmount);
  const written = parseWrittenAmount(writtenAmount);

  if (numeric === null || written === null) {
    return {
      comparable: false,
      match: false,
      numeric,
      written,
      warning: "Could not compare numeric and written amounts."
    };
  }

  return {
    comparable: true,
    match: numeric === written,
    numeric,
    written,
    warning: numeric === written
      ? null
      : "Numeric amount and written amount do not match."
  };
}

Then add it to validation:

import { compareAmounts } from "./amountValidation.js";

export function validateParsedCheck(check) {
  const warnings = [];
  const errors = [];

  if (!check.amount_numeric) {
    errors.push("Numeric amount was not detected.");
  }

  const amountComparison = compareAmounts(
    check.amount_numeric,
    check.amount_written
  );

  if (amountComparison.warning) {
    warnings.push(amountComparison.warning);
  }

  if (amountComparison.comparable && !amountComparison.match) {
    errors.push("Amount mismatch requires manual review.");
  }

  return {
    valid: errors.length === 0,
    review_required: errors.length > 0 || warnings.length > 0,
    errors,
    warnings,
    amount_comparison: amountComparison
  };
}

This gives your app a proper review trigger.

Step 8: Protect sensitive fields

Checks contain bank account data.

That means logs should never casually print the full account number.

Create a redaction helper:

export function redactAccountNumber(accountNumber) {
  if (!accountNumber) {
    return null;
  }

  const digits = String(accountNumber).replace(/\D/g, "");

  if (digits.length <= 4) {
    return "****";
  }

  return `${"*".repeat(Math.max(digits.length - 4, 4))}${digits.slice(-4)}`;
}

export function redactParsedCheck(check) {
  return {
    ...check,
    account_number: redactAccountNumber(check.account_number),
    micr_line: check.micr_line ? "[REDACTED_MICR_LINE]" : null
  };
}

Use redaction in responses when full account data is not required:

import { redactParsedCheck } from "./redactCheck.js";

return res.json({
  check: redactParsedCheck(normalizedCheck),
  validation,
  raw: undefined
});

For internal systems, store sensitive data only when required. Encrypt it. Limit access. Add audit logs.

Step 9: Add a review status

Your parser should produce a workflow status.

For example:

export function getCheckReviewStatus(validation) {
  if (!validation.valid) {
    return "manual_review_required";
  }

  if (validation.warnings.length > 0) {
    return "review_recommended";
  }

  return "auto_approved";
}

Use it:

const reviewStatus = getCheckReviewStatus(validation);

return res.json({
  check: redactParsedCheck(normalizedCheck),
  validation,
  review_status: reviewStatus
});

Example output:

{
  "check": {
    "check_number": "1042",
    "date": "2026-07-29",
    "payee": "Acme Supplies LLC",
    "amount_numeric": 1250,
    "amount_written": "One thousand two hundred fifty and 00/100",
    "routing_number": "071000013",
    "account_number": "*****6789",
    "micr_line": "[REDACTED_MICR_LINE]"
  },
  "validation": {
    "valid": true,
    "review_required": false,
    "errors": [],
    "warnings": []
  },
  "review_status": "auto_approved"
}

This is much more useful than plain OCR text.

Step 10: Use Azure Document Intelligence for bank checks

If you want a provider with a dedicated prebuilt bank check model, Azure Document Intelligence is one option.

Microsoft’s bank check model supports US bank checks and returns structured JSON from printed checks. The current docs describe it as OCR plus deep learning for extracting key information from US checks.

The JavaScript shape for Azure Document Intelligence usually looks like this:

npm install @azure-rest/ai-document-intelligence @azure/core-auth dotenv

Example pattern:

import DocumentIntelligence, {
  getLongRunningPoller,
  isUnexpected
} from "@azure-rest/ai-document-intelligence";
import { AzureKeyCredential } from "@azure/core-auth";
import fs from "fs";
import dotenv from "dotenv";

dotenv.config();

const endpoint = process.env.AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT;
const key = process.env.AZURE_DOCUMENT_INTELLIGENCE_KEY;

const client = DocumentIntelligence(
  endpoint,
  new AzureKeyCredential(key)
);

export async function parseCheckWithAzure(filePath) {
  const fileBuffer = fs.readFileSync(filePath);

  const initialResponse = await client
    .path("/documentModels/{modelId}:analyze", "prebuilt-bankCheck.us")
    .post({
      contentType: "application/octet-stream",
      body: fileBuffer
    });

  if (isUnexpected(initialResponse)) {
    throw new Error(
      `Azure Document Intelligence failed: ${initialResponse.body.error.message}`
    );
  }

  const poller = getLongRunningPoller(client, initialResponse);
  const result = await poller.pollUntilDone();

  return result.body.analyzeResult;
}

Check the current Azure docs before shipping because SDK versions and model IDs can change.

After Azure returns the result, still normalize it into your internal schema. Your app should not be tightly coupled to one provider’s raw response shape.

Step 11: Use DocuClipper-style check OCR APIs

Another option is a check-specific OCR API such as DocuClipper.

DocuClipper’s Check OCR API docs describe extracting fields like:

  1. Check number.
  2. Date.
  3. Amount.
  4. Payee.
  5. Memo.
  6. MICR routing.
  7. Bank.

That is close to the schema most apps need.

The JavaScript call pattern is usually:

import axios from "axios";
import fs from "fs";
import FormData from "form-data";

export async function parseCheckWithDocuClipper(filePath) {
  const form = new FormData();

  form.append("file", fs.createReadStream(filePath));

  const response = await axios.post(
    "https://api.docuclipper.com/api/v1/checks",
    form,
    {
      headers: {
        ...form.getHeaders(),
        Authorization: `Bearer ${process.env.DOCUCLIPPER_API_KEY}`
      }
    }
  );

  return response.data;
}

Treat this as a pattern, then adjust the URL and payload to the provider’s exact documentation.

The important part is the same:

provider response → normalize → validate → redact → review status

Step 12: Add LLMAPI for review notes and automation

A check parser should handle extraction and validation.

LLMAPI can help after that.

For example, if validation finds issues:

{
  "errors": [
    "Routing number failed checksum validation."
  ],
  "warnings": [
    "Written amount confidence is below 0.80."
  ]
}

LLMAPI can turn that into a clear internal note:

This check should go to manual review. The routing number did not pass checksum validation, and the written amount was extracted with low confidence. Ask the reviewer to confirm the MICR line and amount line from the original image.

That is useful for finance teams, support agents, reviewers, or workflow dashboards.

Install OpenAI-compatible client support:

npm install openai

Create llmapiReview.js:

import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.LLMAPI_API_KEY,
  baseURL: process.env.LLMAPI_BASE_URL
});

export async function createCheckReviewNote({ check, validation }) {
  const response = await client.chat.completions.create({
    model: "gpt-5.6-luna",
    messages: [
      {
        role: "system",
        content: "You write concise internal finance review notes. Do not invent facts."
      },
      {
        role: "user",
        content: JSON.stringify({
          parsed_check: check,
          validation
        })
      }
    ]
  });

  return response.choices[0].message.content;
}

Use it only when review is needed:

let reviewNote = null;

if (validation.review_required) {
  reviewNote = await createCheckReviewNote({
    check: redactParsedCheck(normalizedCheck),
    validation
  });
}

return res.json({
  check: redactParsedCheck(normalizedCheck),
  validation,
  review_status: getCheckReviewStatus(validation),
  review_note: reviewNote
});

This keeps the LLM in the right place.

The parser extracts fields. The validation code checks business rules. LLMAPI writes a human-friendly explanation or routes the next action.

What LLMAPI can do after check parsing

LLMAPI is useful when parsed check data needs to become a workflow.

TaskExample
Review noteExplain why a check needs manual review
Customer messageDraft a careful message asking for a clearer image
Internal summarySummarize check details for finance ops
Exception routingRoute failed checks to the right queue
Audit narrativeExplain validation errors in plain language
Data cleanupNormalize memo or payee names
Fraud triage supportSummarize suspicious signals without making final decisions
Batch reportsSummarize failed checks by reason
Support automationCreate a support ticket from parser output

A practical workflow:

check image → parser API → normalized JSON → validation → LLMAPI note/routing → human review if needed

That gives you extraction plus operational clarity.

How to handle handwritten checks

Handwritten checks are harder than printed checks.

Common problems:

  1. Messy payee names.
  2. Ambiguous numbers.
  3. Written amount variation.
  4. Signature overlap.
  5. Slanted writing.
  6. Crossed-out values.
  7. Low image quality.
  8. Missing fields.
  9. Dark backgrounds.
  10. Camera glare.

For handwritten checks, add stricter review rules.

Example:

function requireReviewForHandwrittenCheck(check, validation) {
  const lowConfidenceFields = Object.entries(check.confidence || {})
    .filter(([, confidence]) => {
      return typeof confidence === "number" && confidence < 0.85;
    })
    .map(([field]) => field);

  if (lowConfidenceFields.length > 0) {
    return {
      review_required: true,
      reason: `Low confidence fields: ${lowConfidenceFields.join(", ")}`
    };
  }

  return {
    review_required: validation.review_required,
    reason: null
  };
}

For money movement, low-confidence fields deserve review.

How to handle check images from mobile uploads

Mobile uploads need image quality checks.

A check image should ideally be:

  1. Flat.
  2. Well-lit.
  3. Sharp.
  4. Fully visible.
  5. Uncropped.
  6. Without glare.
  7. High enough resolution.
  8. Taken straight-on.

Your frontend can help by guiding users.

Example upload instructions:

Place the check on a dark flat surface.

Make sure all four corners are visible.

Avoid glare and shadows.

Take the photo straight from above.

Your backend can also reject bad uploads when the parser returns low confidence.

Example response:

{

  “review_status”: “needs_better_image”,

  “message”: “We could not read the amount and MICR line clearly. Please upload a sharper image with all four corners visible.”

}

This saves reviewers from fighting terrible images all day.

What should production output look like?

A production response should include parsed data, confidence, validation, and review status.

Example:

{
  "document_type": "bank_check",
  "parser_provider": "azure_document_intelligence",
  "parser_model": "prebuilt-bankCheck.us",
  "check": {
    "check_number": "1042",
    "date": "2026-07-29",
    "payee": "Acme Supplies LLC",
    "amount_numeric": 1250.00,
    "amount_written": "One thousand two hundred fifty and 00/100",
    "bank_name": "Example Bank",
    "memo": "July invoice",
    "routing_number": "071000013",
    "account_number": "*****6789"
  },
  "validation": {
    "valid": true,
    "review_required": false,
    "errors": [],
    "warnings": [],
    "amount_comparison": {
      "match": true,
      "numeric": 1250.00,
      "written": 1250.00
    }
  },
  "review_status": "auto_approved"
}

That is the kind of output your app can use.

Security notes for check parsing

Bank checks are sensitive financial documents.

Add these safeguards:

SafeguardWhy it matters
Server-side API callsKeeps provider keys private
File size limitsPrevents abuse
File type validationBlocks unexpected uploads
Temporary storage cleanupReduces sensitive file exposure
Encryption at restProtects stored checks
Redacted logsPrevents account number leaks
Access controlsLimits who can view checks
Audit logsTracks sensitive access
Retention policyRemoves old check images
Manual review controlsPrevents automatic risky decisions

Also avoid logging raw parser responses if they include account numbers or MICR lines.

Use redacted versions in normal app logs.

Common mistakes when parsing bank checks

These are the usual ones.

MistakeBetter approach
Using plain OCR onlyUse a check-specific parser
Trusting extracted values blindlyValidate routing number and amounts
Returning raw provider JSON everywhereNormalize to your own schema
Logging full account numbersRedact sensitive fields
Ignoring confidence scoresRoute low-confidence checks to review
Skipping amount comparisonCompare numeric and written amounts
No upload quality guidanceHelp users capture better images
No manual review statusAdd clear workflow states
No provider/model trackingStore parser provider and model version
No retention policyDelete sensitive files when no longer needed

The biggest mistake is treating check parsing like simple OCR.

Checks need financial validation.

A production-ready workflow

Here is the workflow we would actually ship:

  1. User uploads check image.
  2. Backend validates file type and size.
  3. Backend stores file temporarily and securely.
  4. Backend sends image to a check parser API.
  5. Parser returns structured fields.
  6. App normalizes fields into internal schema.
  7. App validates routing number.
  8. App compares numeric and written amount.
  9. App redacts sensitive fields for normal responses.
  10. App sends uncertain checks to manual review.
  11. LLMAPI creates review notes or workflow summaries when helpful.
  12. App stores audit metadata and deletes temporary files.

That gives you a real check parsing pipeline.

The practical takeaway

You can parse bank checks with JavaScript by combining file upload handling, a check-specific OCR or document extraction API, normalization, validation, redaction, and review logic.

A good workflow looks like this:

check image → parser API → normalized JSON → validation → redaction → review or approval

Use JavaScript for the backend workflow. Use a dedicated parser API for field extraction. Validate routing numbers and amount fields before trusting the output. Redact sensitive account data. Send low-confidence checks to review. Use LLMAPI after parsing when you need review notes, routing summaries, customer messages, or audit-friendly explanations.

That is how check parsing becomes useful in a real product: clean structured data, safer validation, and a workflow that knows when a human should take a look.

Deploy in minutes