Constaia
Integraciones

Deno

Usa Constaia en Deno 2 con npm:@constaia/sdk: un script con permisos mínimos y un servidor Deno.serve con subida y webhook verificado.

Vas a construir con Deno 2 y el SDK:

  1. Un script que analiza un fichero local.
  2. Un servidor Deno.serve con POST /api/verify-dni (subida) y POST /webhooks/constaia (eventos firmados).

El SDK se importa con el especificador npm: y solo usa fetch, FormData y WebCrypto. Si prefieres un router, la guía de Hono incluye el arranque en Deno.

Instalación

No hace falta instalar nada: importa npm:@constaia/sdk y Deno lo descarga la primera vez. Si prefieres fijar la versión en deno.json:

deno add npm:@constaia/sdk

Con eso puedes importar "@constaia/sdk" sin prefijo. En esta guía se usa npm:@constaia/sdk para que los ficheros funcionen sin configuración.

Variables de entorno

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

Pasa la clave al cliente con Deno.env.get y ejecuta con --env-file=.env --allow-env. Crea la clave en el panel → API keys; empieza con una ck_test_ (Modo test).

1. Script

scripts/verify.ts
import { Constaia, ConstaiaError } from "npm:@constaia/sdk";

const constaia = new Constaia({ apiKey: Deno.env.get("CONSTAIA_API_KEY") });
const path = Deno.args[0] ?? "dni_valid.jpg";
const file = new File([await Deno.readFile(path)], path.split("/").pop()!);

try {
  const analysis = await constaia.analyze(file, {
    expect: "es_dni",
    checks: { notExpired: true },
    language: "es",
  });
  console.log(analysis.verdict?.status, analysis.verdict?.reasons.map((r) => r.message));
  console.log(analysis.fields.document_number?.value);
} catch (error) {
  if (!(error instanceof ConstaiaError)) throw error;
  console.error(error.name, error.code, error.message, error.requestId);
  Deno.exitCode = 1;
}
deno run --allow-net --allow-env --allow-read --env-file=.env scripts/verify.ts dni_valid.jpg
valid [ "El documento es DNI (España).", "Vigente hasta el 12/03/2031." ]
12345678Z

2. Servidor

Utilidades compartidas

src/constaia.ts
import {
  AuthenticationError,
  ConstaiaError,
  InsufficientCreditsError,
  InvalidRequestError,
  PermissionError,
  RateLimitError,
  type Analysis,
  type WebhookEvent,
} from "npm:@constaia/sdk";

// Lo que devuelves al navegador (y lo que pinta el widget): sin los campos extraídos.
export function publicResult(analysis: Analysis) {
  const { id, object, status, document, verdict, warnings } = analysis;
  return { id, object, status, document, verdict, warnings };
}

const httpError = (status: number, code: string, message: string, retryAfter?: number) =>
  Response.json(
    { error: { code, message } },
    { status, headers: retryAfter ? { "Retry-After": String(retryAfter) } : undefined },
  );

export function errorResponse(error: unknown): Response {
  if (error instanceof InvalidRequestError) {
    // Fichero ilegible, formato no admitido, demasiadas páginas…: el usuario puede corregirlo.
    return httpError(error.status === 413 ? 413 : 422, error.code ?? "invalid_request", error.message);
  }
  if (error instanceof RateLimitError) {
    const wait = Math.ceil(error.retryAfter ?? 1);
    return httpError(429, "rate_limited", "Demasiadas peticiones. Inténtalo en unos segundos.", wait);
  }
  if (error instanceof InsufficientCreditsError) {
    console.error("[constaia] Sin créditos. Recarga en https://app.constaia.com", error.requestId);
    return httpError(503, "unavailable", "La validación no está disponible ahora mismo.");
  }
  if (error instanceof AuthenticationError || error instanceof PermissionError) {
    console.error("[constaia] Revisa CONSTAIA_API_KEY", error.code, error.requestId);
    return httpError(500, "misconfigured", "Error de configuración del servidor.");
  }
  if (error instanceof ConstaiaError) {
    // APIError (5xx), APIConnectionError, APITimeoutError
    console.error("[constaia]", error.name, error.code, error.requestId);
    return httpError(502, "upstream_error", "No se ha podido analizar el documento. Inténtalo de nuevo.");
  }
  console.error(error);
  return httpError(500, "internal_error", "Error interno.");
}

