Constaia
Integraciones

Supabase Edge Functions

Valida documentos con Constaia en Supabase Edge Functions: subida a Storage, URL firmada como fileUrl, secretos con supabase secrets y webhook.

Vas a montar en Supabase:

  1. Un bucket privado kyc donde cada usuario sube su documento con supabase-js.
  2. La función verify-dni: comprueba el JWT del usuario, crea una URL firmada del fichero, se la pasa a Constaia como fileUrl, guarda el veredicto en una tabla y borra el fichero.
  3. La función constaia-webhook: verifica la firma con el cuerpo crudo y deduplica por webhook-id en Postgres.

Las Edge Functions corren en Deno, así que el SDK se importa con npm:@constaia/sdk. La clave vive en los secretos del proyecto, nunca en la app.

Secretos

supabase secrets set CONSTAIA_API_KEY=ck_test_... CONSTAIA_WEBHOOK_SECRET=whsec_...

SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY ya están disponibles dentro de las funciones. En local, pon las dos variables de Constaia en supabase/functions/.env.

Tablas y políticas

supabase/migrations/20260929000000_constaia.sql
create table public.document_checks (
  analysis_id text primary key,
  user_id uuid not null references auth.users on delete cascade,
  status text not null,
  verdict jsonb,
  created_at timestamptz not null default now()
);
alter table public.document_checks enable row level security;
create policy "read own checks" on public.document_checks
  for select to authenticated using (auth.uid() = user_id);

create table public.constaia_webhooks (
  id text primary key,
  received_at timestamptz not null default now()
);
alter table public.constaia_webhooks enable row level security;

insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('kyc', 'kyc', false, 20971520,
        array['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'application/pdf']);
create policy "upload own kyc" on storage.objects
  for insert to authenticated
  with check (bucket_id = 'kyc' and (storage.foldername(name))[1] = auth.uid()::text);

El usuario solo puede subir a kyc/<su id>/… y leer sus propios resultados. Las funciones usan la clave de servicio, que ignora RLS.

Utilidades compartidas

supabase/functions/_shared/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;
  }
}

Función verify-dni

supabase/functions/verify-dni/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
import { Constaia } from "npm:@constaia/sdk";
import { errorResponse, publicResult } from "../_shared/constaia.ts";

const constaia = new Constaia({ apiKey: Deno.env.get("CONSTAIA_API_KEY") });
const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);

const cors = {
  "Access-Control-Allow-Origin": "https://tu-dominio.com",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
const withCors = (res: Response) => {
  for (const [key, value] of Object.entries(cors)) res.headers.set(key, value);
  return res;
};
const fail = (status: number, code: string, message: string) =>
  withCors(Response.json({ error: { code, message } }, { status }));

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response("ok", { headers: cors });

  const jwt = req.headers.get("Authorization")?.replace(/^Bearer /i, "") ?? "";
  const { data: { user } } = await admin.auth.getUser(jwt);
  if (!user) return fail(401, "unauthorized", "Inicia sesión.");

  const { path } = await req.json().catch(() => ({}));
  if (typeof path !== "string" || !path.startsWith(`${user.id}/`)) {
    return fail(403, "forbidden", "Fichero no válido.");
  }

  // URL firmada de 10 minutos: Constaia descarga el fichero (https, máx. 20 MB, 15 s).
  const { data: signed, error } = await admin.storage.from("kyc").createSignedUrl(path, 600);
  if (error || !signed) return fail(404, "missing_file", "No encuentro el fichero.");

  // El titular esperado sale del perfil del usuario, no del cliente.
  const fullName = typeof user.user_metadata?.full_name === "string" ? user.user_metadata.full_name : "";

  try {
    const analysis = await constaia.analyze(
      { fileUrl: signed.signedUrl },
      {
        expect: "es_dni",
        checks: { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) },
        storage: "none",
        language: "es",
        metadata: { user_id: user.id },
      },
    );
    await admin.from("document_checks").upsert({
      analysis_id: analysis.id,
      user_id: user.id,
      status: analysis.verdict?.status ?? analysis.status,
      verdict: analysis.verdict,
    });
    await admin.storage.from("kyc").remove([path]);
    return withCors(Response.json(publicResult(analysis), { status: analysis.status === "completed" ? 200 : 202 }));
  } catch (error) {
    return withCors(errorResponse(error));
  }
});
  • El nombre del objeto va al final de la URL firmada y Constaia lo conserva: en modo test decide la respuesta.
  • expect y checks los fija la función; el cliente solo dice qué fichero ha subido.
  • Si el análisis no termina en 30 s, status es queued o processing y el resultado llega por el webhook.

