Constaia
Integrations

Angular

Integrate Constaia in Angular 18+ with the custom element and CUSTOM_ELEMENTS_SCHEMA, or with HttpClient, always uploading to your backend (Express example).

Esta página ainda não está traduzida para o seu idioma. Mostramos a versão em inglês.

Angular runs in the browser, so it never calls Constaia directly: the API key cannot be in the bundle. The flow is always:

  1. Your Angular component uploads the file to your backend (POST /api/constaia).
  2. 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> (with CUSTOM_ELEMENTS_SCHEMA), or a variant with HttpClient and 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/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: "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.mjs
  • requireUser (yours) checks the session and puts the user in res.locals.user. saveVerification(), markEventProcessed() and updateVerification() are your database.
  • The widget sends options with expect and language, but the server does not trust it: it sets expect and checks and only uses the language.
  • The webhook uses express.raw() to verify the signature over the raw body. Register it before any global express.json().
SDK errorHTTP to AngularMeaning
InvalidRequestErrorthe same (400, 409, 413, 415, 422)Invalid file or request. The user can fix it.
RateLimitError429 + Retry-AfterYou exceeded your key's requests per second.
InsufficientCreditsError503No credits: alert your team.
AuthenticationError, PermissionError500Missing, revoked or wrong key.
APITimeoutError504The SDK hit its timeout.
APIError, APIConnectionError502Constaia 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:

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

In 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.

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>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.

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>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.

Fileverdict.statusMain reason
dni_valid.jpgVálidonot_expired (info): "Valid until 12/03/2031."
dni_expired.jpgNo válidonot_expired (error): expired on 15/06/2020
blurry.jpgRevisarlow_quality (warning); warnings: blurry, low_quality
photo.jpg (any other name)No válidotype_mismatch: generic is detected

More names in Test mode.

Production

  • Authenticate and rate-limit /api/constaia on 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.ts or any Angular file.
  • A webhook endpoint created with the live key: test endpoints do not receive live events.
  • Review Rate limits and Webhooks.

Next steps

Nesta página