Constaia
Integraciones

NestJS

Integra Constaia en NestJS 11 con un módulo que provee el cliente, FileInterceptor para subir documentos y un webhook verificado con rawBody.

Vas a añadir a una aplicación NestJS 11 (plataforma Express):

  • Un ConstaiaModule global que provee el cliente del SDK a partir de ConfigService.
  • Un servicio y un controlador con FileInterceptor para POST /api/verify-dni.
  • Un filtro de excepciones que traduce los errores del SDK a respuestas HTTP.
  • Un controlador de webhook que verifica la firma con rawBody: true y descarta duplicados.

La clave vive solo en el servidor. El navegador o el widget envían el fichero a tu API.

Instalación

npm i @constaia/sdk @nestjs/config
npm i -D @types/multer

@nestjs/platform-express (que incluye multer) ya viene en un proyecto creado con nest new.

Variables de entorno

.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

Crea la clave en el panel → API keys. El secreto del webhook se muestra una sola vez al crear el endpoint (Webhooks).

Módulo del cliente

src/constaia/constaia.module.ts
import { Global, Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Constaia } from "@constaia/sdk";

@Global()
@Module({
  providers: [
    {
      provide: Constaia,
      inject: [ConfigService],
      useFactory: (config: ConfigService) =>
        new Constaia({ apiKey: config.getOrThrow<string>("CONSTAIA_API_KEY") }),
    },
  ],
  exports: [Constaia],
})
export class ConstaiaModule {}

La propia clase Constaia sirve como token de inyección: cualquier servicio puede pedirla en su constructor.

Filtro de errores

src/constaia/constaia-exception.filter.ts
import { type ArgumentsHost, Catch, type ExceptionFilter, Logger } from "@nestjs/common";
import type { Response } from "express";
import {
  AuthenticationError,
  ConstaiaError,
  InsufficientCreditsError,
  InvalidRequestError,
  PermissionError,
  RateLimitError,
} from "@constaia/sdk";

@Catch(ConstaiaError)
export class ConstaiaExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger("Constaia");

  catch(error: ConstaiaError, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse<Response>();
    const send = (status: number, code: string, message: string) =>
      res.status(status).json({ error: { code, message } });

    if (error instanceof InvalidRequestError) {
      return send(error.status === 413 ? 413 : 422, error.code ?? "invalid_request", error.message);
    }
    if (error instanceof RateLimitError) {
      res.setHeader("Retry-After", String(Math.ceil(error.retryAfter ?? 1)));
      return send(429, "rate_limited", "Demasiadas peticiones. Inténtalo en unos segundos.");
    }
    if (error instanceof InsufficientCreditsError) {
      this.logger.error(`Sin créditos. Recarga en https://app.constaia.com (${error.requestId})`);
      return send(503, "unavailable", "La validación no está disponible ahora mismo.");
    }
    if (error instanceof AuthenticationError || error instanceof PermissionError) {
      this.logger.error(`Revisa CONSTAIA_API_KEY: ${error.code} (${error.requestId})`);
      return send(500, "misconfigured", "Error de configuración del servidor.");
    }
    // APIError, APIConnectionError, APITimeoutError
    this.logger.error(`${error.name} ${error.code} (${error.requestId})`);
    return send(502, "upstream_error", "No se ha podido analizar el documento. Inténtalo de nuevo.");
  }
}

Servicio y controlador

src/documents/documents.service.ts
import { Injectable } from "@nestjs/common";
import { Constaia, type Analysis } from "@constaia/sdk";

// Lo que ve el navegador (y 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 };
}

@Injectable()
export class DocumentsService {
  constructor(private readonly constaia: Constaia) {}

