SolidStart
Valida documentos en SolidStart con una API route (APIEvent), una server action con "use server", el widget como web component y un webhook firmado.
En esta guía montas la verificación de un DNI en SolidStart 1.x:
- Una API route
src/routes/api/constaia.tsque recibe el archivo del widget y llama a Constaia con el SDK de JavaScript. - Una página con el widget
<constaia-upload>como web component. - Una variante sin widget con una action
"use server"yuseSubmission. - Un webhook
src/routes/api/webhooks/constaia.tsque verifica la firma conrequest.text().
La clave solo se lee en código de servidor (process.env), nunca en componentes.
Requisitos
- SolidStart 1.x sobre Node ≥ 18.
- 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 VITE_: Vite solo expone al navegador las variables que lo llevan.
1. Cliente y errores
import {
APITimeoutError,
AuthenticationError,
Constaia,
ConstaiaError,
InsufficientCreditsError,
InvalidRequestError,
PermissionError,
RateLimitError,
} from "@constaia/sdk";
let client: Constaia | undefined;
export function getConstaia(): Constaia {
client ??= new Constaia({ apiKey: process.env.CONSTAIA_API_KEY });
return client;
}
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. API route de subida
El widget envía file y options (JSON con expect y language). El servidor fija expect y checks; de options solo toma el idioma.
import type { APIEvent } from "@solidjs/start/server";
import { getConstaia, languageFrom, toHttpError } from "~/lib/constaia.server";
import { getCurrentUser } from "~/lib/auth.server";
import { saveVerification } from "~/lib/verifications.server";
export async function POST({ request }: APIEvent) {
const user = await getCurrentUser(request);
if (!user) {
return Response.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 Response.json({ error: { code: "file_required", message: "Falta el archivo." } }, { status: 400 });
}
try {
const analysis = await getConstaia().analyze(file, {
expect: "es_dni",
checks: { notExpired: true, minAgeYears: 18 },
language: languageFrom(form.get("options")),
metadata: { user_id: String(user.id) },
});
await saveVerification(user.id, analysis);
return Response.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 Response.json(e.body, { status: e.status, headers });
}
}getCurrentUser() y saveVerification() son tuyas (tu sesión y tu 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 como web component
Declara el elemento para TypeScript, impórtalo en onMount (solo en el navegador) y escucha los eventos con addEventListener: sus nombres llevan dos puntos (constaia:result).
import "solid-js";
declare module "solid-js" {
namespace JSX {
interface IntrinsicElements {
"constaia-upload": JSX.HTMLAttributes<HTMLElement> & {
endpoint?: string;
document?: string;
expect?: string;
lang?: string;
};
}
}
}import { createSignal, onCleanup, onMount, Show } from "solid-js";
import type { Analysis, ConstaiaUploadElement, WidgetErrorDetail } from "@constaia/widget";
export default function Verify() {
let uploader: ConstaiaUploadElement | undefined;
const [verdict, setVerdict] = createSignal<string | null>(null);
onMount(() => {
void import("@constaia/widget");
const onResult = (e: Event) => setVerdict((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);
onCleanup(() => {
uploader?.removeEventListener("constaia:result", onResult);
uploader?.removeEventListener("constaia:error", onError);
});
});
return (
<main>
<h1>Verifica tu DNI</h1>
<constaia-upload ref={(el) => (uploader = el as ConstaiaUploadElement)} endpoint="/api/constaia" document="es_dni" lang="es" />
<Show when={verdict() === "valid"}>
<a href="/registro/datos">Continuar</a>
</Show>
<Show when={verdict() === "review"}>
<p>No se lee bien. Haz otra foto con buena luz y sin reflejos.</p>
</Show>
<Show when={verdict() === "invalid"}>
<button type="button" onClick={() => { uploader?.reset(); setVerdict(null); }}>
Probar con otro documento
</button>
</Show>
</main>
);
}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().
Variante: action con "use server"
Sin widget, una action de @solidjs/router con "use server" recibe el FormData del formulario.
import { action, useSubmission } from "@solidjs/router";
import { For, Show } from "solid-js";
import { getRequestEvent } from "solid-js/web";
const verifyDocument = action(async (formData: FormData) => {
"use server";
const { getConstaia, toHttpError } = await import("~/lib/constaia.server");
const { getCurrentUser } = await import("~/lib/auth.server");
const { saveVerification } = await import("~/lib/verifications.server");
const user = await getCurrentUser(getRequestEvent()!.request);
if (!user) return { status: "error", messages: ["Inicia sesión para continuar."] };
const file = formData.get("file");
if (!(file instanceof File) || file.size === 0) {
return { status: "error", messages: ["Selecciona una foto o un PDF del documento."] };
}
try {
const analysis = await getConstaia().analyze(file, {
expect: "es_dni",
checks: { notExpired: true, minAgeYears: 18 },
metadata: { user_id: String(user.id) },
});
await saveVerification(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) {
return { status: "error", messages: [toHttpError(err).body.error.message] };
}
}, "verify-document");
export default function VerifyForm() {
const submission = useSubmission(verifyDocument);
return (
<main>
<h1>Sube tu DNI</h1>
<form action={verifyDocument} method="post" enctype="multipart/form-data">
<input type="file" name="file" accept="image/jpeg,image/png,image/webp,image/heic,application/pdf" required />
<button type="submit" disabled={submission.pending}>
{submission.pending ? "Verificando…" : "Verificar"}
</button>
</form>
<Show when={submission.result?.status === "valid"}>
<p>Documento válido.</p>
</Show>
<Show when={submission.result?.status === "pending"}>
<p>Lo estamos revisando. Te avisaremos al terminar.</p>
</Show>
<For each={submission.result?.messages ?? []}>{(m) => <p>{m}</p>}</For>
</main>
);
}Los import() dentro de la función "use server" mantienen el código de servidor fuera del bundle del cliente. 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
import type { APIEvent } from "@solidjs/start/server";
import { type Analysis, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
import { getConstaia } from "~/lib/constaia.server";
import { markEventProcessed, updateVerification } from "~/lib/verifications.server";
export async function POST({ request }: APIEvent) {
const secret = process.env.CONSTAIA_WEBHOOK_SECRET;
if (!secret) return new Response("CONSTAIA_WEBHOOK_SECRET is not set", { status: 500 });
const raw = await request.text();
let event: WebhookEvent;
try {
event = await getConstaia().webhooks.verify(raw, request.headers, 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 });
}- Verifica sobre el cuerpo crudo (
request.text()), nunca sobre un JSON re-serializado. - 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.
Registra https://tu-dominio.com/api/webhooks/constaia en el panel o con constaia.webhookEndpoints.create() y guarda el secret. Para probar en local:
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: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_... 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 action: cada llamada gasta créditos. - Tamaño del cuerpo: permite al menos 20 MB más el overhead del multipart en tu proxy o plataforma (nginx:
client_max_body_size 25m;) o 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 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
Astro
Valida documentos en Astro con un adaptador SSR, un endpoint src/pages/api/constaia.ts, el widget cargado con una etiqueta script y un webhook firmado.
Angular
Integra Constaia en Angular 18+ con el custom element y CUSTOM_ELEMENTS_SCHEMA, o con HttpClient, subiendo siempre a tu backend (ejemplo con Express).