Constaia
Integrations

Supabase Edge Functions

Validate documents with Constaia in Supabase Edge Functions: Storage uploads, a signed URL as fileUrl, keys in supabase secrets and a webhook.

Esta página ainda não está traduzida para o seu idioma. Mostramos a versão em inglês.

You will build on Supabase:

  1. A private kyc bucket where each user uploads their document with supabase-js.
  2. The verify-dni function: checks the user's JWT, creates a signed URL for the file, passes it to Constaia as fileUrl, stores the verdict in a table and deletes the file.
  3. The constaia-webhook function: verifies the signature on the raw body and dedupes by webhook-id in Postgres.

Edge Functions run on Deno, so the SDK is imported with npm:@constaia/sdk. The key lives in the project secrets, never in the app.

Secrets

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

SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are already available inside functions. Locally, put the two Constaia variables in supabase/functions/.env.

Tables and policies

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);

Users can only upload to kyc/<their id>/… and read their own results. The functions use the service role key, which bypasses RLS.

Shared helpers

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

// What you send back to the browser (and what the widget renders): no extracted fields.
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) {
    // Unreadable file, unsupported format, too many pages…: the user can fix it.
    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", "Too many requests. Try again in a few seconds.", wait);
  }
  if (error instanceof InsufficientCreditsError) {
    console.error("[constaia] Out of credits. Top up at https://app.constaia.com", error.requestId);
    return httpError(503, "unavailable", "Document validation is temporarily unavailable.");
  }
  if (error instanceof AuthenticationError || error instanceof PermissionError) {
    console.error("[constaia] Check CONSTAIA_API_KEY", error.code, error.requestId);
    return httpError(500, "misconfigured", "Server configuration error.");
  }
  if (error instanceof ConstaiaError) {
    // APIError (5xx), APIConnectionError, APITimeoutError
    console.error("[constaia]", error.name, error.code, error.requestId);
    return httpError(502, "upstream_error", "The document could not be analysed. Please try again.");
  }
  console.error(error);
  return httpError(500, "internal_error", "Internal error.");
}

// Dedupe by webhook-id. In production, use a table with a unique key.
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 is the full analysis (with fields). Store it by event.data.id.
      console.log("analysis.completed", event.data.id, event.data.verdict?.status);
      break;
    case "analysis.review_required":
      console.log("Needs manual review", event.data.id);
      break;
    case "analysis.failed":
      console.warn("analysis.failed", event.data.id, event.data.error?.code);
      break;
    case "credits.low":
      console.warn("Credits running low: top up at https://app.constaia.com");
      break;
  }
}

verify-dni function

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://your-domain.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", "Sign in first.");

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

  // 10-minute signed URL: Constaia downloads the file (https, max 20 MB, 15 s).
  const { data: signed, error } = await admin.storage.from("kyc").createSignedUrl(path, 600);
  if (error || !signed) return fail(404, "missing_file", "File not found.");

  // The expected holder comes from the user's profile, not from the client.
  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: "en",
        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));
  }
});
  • The object name sits at the end of the signed URL and Constaia keeps it: in test mode it decides the response.
  • The function sets expect and checks; the client only says which file it uploaded.
  • If the analysis doesn't finish within 30 s, status is queued or processing and the result arrives through the webhook.

constaia-webhook function

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 }); // already processed
  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); // allow the retry
    throw error;
  }
  return new Response(null, { status: 204 });
});

verifyWebhook is the SDK's standalone function: this function doesn't need the API key. req.text() returns the exact bytes Constaia signs.

Deploy

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

--no-verify-jwt is required on the webhook: Constaia sends no Supabase JWT, it authenticates with the signature. Register the URL (https://<project-ref>.supabase.co/functions/v1/constaia-webhook) in the dashboard or with constaia.webhookEndpoints.create. Check the Supabase documentation for your plan's duration and memory limits.

In the 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 sends the session JWT automatically.

Errors

SDK errorWhenWhat your route returns
InvalidRequestErrorEmpty, unreadable or unsupported file, over 20 MB, too many pages or malformed options (400/409/413/415/422)422 (or 413) with the message, so the user can upload another file
RateLimitErrorYou exceed your key's requests per second (429). The SDK already retries twice honouring Retry-After429 with Retry-After
InsufficientCreditsErrorNo credits left (402)503 to the user and an alert for you: top up in the dashboard
AuthenticationError, PermissionErrorMissing, revoked or wrong key (401/403)500: it is your configuration problem, not the user's
APIError, APIConnectionError, APITimeoutErrorConstaia 5xx or network error, after retries are exhausted502 and a "try again" message

All of them extend ConstaiaError and expose status, code and requestId. Always log the requestId: support will ask for it. Every code is described in Errors.

Test in test mode

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

With CONSTAIA_API_KEY=ck_test_..., upload from the app a file named dni_valid.jpg, dni_expired.jpg or blurry.jpg:

Fileverdict.status
dni_valid.jpgvalid (if the user's full_name is María García López or missing)
dni_expired.jpginvalid: not_expired with severity error
blurry.jpgreview: reason low_quality

With supabase start signed URLs point at your machine (127.0.0.1) and Constaia cannot download them. To test locally, read the file inside the function and pass it as a Blob instead of fileUrl:

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

All names are in Test mode.

Production checklist

  • Private kyc bucket, upload policy limited to the user's folder and CORS with your domain.
  • Per-user rate limiting before calling Constaia: every analysis spends credits.
  • 20 MB limit and allowed types on the bucket (file_size_limit, allowed_mime_types).
  • PDFs over 30 pages: async: true and the result through the webhook.
  • ck_live_ key only in supabase secrets.
  • Alert on InsufficientCreditsError and the credits.low event.

Next steps

Nesta página