Constaia
Integraciones

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.

En esta guía montas la verificación de un DNI en SvelteKit (Svelte 5):

  • Un endpoint src/routes/api/constaia/+server.ts que recibe el archivo del widget y llama a Constaia con el SDK de JavaScript.
  • Una página con el widget <constaia-upload>, importado en onMount.
  • Una variante sin widget con una form action.
  • Un webhook src/routes/api/webhooks/constaia/+server.ts que verifica la firma con request.text().

La clave se importa desde $env/static/private en un módulo de $lib/server: SvelteKit impide que ese código llegue al navegador.

Requisitos

  • SvelteKit 2 con Svelte 5 y un adaptador de servidor (por ejemplo @sveltejs/adapter-node).
  • Una clave de test ck_test_... del panel.

Instalación

npm i @constaia/sdk @constaia/widget

Variables de entorno

.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

Sin el prefijo PUBLIC_. $env/static/private inserta el valor al compilar; si prefieres leerlo en tiempo de ejecución (una misma build para test y producción), usa $env/dynamic/private con env.CONSTAIA_API_KEY.

1. Cliente y errores

src/lib/server/constaia.ts
import { CONSTAIA_API_KEY } from "$env/static/private";
import {
  APITimeoutError,
  AuthenticationError,
  Constaia,
  ConstaiaError,
  InsufficientCreditsError,
  InvalidRequestError,
  PermissionError,
  RateLimitError,
} from "@constaia/sdk";

export const constaia = new Constaia({ apiKey: CONSTAIA_API_KEY });

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: FormDataEntryValue | null): "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 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. Endpoint de subida

El widget envía file y options (JSON con expect y language). El servidor fija expect y checks; de options solo se usa el idioma.

src/routes/api/constaia/+server.ts
import { json } from "@sveltejs/kit";
import { constaia, languageFrom, toHttpError } from "$lib/server/constaia";
import { saveVerification } from "$lib/server/verifications";
import type { RequestHandler } from "./$types";

export const POST: RequestHandler = async ({ request, locals }) => {
  if (!locals.user) {
    return json({ error: { code: "unauthorized", message: "Inicia sesión para continuar." } }, { status: 401 });
  }

  const form = await request.formData();
  const file = form.get("file");
  if (!(file instanceof File) || file.size === 0) {
    return json({ error: { code: "file_required", message: "Falta el archivo." } }, { status: 400 });
  }

  try {
    const analysis = await constaia.analyze(file, {
      expect: "es_dni",
      checks: { notExpired: true, minAgeYears: 18 },
      language: languageFrom(form.get("options")),
      metadata: { user_id: String(locals.user.id) },
    });
    await saveVerification(locals.user.id, analysis);
    return 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 json(e.body, { status: e.status, headers });
  }
};

locals.user lo rellena tu hooks.server.ts (tu sistema de sesión) y saveVerification() es tu acceso a base de datos. Si el análisis tarda más de 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

@constaia/widget registra <constaia-upload> al importarse. Impórtalo en onMount para que solo se ejecute en el navegador. Los eventos del widget llevan dos puntos en el nombre (constaia:result), así que lo más seguro es escucharlos con bind:this y addEventListener en lugar de la sintaxis de atributos de evento.

src/routes/verify/+page.svelte
<script lang="ts">
  import { onMount } from "svelte";
  import type { Analysis, ConstaiaUploadElement, WidgetErrorDetail } from "@constaia/widget";

  let uploader: ConstaiaUploadElement | undefined = $state();
  let verdict = $state<string | null>(null);

  onMount(() => {
    void import("@constaia/widget");

    const onResult = (e: Event) => {
      verdict = (e as CustomEvent<Analysis>).detail.verdict?.status ?? null;
    };
    const onError = (e: Event) => {
      const { code, message } = (e as CustomEvent<WidgetErrorDetail>).detail;
      console.warn(code, message);
    };

    uploader?.addEventListener("constaia:result", onResult);
    uploader?.addEventListener("constaia:error", onError);
    return () => {
      uploader?.removeEventListener("constaia:result", onResult);
      uploader?.removeEventListener("constaia:error", onError);
    };
  });

  function retry() {
    uploader?.reset();
    verdict = null;
  }
</script>

<h1>Verifica tu DNI</h1>
<constaia-upload bind:this={uploader} endpoint="/api/constaia" document="es_dni" lang="es"></constaia-upload>

