Constaia
Integrations

Nuxt

Validate documents in Nuxt 3 and 4 with a Nitro server route, private runtimeConfig, the Vue widget in a client-only component 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 Nuxt 3 or 4 app:

  • A server route server/api/constaia.post.ts that reads the file with readMultipartFormData and calls Constaia with the JavaScript SDK.
  • A client-only component with the widget and its Vue wrapper.
  • A webhook server/api/webhooks/constaia.post.ts that verifies the signature with readRawBody.

The key lives in the private runtimeConfig, which Nuxt never sends to the browser.

Requirements

  • Nuxt 3 or 4 with Nitro's Node preset (Node ≥ 18).
  • A ck_test_... test key from the dashboard.

Install

npm i @constaia/sdk @constaia/widget

Configuration and environment variables

Top-level runtimeConfig keys are private (server only). Never put the key in runtimeConfig.public.

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

Nuxt fills those values at runtime from variables prefixed with NUXT_:

.env
NUXT_CONSTAIA_API_KEY=ck_test_...
NUXT_CONSTAIA_WEBHOOK_SECRET=whsec_...

1. Client and errors

Everything in server/utils/ is auto-imported in server routes. The helper maps each SDK error to an HTTP status and a { error: { code, message } } body, which is what the widget shows the user.

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 = "Unexpected error.";

  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 = "Too many requests. Try again in a few seconds.";
    if (err.retryAfter) setResponseHeader(event, "Retry-After", String(err.retryAfter));
  } else if (err instanceof InsufficientCreditsError) {
    status = 503;
    code = "verification_unavailable";
    message = "Verification is not available right now.";
  } else if (err instanceof AuthenticationError || err instanceof PermissionError) {
    code = "server_misconfigured";
    message = "Server configuration error.";
  } else if (err instanceof APITimeoutError) {
    status = 504;
    code = "timeout";
    message = "Verification took too long. Please try again.";
  } else if (err instanceof ConstaiaError) {
    status = 502;
    code = "upstream_error";
    message = "The document could not be verified. Please try again.";
  }

  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 : "en";
  } catch {
    return "en";
  }
}
SDK errorHTTP to your frontendMeaning
InvalidRequestErrorthe same (400, 409, 413, 415, 422)Invalid file or request. The user can fix it.
RateLimitError429 + Retry-AfterYou exceeded your key's requests per second.
InsufficientCreditsError503No credits: alert your team.
AuthenticationError, PermissionError500Missing, revoked or wrong key.
APITimeoutError504The SDK hit its timeout.
APIError, APIConnectionError502Constaia 5xx or network error.

2. Upload server route

The widget sends file and options (JSON with expect and language). Treat options as a hint: the server sets expect and checks, and only takes the language from the client.

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: "Please sign in to continue." } };
  }

  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: "The file is missing." } };
  }

  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() and saveVerification() are yours (for example in server/utils/): your session and your database. readMultipartFormData returns each part with name, filename, type and data (a Buffer); the SDK accepts the Buffer together with the filename option.

If the analysis does not finish within 30 s, Constaia returns 202 with status: "queued" or "processing". The widget shows a "queued" notice and the result arrives through the webhook.

3. The widget in a client component

The .client.vue suffix makes Nuxt render the component only in the browser.

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="en"
    @result="onResult"
    @error="onError"
  />
  <NuxtLink v-if="verdict === 'valid'" to="/signup/details">Continue</NuxtLink>
  <p v-else-if="verdict === 'review'">We could not read it well. Take another photo in good light, without glare.</p>
  <button v-else-if="verdict === 'invalid'" type="button" @click="upload?.reset(); verdict = null">
    Try another document
  </button>
</template>
pages/verify.vue
<template>
  <main>
    <h1>Verify your ID</h1>
    <DocumentUpload />
  </main>
</template>

With document="es_dni" the widget asks for both sides and merges them into one JPEG. On /signup/details, check on the server what you stored in saveVerification(); the verdict held by the browser is not proof.

If you prefer the raw <constaia-upload> tag without the wrapper, tell the Vue compiler it is a custom element and import @constaia/widget in a .client.ts plugin:

nuxt.config.ts
export default defineNuxtConfig({
  vue: {
    compilerOptions: { isCustomElement: (tag) => tag.startsWith("constaia-") },
  },
});

4. Webhook

The signature is computed over the raw body: read it with readRawBody and do not call readBody before verifying.

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;
});
  • Answer within 15 s. If processing is heavy, enqueue it and answer right away.
  • markEventProcessed() (yours) stores the webhook-id under a unique key and returns false if it already existed: retries repeat the same id.
  • analysis.review_required arrives in addition to analysis.completed; updateVerification() must be idempotent.

Register https://your-domain.com/api/webhooks/constaia in the dashboard or with constaia.webhookEndpoints.create() and store the secret (shown once). More in Webhooks. To test locally, sign an event with the SDK's signWebhook and send it to your route:

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. Test mode

With ck_test_... no credits are spent and the result depends on the file name (which must be a real image or PDF). The widget merges both sides into a JPEG named after the front.

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

More names in Test mode.

Production

  • Authenticate and rate-limit /api/constaia: every call spends credits. Require a session and cap attempts per user.
  • Body size: Nitro sets no limit of its own, but your proxy or platform may. Allow at least 20 MB plus multipart overhead (in nginx, client_max_body_size 25m;) or lower the widget's max-size-mb.
  • Timeouts: a synchronous analysis waits up to 30 s and the SDK uses a 60 s timeout per attempt. Tune your proxy timeouts (for example proxy_read_timeout 90s;) or your serverless platform's.
  • Live key only in production (NUXT_CONSTAIA_API_KEY) and a webhook endpoint created with the live key.
  • Decide on the server with the stored result or GET /v1/analyses/{id}, never with the verdict the browser sends back.
  • Review Rate limits and Errors.

Next steps

Sur cette page