Nuxt
Valida documentos en Nuxt 3 y 4 con una server route de Nitro, runtimeConfig privado, el widget de Vue en un componente cliente y un webhook firmado.
En esta guía montas la verificación de un DNI en una app de Nuxt 3 o 4:
- Una server route
server/api/constaia.post.tsque lee el archivo conreadMultipartFormDatay llama a Constaia con el SDK de JavaScript. - Un componente solo de cliente con el widget y su wrapper de Vue.
- Un webhook
server/api/webhooks/constaia.post.tsque verifica la firma conreadRawBody.
La clave vive en el runtimeConfig privado, que Nuxt nunca envía al navegador.
Requisitos
- Nuxt 3 o 4 con el preset de Node de Nitro (Node ≥ 18).
- Una clave de test
ck_test_...del panel.
Instalación
npm i @constaia/sdk @constaia/widgetConfiguración y variables de entorno
Las claves de primer nivel de runtimeConfig son privadas (solo servidor). Nunca pongas la clave en runtimeConfig.public.
export default defineNuxtConfig({
runtimeConfig: {
constaiaApiKey: "",
constaiaWebhookSecret: "",
},
});Nuxt rellena esos valores en tiempo de ejecución desde variables con prefijo NUXT_:
NUXT_CONSTAIA_API_KEY=ck_test_...
NUXT_CONSTAIA_WEBHOOK_SECRET=whsec_...1. Cliente y errores
Todo lo que esté en server/utils/ se autoimporta en las server routes. El helper traduce cada error del SDK a un estado HTTP y a un cuerpo { error: { code, message } }, que es lo que el widget muestra al usuario.
import type { H3Event } from "h3";
import {
APITimeoutError,
AuthenticationError,
Constaia,
ConstaiaError,
InsufficientCreditsError,
InvalidRequestError,
PermissionError,
RateLimitError,
} from "@constaia/sdk";
let client: Constaia | undefined;
export function getConstaia(event: H3Event): Constaia {
client ??= new Constaia({ apiKey: useRuntimeConfig(event).constaiaApiKey });
return client;
}
export function sendConstaiaError(event: H3Event, err: unknown) {
if (err instanceof ConstaiaError) console.error("constaia", err.status, err.code, err.requestId, err.message);
else console.error(err);
let status = 500;
let code = "internal_error";
let message = "Error inesperado.";
if (err instanceof InvalidRequestError) {
status = err.status ?? 400;
code = err.code ?? "invalid_request";
message = err.message;
} else if (err instanceof RateLimitError) {
status = 429;
code = "rate_limited";
message = "Hay mucha demanda. Vuelve a intentarlo en unos segundos.";
if (err.retryAfter) setResponseHeader(event, "Retry-After", String(err.retryAfter));
} else if (err instanceof InsufficientCreditsError) {
status = 503;
code = "verification_unavailable";
message = "La verificación no está disponible ahora mismo.";
} else if (err instanceof AuthenticationError || err instanceof PermissionError) {
code = "server_misconfigured";
message = "Error de configuración del servidor.";
} else if (err instanceof APITimeoutError) {
status = 504;
code = "timeout";
message = "La verificación ha tardado demasiado. Inténtalo de nuevo.";
} else if (err instanceof ConstaiaError) {
status = 502;
code = "upstream_error";
message = "No se ha podido verificar el documento. Inténtalo de nuevo.";
}
setResponseStatus(event, status);
return { error: { code, message } };
}
export function languageFrom(raw: string | undefined): "es" | "en" | "pt" | "fr" {
try {
const value = JSON.parse(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) | Archivo o petición no válidos. El usuario puede corregirlo. |
RateLimitError | 429 + Retry-After | Superaste las peticiones por segundo de tu clave. |
InsufficientCreditsError | 503 | Sin créditos: avisa a tu equipo. |
AuthenticationError, PermissionError | 500 | Clave ausente, revocada o incorrecta. |
APITimeoutError | 504 | El SDK agotó su timeout. |
APIError, APIConnectionError | 502 | Error 5xx de Constaia o de red. |
2. Server route de subida
El widget envía file y options (JSON con expect y language). Trata options como una pista: el servidor fija expect y checks, y del cliente solo toma el idioma.
export default defineEventHandler(async (event) => {
const user = await getCurrentUser(event);
if (!user) {
setResponseStatus(event, 401);
return { error: { code: "unauthorized", message: "Inicia sesión para continuar." } };
}
const parts = await readMultipartFormData(event);
const file = parts?.find((p) => p.name === "file");
const options = parts?.find((p) => p.name === "options");
if (!file?.data.length) {
setResponseStatus(event, 400);
return { error: { code: "file_required", message: "Falta el archivo." } };
}
try {
const analysis = await getConstaia(event).analyze(file.data, {
filename: file.filename ?? "document",
expect: "es_dni",
checks: { notExpired: true, minAgeYears: 18 },
language: languageFrom(options?.data.toString("utf8")),
metadata: { user_id: String(user.id) },
});
await saveVerification(user.id, analysis);
setResponseStatus(event, analysis.status === "completed" ? 200 : 202);
return analysis;
} catch (err) {
return sendConstaiaError(event, err);
}
});getCurrentUser() y saveVerification() son tuyas (por ejemplo en server/utils/): tu sesión y tu base de datos. readMultipartFormData devuelve cada parte con name, filename, type y data (un Buffer); el SDK acepta el Buffer junto con la opción filename.
Si el análisis no termina en 30 s, Constaia responde 202 con status: "queued" o "processing". El widget muestra un aviso de "en cola" y el resultado llega por el webhook.
3. El widget en un componente de cliente
El sufijo .client.vue hace que Nuxt renderice el componente solo en el navegador.
<script setup lang="ts">
import { ConstaiaUpload } from "@constaia/widget/vue";
import type { Analysis, WidgetErrorDetail } from "@constaia/widget/vue";
const verdict = ref<string | null>(null);
const upload = ref<{ reset: () => void } | null>(null);
function onResult(analysis: Analysis) {
verdict.value = analysis.verdict?.status ?? null;
}
function onError(error: WidgetErrorDetail) {
console.warn(error.code, error.message);
}
</script>
<template>
<ConstaiaUpload
ref="upload"
endpoint="/api/constaia"
document="es_dni"
lang="es"
@result="onResult"
@error="onError"
/>
<NuxtLink v-if="verdict === 'valid'" to="/registro/datos">Continuar</NuxtLink>
<p v-else-if="verdict === 'review'">No se lee bien. Haz otra foto con buena luz y sin reflejos.</p>
<button v-else-if="verdict === 'invalid'" type="button" @click="upload?.reset(); verdict = null">
Probar con otro documento
</button>
</template><template>
<main>
<h1>Verifica tu DNI</h1>
<DocumentUpload />
</main>
</template>Con document="es_dni" el widget pide las dos caras y las une en un JPEG. En /registro/datos comprueba en el servidor lo que guardaste en saveVerification(); el veredicto que tiene el navegador no sirve como prueba.
Si prefieres la etiqueta <constaia-upload> sin wrapper, dile al compilador de Vue que es un custom element e importa @constaia/widget en un plugin .client.ts:
export default defineNuxtConfig({
vue: {
compilerOptions: { isCustomElement: (tag) => tag.startsWith("constaia-") },
},
});4. Webhook
La firma se calcula sobre el cuerpo crudo: léelo con readRawBody y no uses readBody antes de verificar.
import { type Analysis, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
export default defineEventHandler(async (event) => {
const { constaiaWebhookSecret } = useRuntimeConfig(event);
const raw = await readRawBody(event, "utf8");
if (!raw) {
setResponseStatus(event, 400);
return "empty body";
}
let payload: WebhookEvent;
try {
payload = await getConstaia(event).webhooks.verify(raw, getRequestHeaders(event), constaiaWebhookSecret);
} catch (err) {
if (err instanceof WebhookVerificationError) {
setResponseStatus(event, 400);
return "invalid signature";
}
throw err;
}
const messageId = getRequestHeader(event, "webhook-id") ?? "";
if (await markEventProcessed(messageId)) {
switch (payload.type) {
case "analysis.completed":
case "analysis.review_required":
case "analysis.failed":
await updateVerification(payload.data as Analysis);
break;
}
}
setResponseStatus(event, 204);
return null;
});- Responde en menos de 15 s. Si el procesamiento es pesado, encólalo y responde ya.
markEventProcessed()(tuya) guarda elwebhook-idcon clave única y devuelvefalsesi ya existía: los reintentos repiten el mismo id.analysis.review_requiredllega además deanalysis.completed;updateVerification()debe ser idempotente.
Registra https://tu-dominio.com/api/webhooks/constaia en el panel o con constaia.webhookEndpoints.create() y guarda el secret (se muestra una vez). Más en Webhooks. Para probar en local, firma un evento con signWebhook del SDK y envíalo a tu ruta:
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.NUXT_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 scripts/send-test-webhook.mjs5. Probar en modo test
Con ck_test_... no se gastan créditos y el resultado depende del nombre del archivo (que debe ser una imagen o PDF real). El widget une las dos caras en un JPEG con el nombre 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 |
Más nombres en Modo test.
Producción
- Autentica y limita
/api/constaia: cada llamada gasta créditos. Exige sesión y aplica un límite de intentos por usuario. - Tamaño del cuerpo: Nitro no fija un límite propio, pero tu proxy o plataforma sí puede. Permite al menos 20 MB más el overhead del multipart (en nginx,
client_max_body_size 25m;) o bajamax-size-mben el widget. - Tiempos: el análisis síncrono espera hasta 30 s y el SDK usa 60 s de
timeoutpor intento. Ajusta los timeouts del proxy (por ejemploproxy_read_timeout 90s;) o los de tu plataforma serverless. - Clave live solo en producción (
NUXT_CONSTAIA_API_KEY) y un endpoint de webhook creado con la clave live. - Decide en el servidor con el resultado guardado o
GET /v1/analyses/{id}, nunca con el veredicto que reenvíe el navegador. - Revisa Límites de uso y Errores.
Siguientes pasos
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.
SvelteKit
Valida documentos en SvelteKit con un endpoint +server.ts o una form action, $env/static/private, el widget cargado en onMount y un webhook firmado.