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.
In this guide you add Spanish ID card (DNI) verification to a Next.js App Router app:
- A route handler
app/api/constaia/route.tsthat 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.tsthat 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-onlyEnvironment variables
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.
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.
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 error | HTTP to your frontend | Meaning |
|---|---|---|
InvalidRequestError | the same (400, 409, 413, 415, 422) | The file or request is not acceptable (empty, unsupported format, unreadable PDF…). The user can fix it. |
RateLimitError | 429 + Retry-After | You exceeded your key's requests per second. |
InsufficientCreditsError | 503 | You are out of credits. Alert your team, not the user. |
AuthenticationError, PermissionError | 500 | Missing, revoked or wrong key. |
APITimeoutError | 504 | The SDK hit its timeout (60 s by default) after its retries. |
APIError, APIConnectionError | 502 | Constaia 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.
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.
"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>
);
}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.
"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] };
}
}"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>
);
}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:
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.
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 thewebhook-idinto a table with a unique key and returnfalseif it already existed. Retries carry the samewebhook-id.analysis.review_requiredarrives in addition toanalysis.completed, soupdateVerification()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:
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.mjs7. 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.
| 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 |
All available names are in Test mode.
Production
- Authenticate and rate-limit
app/api/constaiaand 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-mbto match. If you have middleware (proxy in Next.js 16), exclude these routes from itsmatcherso its body limit does not apply. For server actions,serverActions.bodySizeLimit. - Timeouts:
maxDuration = 60on the route and on the server action page. The SDK has a 60 stimeoutper attempt and 2 retries; tunetimeoutandmaxRetriesif 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
expectandchecks, and the next step of the flow reads the stored result (orGET /v1/analyses/{id}), never the verdict the browser sends back. - Review Rate limits and Storage and privacy (
storage: "none"by default).