Constaia
Integraciones

Angular

Integra Constaia en Angular 18+ con el custom element y CUSTOM_ELEMENTS_SCHEMA, o con HttpClient, subiendo siempre a tu backend (ejemplo con Express).

Angular se ejecuta en el navegador, así que nunca llama a Constaia directamente: la clave de API no puede estar en el bundle. El flujo es siempre:

  1. Tu componente Angular sube el archivo a tu backend (POST /api/constaia).
  2. Tu backend llama a Constaia con la clave, decide qué documento espera y qué comprueba, y devuelve el análisis.

En esta guía montas las dos piezas:

  • Un componente standalone con el widget <constaia-upload> (con CUSTOM_ELEMENTS_SCHEMA), o una variante con HttpClient y tu propio <input type="file">.
  • Un backend mínimo con Node y Express, con la ruta de subida y el webhook. La guía completa está en Express.

Requisitos

  • Angular 18 o superior con componentes standalone.
  • Node ≥ 18 para el backend.
  • Una clave de test ck_test_... del panel.

1. Backend mínimo (Express)

npm i express multer @constaia/sdk
server/.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...
server/server.mjs
import express from "express";
import multer from "multer";
import {
  APITimeoutError,
  AuthenticationError,
  Constaia,
  ConstaiaError,
  InsufficientCreditsError,
  InvalidRequestError,
  PermissionError,
  RateLimitError,
  WebhookVerificationError,
} from "@constaia/sdk";
import { requireUser } from "./auth.mjs";
import { markEventProcessed, saveVerification, updateVerification } from "./verifications.mjs";

const constaia = new Constaia({ apiKey: process.env.CONSTAIA_API_KEY });
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
const app = express();

app.post("/api/constaia", requireUser, upload.single("file"), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: { code: "file_required", message: "Falta el archivo." } });
  }
  try {
    const analysis = await constaia.analyze(req.file.buffer, {
      filename: req.file.originalname,
      expect: "es_dni",
      checks: { notExpired: true, minAgeYears: 18 },
      language: languageFrom(req.body.options),
      metadata: { user_id: String(res.locals.user.id) },
    });
    await saveVerification(res.locals.user.id, analysis);
    res.status(analysis.status === "completed" ? 200 : 202).json(analysis);
  } catch (err) {
    sendError(res, err);
  }
});

app.post("/api/webhooks/constaia", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = await constaia.webhooks.verify(req.body, req.headers, process.env.CONSTAIA_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(err instanceof WebhookVerificationError ? 400 : 500).send("invalid webhook");
  }
  try {
    if (await markEventProcessed(req.get("webhook-id"))) {
      if (["analysis.completed", "analysis.review_required", "analysis.failed"].includes(event.type)) {
        await updateVerification(event.data);
      }
    }
    res.sendStatus(204);
  } catch (err) {
    console.error(err);
    res.sendStatus(500);
  }
});

app.use((err, _req, res, next) => {
  if (err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE") {
    return res.status(413).json({ error: { code: "file_too_large", message: "El archivo supera los 20 MB." } });
  }
  next(err);
});

function sendError(res, err) {
  if (err instanceof ConstaiaError) console.error("constaia", err.status, err.code, err.requestId, err.message);
  else console.error(err);

  const reply = (status, code, message) => res.status(status).json({ error: { code, message } });
  if (err instanceof InvalidRequestError) return reply(err.status ?? 400, err.code ?? "invalid_request", err.message);
  if (err instanceof RateLimitError) {
    if (err.retryAfter) res.set("Retry-After", String(err.retryAfter));
    return reply(429, "rate_limited", "Hay mucha demanda. Vuelve a intentarlo en unos segundos.");
  }
  if (err instanceof InsufficientCreditsError) return reply(503, "verification_unavailable", "La verificación no está disponible ahora mismo.");
  if (err instanceof AuthenticationError || err instanceof PermissionError) return reply(500, "server_misconfigured", "Error de configuración del servidor.");
  if (err instanceof APITimeoutError) return reply(504, "timeout", "La verificación ha tardado demasiado. Inténtalo de nuevo.");
  if (err instanceof ConstaiaError) return reply(502, "upstream_error", "No se ha podido verificar el documento. Inténtalo de nuevo.");
  return reply(500, "internal_error", "Error inesperado.");
}

