Constaia
Guías por caso

Verificación de ingresos con nóminas y extractos bancarios

Analiza en un lote nóminas y extractos bancarios de EE. UU. con los tipos us_paystub y us_bank_statement, con antigüedad y titular comprobados, y calcula el ingreso mensual en tu código.

La selección de inquilinos, los préstamos, el leasing o el pago aplazado suelen pedir al solicitante sus últimas nóminas (paystubs) y varios meses de extractos bancarios. Después alguien teclea en una hoja de cálculo el empleador, los periodos de pago y los importes para estimar el ingreso mensual. Esta guía automatiza el tecleo y las comprobaciones mecánicas (tipo de documento, antigüedad, titular) y deja la decisión donde debe estar: en tus reglas y en tus revisores.

El flujo

El solicitante sube los documentos

Tu app pide, por ejemplo, las 2–3 últimas nóminas y 2–3 extractos mensuales, en huecos de subida separados, y los envía a tu backend.

Tu backend envía un lote por solicitud

Un lote admite hasta 100 documentos. Cada documento lleva su propio expect (us_paystub o us_bank_statement) y su antigüedad máxima; el nombre del solicitante va en las opciones comunes.

Calculas el ingreso cuando termina el lote

Con el webhook batch.completed lees cada análisis, revisas su verdict, calculas el ingreso mensual en tu código, marcas las incoherencias y envías lo dudoso a una persona. Después borras los análisis.

Como el hueco de subida ya te dice qué es cada fichero, no hace falta clasificarlos antes: expect ya comprueba que el documento es del tipo esperado y, si no lo es, el veredicto lo marca con type_mismatch. Si recibes ficheros mezclados sin etiquetar, POST /v1/classify los clasifica por 0,2 créditos cada uno.

Los tipos

TipoCampos principalesValidación de formato
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, dígito de control), postal_code (us_zip)

Los dos admiten max_age_days, reference_date, holder y require_fields. max_age_days se mide desde pay_date en la nómina y desde period_end en el extracto; holder compara el nombre esperado con employee_name o account_holder (normalizado, sin tener en cuenta el orden ni las tildes). La lista completa de campos y checks está en GET /v1/document-types y en el catálogo.

Las validaciones de formato son soft: un routing number con dígito de control incorrecto aparece en checks[] como id_number_checksum y en verdict.reasons como warning, con lo que el veredicto pasa a review. account_number se devuelve tal como aparece en el extracto, a menudo enmascarado; guarda solo los cuatro últimos dígitos.

Enviar el lote

Los lotes siempre son asíncronos y atómicos: todos los ficheros se descargan y validan antes de crear nada, y si uno falla no se crea ni se cobra ningún análisis (el error indica cuál con param: "items[i]"). Las options de cada item se combinan con las comunes; en checks, clave a clave, así que cada documento conserva el holder común y añade su max_age_days. metadata de las opciones comunes pertenece al lote y vuelve en 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(); // lee CONSTAIA_API_KEY

// Ajusta la antigüedad máxima a tu política.
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) {
    // En modo live un lote necesita por adelantado al menos 1 crédito por documento.
    if (err instanceof InsufficientCreditsError) throw new Error("Recarga créditos antes de enviar solicitudes");
    throw err;
  }
}

Un extracto suele tener varias páginas: cuesta 1 crédito hasta 2 páginas y 1 más por cada 2 páginas adicionales, como cualquier análisis. Los análisis síncronos aceptan hasta 30 páginas; en lotes el límite es de 200 páginas por PDF. Consulta Créditos y facturación.

Calcular el ingreso en 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);

// Veredicto y avisos: 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); // negativo = cargo
    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 };
}

Un documento invalid (otro tipo de documento, demasiado antiguo o de otro titular) no entra en el cálculo, pero sus motivos quedan en flags para que el revisor los vea. Los ingresos en cuenta no son salario: también aparecen transferencias entre cuentas propias, devoluciones y préstamos. Usa los extractos para corroborar las nóminas y deja que tu política defina qué ingresos cuentan.

