Constaia
Integraciones

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.ts que 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" y useSubmission.
  • Un webhook src/routes/api/webhooks/constaia.ts que verifica la firma con request.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/widget

Variables de entorno

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

src/lib/constaia.server.ts
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 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. 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.

src/routes/api/constaia.ts
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).

src/constaia-upload.d.ts
import "solid-js";

declare module "solid-js" {
  namespace JSX {
    interface IntrinsicElements {
      "constaia-upload": JSX.HTMLAttributes<HTMLElement> & {
        endpoint?: string;
        document?: string;
        expect?: string;
        lang?: string;
      };
    }
  }
}
src/routes/verify.tsx
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.

src/routes/verify-form.tsx
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

src/routes/api/webhooks/constaia.ts
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 el webhook-id con clave única: los reintentos repiten el mismo id. analysis.review_required llega además de analysis.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:

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: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_... 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 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 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 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