Constaia
Integrations

Next.js

Validate documents in Next.js App Router with a route handler, a server action with useActionState, the React widget and a signed webhook.

Cette page n'est pas encore traduite dans votre langue. Voici la version anglaise.

In this guide you add Spanish ID card (DNI) verification to a Next.js App Router app:

  • A route handler app/api/constaia/route.ts that receives the file and calls Constaia with the JavaScript SDK.
  • A page with the widget <ConstaiaUpload> (client component) that uploads the file to that route handler.
  • A variant without the widget: a form with a server action and useActionState.
  • A webhook at app/api/webhooks/constaia/route.ts that verifies the signature against the raw body.

The API key only lives on the server. The browser talks to your route handler, never to api.constaia.com.

Requirements

  • Next.js 15 or 16 with App Router, Node.js runtime (Node ≥ 18).
  • A ck_test_... test key from the dashboard. No account yet? Sign up.

Install

npm i @constaia/sdk @constaia/widget server-only

Environment variables

.env.local
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

Never with NEXT_PUBLIC_

Do not put the key in a NEXT_PUBLIC_* variable: Next.js would inline it into the JavaScript the browser downloads and anyone could spend your credits. Without that prefix the variable only exists on the server.

1. Shared client

Create the client lazily: the constructor throws if CONSTAIA_API_KEY is missing, and this way it does not break next build in environments without the variable. server-only makes the build fail if someone imports this module from a client component.

lib/constaia.ts
import "server-only";
import { Constaia } from "@constaia/sdk";

let client: Constaia | undefined;

export function getConstaia(): Constaia {
  client ??= new Constaia({ apiKey: process.env.CONSTAIA_API_KEY });
  return client;
}

2. SDK errors to HTTP responses

The widget shows the user the error.message your backend returns (with a non-2xx status). This helper maps each SDK error class to an HTTP status and a message that makes sense to the end user. Errors that are your fault (misconfigured key, no credits) are not explained to the user: they are logged with the requestId.

lib/constaia-errors.ts
import "server-only";
import {
  APITimeoutError,
  AuthenticationError,
  ConstaiaError,
  InsufficientCreditsError,
  InvalidRequestError,
  PermissionError,
  RateLimitError,
} from "@constaia/sdk";

export interface HttpError {
  status: number;
  body: { error: { code: string; message: string } };
  retryAfter?: number;
}

function fail(status: number, code: string, message: string, retryAfter?: number): HttpError {
  return { status, body: { error: { code, message } }, retryAfter };
}

export function toHttpError(err: unknown): HttpError {
  if (err instanceof ConstaiaError) {
    console.error("constaia", err.status, err.code, err.requestId, err.message);
  } else {
    console.error(err);
  }

  if (err instanceof InvalidRequestError) {
    return fail(err.status ?? 400, err.code ?? "invalid_request", err.message);
  }
  if (err instanceof RateLimitError) {
    return fail(429, "rate_limited", "Too many requests. Try again in a few seconds.", err.retryAfter);
  }
  if (err instanceof InsufficientCreditsError) {
    return fail(503, "verification_unavailable", "Verification is not available right now.");
  }
  if (err instanceof AuthenticationError || err instanceof PermissionError) {
    return fail(500, "server_misconfigured", "Server configuration error.");
  }
  if (err instanceof APITimeoutError) {
    return fail(504, "timeout", "Verification took too long. Please try again.");
  }
  if (err instanceof ConstaiaError) {
    return fail(502, "upstream_error", "The document could not be verified. Please try again.");
  }
  return fail(500, "internal_error", "Unexpected error.");
}

export function languageFrom(raw: unknown): "es" | "en" | "pt" | "fr" {
  try {
    const value = JSON.parse(String(raw ?? "{}")).language;
    return ["es", "en", "pt", "fr"].includes(value) ? value : "en";
  } catch {
    return "en";
  }
}
SDK errorHTTP to your frontendMeaning
InvalidRequestErrorthe same (400, 409, 413, 415, 422)The file or request is not acceptable (empty, unsupported format, unreadable PDF…). The user can fix it.
RateLimitError429 + Retry-AfterYou exceeded your key's requests per second.
InsufficientCreditsError503You are out of credits. Alert your team, not the user.
AuthenticationError, PermissionError500Missing, revoked or wrong key.
APITimeoutError504The SDK hit its timeout (60 s by default) after its retries.
APIError, APIConnectionError502Constaia 5xx or network error.

The full list of codes is in Errors.

3. Upload route handler

The widget sends multipart/form-data with a file field and an options field (JSON with expect and language). Do not trust options: anyone can edit the request. Here the server decides which document it expects and which checks apply; only the message language is taken from the client.

app/api/constaia/route.ts
import { getConstaia } from "@/lib/constaia";
import { languageFrom, toHttpError } from "@/lib/constaia-errors";
import { getCurrentUser } from "@/lib/auth";
import { saveVerification } from "@/lib/verifications";