  async verifyDni(file: Express.Multer.File, holderName?: string): Promise<Analysis> {
    const analysis = await this.constaia.analyze(file.buffer, {
      filename: file.originalname,
      expect: "es_dni",
      checks: { notExpired: true, ...(holderName ? { holder: { fullName: holderName } } : {}) },
      storage: "none",
      language: "es",
    });
    // Guarda analysis.id y analysis.verdict en tu base de datos.
    return analysis;
  }
}
src/documents/documents.controller.ts
import {
  BadRequestException,
  Body,
  Controller,
  HttpCode,
  Post,
  UploadedFile,
  UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { DocumentsService, publicResult } from "./documents.service";

@Controller("api")
export class DocumentsController {
  constructor(private readonly documents: DocumentsService) {}

  // Añade aquí tu guard de autenticación y un throttler: cada llamada gasta créditos.
  @Post("verify-dni")
  @HttpCode(200)
  @UseInterceptors(FileInterceptor("file", { limits: { fileSize: 20 * 1024 * 1024, files: 1 } }))
  async verifyDni(
    @UploadedFile() file: Express.Multer.File | undefined,
    @Body("full_name") fullName?: string,
  ) {
    if (!file) {
      throw new BadRequestException({ error: { code: "missing_file", message: "Falta el fichero." } });
    }
    // Mejor aún: el nombre del usuario autenticado, no el del formulario.
    const analysis = await this.documents.verifyDni(file, fullName?.trim());
    return publicResult(analysis);
  }
}
  • FileInterceptor usa multer en memoria: file.buffer y file.originalname van directos al SDK. Si el fichero supera fileSize, Nest responde 413.
  • El servidor fija expect y checks; nunca los tomes del cliente.
  • Si el análisis tarda más de 30 s, status llega como queued o processing y el resultado final lo recibes por webhook.

Webhook

src/webhooks/webhooks.controller.ts
import {
  BadRequestException,
  Controller,
  Headers,
  HttpCode,
  Logger,
  Post,
  type RawBodyRequest,
  Req,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Request } from "express";
import { Constaia, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";

@Controller("webhooks")
export class WebhooksController {
  private readonly logger = new Logger(WebhooksController.name);
  // En producción, una tabla con restricción única sobre webhook-id.
  private readonly processed = new Set<string>();

  constructor(
    private readonly constaia: Constaia,
    private readonly config: ConfigService,
  ) {}

  @Post("constaia")
  @HttpCode(200)
  async handle(@Req() req: RawBodyRequest<Request>, @Headers("webhook-id") webhookId: string) {
    let event: WebhookEvent;
    try {
      event = await this.constaia.webhooks.verify(
        req.rawBody ?? "",
        req.headers,
        this.config.getOrThrow<string>("CONSTAIA_WEBHOOK_SECRET"),
      );
    } catch (error) {
      if (error instanceof WebhookVerificationError) throw new BadRequestException("Invalid signature");
      throw error;
    }

    if (this.processed.has(webhookId)) return;

    switch (event.type) {
      case "analysis.completed":
        this.logger.log(`analysis.completed ${event.data.id} ${event.data.verdict?.status}`);
        break;
      case "analysis.review_required":
        this.logger.log(`A revisión manual: ${event.data.id}`);
        break;
      case "analysis.failed":
        this.logger.warn(`analysis.failed ${event.data.id} ${event.data.error?.code}`);
        break;
      case "credits.low":
        this.logger.warn("Quedan pocos créditos: recarga en https://app.constaia.com");
        break;
    }
    this.processed.add(webhookId);
  }
}

req.rawBody es el Buffer exacto que llegó. req.body también existe (Nest lo parsea igualmente), pero reserializarlo cambia los bytes y rompe la firma.

Módulo raíz y arranque

src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ConstaiaModule } from "./constaia/constaia.module";
import { DocumentsController } from "./documents/documents.controller";
import { DocumentsService } from "./documents/documents.service";
import { WebhooksController } from "./webhooks/webhooks.controller";

@Module({
  imports: [ConfigModule.forRoot({ isGlobal: true }), ConstaiaModule],
  controllers: [DocumentsController, WebhooksController],
  providers: [DocumentsService],
})
export class AppModule {}
src/main.ts
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { AppModule } from "./app.module";
import { ConstaiaExceptionFilter } from "./constaia/constaia-exception.filter";

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
  app.useBodyParser("json", { limit: "1mb" });
  app.useGlobalFilters(new ConstaiaExceptionFilter());
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
  • rawBody: true guarda el cuerpo sin parsear en req.rawBody para todas las rutas con parser JSON.
  • useBodyParser("json", { limit: "1mb" }) sube el límite por defecto (100 KB), por si un evento trae un análisis grande.

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

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.

Probar en modo test

npm run start:dev
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 el webhook, firma un cuerpo con signWebhook y envíalo a la 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

  • Guard de autenticación y @nestjs/throttler en DocumentsController; el webhook queda fuera del guard (se autentica con la firma).
  • Límite de subida de 20 MB en FileInterceptor y algo más (21 MB) en el proxy.
  • 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 persistente (restricción única sobre webhook-id).
  • Clave ck_live_ solo en el entorno del servidor.
  • Alerta ante InsufficientCreditsError y el evento credits.low.

Siguientes pasos

En esta página