Constaia
Integraciones

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.

Vas a montar un servidor Express 5 con dos rutas:

  • POST /api/verify-dni: recibe un fichero, lo analiza con Constaia y devuelve el veredicto (valid, invalid o review).
  • POST /webhooks/constaia: recibe los eventos de Constaia, verifica la firma con el cuerpo crudo y descarta duplicados.

La clave de API vive solo en el servidor. El navegador, una app móvil o el widget envían el fichero a tu ruta, nunca a Constaia.

Instalación

npm i express multer @constaia/sdk
npm i -D typescript tsx @types/express @types/multer

Requisitos: Node.js 20 o superior, Express 5 y multer 2.

Variables de entorno

.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...
  • CONSTAIA_API_KEY: créala en el panel → API keys. Empieza con una ck_test_ (gratis, respuestas deterministas).
  • CONSTAIA_WEBHOOK_SECRET: se muestra una sola vez al crear el endpoint de webhook. Ver Webhooks.

Cliente y utilidades compartidas

Este módulo crea el cliente, recorta la respuesta que ve el navegador, traduce los errores del SDK a respuestas HTTP y procesa los eventos del webhook.

src/constaia.ts
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

src/server.ts
import express, { type NextFunction, type Request, type Response } from "express";
import multer from "multer";
import { WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
import {
  alreadyProcessed,
  constaia,
  handleEvent,
  markProcessed,
  publicResult,
  toHttpError,
} from "./constaia.js";

const app = express();
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 20 * 1024 * 1024, files: 1 },
});

// El webhook va ANTES de express.json(): necesita el cuerpo sin parsear.
app.post(
  "/webhooks/constaia",
  express.raw({ type: "application/json", limit: "1mb" }),
  async (req: Request, res: Response) => {
    let event: WebhookEvent;
    try {
      event = await constaia.webhooks.verify(req.body, req.headers, process.env.CONSTAIA_WEBHOOK_SECRET!);
    } catch (error) {
      if (error instanceof WebhookVerificationError) {
        res.status(400).send("Invalid signature");
        return;
      }
      throw error;
    }

    const webhookId = req.get("webhook-id")!;
    if (alreadyProcessed(webhookId)) {
      res.sendStatus(200);
      return;
    }

    await handleEvent(event);
    markProcessed(webhookId);
    res.sendStatus(200);
  },
);

app.use(express.json());

// Aquí va tu autenticación: solo usuarios con sesión deberían gastar créditos.
app.post("/api/verify-dni", upload.single("file"), async (req: Request, res: Response) => {
  if (!req.file) {
    res.status(400).json({ error: { code: "missing_file", message: "Falta el fichero." } });
    return;
  }
  // Mejor aún: toma el nombre del usuario autenticado, no del formulario.
  const fullName = String(req.body?.full_name ?? "").trim();

  try {
    const analysis = await constaia.analyze(req.file.buffer, {
      filename: req.file.originalname,
      expect: "es_dni",
      checks: { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) },
      storage: "none",
      language: "es",
    });
    // Guarda analysis.id y analysis.verdict en tu base de datos.
    res.status(analysis.status === "completed" ? 200 : 202).json(publicResult(analysis));
  } catch (error) {
    const { status, body, retryAfter } = toHttpError(error);
    if (retryAfter) res.set("Retry-After", String(retryAfter));
    res.status(status).json(body);
  }
});

app.use((error: unknown, _req: Request, res: Response, next: NextFunction) => {
  if (error instanceof multer.MulterError && error.code === "LIMIT_FILE_SIZE") {
    res.status(413).json({ error: { code: "file_too_large", message: "Máximo 20 MB." } });
    return;
  }
  next(error);
});

const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`http://localhost:${port}`));

Arranca con:

npx tsx --env-file=.env src/server.ts

