Constaia
Integraciones

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.ts que lee el archivo con readMultipartFormData y 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.ts que verifica la firma con readRawBody.

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/widget

Configuración y variables de entorno

Las claves de primer nivel de runtimeConfig son privadas (solo servidor). Nunca pongas la clave en runtimeConfig.public.

nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    constaiaApiKey: "",
    constaiaWebhookSecret: "",
  },
});

Nuxt rellena esos valores en tiempo de ejecución desde variables con prefijo NUXT_:

.env
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.

server/utils/constaia.ts
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 SDKHTTP hacia tu frontendQué significa
InvalidRequestErrorel mismo (400, 409, 413, 415, 422)Archivo o petición no válidos. El usuario puede corregirlo.
RateLimitError429 + Retry-AfterSuperaste las peticiones por segundo de tu clave.
InsufficientCreditsError503Sin créditos: avisa a tu equipo.
AuthenticationError, PermissionError500Clave ausente, revocada o incorrecta.
APITimeoutError504El SDK agotó su timeout.
APIError, APIConnectionError502Error 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.

server/api/constaia.post.ts
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.

components/DocumentUpload.client.vue
<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>
pages/verify.vue
<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:

nuxt.config.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.

server/api/webhooks/constaia.post.ts
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 el webhook-id con clave única y devuelve false si ya existía: los reintentos repiten el mismo id.
  • analysis.review_required llega además de analysis.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:

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.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.mjs

5. 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.

Archivoverdict.statusMotivo principal
dni_valid.jpgVálidonot_expired (info): "Vigente hasta el 12/03/2031."
dni_expired.jpgNo válidonot_expired (error): "Caducado el 15/06/2020."
blurry.jpgRevisarlow_quality (warning); warnings: blurry, low_quality
foto.jpg (otro nombre)No válidotype_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 baja max-size-mb en el widget.
  • Tiempos: el análisis síncrono espera hasta 30 s y el SDK usa 60 s de timeout por intento. Ajusta los timeouts del proxy (por ejemplo proxy_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

En esta página