export const runtime = "nodejs";
export const maxDuration = 60;

export async function POST(req: Request) {
  const user = await getCurrentUser();
  if (!user) {
    return Response.json({ error: { code: "unauthorized", message: "Please sign in to continue." } }, { status: 401 });
  }

  const form = await req.formData();
  const file = form.get("file");
  if (!(file instanceof File) || file.size === 0) {
    return Response.json({ error: { code: "file_required", message: "The file is missing." } }, { status: 400 });
  }

  try {
    const analysis = await getConstaia().analyze(file, {
      expect: "es_dni",
      checks: { notExpired: true, minAgeYears: 18 },
      language: languageFrom(form.get("options")),
      metadata: { user_id: String(user.id) },
    });
    await saveVerification(user.id, analysis);
    return Response.json(analysis, { status: analysis.status === "completed" ? 200 : 202 });
  } catch (err) {
    const e = toHttpError(err);
    const headers = e.retryAfter ? { "Retry-After": String(e.retryAfter) } : undefined;
    return Response.json(e.body, { status: e.status, headers });
  }
}

getCurrentUser() and saveVerification() are your own functions (your session system and your database). Storing the analysis.id and verdict.status against the user lets the server decide later whether they can continue.

A synchronous analysis waits up to 30 s. If Constaia has not finished, it returns 202 with status: "queued" or "processing"; the widget then shows a "queued" message and the result reaches you by webhook. That is why maxDuration = 60 leaves plenty of room.

4. The widget in a client component

@constaia/widget/react registers <constaia-upload> only in the browser, so it works with server rendering. The useConstaiaUpload hook gives you the state and result to react in your UI.

app/verify/verify-document.tsx
"use client";

import { ConstaiaUpload, useConstaiaUpload } from "@constaia/widget/react";

export function VerifyDocument() {
  const { ref, result, error, reset } = useConstaiaUpload();
  const verdict = result?.verdict?.status;

  return (
    <section>
      <ConstaiaUpload
        ref={ref}
        endpoint="/api/constaia"
        document="es_dni"
        lang="en"
        onError={(e) => console.warn(e.code, e.message)}
      />
      {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>
  );
}
app/verify/page.tsx
import { VerifyDocument } from "./verify-document";

export default function VerifyPage() {
  return (
    <main style={{ maxWidth: 560, margin: "0 auto", padding: 16 }}>
      <h1>Verify your ID</h1>
      <VerifyDocument />
    </main>
  );
}

With document="es_dni" the widget asks for front and back and merges them into a single JPEG before uploading. The "Continue" link is only a UI convenience: on /signup/details, check on the server the result you stored in saveVerification(), not what the browser says.

5. Variant: server action with useActionState

If you prefer a plain form without the widget, a server action receives the FormData directly.

app/verify-form/actions.ts
"use server";

import { getConstaia } from "@/lib/constaia";
import { toHttpError } from "@/lib/constaia-errors";
import { getCurrentUser } from "@/lib/auth";
import { saveVerification } from "@/lib/verifications";

export type VerifyState =
  | { status: "idle" }
  | { status: "pending" }
  | { status: "valid" | "invalid" | "review" | "error"; messages: string[] };

export async function verifyDocument(_prev: VerifyState, formData: FormData): Promise<VerifyState> {
  const user = await getCurrentUser();
  if (!user) return { status: "error", messages: ["Please sign in to continue."] };

  const file = formData.get("file");
  if (!(file instanceof File) || file.size === 0) {
    return { status: "error", messages: ["Choose a photo or a PDF of the document."] };
  }

  try {
    const analysis = await getConstaia().analyze(file, {
      expect: "es_dni",
      checks: { notExpired: true, minAgeYears: 18 },
      language: "en",
      metadata: { user_id: String(user.id) },
    });
    await saveVerification(user.id, analysis);

    if (analysis.status === "failed") {
      return { status: "error", messages: [analysis.error?.message ?? "The document could not be analysed."] };
    }
    if (analysis.status !== "completed" || !analysis.verdict) return { status: "pending" };

    return {
      status: analysis.verdict.status,
      messages: analysis.verdict.reasons.filter((r) => r.severity !== "info").map((r) => r.message),
    };
  } catch (err) {
    return { status: "error", messages: [toHttpError(err).body.error.message] };
  }
}
app/verify-form/verify-form.tsx
"use client";

import { useActionState } from "react";
import { type VerifyState, verifyDocument } from "./actions";

const initialState: VerifyState = { status: "idle" };

export function VerifyForm() {
  const [state, formAction, isPending] = useActionState(verifyDocument, initialState);

  return (
    <form action={formAction}>
      <input type="file" name="file" accept="image/jpeg,image/png,image/webp,image/heic,application/pdf" required />
      <button type="submit" disabled={isPending}>
        {isPending ? "Verifying…" : "Verify"}
      </button>

      {state.status === "valid" && <p>Valid document.</p>}
      {state.status === "pending" && <p>We are checking it. We will let you know when it is done.</p>}
      {"messages" in state && state.status !== "valid" && (
        <ul>
          {state.messages.map((m) => (
            <li key={m}>{m}</li>
          ))}
        </ul>
      )}
    </form>
  );
}
app/verify-form/page.tsx
import { VerifyForm } from "./verify-form";

export const maxDuration = 60;

export default function VerifyFormPage() {
  return (
    <main>
      <h1>Upload your ID</h1>
      <VerifyForm />
    </main>
  );
}

Next.js limits the server action body to 1 MB by default. Raise it to accept files up to 20 MB:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    serverActions: { bodySizeLimit: "25mb" },
  },
};