// Deduplicación por webhook-id. En producción, una tabla con clave única.
const processed = new Set<string>();
export const alreadyProcessed = (webhookId: string) => processed.has(webhookId);
export const markProcessed = (webhookId: string) => void processed.add(webhookId);

export async function handleEvent(event: WebhookEvent) {
  switch (event.type) {
    case "analysis.completed":
      // event.data es el análisis completo (con fields). Guárdalo por event.data.id.
      console.log("analysis.completed", event.data.id, event.data.verdict?.status);
      break;
    case "analysis.review_required":
      console.log("A revisión manual", event.data.id);
      break;
    case "analysis.failed":
      console.warn("analysis.failed", event.data.id, event.data.error?.code);
      break;
    case "credits.low":
      console.warn("Quedan pocos créditos: recarga en https://app.constaia.com");
      break;
  }
}

Servidor

src/server.ts
import { Constaia, WebhookVerificationError } from "npm:@constaia/sdk";
import { alreadyProcessed, errorResponse, handleEvent, markProcessed, publicResult } from "./constaia.ts";

const constaia = new Constaia({ apiKey: Deno.env.get("CONSTAIA_API_KEY") });
const webhookSecret = Deno.env.get("CONSTAIA_WEBHOOK_SECRET")!;
const MAX_UPLOAD = 21 * 1024 * 1024;

// Aquí va tu autenticación: solo usuarios con sesión deberían gastar créditos.
async function verifyDni(req: Request): Promise<Response> {
  if (Number(req.headers.get("content-length") ?? 0) > MAX_UPLOAD) {
    return Response.json({ error: { code: "file_too_large", message: "Máximo 20 MB." } }, { status: 413 });
  }
  const form = await req.formData().catch(() => null);
  const file = form?.get("file");
  if (!(file instanceof File) || file.size === 0) {
    return Response.json({ error: { code: "missing_file", message: "Falta el fichero." } }, { status: 400 });
  }
  // Mejor aún: el nombre del usuario autenticado, no el del formulario.
  const fullName = String(form?.get("full_name") ?? "").trim();

  try {
    const analysis = await constaia.analyze(file, {
      expect: "es_dni",
      checks: { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) },
      storage: "none",
      language: "es",
    });
    // Guarda analysis.id y analysis.verdict en tu base de datos.
    return Response.json(publicResult(analysis), { status: analysis.status === "completed" ? 200 : 202 });
  } catch (error) {
    return errorResponse(error);
  }
}

async function webhook(req: Request): Promise<Response> {
  const rawBody = await req.text();
  let event;
  try {
    event = await constaia.webhooks.verify(rawBody, req.headers, webhookSecret);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response("Invalid signature", { status: 400 });
    throw error;
  }

  const webhookId = req.headers.get("webhook-id")!;
  if (!alreadyProcessed(webhookId)) {
    await handleEvent(event);
    markProcessed(webhookId);
  }
  return new Response(null, { status: 204 });
}

Deno.serve({ port: 8000 }, (req) => {
  const { pathname } = new URL(req.url);
  if (req.method === "POST" && pathname === "/api/verify-dni") return verifyDni(req);
  if (req.method === "POST" && pathname === "/webhooks/constaia") return webhook(req);
  return new Response("Not found", { status: 404 });
});
deno run --allow-net --allow-env --env-file=.env src/server.ts

Puntos clave:

  • Permisos. El servidor solo necesita red y entorno. Para acotar más: --allow-net=api.constaia.com,0.0.0.0:8000.
  • El nombre del File se envía a Constaia; en modo test decide la respuesta.
  • Cuerpo crudo. req.text() devuelve los bytes que firma Constaia. No uses req.json() en el webhook.
  • El servidor decide expect y checks, nunca el cliente. 202 si el análisis sigue en queued o processing tras 30 s.
  • Deno Deploy. El mismo src/server.ts funciona; define CONSTAIA_API_KEY y CONSTAIA_WEBHOOK_SECRET en las variables de entorno del proyecto y revisa en su documentación los límites de tamaño y duración de petición.

