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.tsque recibe el archivo del widget y llama a Constaia con el SDK de JavaScript. - Una página con el widget
<constaia-upload>, importado enonMount. - Una variante sin widget con una form action.
- Un webhook
src/routes/api/webhooks/constaia/+server.tsque verifica la firma conrequest.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/widgetVariables de entorno
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
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 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. 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.
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.
<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().
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] });
}
},
};<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().
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 elwebhook-idcon clave única: 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. Para probar en local, firma un evento 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: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.mjs5. 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.
| 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/constaiay la form action: cada llamada gasta créditos. - Tamaño del cuerpo:
adapter-noderechaza cuerpos de más de 512 KB por defecto. Arranca el servidor conBODY_SIZE_LIMIT=25M(y sube también el límite de tu proxy, por ejemploclient_max_body_size 25m;en nginx). En adaptadores serverless, revisa el límite de la plataforma y ajustamax-size-mben 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
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.
Remix y React Router
Valida documentos en Remix y React Router v7 (modo framework) con una action que lee request.formData(), el widget de React y una resource route de webhook.