Ruta del webhook

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(); // lee CONSTAIA_API_KEY
const secret = process.env.CONSTAIA_WEBHOOK_SECRET!;
const processed = new Set<string>(); // en producción, usa tu base de datos

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 conserva el tipo que pediste aunque el documento sea de otro.
  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 }); // sustitúyelo por tu base de datos
}

app.listen(3000, () => console.log("Listening on :3000"));

Los documentos de un lote no emiten analysis.completed; recibes un único batch.completed cuando han terminado todos. Mantén en los lotes el valor por defecto keep_results: true para poder leer cada análisis y bórralos con DELETE /v1/analyses/{id} cuando hayas guardado lo que necesitas. Más en Webhooks.

Python (sondeo)

Con el SDK oficial de Python (pip install constaia), este script consulta el lote periódicamente en lugar de usar un webhook, lo que vale para scripts y tareas de backoffice. Envía solo nóminas, con opciones comunes para todo el lote.

income_batch.py
import sys
import time

from constaia import Constaia, ConstaiaError, InsufficientCreditsError

client = Constaia()  # lee 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  # otro documento, demasiado antiguo o de otro titular: queda en 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("Recarga créditos antes de enviar solicitudes")
    except ConstaiaError as err:
        sys.exit(f"Constaia error {err.status} {err.code} (request {err.request_id})")

Otras pruebas de ingresos

Para trabajadores por cuenta propia o para confirmar ingresos anuales, el catálogo tiene también el formulario W-2 (us_w2: wages, federal_income_tax_withheld, employer_ein…) y los 1099 (us_1099_nec, us_1099_misc, us_1099_int, con payer_tin, recipient_tin y los importes de cada casilla). Se usan igual: expect con el tipo y holder con el nombre del solicitante. El SSN del empleado o del perceptor suele venir enmascarado (XXX-XX-5678); en ese caso la validación de formato us_ssn falla con un aviso y el veredicto pasa a review, algo que puedes tratar como esperado.

Los avisos son señales, no pruebas

Avisos como edited_suspected, screen_photo_suspected o cropped significan "una persona debería mirar esto". No son prueba de fraude, y un resultado limpio tampoco es prueba de autenticidad: Constaia extrae datos y valida formatos, pero no contacta con empleadores, bancos ni el IRS. Envía las solicitudes marcadas a revisión humana y no deniegues nunca una solicitud solo por un aviso.

Si tus decisiones están sujetas a normas de protección del consumidor o de crédito justo (por ejemplo, las notificaciones de adverse action), revisa tu proceso con tu asesoría jurídica. Constaia no es una agencia de informes de consumidores (consumer reporting agency) según la FCRA y no toma decisiones de elegibilidad; actúa como proveedor de servicios (service provider) dentro de tu programa de seguridad de la GLBA y según la CCPA. Esta guía no es asesoramiento jurídico.

CódigoSignificado
low_qualityCalidad baja en general.
blurryImagen desenfocada.
croppedEl documento está recortado.
glareReflejos que tapan datos.
screen_photo_suspectedPosible foto de una pantalla.
photocopy_suspectedPosible fotocopia.
edited_suspectedPosible edición digital.
multiple_documentsHay más de un documento en el fichero.
side_missingFalta una cara.
language_mismatchEl idioma no es el esperado para el tipo.

Los warnings son indicios, no prueba de autenticidad.

Próximamente: región de EE. UU.

Hoy todo el procesamiento y el almacenamiento, también el de clientes de EE. UU., se hace en la UE. Está prevista una región de EE. UU., sin fecha todavía. Consulta Residencia de datos y cumplimiento.

Modo test

El simulador del modo test (claves ck_test_) no tiene escenarios de documentos de EE. UU. Las nóminas y los extractos se responden con el escenario generic, así que el veredicto es review con el motivo type_unknown (los nombres de fichero que contienen invoice o receipt se responden con escenarios de factura y justificante de pago españoles). Úsalo para probar la integración de lotes y webhooks; para ver resultados reales usa una clave live. El plan gratuito incluye 150 créditos al mes; los créditos live gratuitos requieren un email verificado. Consulta Modo test.

Siguientes pasos

En esta página