Angular
Integrate Constaia in Angular 18+ with the custom element and CUSTOM_ELEMENTS_SCHEMA, or with HttpClient, always uploading to your backend (Express example).
Angular runs in the browser, so it never calls Constaia directly: the API key cannot be in the bundle. The flow is always:
- Your Angular component uploads the file to your backend (
POST /api/constaia). - Your backend calls Constaia with the key, decides which document it expects and what it checks, and returns the analysis.
In this guide you build both pieces:
- A standalone component with the widget
<constaia-upload>(withCUSTOM_ELEMENTS_SCHEMA), or a variant withHttpClientand your own<input type="file">. - A minimal Node and Express backend with the upload route and the webhook. The full guide is in Express.
Requirements
- Angular 18 or later with standalone components.
- Node ≥ 18 for the backend.
- A
ck_test_...test key from the dashboard.
1. Minimal backend (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: "The file is missing." } });
}
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: "The file is larger than 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", "Too many requests. Try again in a few seconds.");
}
if (err instanceof InsufficientCreditsError) return reply(503, "verification_unavailable", "Verification is not available right now.");
if (err instanceof AuthenticationError || err instanceof PermissionError) return reply(500, "server_misconfigured", "Server configuration error.");
if (err instanceof APITimeoutError) return reply(504, "timeout", "Verification took too long. Please try again.");
if (err instanceof ConstaiaError) return reply(502, "upstream_error", "The document could not be verified. Please try again.");
return reply(500, "internal_error", "Unexpected error.");
}
function languageFrom(raw) {
try {
const value = JSON.parse(raw ?? "{}").language;
return ["es", "en", "pt", "fr"].includes(value) ? value : "en";
} catch {
return "en";
}
}
app.listen(3000, () => console.log("API on http://localhost:3000"));node --env-file=.env server.mjsrequireUser(yours) checks the session and puts the user inres.locals.user.saveVerification(),markEventProcessed()andupdateVerification()are your database.- The widget sends
optionswithexpectandlanguage, but the server does not trust it: it setsexpectandchecksand only uses the language. - The webhook uses
express.raw()to verify the signature over the raw body. Register it before any globalexpress.json().
| SDK error | HTTP to Angular | Meaning |
|---|---|---|
InvalidRequestError | the same (400, 409, 413, 415, 422) | Invalid file or request. The user can fix it. |
RateLimitError | 429 + Retry-After | You exceeded your key's requests per second. |
InsufficientCreditsError | 503 | No credits: alert your team. |
AuthenticationError, PermissionError | 500 | Missing, revoked or wrong key. |
APITimeoutError | 504 | The SDK hit its timeout. |
APIError, APIConnectionError | 502 | Constaia 5xx or network error. |
2. Development proxy
So that Angular and the backend share an origin (and session cookies) in development, forward /api to the backend:
{
"/api": { "target": "http://localhost:3000", "secure": false }
}npm i @constaia/widget
ng serve --proxy-config proxy.conf.jsonIn production, serve Angular and the backend under the same domain (or use the widget's with-credentials attribute for a backend on another origin with CORS).
3. Option A: the widget as a custom element
Import @constaia/widget once (it registers <constaia-upload>) and add CUSTOM_ELEMENTS_SCHEMA to the component. Do not use the (constaia:result)="…" syntax: in Angular, a colon in an event means "target:event" (like window:resize). Listen with addEventListener on a reference to the element.
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>Verify your ID</h1>
<constaia-upload #uploader endpoint="/api/constaia" document="es_dni" lang="en"></constaia-upload>
@if (verdict() === "valid") {
<a routerLink="/signup/details">Continue</a>
} @else if (verdict() === "review") {
<p>We could not read it well. Take another photo in good light, without glare.</p>
} @else if (verdict() === "invalid") {
<button type="button" (click)="retry()">Try another document</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);
}
}With document="es_dni" the widget asks for both sides, checks image quality and merges them into one JPEG before uploading. If your backend authenticates with a token instead of cookies, pass it through the element's headers property, for example [headers]="{ Authorization: 'Bearer ' + token }".
4. Option B: HttpClient and your own form
If you want your own UI, upload the file with HttpClient to the same endpoint. You need provideHttpClient() in 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>Verifying…</p>
}
@if (verdict()) {
<p>Result: {{ 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 ?? "The document could not be verified."]);
this.sending.set(false);
},
});
}
}Do not set the Content-Type header: the browser sets it with the multipart boundary. With a single file, for both sides of the ID ask for one image with both or a two-page PDF.
The browser's verdict is not proof
Anyone can tamper with what Angular sees. When the user moves on, your backend must read the result it stored in saveVerification() (or GET /v1/analyses/{id}), not the one the client sends.
5. Test mode
With ck_test_... no credits are spent and the result depends on the file name, which must be a real image or PDF. The widget merges both sides into a JPEG named after the front.
| File | verdict.status | Main reason |
|---|---|---|
dni_valid.jpg | Válido | not_expired (info): "Valid until 12/03/2031." |
dni_expired.jpg | No válido | not_expired (error): expired on 15/06/2020 |
blurry.jpg | Revisar | low_quality (warning); warnings: blurry, low_quality |
photo.jpg (any other name) | No válido | type_mismatch: generic is detected |
More names in Test mode.
Production
- Authenticate and rate-limit
/api/constaiaon the backend (for example with a per-user limiter): every call spends credits. - Body size: multer already accepts 20 MB; raise your proxy limit too (nginx:
client_max_body_size 25m;). - Timeouts: a synchronous analysis waits up to 30 s and the SDK uses 60 s per attempt. Tune the proxy timeout (for example
proxy_read_timeout 90s;). - Live key only in the production backend environment, never in
environment.tsor any Angular file. - A webhook endpoint created with the live key: test endpoints do not receive live events.
- Review Rate limits and Webhooks.