{#if verdict === "valid"}
  <a href="/registro/datos">Continuar</a>
{:else if verdict === "review"}
  <p>No se lee bien. Haz otra foto con buena luz y sin reflejos.</p>
{:else if verdict === "invalid"}
  <button type="button" onclick={retry}>Probar con otro documento</button>
{/if}

Con document="es_dni" el widget pide las dos caras y las une en un JPEG. En /registro/datos, comprueba en el load del servidor lo que guardaste en saveVerification(), no lo que diga el navegador.

Variante: form action

Sin widget, una form action recibe el archivo con request.formData().

src/routes/verify-form/+page.server.ts
import { fail } from "@sveltejs/kit";
import { constaia, toHttpError } from "$lib/server/constaia";
import { saveVerification } from "$lib/server/verifications";
import type { Actions } from "./$types";

export const actions: Actions = {
  default: async ({ request, locals }) => {
    if (!locals.user) return fail(401, { messages: ["Inicia sesión para continuar."] });

    const file = (await request.formData()).get("file");
    if (!(file instanceof File) || file.size === 0) {
      return fail(400, { messages: ["Selecciona una foto o un PDF del documento."] });
    }

    try {
      const analysis = await constaia.analyze(file, {
        expect: "es_dni",
        checks: { notExpired: true, minAgeYears: 18 },
        metadata: { user_id: String(locals.user.id) },
      });
      await saveVerification(locals.user.id, analysis);
      if (analysis.status !== "completed" || !analysis.verdict) return { status: "pending", messages: [] };
      return {
        status: analysis.verdict.status,
        messages: analysis.verdict.reasons.filter((r) => r.severity !== "info").map((r) => r.message),
      };
    } catch (err) {
      const e = toHttpError(err);
      return fail(e.status, { messages: [e.body.error.message] });
    }
  },
};
src/routes/verify-form/+page.svelte
<script lang="ts">
  import { enhance } from "$app/forms";

  let { form } = $props();
  let sending = $state(false);
</script>

<form
  method="POST"
  enctype="multipart/form-data"
  use:enhance={() => {
    sending = true;
    return async ({ update }) => {
      await update();
      sending = false;
    };
  }}
>
  <input type="file" name="file" accept="image/jpeg,image/png,image/webp,image/heic,application/pdf" required />
  <button disabled={sending}>{sending ? "Verificando…" : "Verificar"}</button>
</form>

{#if form?.status === "valid"}
  <p>Documento válido.</p>
{:else if form?.status === "pending"}
  <p>Lo estamos revisando. Te avisaremos al terminar.</p>
{/if}
{#each form?.messages ?? [] as message}
  <p>{message}</p>
{/each}

Aquí el usuario sube un único archivo: para las dos caras del DNI, una imagen con ambas o un PDF de dos páginas (1 crédito).

4. Webhook

Lee el cuerpo crudo con request.text() antes de cualquier request.json().

src/routes/api/webhooks/constaia/+server.ts
import { CONSTAIA_WEBHOOK_SECRET } from "$env/static/private";
import { type Analysis, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
import { constaia } from "$lib/server/constaia";
import { markEventProcessed, updateVerification } from "$lib/server/verifications";
import type { RequestHandler } from "./$types";

export const POST: RequestHandler = async ({ request }) => {
  const raw = await request.text();

  let event: WebhookEvent;
  try {
    event = await constaia.webhooks.verify(raw, request.headers, CONSTAIA_WEBHOOK_SECRET);
  } catch (err) {
    if (err instanceof WebhookVerificationError) return new Response("invalid signature", { status: 400 });
    throw err;
  }

  if (await markEventProcessed(request.headers.get("webhook-id") ?? "")) {
    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 });
};
  • Responde en menos de 15 s; si el trabajo es pesado, encólalo.
  • markEventProcessed() (tuya) guarda el webhook-id con clave única: 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. Para probar en local, firma un evento con signWebhook:

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.CONSTAIA_WEBHOOK_SECRET);
const res = await fetch("http://localhost:5173/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_... 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 y la form action: cada llamada gasta créditos.
  • Tamaño del cuerpo: adapter-node rechaza cuerpos de más de 512 KB por defecto. Arranca el servidor con BODY_SIZE_LIMIT=25M (y sube también el límite de tu proxy, por ejemplo client_max_body_size 25m; en nginx). En adaptadores serverless, revisa el límite de la plataforma y ajusta max-size-mb en el widget.
  • Tiempos: el análisis síncrono espera hasta 30 s y el SDK usa 60 s por intento. Ajusta los timeouts del proxy o la duración máxima de tus funciones.
  • Clave live solo en producción y un endpoint de webhook creado con la clave live.
  • Decide en el servidor con el resultado guardado o GET /v1/analyses/{id}.
  • Revisa Límites de uso y Errores.

Siguientes pasos

En esta página