export default nextConfig;

Here the user uploads a single file: for a two-sided ID, ask for one image with both sides or a two-page PDF (1 credit).

6. Webhook

You receive the result of analyses that did not finish within 30 s, plus the analysis.review_required and analysis.failed events. The signature is computed over the raw body: read req.text() and do not call req.json() before verifying.

app/api/webhooks/constaia/route.ts
import { after } from "next/server";
import { type Analysis, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
import { getConstaia } from "@/lib/constaia";
import { markEventProcessed, updateVerification } from "@/lib/verifications";

export const runtime = "nodejs";

export async function POST(req: Request) {
  const secret = process.env.CONSTAIA_WEBHOOK_SECRET;
  if (!secret) return new Response("CONSTAIA_WEBHOOK_SECRET is not set", { status: 500 });

  const raw = await req.text();
  let event: WebhookEvent;
  try {
    event = await getConstaia().webhooks.verify(raw, req.headers, secret);
  } catch (err) {
    if (err instanceof WebhookVerificationError) return new Response("invalid signature", { status: 400 });
    throw err;
  }

  const messageId = req.headers.get("webhook-id") ?? "";

  after(async () => {
    if (!(await markEventProcessed(messageId))) return;
    switch (event.type) {
      case "analysis.completed":
      case "analysis.review_required":
      case "analysis.failed":
        await updateVerification(event.data as Analysis);
        break;
    }
  });

  return new Response(null, { status: 204 });
}
  • after() answers 204 immediately and processes afterwards: Constaia treats a delivery that takes longer than 15 s as failed.
  • markEventProcessed() is yours: insert the webhook-id into a table with a unique key and return false if it already existed. Retries carry the same webhook-id.
  • analysis.review_required arrives in addition to analysis.completed, so updateVerification() must be idempotent.

Register the URL (https://your-domain.com/api/webhooks/constaia) in the dashboard or with constaia.webhookEndpoints.create(), and store the secret, which is shown only once. An endpoint created with a test key only receives test events. More in Webhooks.

To test the route locally without exposing it to the internet, sign an event yourself with signWebhook:

scripts/send-test-webhook.mjs
import { signWebhook } from "@constaia/sdk";

const payload = JSON.stringify({
  type: "analysis.completed",
  created_at: new Date().toISOString(),
  data: { id: "an_test", object: "analysis", status: "completed" },
});
const headers = await signWebhook(payload, process.env.CONSTAIA_WEBHOOK_SECRET);

const res = await fetch("http://localhost:3000/api/webhooks/constaia", {
  method: "POST",
  headers: { ...headers, "content-type": "application/json" },
  body: payload,
});
console.log(res.status);
node --env-file=.env.local scripts/send-test-webhook.mjs

7. Test mode

With a ck_test_... key no credits are spent and the response depends on the file name. The file must be a real JPEG, PNG, WEBP, HEIC or PDF: rename any photo. With document="es_dni" the widget merges both sides into a JPEG named after the front, so the front's name is the one that counts.

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

All available names are in Test mode.

Production

  • Authenticate and rate-limit app/api/constaia and the server action: every call spends credits. Require a session and apply a per-user limit (for example, a few attempts per minute) with your middleware or rate-limiting store.
  • Body size: route handlers have no limit of their own, but your platform may (for example, Vercel limits function bodies to 4.5 MB). If your platform caps below 20 MB, set the widget's max-size-mb to match. If you have middleware (proxy in Next.js 16), exclude these routes from its matcher so its body limit does not apply. For server actions, serverActions.bodySizeLimit.
  • Timeouts: maxDuration = 60 on the route and on the server action page. The SDK has a 60 s timeout per attempt and 2 retries; tune timeout and maxRetries if your platform cuts off earlier.
  • Live key only in production environment variables, and a webhook endpoint created with the live key (test endpoints do not receive live events).
  • Decide on the server: your code sets expect and checks, and the next step of the flow reads the stored result (or GET /v1/analyses/{id}), never the verdict the browser sends back.
  • Review Rate limits and Storage and privacy (storage: "none" by default).

Next steps

Sur cette page