Income verification with paystubs and bank statements
Analyze US paystubs and bank statements in one batch with the us_paystub and us_bank_statement types, with recency and holder checked, and compute monthly income in your code.
Tenant screening, lending, leasing and buy-now-pay-later flows usually ask applicants for their latest paystubs and several months of bank statements. Then someone types the employer, pay periods and amounts into a spreadsheet to estimate monthly income. This guide automates the typing and the mechanical checks (document type, recency, holder) and leaves the decision where it belongs: in your rules and with your reviewers.
The flow
The applicant uploads documents
Your app asks for, say, the last 2–3 paystubs and 2–3 monthly statements, in separate upload slots, and sends them to your backend.
Your backend sends one batch per application
A batch accepts up to 100 documents. Each document carries its own expect (us_paystub or
us_bank_statement) and its own maximum age; the applicant's name goes in the common options.
You compute income when the batch completes
On the batch.completed webhook you read each analysis, check its verdict, compute monthly income in your code, flag
inconsistencies and send anything doubtful to a person. Then you delete the analyses.
Because the upload slot already tells you what each file is, you don't need to classify them first: expect already
checks that the document is the expected type and, if it isn't, the verdict flags it with type_mismatch. If you
receive mixed, unlabelled files, POST /v1/classify classifies them for 0.2 credits each.
The types
| Type | Main fields | Format validation |
|---|---|---|
us_paystub | employer_name, employee_name, pay_period_start, pay_period_end, pay_date, pay_frequency, gross_pay, net_pay, ytd_gross, ytd_net, deductions | — |
us_bank_statement | bank_name, account_holder, account_number, routing_number, period_start, period_end, opening_balance, closing_balance, total_deposits, total_withdrawals, transactions | routing_number (us_aba, check digit), postal_code (us_zip) |
Both support max_age_days, reference_date, holder and require_fields. max_age_days is measured from
pay_date on the paystub and from period_end on the statement; holder compares the expected name with
employee_name or account_holder (normalized, ignoring word order and accents). The full list of fields and checks
is in GET /v1/document-types and in the catalogue.
Format validations are soft: a routing number with a wrong check digit appears in checks[] as
id_number_checksum and in verdict.reasons as a warning, so the verdict becomes review. account_number is
returned as printed on the statement, often masked; store only the last four digits.
Send the batch
Batches are always asynchronous and atomic: every file is downloaded and validated before anything is created, and if
one fails no analysis is created or charged (the error points to it with param: "items[i]"). Each item's options are
merged with the common ones; in checks, key by key, so each document keeps the common holder and adds its own
max_age_days. metadata in the common options belongs to the batch and comes back in batch.completed.
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import { Constaia, InsufficientCreditsError } from "@constaia/sdk";
const constaia = new Constaia(); // reads CONSTAIA_API_KEY
// Adjust the maximum age to your policy.
const MAX_AGE_DAYS = { us_paystub: 45, us_bank_statement: 90 } as const;
type Kind = keyof typeof MAX_AGE_DAYS;
async function item(path: string, kind: Kind) {
return {
base64: (await readFile(path)).toString("base64"),
filename: basename(path),
options: { expect: kind, checks: { maxAgeDays: MAX_AGE_DAYS[kind] } },
};
}
export async function submitApplication(applicationId: string, applicantName: string, paystubs: string[], statements: string[]) {
try {
const batch = await constaia.batches.create({
items: [
...(await Promise.all(paystubs.map((p) => item(p, "us_paystub")))),
...(await Promise.all(statements.map((p) => item(p, "us_bank_statement")))),
],
options: {
checks: { holder: { fullName: applicantName } },
storage: "none",
metadata: { application_id: applicationId },
},
});
return batch.id;
} catch (err) {
// In live mode a batch needs at least 1 credit per document up front.
if (err instanceof InsufficientCreditsError) throw new Error("Top up credits before submitting applications");
throw err;
}
}A statement is often several pages: it costs 1 credit up to 2 pages and 1 more per 2 extra pages, like any analysis. Synchronous analyses accept up to 30 pages; in batches the limit is 200 pages per PDF. See Credits and billing.
Compute income on batch.completed
import type { Analysis } from "@constaia/sdk";
const PERIODS_PER_MONTH = { weekly: 52 / 12, biweekly: 26 / 12, semimonthly: 2, monthly: 1 } as const;
type Frequency = keyof typeof PERIODS_PER_MONTH;
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : null);
const avg = (xs: number[]) => (xs.length ? Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 100) / 100 : null);
// Verdict and warnings: type_mismatch, max_age_days, holder, id_number_checksum, edited_suspected…
function commonFlags(a: Analysis): string[] {
if (a.status !== "completed") return [`${a.id}:failed`];
const reasons = (a.verdict?.reasons ?? [])
.filter((r) => r.severity !== "info")
.map((r) => `${a.id}:${r.code}:${r.severity}`);
return [...reasons, ...a.warnings.map((w) => `${a.id}:${w}`)];
}
export function monthlyFromPaystubs(analyses: Analysis[]) {
const flags: string[] = [];
const monthly: number[] = [];
for (const a of analyses) {
flags.push(...commonFlags(a));
if (a.status !== "completed" || a.verdict?.status === "invalid") continue;
const gross = num(a.fields.gross_pay?.value);
const net = num(a.fields.net_pay?.value);
const ytd = num(a.fields.ytd_gross?.value);
const freq = a.fields.pay_frequency?.value as Frequency | undefined;
if (gross === null || !freq || !(freq in PERIODS_PER_MONTH)) {
flags.push(`${a.id}:incomplete`);
continue;
}
if (net !== null && net > gross) flags.push(`${a.id}:net_above_gross`);
if (ytd !== null && ytd < gross) flags.push(`${a.id}:ytd_below_gross`);
monthly.push(gross * PERIODS_PER_MONTH[freq]);
}
return { monthlyGross: avg(monthly), documents: monthly.length, flags };
}
export function monthlyFromStatements(analyses: Analysis[]) {
const flags: string[] = [];
const monthly: number[] = [];
for (const a of analyses) {
flags.push(...commonFlags(a));
if (a.status !== "completed" || a.verdict?.status === "invalid") continue;
const transactions = (a.fields.transactions?.value as { amount?: number }[] | undefined) ?? [];
const sum = transactions.reduce((s, t) => s + Math.max(num(t.amount) ?? 0, 0), 0); // negative = debit
const total = num(a.fields.total_deposits?.value) ?? sum;
if (transactions.length && Math.abs(total - sum) > 1) flags.push(`${a.id}:deposits_do_not_add_up`);
monthly.push(total);
}
return { monthlyDeposits: avg(monthly), documents: monthly.length, flags };
}An invalid document (a different document type, too old or someone else's) is left out of the calculation, but its
reasons stay in flags for the reviewer to see. Deposits are not salary: transfers between the applicant's own
accounts, refunds and loans show up too. Use statements to corroborate paystubs and let your policy define which
deposits count.
Webhook route
import express from "express";
import { Constaia, WebhookVerificationError, type Analysis, type Batch } from "@constaia/sdk";
import { monthlyFromPaystubs, monthlyFromStatements } from "./compute";
const constaia = new Constaia(); // reads CONSTAIA_API_KEY
const secret = process.env.CONSTAIA_WEBHOOK_SECRET!;
const processed = new Set<string>(); // use your database in production
const app = express();
app.post("/webhooks/constaia", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await constaia.webhooks.verify(req.body, req.headers, secret);
} catch (err) {
if (err instanceof WebhookVerificationError) return res.status(400).send("invalid signature");
throw err;
}
res.sendStatus(200);
const deliveryId = req.header("webhook-id")!;
if (processed.has(deliveryId)) return;
processed.add(deliveryId);
if (event.type === "batch.completed") {
handleBatch(event.data as unknown as Batch).catch((e) => console.error("batch handling failed", e));
}
});
async function handleBatch(batch: Batch) {
const analyses: Analysis[] = [];
for (const id of batch.analyses) analyses.push(await constaia.analyses.get(id));
// verdict.expected keeps the type you asked for even when the document is something else.
const expected = (a: Analysis) => a.verdict?.expected?.[0];
const paystubs = monthlyFromPaystubs(analyses.filter((a) => expected(a) === "us_paystub"));
const statements = monthlyFromStatements(analyses.filter((a) => expected(a) === "us_bank_statement"));
const flags = [...paystubs.flags, ...statements.flags];
const needsReview = flags.length > 0 || paystubs.documents === 0;
await saveIncomeResult(batch.metadata?.application_id, { paystubs, statements }, needsReview);
for (const a of analyses) await constaia.analyses.delete(a.id);
}
async function saveIncomeResult(applicationId: string | undefined, result: object, needsReview: boolean) {
console.log({ applicationId, result, needsReview }); // replace with your database
}
app.listen(3000, () => console.log("Listening on :3000"));Documents in a batch do not emit analysis.completed; you get a single batch.completed when all of them have
finished. Keep the default keep_results: true in batches so you can read each analysis, and delete them with
DELETE /v1/analyses/{id} once you have saved what you need. More in Webhooks.
Python (polling)
With the official Python SDK (pip install constaia), this script polls the batch instead of using a webhook, which
works for scripts and back-office jobs. It sends paystubs only, with common options for the whole batch.
import sys
import time
from constaia import Constaia, ConstaiaError, InsufficientCreditsError
client = Constaia() # reads CONSTAIA_API_KEY
PERIODS_PER_MONTH = {"weekly": 52 / 12, "biweekly": 26 / 12, "semimonthly": 2, "monthly": 1}
def run(application_id: str, applicant_name: str, paths: list[str]) -> dict:
batch = client.batches.create(
files=paths,
options={
"expect": "us_paystub",
"checks": {"max_age_days": 45, "holder": {"full_name": applicant_name}},
"storage": "none",
"metadata": {"application_id": application_id},
},
)
while batch["status"] != "completed":
time.sleep(5)
batch = client.batches.get(batch["id"])
monthly, flags = [], []
for analysis_id in batch["analyses"]:
a = client.analyses.get(analysis_id)
if a["status"] != "completed":
flags.append(f"{analysis_id}:failed")
else:
verdict = a.get("verdict") or {}
flags += [f"{analysis_id}:{r['code']}:{r['severity']}" for r in verdict.get("reasons", []) if r["severity"] != "info"]
flags += [f"{analysis_id}:{w}" for w in a["warnings"]]
f = a["fields"]
gross = (f.get("gross_pay") or {}).get("value")
freq = (f.get("pay_frequency") or {}).get("value")
if verdict.get("status") == "invalid":
pass # different document, too old or someone else's: kept in flags
elif isinstance(gross, (int, float)) and freq in PERIODS_PER_MONTH:
monthly.append(gross * PERIODS_PER_MONTH[freq])
else:
flags.append(f"{analysis_id}:incomplete")
client.analyses.delete(analysis_id)
avg = round(sum(monthly) / len(monthly), 2) if monthly else None
return {"monthly_gross": avg, "flags": flags, "needs_review": bool(flags) or not monthly}
if __name__ == "__main__":
try:
print(run("app_123", "Jane Q Specimen", sys.argv[1:]))
except InsufficientCreditsError:
sys.exit("Top up credits before submitting applications")
except ConstaiaError as err:
sys.exit(f"Constaia error {err.status} {err.code} (request {err.request_id})")Other proof of income
For self-employed applicants or to confirm annual income, the catalogue also has Form W-2 (us_w2: wages,
federal_income_tax_withheld, employer_ein…) and the 1099 forms (us_1099_nec, us_1099_misc, us_1099_int, with
payer_tin, recipient_tin and the amounts in each box). They work the same way: expect with the type and holder
with the applicant's name. The employee's or recipient's SSN is often masked (XXX-XX-5678); in that case the us_ssn
format validation fails with a warning and the verdict becomes review, which you can treat as expected.
Warnings are signals, not proof
Warnings like edited_suspected, screen_photo_suspected or cropped mean "a person should look at this". They are
not proof of fraud, and a clean result is not proof of authenticity either: Constaia extracts data and validates
formats, but it does not contact employers, banks or the IRS. Send flagged applications to
human review and never deny an application on a warning alone.
If your decisions are subject to consumer protection or fair lending rules (for example, adverse action notices), review your process with your legal counsel. Constaia is not a consumer reporting agency under the FCRA and does not make eligibility decisions; it acts as a service provider within your GLBA information security program and under the CCPA. This guide is not legal advice.
| Code | Meaning |
|---|---|
low_quality | Overall low quality. |
blurry | Blurry image. |
cropped | The document is cropped. |
glare | Glare hides data. |
screen_photo_suspected | Possible photo of a screen. |
photocopy_suspected | Possible photocopy. |
edited_suspected | Possible digital edit. |
multiple_documents | More than one document in the file. |
side_missing | One side is missing. |
language_mismatch | The language is not the expected one for the type. |
Warnings are signals, not proof of authenticity.
Coming soon: US region
Today all processing and storage, including for US customers, happens in the EU. A US region is planned, with no date yet. See Data residency and compliance.
Test mode
The test-mode simulator (ck_test_ keys) has no US document scenarios. Paystubs and statements are answered with the
generic scenario, so the verdict is review with the reason type_unknown (filenames containing invoice or
receipt are answered with Spanish invoice and payment receipt scenarios). Use it to test your batch and webhook
integration; to see real results use a live key. The free plan includes 150 credits a month; free live credits
require a verified email. See Test mode.
Next steps
Onboard contractors with Form W-9
Validate Form W-9 with the us_w9 type without storing it, get a verdict covering signature and name, and receive the name, tax classification, TIN and address.
Certificates of insurance (ACORD 25)
Validate vendor certificates of insurance with the acord_25 type, with expiry and insured checked, and read policies, limits and additional insured to apply your contract rules.