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.
| Field | Why it matters |
| Check number | Identifies the check |
| Date | Payment date |
| Payee | Who receives the money |
| Numeric amount | Amount box value |
| Written amount | Legal amount line |
| Payer name | Account holder or sender |
| Bank name | Issuing bank |
| Bank address | Sometimes needed for review |
| Memo | Optional payment note |
| Signature presence | Useful for review |
| Routing number | Bank routing identifier |
| Account number | Payer account identifier |
| MICR line | Machine-readable check line |
| Confidence scores | Review routing |
| Bounding boxes | UI highlighting and audit review |
The two most important validation fields are usually:
- Numeric amount.
- 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?
- API keys stay private.
- File size limits are easier to control.
- You can scan and validate uploads.
- You can redact logs.
- You can store audit metadata.
- You can apply business validation before returning data.
- 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:
| Package | Why |
| express | Small backend API |
| multer | File uploads |
| axios | HTTP requests to parser APIs |
| dotenv | Environment 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:
- Check number.
- Date.
- Amount.
- Payee.
- Memo.
- MICR routing.
- 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.
| Task | Example |
| Review note | Explain why a check needs manual review |
| Customer message | Draft a careful message asking for a clearer image |
| Internal summary | Summarize check details for finance ops |
| Exception routing | Route failed checks to the right queue |
| Audit narrative | Explain validation errors in plain language |
| Data cleanup | Normalize memo or payee names |
| Fraud triage support | Summarize suspicious signals without making final decisions |
| Batch reports | Summarize failed checks by reason |
| Support automation | Create 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:
- Messy payee names.
- Ambiguous numbers.
- Written amount variation.
- Signature overlap.
- Slanted writing.
- Crossed-out values.
- Low image quality.
- Missing fields.
- Dark backgrounds.
- 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:
- Flat.
- Well-lit.
- Sharp.
- Fully visible.
- Uncropped.
- Without glare.
- High enough resolution.
- 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:
| Safeguard | Why it matters |
| Server-side API calls | Keeps provider keys private |
| File size limits | Prevents abuse |
| File type validation | Blocks unexpected uploads |
| Temporary storage cleanup | Reduces sensitive file exposure |
| Encryption at rest | Protects stored checks |
| Redacted logs | Prevents account number leaks |
| Access controls | Limits who can view checks |
| Audit logs | Tracks sensitive access |
| Retention policy | Removes old check images |
| Manual review controls | Prevents 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.
| Mistake | Better approach |
| Using plain OCR only | Use a check-specific parser |
| Trusting extracted values blindly | Validate routing number and amounts |
| Returning raw provider JSON everywhere | Normalize to your own schema |
| Logging full account numbers | Redact sensitive fields |
| Ignoring confidence scores | Route low-confidence checks to review |
| Skipping amount comparison | Compare numeric and written amounts |
| No upload quality guidance | Help users capture better images |
| No manual review status | Add clear workflow states |
| No provider/model tracking | Store parser provider and model version |
| No retention policy | Delete 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:
- User uploads check image.
- Backend validates file type and size.
- Backend stores file temporarily and securely.
- Backend sends image to a check parser API.
- Parser returns structured fields.
- App normalizes fields into internal schema.
- App validates routing number.
- App compares numeric and written amount.
- App redacts sensitive fields for normal responses.
- App sends uncertain checks to manual review.
- LLMAPI creates review notes or workflow summaries when helpful.
- 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.