Constaia
Integrations

React

Add the Constaia widget to a React app with the ConstaiaUpload component and the useConstaiaUpload hook, uploading files to your own backend.

@constaia/widget/react is a thin wrapper over the <constaia-upload> web component: it captures the document (drag and drop, file picker or camera), checks image quality and uploads it to your backend. It works with React 18 and 19, and with server rendering.

You need a backend

React runs in the browser and the API key can never be there. The widget uploads the file to an endpoint of yours (/api/constaia) and that server calls Constaia. If you use Next.js or React Router, follow Next.js or Remix and React Router; for a Vite SPA, build the backend with Express (or any of the other integrations).

Install

npm i @constaia/widget

What your backend must do

The widget sends POST multipart/form-data to endpoint with two fields:

FieldContent
fileThe document. For DNI, NIE and EU ID cards, front and back merged into a single JPEG.
optionsJSON with expect and language taken from the attributes. Only a hint.

Your backend must:

  1. Authenticate the user and limit attempts (every analysis spends credits).
  2. Call Constaia with expect and checks decided on the server, not the ones the browser sends.
  3. Return the analysis as-is (JSON) with status 200, or 202 if it is still queued.
  4. On error, return a non-2xx status with { "error": { "message": "…" } }: the widget shows that message.

A minimal Express example:

server/server.mjs
import express from "express";
import multer from "multer";
import { Constaia, ConstaiaError, InvalidRequestError } from "@constaia/sdk";

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", upload.single("file"), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: { 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 },
    });
    res.status(analysis.status === "completed" ? 200 : 202).json(analysis);
  } catch (err) {
    if (err instanceof InvalidRequestError) return res.status(err.status ?? 400).json({ error: { message: err.message } });
    console.error(err instanceof ConstaiaError ? err.requestId : "", err);
    res.status(502).json({ error: { message: "The document could not be verified. Please try again." } });
  }
});

app.listen(3000);

The full version (session, every SDK error, signed webhook) is in Express.

In development, have Vite forward /api to the backend to share origin and cookies:

vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: { "/api": "http://localhost:3000" },
  },
});

The ConstaiaUpload component

src/VerifyDocument.tsx
import { ConstaiaUpload } from "@constaia/widget/react";
import { useState } from "react";

export function VerifyDocument() {
  const [verdict, setVerdict] = useState<string | null>(null);

  return (
    <section>
      <h1>Verify your ID</h1>
      <ConstaiaUpload
        endpoint="/api/constaia"
        document="es_dni"
        lang="en"
        onResult={(analysis) => setVerdict(analysis.verdict?.status ?? null)}
        onError={(error) => console.warn(error.code, error.message)}
        onStatus={(status) => status === "idle" && setVerdict(null)}
      />
      {verdict === "valid" && <a href="/signup/details">Continue</a>}
    </section>
  );
}

Props are the widget attributes in camelCase plus event handlers:

PropDescription
endpointYour backend URL.
documentDocument type for the UI (card frame, two sides). If expect is missing, it is also sent as expect.
expectExpected type or types (string or string[]), sent in options.
sides, frame1 or 2 sides; card or none frame. Automatic for DNI, NIE and EU ID cards.
accept, maxSizeMbAccepted types and maximum size (20 MB by default).
autoSubmitfalse waits for the user (or submit()) after the quality check.
withCredentialsSends cookies to a cross-origin endpoint.
lang, theme, cameraLanguage (es, en, pt, fr), theme (light, dark, auto) and camera button (auto, always, never).
headersExtra headers, for example a CSRF token.
messagesOverrides UI texts.
onFile, onQuality, onProgress, onResult, onError, onStatusWidget events. onResult receives the analysis exactly as your backend returns it.

The full reference of attributes, events and styling is in Widget.

The useConstaiaUpload hook

The hook exposes the widget state so you can build your own UI around it: ref, element, status, progress, result, error, reset() and submit(). It accepts the same attributes as the component, plus messages and headers.

src/VerifyWithControls.tsx
import { ConstaiaUpload, useConstaiaUpload } from "@constaia/widget/react";

export function VerifyWithControls({ csrfToken }: { csrfToken: string }) {
  const { ref, status, progress, result, error, reset, submit } = useConstaiaUpload({
    endpoint: "/api/constaia",
    document: "es_dni",
    lang: "en",
    autoSubmit: false,
    headers: { "X-CSRF-Token": csrfToken },
  });
  const verdict = result?.verdict?.status;

  return (
    <section>
      <ConstaiaUpload ref={ref} />

      {status === "ready" && (
        <button type="button" onClick={() => submit()}>
          Send document
        </button>
      )}
      {status === "uploading" && <progress value={progress} max={100} />}
      {status === "analyzing" && <p>Analysing…</p>}

      {verdict === "valid" && <a href="/signup/details">Continue</a>}
      {verdict === "review" && <p>We could not read it well. Take another photo in good light, without glare.</p>}
      {(verdict === "invalid" || error) && (
        <button type="button" onClick={reset}>
          Try another document
        </button>
      )}
    </section>
  );
}

States are idle, checking, quality, ready, uploading, analyzing, done and error. If the analysis takes longer than 30 s, your backend returns the analysis as queued or processing and the widget shows a "queued" notice; the final result reaches your backend by webhook.

Do not trust the result in the browser

result is for the UI. When the user moves on, your backend must decide with the result it stored when calling Constaia (or with GET /v1/analyses/{id}), never with a verdict the browser sends back.

Test mode

With a ck_test_... key in your backend 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

  • The key only lives on the backend. Never in VITE_* or REACT_APP_* variables, which end up in the bundle.
  • The upload endpoint requires a session and limits attempts per user: every call spends credits.
  • Backend and proxy accept bodies of at least 20 MB (or lower maxSizeMb to your platform's limit).
  • Backend and proxy timeouts of 60 s or more: a synchronous analysis can take up to 30 s.
  • If the backend is on another domain, configure CORS with credentials and use withCredentials, or pass a token through headers.

Next steps

On this page