Qué recibe el navegador

{
  "id": "an_01J…",
  "object": "analysis",
  "status": "completed",
  "document": { "type": "es_dni", "label": "DNI (España)", "confidence": 0.97, "side": "both", "country": "ESP" },
  "verdict": {
    "expected": ["es_dni"],
    "match": true,
    "status": "valid",
    "reasons": [
      { "code": "type_match", "severity": "info", "message": "El documento es DNI (España)." },
      { "code": "not_expired", "severity": "info", "message": "Vigente hasta el 12/03/2031." },
      { "code": "holder", "severity": "info", "message": "Los datos del titular coinciden (full_name)." }
    ]
  },
  "warnings": []
}

Errores

Error del SDKCuándoQué devuelve tu ruta
InvalidRequestErrorFichero vacío, ilegible, formato no admitido, más de 20 MB, demasiadas páginas u opciones mal formadas (400/409/413/415/422)422 (o 413) con el mensaje, para que el usuario suba otro fichero
RateLimitErrorSuperas las peticiones por segundo de tu clave (429). El SDK ya reintenta dos veces respetando Retry-After429 con Retry-After
InsufficientCreditsErrorNo quedan créditos (402)503 al usuario y una alerta para ti: recarga en el panel
AuthenticationError, PermissionErrorClave ausente, revocada o incorrecta (401/403)500: es un fallo de configuración tuyo, no del usuario
APIError, APIConnectionError, APITimeoutErrorError 5xx de Constaia o de red, tras agotar los reintentos502 y un mensaje de reintentar

Todas extienden ConstaiaError y exponen status, code y requestId. Registra siempre el requestId: es lo que te pedirá soporte. Detalle de cada código en Errores.

Probar en modo test

curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:8000/api/verify-dni
Ficherofull_nameResultado
dni_valid.jpgMaría García Lópezvalid
dni_valid.jpgJuan Pérezinvalid: motivo holder con severidad error
dni_expired.jpg(vacío)invalid: not_expired con el mensaje "Caducado el 15/06/2020."
blurry.jpg(vacío)review: motivo low_quality y warnings: ["blurry", "low_quality"]
factura.jpg(vacío)invalid: type_mismatch, no es un DNI

En modo test la respuesta depende del nombre del fichero, que debe ser un JPEG, PNG, WEBP, HEIC o PDF real (vale cualquier imagen renombrada). No gasta créditos y livemode es false. Todos los nombres en Modo test.

Para el webhook, firma un cuerpo con signWebhook y envíalo:

scripts/send-test-webhook.ts
import { signWebhook } from "npm:@constaia/sdk";

const body = JSON.stringify({
  type: "analysis.completed",
  created_at: new Date().toISOString(),
  data: { id: "an_test", object: "analysis", status: "completed", verdict: { status: "valid", reasons: [] } },
});
const headers = await signWebhook(body, Deno.env.get("CONSTAIA_WEBHOOK_SECRET")!);

const res = await fetch("http://localhost:8000/webhooks/constaia", {
  method: "POST",
  headers: { ...headers, "content-type": "application/json" },
  body,
});
console.log(res.status); // 204

Checklist de producción

  • Autenticación y rate limit por usuario en /api/verify-dni: cada llamada gasta créditos.
  • Límite de subida de 20 MB en tu código y en el proxy o plataforma.
  • Timeouts de 60 s o más en proxy y plataforma; un análisis síncrono puede tardar hasta 30 s.
  • PDF de más de 30 páginas o volumen alto: async: true + webhook, o lotes.
  • Deduplicación del webhook persistente (por ejemplo, Deno KV o tu base de datos).
  • Clave ck_live_ solo en el entorno del servidor.
  • Alerta ante InsufficientCreditsError y el evento credits.low.

Siguientes pasos

En esta página