Next.js
Valida documentos en Next.js App Router con un route handler, una server action con useActionState, el widget de React y un webhook firmado.
En esta guía montas la verificación de un DNI en una app de Next.js con App Router:
- Un route handler
app/api/constaia/route.tsque recibe el archivo y llama a Constaia con el SDK de JavaScript. - Una página con el widget
<ConstaiaUpload>(componente de cliente) que sube el archivo a ese route handler. - Una variante sin widget: un formulario con server action y
useActionState. - Un webhook en
app/api/webhooks/constaia/route.tsque verifica la firma con el cuerpo crudo.
La clave de API solo vive en el servidor. El navegador habla con tu route handler, nunca con api.constaia.com.
Requisitos
- Next.js 15 o 16 con App Router, runtime de Node.js (Node ≥ 18).
- Una clave de test
ck_test_...del panel. Si aún no tienes cuenta, regístrate.
Instalación
npm i @constaia/sdk @constaia/widget server-onlyVariables de entorno
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...Nunca con NEXT_PUBLIC_
No pongas la clave en una variable NEXT_PUBLIC_*: Next.js la incrustaría en el JavaScript que descarga el navegador y cualquiera podría gastar tus créditos. Sin ese prefijo, la variable solo existe en el servidor.
1. Cliente compartido
Crea el cliente de forma perezosa: el constructor lanza un error si falta CONSTAIA_API_KEY, y así no rompe el next build en entornos sin la variable. server-only hace que el build falle si alguien importa este módulo desde un componente de cliente.
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. Errores del SDK a respuestas HTTP
El widget muestra al usuario el error.message que devuelva tu backend (con un estado distinto de 2xx). Este helper traduce cada clase de error del SDK a un código HTTP y a un mensaje que tiene sentido para el usuario final. Los errores que son culpa tuya (clave mal configurada, sin créditos) no se explican al usuario: se registran en tus logs con el 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", "Hay mucha demanda. Vuelve a intentarlo en unos segundos.", err.retryAfter);
}
if (err instanceof InsufficientCreditsError) {
return fail(503, "verification_unavailable", "La verificación no está disponible ahora mismo.");
}
if (err instanceof AuthenticationError || err instanceof PermissionError) {
return fail(500, "server_misconfigured", "Error de configuración del servidor.");
}
if (err instanceof APITimeoutError) {
return fail(504, "timeout", "La verificación ha tardado demasiado. Inténtalo de nuevo.");
}
if (err instanceof ConstaiaError) {
return fail(502, "upstream_error", "No se ha podido verificar el documento. Inténtalo de nuevo.");
}
return fail(500, "internal_error", "Error inesperado.");
}
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 : "es";
} catch {
return "es";
}
}| Error del SDK | HTTP hacia tu frontend | Qué significa |
|---|---|---|
InvalidRequestError | el mismo (400, 409, 413, 415, 422) | El archivo o la petición no valen (vacío, formato no admitido, PDF ilegible…). El usuario puede corregirlo. |
RateLimitError | 429 + Retry-After | Superaste las peticiones por segundo de tu clave. |
InsufficientCreditsError | 503 | Te has quedado sin créditos. Avisa a tu equipo, no al usuario. |
AuthenticationError, PermissionError | 500 | Clave ausente, revocada o incorrecta. |
APITimeoutError | 504 | El SDK agotó su timeout (60 s por defecto) tras sus reintentos. |
APIError, APIConnectionError | 502 | Error 5xx de Constaia o de red. |
La lista completa de códigos está en Errores.
3. Route handler de subida
El widget envía un multipart/form-data con el campo file y un campo options (JSON con expect y language). No te fíes de options: cualquiera puede editar la petición. Aquí el servidor decide qué documento espera y qué comprobaciones aplica; del cliente solo se toma el idioma de los mensajes.
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: "Inicia sesión para continuar." } }, { 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: "Falta el archivo." } }, { 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() y saveVerification() son funciones tuyas (tu sistema de sesión y tu base de datos). Guardar el analysis.id y el verdict.status asociados al usuario te permite decidir en el servidor, más adelante, si puede continuar.
El análisis síncrono espera hasta 30 s. Si Constaia no ha terminado, responde 202 con status: "queued" o "processing"; el widget muestra entonces un mensaje de "en cola" y el resultado te llega por webhook. Por eso maxDuration = 60 deja margen de sobra.
4. El widget en un componente de cliente
@constaia/widget/react registra <constaia-upload> solo en el navegador, así que funciona con renderizado en servidor. El hook useConstaiaUpload te da el estado y el resultado para reaccionar en tu 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="es"
onError={(e) => console.warn(e.code, e.message)}
/>
{verdict === "valid" && <a href="/registro/datos">Continuar</a>}
{verdict === "review" && <p>No se lee bien. Haz otra foto con buena luz y sin reflejos.</p>}
{(verdict === "invalid" || error) && (
<button type="button" onClick={reset}>
Probar con otro documento
</button>
)}
</section>
);
}import { VerifyDocument } from "./verify-document";
export default function VerifyPage() {
return (
<main style={{ maxWidth: 560, margin: "0 auto", padding: 16 }}>
<h1>Verifica tu DNI</h1>
<VerifyDocument />
</main>
);
}Con document="es_dni" el widget pide anverso y reverso y los une en un solo JPEG antes de subirlo. El botón "Continuar" es solo comodidad de UI: en /registro/datos comprueba en el servidor el resultado que guardaste en saveVerification(), no lo que diga el navegador.
5. Variante: server action con useActionState
Si prefieres un formulario normal sin widget, una server action recibe el FormData directamente.
"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: ["Inicia sesión para continuar."] };
const file = formData.get("file");
if (!(file instanceof File) || file.size === 0) {
return { status: "error", messages: ["Selecciona una foto o un PDF del documento."] };
}
try {
const analysis = await getConstaia().analyze(file, {
expect: "es_dni",
checks: { notExpired: true, minAgeYears: 18 },
metadata: { user_id: String(user.id) },
});
await saveVerification(user.id, analysis);
if (analysis.status === "failed") {
return { status: "error", messages: [analysis.error?.message ?? "No se ha podido analizar el documento."] };
}
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 ? "Verificando…" : "Verificar"}
</button>
{state.status === "valid" && <p>Documento válido.</p>}
{state.status === "pending" && <p>Lo estamos revisando. Te avisaremos al terminar.</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>Sube tu DNI</h1>
<VerifyForm />
</main>
);
}Next.js limita el cuerpo de las server actions a 1 MB por defecto. Súbelo para admitir archivos de hasta 20 MB:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
serverActions: { bodySizeLimit: "25mb" },
},
};
export default nextConfig;Aquí el usuario sube un único archivo: para un DNI por las dos caras, pide una imagen con ambas caras o un PDF de dos páginas (1 crédito).
6. Webhook
Recibes el resultado de los análisis que no terminaron en 30 s y los eventos analysis.review_required y analysis.failed. La firma se calcula sobre el cuerpo crudo: lee req.text() y no hagas req.json() antes de verificar.
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()responde 204 al momento y procesa después: Constaia considera fallida una entrega que tarda más de 15 s.markEventProcessed()es tuya: inserta elwebhook-iden una tabla con clave única y devuelvefalsesi ya existía. Los reintentos llevan el mismowebhook-id.analysis.review_requiredllega además deanalysis.completed, así queupdateVerification()debe ser idempotente.
Registra la URL (https://tu-dominio.com/api/webhooks/constaia) en el panel o con constaia.webhookEndpoints.create(), y guarda el secret que se muestra una sola vez. Un endpoint creado con una clave de test solo recibe eventos de test. Más detalles en Webhooks.
Para probar la ruta en local sin exponerla a internet, firma un evento tú mismo con 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. Probar en modo test
Con una clave ck_test_... no se gastan créditos y la respuesta depende del nombre del archivo. El archivo tiene que ser un JPEG, PNG, WEBP, HEIC o PDF real: renombra cualquier foto. Con document="es_dni" el widget combina las dos caras en un JPEG con el nombre del anverso, así que el nombre que cuenta es el del anverso.
| Archivo | verdict.status | Motivo principal |
|---|---|---|
dni_valid.jpg | Válido | not_expired (info): "Vigente hasta el 12/03/2031." |
dni_expired.jpg | No válido | not_expired (error): "Caducado el 15/06/2020." |
blurry.jpg | Revisar | low_quality (warning); warnings: blurry, low_quality |
foto.jpg (otro nombre) | No válido | type_mismatch: se detecta generic |
Todos los nombres disponibles están en Modo test.
Producción
- Autentica y limita
app/api/constaiay la server action: cada llamada gasta créditos. Exige sesión y aplica un límite por usuario (por ejemplo, unos pocos intentos por minuto) con tu middleware o tu almacén de rate limiting. - Tamaño del cuerpo: los route handlers no imponen límite propio, pero tu plataforma sí puede (por ejemplo, Vercel limita el cuerpo de las funciones a 4,5 MB). Si tu plataforma limita por debajo de 20 MB, pon
max-size-mben el widget acorde. Si tienes middleware (proxy en Next.js 16), excluye estas rutas de sumatcherpara que no se aplique su límite de cuerpo. Para server actions,serverActions.bodySizeLimit. - Tiempos:
maxDuration = 60en la ruta y en la página de la server action. El SDK tienetimeoutde 60 s por intento y 2 reintentos; ajustatimeoutymaxRetriessi tu plataforma corta antes. - Clave live solo en las variables de entorno de producción, y un endpoint de webhook creado con la clave live (los endpoints de test no reciben eventos live).
- Decide en el servidor:
expectycheckslos fija tu código, y el paso siguiente del flujo lee el resultado guardado (oGET /v1/analyses/{id}), nunca el veredicto que reenvíe el navegador. - Revisa Límites de uso y Almacenamiento y privacidad (por defecto
storage: "none").