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
| Tipo | Campos principales | Validación de formato |
|---|---|---|
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, 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.
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
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
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.
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ódigo | Significado |
|---|---|
low_quality | Calidad baja en general. |
blurry | Imagen desenfocada. |
cropped | El documento está recortado. |
glare | Reflejos que tapan datos. |
screen_photo_suspected | Posible foto de una pantalla. |
photocopy_suspected | Posible fotocopia. |
edited_suspected | Posible edición digital. |
multiple_documents | Hay más de un documento en el fichero. |
side_missing | Falta una cara. |
language_mismatch | El 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
Documentos de EE. UU.
Todos los tipos de EE. UU. del catálogo y qué comprueba cada uno.
Lotes masivos
Límites de los lotes, exportaciones combinadas y errores.
Revisión humana
Monta la cola de solicitudes marcadas.
Webhooks
Firmas, reintentos y handlers idempotentes.
Justificante de domicilio
Facturas de suministros con antigüedad y cotejo de dirección.
Alta de contratistas con el formulario W-9
Valida el formulario W-9 con el tipo us_w9 sin guardarlo, obtén un veredicto con firma y titular, y recibe el nombre, la clasificación fiscal, el TIN y la dirección.
Certificados de seguro (ACORD 25)
Valida los certificados de seguro de proveedores con el tipo acord_25, con vigencia y asegurado comprobados, y lee pólizas, límites y asegurado adicional para aplicar las reglas de tu contrato.