Node.js
Usa Constaia desde Node.js sin framework: un script que analiza ficheros locales y un servidor node:http con subida y webhook verificado.
Esta guía usa solo Node.js y el SDK, sin dependencias de framework. Vas a construir:
- Un script que analiza un fichero local y muestra el veredicto. Útil para procesos por lotes, cron o pruebas.
- Un servidor
node:httpconPOST /api/verify-dni(subida desde tu web o app) yPOST /webhooks/constaia(eventos firmados).
Si ya usas un framework, ve directamente a Express, Fastify, Hono, NestJS o Koa.
Instalación
npm i @constaia/sdk
npm i -D typescript tsx @types/nodeEl SDK no tiene dependencias y funciona con Node.js 18 o superior. Esta guía usa Node.js 20+ (por --env-file y File global).
Variables de entorno
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...new Constaia() lee CONSTAIA_API_KEY del entorno. Crea la clave en el panel → API keys; empieza con una ck_test_, que es gratuita y responde de forma determinista (Modo test).
1. Script
import { Constaia, ConstaiaError } from "@constaia/sdk";
import { fromPath } from "@constaia/sdk/node";
const constaia = new Constaia();
const path = process.argv[2] ?? "dni_valid.jpg";
try {
const analysis = await constaia.analyze(await fromPath(path), {
expect: "es_dni",
checks: { notExpired: true },
language: "es",
});
console.log(analysis.document?.label, "→", analysis.verdict?.status);
for (const reason of analysis.verdict?.reasons ?? []) {
console.log(` [${reason.severity}] ${reason.code}: ${reason.message}`);
}
console.log("Número:", analysis.fields.document_number?.value);
console.log("Caduca:", analysis.fields.expiry_date?.value);
} catch (error) {
if (!(error instanceof ConstaiaError)) throw error;
console.error(error.name, error.code, error.message, error.requestId);
process.exitCode = 1;
}npx tsx --env-file=.env scripts/verify.ts dni_valid.jpgDNI (España) → valid
[info] type_match: El documento es DNI (España).
[info] not_expired: Vigente hasta el 12/03/2031.
Número: 12345678Z
Caduca: 2031-03-12fromPath (de @constaia/sdk/node) lee el fichero y conserva su nombre. Con dni_expired.jpg verás [error] not_expired: Caducado el 15/06/2020. y el veredicto invalid.
2. Servidor node:http
Utilidades compartidas
import {
AuthenticationError,
Constaia,
ConstaiaError,
InsufficientCreditsError,
InvalidRequestError,
PermissionError,
RateLimitError,
type Analysis,
type WebhookEvent,
} from "@constaia/sdk";
// Lee CONSTAIA_API_KEY del entorno.
export const constaia = new Constaia();
// Lo que devuelves al navegador (y lo que pinta el widget): sin los campos extraídos.
export function publicResult(analysis: Analysis) {
const { id, object, status, document, verdict, warnings } = analysis;
return { id, object, status, document, verdict, warnings };
}
export type HttpError = {
status: number;
body: { error: { code: string; message: string } };
retryAfter?: number;
};
const httpError = (status: number, code: string, message: string, retryAfter?: number): HttpError => ({
status,
body: { error: { code, message } },
retryAfter,
});
export function toHttpError(error: unknown): HttpError {
if (error instanceof InvalidRequestError) {
// Fichero ilegible, formato no admitido, demasiadas páginas…: el usuario puede corregirlo.
return httpError(error.status === 413 ? 413 : 422, error.code ?? "invalid_request", error.message);
}
if (error instanceof RateLimitError) {
const wait = Math.ceil(error.retryAfter ?? 1);
return httpError(429, "rate_limited", "Demasiadas peticiones. Inténtalo en unos segundos.", wait);
}
if (error instanceof InsufficientCreditsError) {
console.error("[constaia] Sin créditos. Recarga en https://app.constaia.com", error.requestId);
return httpError(503, "unavailable", "La validación no está disponible ahora mismo.");
}
if (error instanceof AuthenticationError || error instanceof PermissionError) {
console.error("[constaia] Revisa CONSTAIA_API_KEY", error.code, error.requestId);
return httpError(500, "misconfigured", "Error de configuración del servidor.");
}
if (error instanceof ConstaiaError) {
// APIError (5xx), APIConnectionError, APITimeoutError
console.error("[constaia]", error.name, error.code, error.requestId);
return httpError(502, "upstream_error", "No se ha podido analizar el documento. Inténtalo de nuevo.");
}
console.error(error);
return httpError(500, "internal_error", "Error interno.");
}
// Deduplicación por webhook-id. En producción, una tabla con clave única.
const processed = new Set<string>();
export const alreadyProcessed = (webhookId: string) => processed.has(webhookId);
export const markProcessed = (webhookId: string) => void processed.add(webhookId);
export async function handleEvent(event: WebhookEvent) {
switch (event.type) {
case "analysis.completed":
// event.data es el análisis completo (con fields). Guárdalo por event.data.id.
console.log("analysis.completed", event.data.id, event.data.verdict?.status);
break;
case "analysis.review_required":
console.log("A revisión manual", event.data.id);
break;
case "analysis.failed":
console.warn("analysis.failed", event.data.id, event.data.error?.code);
break;
case "credits.low":
console.warn("Quedan pocos créditos: recarga en https://app.constaia.com");
break;
}
}Servidor
Node.js no parsea multipart/form-data por sí mismo, pero Response.formData() (de la API fetch integrada) sí. El servidor lee el cuerpo con un límite, lo convierte en FormData y pasa el File al SDK.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { WebhookVerificationError } from "@constaia/sdk";
import {
alreadyProcessed,
constaia,
handleEvent,
markProcessed,
publicResult,
toHttpError,
} from "./constaia.js";
const MAX_UPLOAD = 21 * 1024 * 1024; // 20 MB de fichero + margen del multipart
const MAX_WEBHOOK = 1024 * 1024;
function sendJson(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}) {
res.writeHead(status, { "content-type": "application/json; charset=utf-8", ...headers });
res.end(JSON.stringify(body));
}
async function readBody(req: IncomingMessage, limit: number): Promise<Buffer | null> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > limit) return null;
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
// Aquí va tu autenticación: solo usuarios con sesión deberían gastar créditos.
async function verifyDni(req: IncomingMessage, res: ServerResponse) {
const body = await readBody(req, MAX_UPLOAD);
if (!body) return sendJson(res, 413, { error: { code: "file_too_large", message: "Máximo 20 MB." } });
let form: FormData;
try {
form = await new Response(body, {
headers: { "content-type": req.headers["content-type"] ?? "" },
}).formData();
} catch {
return sendJson(res, 400, { error: { code: "invalid_multipart", message: "Envía multipart/form-data." } });
}
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) {
return sendJson(res, 400, { error: { code: "missing_file", message: "Falta el fichero." } });
}
const fullName = String(form.get("full_name") ?? "").trim();
try {
const analysis = await constaia.analyze(file, {
expect: "es_dni",
checks: { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) },
storage: "none",
language: "es",
});
// Guarda analysis.id y analysis.verdict en tu base de datos.
sendJson(res, analysis.status === "completed" ? 200 : 202, publicResult(analysis));
} catch (error) {
const { status, body, retryAfter } = toHttpError(error);
sendJson(res, status, body, retryAfter ? { "retry-after": String(retryAfter) } : {});
}
}
async function webhook(req: IncomingMessage, res: ServerResponse) {
const rawBody = await readBody(req, MAX_WEBHOOK);
if (!rawBody) return sendJson(res, 413, { error: { code: "payload_too_large", message: "Too large" } });
let event;
try {
event = await constaia.webhooks.verify(rawBody, req.headers, process.env.CONSTAIA_WEBHOOK_SECRET!);
} catch (error) {
if (!(error instanceof WebhookVerificationError)) throw error;
return sendJson(res, 400, { error: { code: "invalid_signature", message: error.message } });
}
const webhookId = req.headers["webhook-id"] as string;
if (!alreadyProcessed(webhookId)) {
await handleEvent(event);
markProcessed(webhookId);
}
res.writeHead(200).end();
}
const server = createServer(async (req, res) => {
try {
if (req.method === "POST" && req.url === "/api/verify-dni") return await verifyDni(req, res);
if (req.method === "POST" && req.url === "/webhooks/constaia") return await webhook(req, res);
sendJson(res, 404, { error: { code: "not_found", message: "Not found" } });
} catch (error) {
console.error(error);
if (!res.headersSent) sendJson(res, 500, { error: { code: "internal_error", message: "Error interno." } });
}
});
server.requestTimeout = 120_000;
server.listen(Number(process.env.PORT ?? 3000), () => console.log("http://localhost:3000"));npx tsx --env-file=.env src/server.tsPuntos clave:
- El servidor decide
expectychecks. No aceptes esas opciones del cliente. El nombre del titular, mejor de la sesión que del formulario. Fileconserva el nombre original, que en modo test decide la respuesta.- Cuerpo crudo en el webhook.
readBodydevuelve los bytes exactos, que es lo que firma Constaia. Nunca verifiques sobreJSON.stringify(JSON.parse(body)). - 202. Si el análisis no termina en 30 s, la API devuelve el análisis en
queuedoprocessingy el resultado llega por webhook. requestTimeoutcontrola cuánto espera Node a recibir la petición completa. Déjalo por encima de 60 s.
Qué recibe el navegador
{
"id": "an_01J…",
"object": "analysis",
"status": "completed",
"document": { "type": "es_dni", "label": "DNI (España)", "confidence": 0.97, "side": "both", "country": "ESP" },
"verdict": {
"expected": ["es_dni"],
"match": true,
"status": "valid",
"reasons": [
{ "code": "type_match", "severity": "info", "message": "El documento es DNI (España)." },
{ "code": "not_expired", "severity": "info", "message": "Vigente hasta el 12/03/2031." },
{ "code": "holder", "severity": "info", "message": "Los datos del titular coinciden (full_name)." }
]
},
"warnings": []
}Es el formato que pinta el widget si lo apuntas a esta ruta con endpoint="/api/verify-dni".
Errores
| Error del SDK | Cuándo | Qué devuelve tu ruta |
|---|---|---|
InvalidRequestError | Fichero vacío, ilegible, formato no admitido, más de 20 MB, demasiadas páginas u opciones mal formadas (400/409/413/415/422) | 422 (o 413) con el mensaje, para que el usuario suba otro fichero |
RateLimitError | Superas las peticiones por segundo de tu clave (429). El SDK ya reintenta dos veces respetando Retry-After | 429 con Retry-After |
InsufficientCreditsError | No quedan créditos (402) | 503 al usuario y una alerta para ti: recarga en el panel |
AuthenticationError, PermissionError | Clave ausente, revocada o incorrecta (401/403) | 500: es un fallo de configuración tuyo, no del usuario |
APIError, APIConnectionError, APITimeoutError | Error 5xx de Constaia o de red, tras agotar los reintentos | 502 y un mensaje de reintentar |
Todas extienden ConstaiaError y exponen status, code y requestId. Registra siempre el requestId: es lo que te pedirá soporte. Detalle de cada código en Errores.
Probar en modo test
curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:3000/api/verify-dni| Fichero | full_name | Resultado |
|---|---|---|
dni_valid.jpg | María García López | valid |
dni_valid.jpg | Juan Pérez | invalid: motivo holder con severidad error |
dni_expired.jpg | (vacío) | invalid: not_expired con el mensaje "Caducado el 15/06/2020." |
blurry.jpg | (vacío) | review: motivo low_quality y warnings: ["blurry", "low_quality"] |
factura.jpg | (vacío) | invalid: type_mismatch, no es un DNI |
En modo test la respuesta depende del nombre del fichero, que debe ser un JPEG, PNG, WEBP, HEIC o PDF real (vale cualquier imagen renombrada). No gasta créditos y livemode es false. Todos los nombres en Modo test.
Para el webhook, firma un cuerpo con signWebhook y envíalo a tu servidor:
import { signWebhook } from "@constaia/sdk";
const body = JSON.stringify({
type: "analysis.completed",
created_at: new Date().toISOString(),
data: { id: "an_test", object: "analysis", status: "completed", verdict: { status: "valid", reasons: [] } },
});
const headers = await signWebhook(body, process.env.CONSTAIA_WEBHOOK_SECRET!);
const res = await fetch("http://localhost:3000/webhooks/constaia", {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body,
});
console.log(res.status); // 200Checklist de producción
- Autenticación y rate limit por usuario en
/api/verify-dni: cada llamada gasta créditos. - Límite de cuerpo de al menos 20 MB en el servidor y en el proxy (nginx
client_max_body_size 21m). - Timeouts de 60 s o más en proxy y balanceador; un análisis síncrono puede tardar hasta 30 s.
- PDF de más de 30 páginas o volumen alto:
async: true+ webhook, o lotes. - Deduplicación del webhook en base de datos (restricción única sobre
webhook-id), no en memoria. - Clave
ck_live_solo en el entorno del servidor. - Alerta ante
InsufficientCreditsErrory el eventocredits.low.
Siguientes pasos
Integraciones
Integra Constaia con cualquier stack: JavaScript, PHP, Python, Go, Java, .NET, Ruby, no-code y agentes de IA. Código completo con subida, errores y webhooks.
Express
Valida DNI y otros documentos en Express 5 con el SDK de Constaia, multer en memoria y un webhook verificado con el cuerpo crudo.