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:
- Tu componente Angular sube el archivo a tu backend (
POST /api/constaia). - 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>(conCUSTOM_ELEMENTS_SCHEMA), o una variante conHttpClienty 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/sdkCONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...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.mjsrequireUser(tuyo) comprueba la sesión y deja el usuario enres.locals.user.saveVerification(),markEventProcessed()yupdateVerification()son tu base de datos.- El widget envía
optionsconexpectylanguage, pero el servidor no se fía: fijaexpectychecksy solo usa el idioma. - El webhook usa
express.raw()para verificar la firma sobre el cuerpo crudo. Regístralo antes de cualquierexpress.json()global.
| Error del SDK | HTTP hacia Angular | Qué significa |
|---|---|---|
InvalidRequestError | el mismo (400, 409, 413, 415, 422) | Archivo o petición no válidos. El usuario puede corregirlo. |
RateLimitError | 429 + Retry-After | Superaste las peticiones por segundo de tu clave. |
InsufficientCreditsError | 503 | Sin créditos: avisa a tu equipo. |
AuthenticationError, PermissionError | 500 | Clave ausente, revocada o incorrecta. |
APITimeoutError | 504 | El SDK agotó su timeout. |
APIError, APIConnectionError | 502 | Error 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:
{
"/api": { "target": "http://localhost:3000", "secure": false }
}npm i @constaia/widget
ng serve --proxy-config proxy.conf.jsonEn 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.
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.
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.
| Archivo | verdict.status | Motivo principal |
|---|---|---|
dni_valid.jpg | Válido | not_expired (info): "Vigente hasta el 12/03/2031." |
dni_expired.jpg | No válido | not_expired (error): "Caducado el 15/06/2020." |
blurry.jpg | Revisar | low_quality (warning); warnings: blurry, low_quality |
foto.jpg (otro nombre) | No válido | type_mismatch: se detecta generic |
Más nombres en Modo test.
Producción
- Autentica y limita
/api/constaiaen 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.tsni 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
SolidStart
Valida documentos en SolidStart con una API route (APIEvent), una server action con "use server", el widget como web component y un webhook firmado.
React
Añade el widget de Constaia a una app React con el componente ConstaiaUpload y el hook useConstaiaUpload, subiendo los archivos a tu propio backend.