function languageFrom(raw) {
  try {
    const value = JSON.parse(raw ?? "{}").language;
    return ["es", "en", "pt", "fr"].includes(value) ? value : "es";
  } catch {
    return "es";
  }
}

app.listen(3000, () => console.log("API en http://localhost:3000"));
node --env-file=.env server.mjs
  • requireUser (tuyo) comprueba la sesión y deja el usuario en res.locals.user. saveVerification(), markEventProcessed() y updateVerification() son tu base de datos.
  • El widget envía options con expect y language, pero el servidor no se fía: fija expect y checks y solo usa el idioma.
  • El webhook usa express.raw() para verificar la firma sobre el cuerpo crudo. Regístralo antes de cualquier express.json() global.
Error del SDKHTTP hacia AngularQué significa
InvalidRequestErrorel mismo (400, 409, 413, 415, 422)Archivo o petición no válidos. El usuario puede corregirlo.
RateLimitError429 + Retry-AfterSuperaste las peticiones por segundo de tu clave.
InsufficientCreditsError503Sin créditos: avisa a tu equipo.
AuthenticationError, PermissionError500Clave ausente, revocada o incorrecta.
APITimeoutError504El SDK agotó su timeout.
APIError, APIConnectionError502Error 5xx de Constaia o de red.

2. Proxy de desarrollo

Para que Angular y el backend compartan origen (y las cookies de sesión) en desarrollo, redirige /api al backend:

proxy.conf.json
{
  "/api": { "target": "http://localhost:3000", "secure": false }
}
npm i @constaia/widget
ng serve --proxy-config proxy.conf.json

En producción, sirve Angular y el backend bajo el mismo dominio (o usa el atributo with-credentials del widget para un backend en otro origen con CORS).

3. Opción A: el widget como custom element

Importa @constaia/widget una vez (registra <constaia-upload>) y añade CUSTOM_ELEMENTS_SCHEMA al componente. No uses la sintaxis (constaia:result)="…": en Angular, los dos puntos en un evento significan "objetivo:evento" (como window:resize). Escucha con addEventListener sobre una referencia al elemento.

src/app/verify/verify.component.ts
import "@constaia/widget";
import {
  AfterViewInit,
  Component,
  CUSTOM_ELEMENTS_SCHEMA,
  ElementRef,
  OnDestroy,
  signal,
  ViewChild,
} from "@angular/core";
import { RouterLink } from "@angular/router";
import type { Analysis, ConstaiaUploadElement, WidgetErrorDetail } from "@constaia/widget";

