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:
- Un bucket privado
kycdonde cada usuario sube su documento consupabase-js. - La función
verify-dni: comprueba el JWT del usuario, crea una URL firmada del fichero, se la pasa a Constaia comofileUrl, guarda el veredicto en una tabla y borra el fichero. - La función
constaia-webhook: verifica la firma con el cuerpo crudo y deduplica porwebhook-iden 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
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
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
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.
expectycheckslos fija la función; el cliente solo dice qué fichero ha subido.- Si el análisis no termina en 30 s,
statusesqueuedoprocessingy el resultado llega por el webhook.
Función constaia-webhook
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
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 SDK | Cuándo | Qué devuelve tu ruta |
|---|---|---|
InvalidRequestError | Fichero 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 |
RateLimitError | Superas las peticiones por segundo de tu clave (429). El SDK ya reintenta dos veces respetando Retry-After | 429 con Retry-After |
InsufficientCreditsError | No quedan créditos (402) | 503 al usuario y una alerta para ti: recarga en el panel |
AuthenticationError, PermissionError | Clave ausente, revocada o incorrecta (401/403) | 500: es un fallo de configuración tuyo, no del usuario |
APIError, APIConnectionError, APITimeoutError | Error 5xx de Constaia o de red, tras agotar los reintentos | 502 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/.envCon CONSTAIA_API_KEY=ck_test_..., sube desde la app un fichero llamado dni_valid.jpg, dni_expired.jpg o blurry.jpg:
| Fichero | verdict.status |
|---|---|
dni_valid.jpg | valid (si full_name del usuario es María García López o no existe) |
dni_expired.jpg | invalid: not_expired con severidad error |
blurry.jpg | review: 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:
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
kycprivado, 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: truey resultado por el webhook. - Clave
ck_live_solo ensupabase secrets. - Alerta ante
InsufficientCreditsErrory el eventocredits.low.
Siguientes pasos
Firebase Functions
Valida documentos con Constaia en Cloud Functions for Firebase v2: trigger de Storage con URL firmada, onRequest con base64, defineSecret y webhook.
PHP
Valida documentos desde PHP 8.1+ sin framework con el SDK constaia/constaia-php: formulario con $_FILES, webhook firmado y variante con cURL y CURLFile.