Constaia
Use-case guides

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.

Cette page n'est pas encore traduite dans votre langue. Voici la version anglaise.

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

TypeMain fieldsFormat validation
us_paystubemployer_name, employee_name, pay_period_start, pay_period_end, pay_date, pay_frequency, gross_pay, net_pay, ytd_gross, ytd_net, deductions—
us_bank_statementbank_name, account_holder, account_number, routing_number, period_start, period_end, opening_balance, closing_balance, total_deposits, total_withdrawals, transactionsrouting_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.

income/submit.ts
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

income/compute.ts
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

income/server.ts
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.

income_batch.py
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.

CodeSignification
low_qualityQualité globalement faible.
blurryImage floue.
croppedLe document est rogné.
glareDes reflets masquent des données.
screen_photo_suspectedPhoto d'écran possible.
photocopy_suspectedPhotocopie possible.
edited_suspectedRetouche numérique possible.
multiple_documentsPlusieurs documents dans le fichier.
side_missingUne face manque.
language_mismatchLa langue n'est pas celle attendue pour ce type.

Les warnings sont des indices, pas une preuve d'authenticité.

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

Sur cette page