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.tsque recibe el archivo y llama a Constaia con el SDK de JavaScript. - Una página
.astrocon el widget<constaia-upload>cargado mediante una etiqueta<script>. - Un webhook
src/pages/api/webhooks/constaia.tsque verifica la firma conrequest.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/widgetimport { 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
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
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 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 toma el idioma.
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.
---
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
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 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: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.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/constaiaen 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 ajustamax-size-mben 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
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.
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.