@Component({
  selector: "app-verify",
  standalone: true,
  imports: [RouterLink],
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <h1>Verifica tu DNI</h1>
    <constaia-upload #uploader endpoint="/api/constaia" document="es_dni" lang="es"></constaia-upload>

    @if (verdict() === "valid") {
      <a routerLink="/registro/datos">Continuar</a>
    } @else if (verdict() === "review") {
      <p>No se lee bien. Haz otra foto con buena luz y sin reflejos.</p>
    } @else if (verdict() === "invalid") {
      <button type="button" (click)="retry()">Probar con otro documento</button>
    }
  `,
})
export class VerifyComponent implements AfterViewInit, OnDestroy {
  @ViewChild("uploader") uploader!: ElementRef<ConstaiaUploadElement>;
  readonly verdict = signal<string | null>(null);

  private readonly onResult = (e: Event) =>
    this.verdict.set((e as CustomEvent<Analysis>).detail.verdict?.status ?? null);

  private readonly onError = (e: Event) => {
    const { code, message } = (e as CustomEvent<WidgetErrorDetail>).detail;
    console.warn(code, message);
  };

  ngAfterViewInit() {
    const el = this.uploader.nativeElement;
    el.addEventListener("constaia:result", this.onResult);
    el.addEventListener("constaia:error", this.onError);
  }

  ngOnDestroy() {
    const el = this.uploader.nativeElement;
    el.removeEventListener("constaia:result", this.onResult);
    el.removeEventListener("constaia:error", this.onError);
  }

  retry() {
    this.uploader.nativeElement.reset();
    this.verdict.set(null);
  }
}

Con document="es_dni" el widget pide las dos caras, comprueba la calidad de la imagen y las une en un JPEG antes de subirlo. Si tu backend autentica con un token en lugar de cookies, pásalo con la propiedad headers del elemento, por ejemplo [headers]="{ Authorization: 'Bearer ' + token }".

4. Opción B: HttpClient y tu propio formulario

Si quieres tu propia interfaz, sube el archivo con HttpClient al mismo endpoint. Necesitas provideHttpClient() en app.config.ts.

src/app/verify-http/verify-http.component.ts
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
import { Component, inject, signal } from "@angular/core";
import type { Analysis } from "@constaia/widget";

@Component({
  selector: "app-verify-http",
  standalone: true,
  template: `
    <input
      type="file"
      accept="image/jpeg,image/png,image/webp,image/heic,application/pdf"
      [disabled]="sending()"
      (change)="onFile($event)"
    />
    @if (sending()) {
      <p>Verificando…</p>
    }
    @if (verdict()) {
      <p>Resultado: {{ verdict() }}</p>
    }
    @for (message of messages(); track message) {
      <p>{{ message }}</p>
    }
  `,
})
export class VerifyHttpComponent {
  private readonly http = inject(HttpClient);
  readonly sending = signal(false);
  readonly verdict = signal<string | null>(null);
  readonly messages = signal<string[]>([]);

  onFile(event: Event) {
    const file = (event.target as HTMLInputElement).files?.[0];
    if (!file) return;

    const body = new FormData();
    body.append("file", file);
    this.sending.set(true);

    this.http.post<Analysis>("/api/constaia", body).subscribe({
      next: (analysis) => {
        this.verdict.set(analysis.verdict?.status ?? "pending");
        this.messages.set(
          (analysis.verdict?.reasons ?? []).filter((r) => r.severity !== "info").map((r) => r.message),
        );
        this.sending.set(false);
      },
      error: (err: HttpErrorResponse) => {
        this.verdict.set(null);
        this.messages.set([err.error?.error?.message ?? "No se ha podido verificar el documento."]);
        this.sending.set(false);
      },
    });
  }
}

No fijes la cabecera Content-Type: el navegador la pone con el boundary del multipart. Con un único archivo, para las dos caras del DNI pide una imagen con ambas o un PDF de dos páginas.

El veredicto del navegador no es una prueba

Cualquiera puede alterar lo que ve Angular. Cuando el usuario continúe, tu backend debe leer el resultado que guardó en saveVerification() (o GET /v1/analyses/{id}), no el que envíe el cliente.

5. Probar en modo test

Con ck_test_... no se gastan créditos y el resultado depende del nombre del archivo, que debe ser una imagen o PDF real. El widget une las dos caras en un JPEG con el nombre del anverso.

Archivoverdict.statusMotivo principal
dni_valid.jpgVálidonot_expired (info): "Vigente hasta el 12/03/2031."
dni_expired.jpgNo válidonot_expired (error): "Caducado el 15/06/2020."
blurry.jpgRevisarlow_quality (warning); warnings: blurry, low_quality
foto.jpg (otro nombre)No válidotype_mismatch: se detecta generic

Más nombres en Modo test.

Producción

  • Autentica y limita /api/constaia en el backend (por ejemplo con un limitador por usuario): cada llamada gasta créditos.
  • Tamaño del cuerpo: multer ya acepta 20 MB; sube también el límite de tu proxy (nginx: client_max_body_size 25m;).
  • Tiempos: el análisis síncrono espera hasta 30 s y el SDK usa 60 s por intento. Ajusta el timeout del proxy (por ejemplo proxy_read_timeout 90s;).
  • Clave live solo en el entorno del backend de producción, nunca en environment.ts ni en ningún fichero de Angular.
  • Un endpoint de webhook creado con la clave live: los endpoints de test no reciben eventos live.
  • Revisa Límites de uso y Webhooks.

Siguientes pasos

En esta página