Constaia
Integraciones

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.

En esta guía montas la verificación de un DNI en un sitio Astro:

  • Un endpoint src/pages/api/constaia.ts que recibe el archivo y llama a Constaia con el SDK de JavaScript.
  • Una página .astro con el widget <constaia-upload> cargado mediante una etiqueta <script>.
  • Un webhook src/pages/api/webhooks/constaia.ts que verifica la firma con request.text().

Los endpoints que reciben peticiones necesitan renderizado bajo demanda, así que hace falta un adaptador SSR. Un sitio 100 % estático no puede guardar la clave: en ese caso, el widget debe apuntar a un backend aparte (por ejemplo Express).

Requisitos

  • Astro 4 o 5 con un adaptador de servidor. Aquí se usa @astrojs/node.
  • Una clave de test ck_test_... del panel.

Instalación

npx astro add node
npm i @constaia/sdk @constaia/widget
astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  output: "server",
  adapter: node({ mode: "standalone" }),
});

Si prefieres mantener el sitio estático salvo estos endpoints, deja la salida por defecto y añade export const prerender = false; en cada endpoint (ya está en el código de abajo).

Variables de entorno

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

Sin el prefijo PUBLIC_: Astro solo expone al navegador las variables que lo llevan. En desarrollo, import.meta.env lee el .env. En producción con el adaptador de Node, las variables del entorno del proceso se leen con process.env; el helper de abajo prueba ambas.

1. Cliente y errores

src/lib/constaia.ts
import {
  APITimeoutError,
  AuthenticationError,
  Constaia,
  ConstaiaError,
  InsufficientCreditsError,
  InvalidRequestError,
  PermissionError,
  RateLimitError,
} from "@constaia/sdk";

export function env(name: "CONSTAIA_API_KEY" | "CONSTAIA_WEBHOOK_SECRET"): string | undefined {
  return import.meta.env[name] ?? process.env[name];
}

let client: Constaia | undefined;

export function getConstaia(): Constaia {
  client ??= new Constaia({ apiKey: env("CONSTAIA_API_KEY") });
  return client;
}

export function errorResponse(err: unknown): Response {
  if (err instanceof ConstaiaError) console.error("constaia", err.status, err.code, err.requestId, err.message);
  else console.error(err);

  const reply = (status: number, code: string, message: string, headers?: Record<string, string>) =>
    Response.json({ error: { code, message } }, { status, headers });

  if (err instanceof InvalidRequestError) return reply(err.status ?? 400, err.code ?? "invalid_request", err.message);
  if (err instanceof RateLimitError) {
    const headers = err.retryAfter ? { "Retry-After": String(err.retryAfter) } : undefined;
    return reply(429, "rate_limited", "Hay mucha demanda. Vuelve a intentarlo en unos segundos.", headers);
  }
  if (err instanceof InsufficientCreditsError) {
    return reply(503, "verification_unavailable", "La verificación no está disponible ahora mismo.");
  }
  if (err instanceof AuthenticationError || err instanceof PermissionError) {
    return reply(500, "server_misconfigured", "Error de configuración del servidor.");
  }
  if (err instanceof APITimeoutError) {
    return reply(504, "timeout", "La verificación ha tardado demasiado. Inténtalo de nuevo.");
  }
  if (err instanceof ConstaiaError) {
    return reply(502, "upstream_error", "No se ha podido verificar el documento. Inténtalo de nuevo.");
  }
  return reply(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";
  }
}

Importa este módulo solo desde endpoints y frontmatter de páginas, nunca desde un <script> de cliente.

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 toma el idioma.

src/pages/api/constaia.ts
import type { APIRoute } from "astro";
import { errorResponse, getConstaia, languageFrom } from "../../lib/constaia";
import { saveVerification } from "../../lib/verifications";

export const prerender = false;

export const POST: APIRoute = async ({ request, locals }) => {
  const user = locals.user;
  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) {
    return errorResponse(err);
  }
};

locals.user lo rellena tu middleware de sesión (src/middleware.ts) 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 con una etiqueta script

Astro empaqueta los <script> de las páginas como módulos del navegador, así que puedes importar el paquete de npm. Los eventos del widget se escuchan con addEventListener.

src/pages/verify.astro
---
export const prerender = false;
---

<html lang="es">
  <body>
    <main>
      <h1>Verifica tu DNI</h1>
      <constaia-upload endpoint="/api/constaia" document="es_dni" lang="es"></constaia-upload>
      <p id="next" hidden><a href="/registro/datos">Continuar</a></p>
    </main>

    <script>
      import "@constaia/widget";
      import type { Analysis } from "@constaia/widget";

      const uploader = document.querySelector("constaia-upload");
      const next = document.getElementById("next");

      uploader?.addEventListener("constaia:result", (event) => {
        const analysis = (event as CustomEvent<Analysis>).detail;
        if (next) next.hidden = analysis.verdict?.status !== "valid";
      });
      uploader?.addEventListener("constaia:error", (event) => {
        const { code, message } = (event as CustomEvent<{ code: string; message: string }>).detail;
        console.warn(code, message);
      });
    </script>
  </body>
</html>

Si no quieres pasar por el bundler, carga el widget desde el CDN con is:inline:

<script is:inline type="module" src="https://cdn.jsdelivr.net/npm/@constaia/widget@0.1"></script>

Con document="es_dni" el widget pide las dos caras y las une en un JPEG. El enlace "Continuar" es solo UI: en /registro/datos comprueba en el servidor lo que guardaste en saveVerification().

4. Webhook

src/pages/api/webhooks/constaia.ts
import type { APIRoute } from "astro";
import { type Analysis, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
import { env, getConstaia } from "../../../lib/constaia";
import { markEventProcessed, updateVerification } from "../../../lib/verifications";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  const secret = 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:4321/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 en tu middleware: cada llamada gasta créditos.
  • Tamaño del cuerpo: el adaptador de Node no fija un límite propio, pero tu proxy o plataforma sí puede. Permite al menos 20 MB más el overhead del multipart (nginx: client_max_body_size 25m;) o ajusta max-size-mb en el widget. En adaptadores serverless (Vercel, Netlify), revisa su límite de cuerpo y de duración.
  • 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 el entorno de 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