Función constaia-webhook

supabase/functions/constaia-webhook/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
import { verifyWebhook, WebhookVerificationError, type WebhookEvent } from "npm:@constaia/sdk";
import { handleEvent } from "../_shared/constaia.ts";

const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);

Deno.serve(async (req) => {
  const rawBody = await req.text();
  let event: WebhookEvent;
  try {
    event = await verifyWebhook(rawBody, req.headers, Deno.env.get("CONSTAIA_WEBHOOK_SECRET")!);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response("Invalid signature", { status: 400 });
    throw error;
  }

  const webhookId = req.headers.get("webhook-id")!;
  const { error: dupError } = await admin.from("constaia_webhooks").insert({ id: webhookId });
  if (dupError?.code === "23505") return new Response(null, { status: 200 }); // ya procesado
  if (dupError) throw dupError;

  try {
    if (event.type === "analysis.completed" || event.type === "analysis.review_required") {
      await admin
        .from("document_checks")
        .update({ status: event.data.verdict?.status ?? event.data.status, verdict: event.data.verdict })
        .eq("analysis_id", event.data.id);
    }
    await handleEvent(event);
  } catch (error) {
    await admin.from("constaia_webhooks").delete().eq("id", webhookId); // permite el reintento
    throw error;
  }
  return new Response(null, { status: 204 });
});

verifyWebhook es la función independiente del SDK: esta función no necesita la clave de API. req.text() devuelve los bytes exactos que firma Constaia.

Despliegue

supabase db push
supabase functions deploy verify-dni
supabase functions deploy constaia-webhook --no-verify-jwt

--no-verify-jwt es obligatorio en el webhook: Constaia no envía un JWT de Supabase, se autentica con la firma. Registra la URL (https://<project-ref>.supabase.co/functions/v1/constaia-webhook) en el panel o con constaia.webhookEndpoints.create. Revisa en la documentación de Supabase los límites de duración y memoria de tu plan.

En la app

web/verify.ts
import type { SupabaseClient } from "@supabase/supabase-js";

export async function verifyDni(supabase: SupabaseClient, file: File) {
  const { data: { user } } = await supabase.auth.getUser();
  const path = `${user!.id}/${file.name}`;

  const { error } = await supabase.storage.from("kyc").upload(path, file, { upsert: true });
  if (error) throw error;

  const { data, error: fnError } = await supabase.functions.invoke("verify-dni", { body: { path } });
  if (fnError) throw fnError;
  return data; // { id, status, verdict, warnings, … }
}

functions.invoke envía el JWT de la sesión automáticamente.

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

supabase start
supabase functions serve --env-file supabase/functions/.env

Con CONSTAIA_API_KEY=ck_test_..., sube desde la app un fichero llamado dni_valid.jpg, dni_expired.jpg o blurry.jpg:

Ficheroverdict.status
dni_valid.jpgvalid (si full_name del usuario es María García López o no existe)
dni_expired.jpginvalid: not_expired con severidad error
blurry.jpgreview: motivo low_quality

Con supabase start las URLs firmadas apuntan a tu máquina (127.0.0.1) y Constaia no puede descargarlas. Para probar en local, lee el fichero en la función y pásalo como Blob en lugar de fileUrl:

supabase/functions/verify-dni/index.ts (solo en local)
const { data: blob } = await admin.storage.from("kyc").download(path);
const analysis = await constaia.analyze(blob!, { filename: path.split("/").pop(), expect: "es_dni" });

Todos los nombres en Modo test.

Checklist de producción

  • Bucket kyc privado, política de subida limitada a la carpeta del usuario y CORS con tu dominio.
  • Rate limit por usuario antes de llamar a Constaia: cada análisis gasta créditos.
  • Límite de 20 MB y tipos admitidos en el bucket (file_size_limit, allowed_mime_types).
  • PDF de más de 30 páginas: async: true y resultado por el webhook.
  • Clave ck_live_ solo en supabase secrets.
  • Alerta ante InsufficientCreditsError y el evento credits.low.

Siguientes pasos

En esta página