Puntos clave:

  • El servidor decide expect y checks. Nunca aceptes del cliente el tipo esperado ni las comprobaciones: cualquiera podría pedir generic y saltarse la validación.
  • filename. Al pasar un Buffer, indica el nombre original. En modo test el nombre decide la respuesta.
  • storage: "none". El fichero se procesa en memoria y no se guarda. Ver Almacenamiento y privacidad.
  • 202. Si el análisis tarda más de 30 s, la API devuelve el análisis en queued o processing y el resultado llega por webhook. Por eso la ruta responde 202 en ese caso.
  • Express 5 propaga los errores de los handlers async al middleware de errores; no necesitas express-async-errors.

Qué recibe el navegador

publicResult devuelve el formato que entiende el widget (verdict.status, verdict.reasons[].message, warnings) sin exponer los datos extraídos:

{
  "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": []
}

Con <constaia-upload endpoint="/api/verify-dni"> no necesitas más código en el cliente. El widget envía también un campo options con expect como pista; esta ruta lo ignora a propósito.

Errores

Error del SDKCuándoQué devuelve tu ruta
InvalidRequestErrorFichero 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
RateLimitErrorSuperas las peticiones por segundo de tu clave (429). El SDK ya reintenta dos veces respetando Retry-After429 con Retry-After
InsufficientCreditsErrorNo quedan créditos (402)503 al usuario y una alerta para ti: recarga en el panel
AuthenticationError, PermissionErrorClave ausente, revocada o incorrecta (401/403)500: es un fallo de configuración tuyo, no del usuario
APIError, APIConnectionError, APITimeoutErrorError 5xx de Constaia o de red, tras agotar los reintentos502 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.

Webhook

  • Cuerpo crudo. express.raw() deja req.body como Buffer. Si express.json() lo parsea antes, el JSON reserializado no coincide byte a byte y la firma falla. Por eso la ruta del webhook se registra antes.
  • Deduplicación. webhook-id es estable entre reintentos. Guárdalo con una restricción única y responde 200 si ya lo has visto.
  • Responde rápido. Constaia espera un 2xx en menos de 15 s. Si el procesamiento es pesado, encólalo y responde.
  • Sin sesión. Si tienes un middleware de autenticación global, excluye /webhooks/constaia: Constaia no envía cookies ni tokens, se autentica con la firma.

Registra el endpoint en el panel o con el SDK:

scripts/create-webhook.ts
import { Constaia } from "@constaia/sdk";

const constaia = new Constaia();
const endpoint = await constaia.webhookEndpoints.create({
  url: "https://tu-dominio.com/webhooks/constaia",
  events: ["analysis.completed", "analysis.failed", "analysis.review_required", "credits.low"],
});
console.log(endpoint.secret); // whsec_…: guárdalo en CONSTAIA_WEBHOOK_SECRET, solo se muestra ahora

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
Ficherofull_nameResultado
dni_valid.jpgMaría García Lópezvalid
dni_valid.jpgJuan Pérezinvalid: 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 probar el webhook en local sin exponer tu máquina, firma un cuerpo con signWebhook y envíalo a tu ruta:

scripts/send-test-webhook.ts
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); // 200

Checklist de producción

  • Autenticación y rate limit por usuario en /api/verify-dni (por ejemplo express-rate-limit). Cada llamada gasta créditos.
  • Límite de cuerpo de al menos 20 MB en multer, en tu proxy (nginx client_max_body_size 21m) y en tu balanceador.
  • Timeouts de 60 s o más en el proxy y en el servidor: un análisis síncrono puede tardar hasta 30 s.
  • PDF largos (más de 30 páginas) o volúmenes altos: async: true y resultado por webhook. Ver Lotes.
  • Guarda analysis.id con tu registro; los datos extraídos, solo si los necesitas.
  • Clave ck_live_ solo en las variables de entorno del servidor, nunca en el repositorio.
  • Alerta cuando recibas InsufficientCreditsError o el evento credits.low.

Siguientes pasos

En esta página