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.
You will build on Supabase:
- A private
kycbucket where each user uploads their document withsupabase-js. - The
verify-dnifunction: checks the user's JWT, creates a signed URL for the file, passes it to Constaia asfileUrl, stores the verdict in a table and deletes the file. - The
constaia-webhookfunction: verifies the signature on the raw body and dedupes bywebhook-idin 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
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
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
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
expectandchecks; the client only says which file it uploaded. - If the analysis doesn't finish within 30 s,
statusisqueuedorprocessingand the result arrives through the webhook.
constaia-webhook function
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
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 error | When | What your route returns |
|---|---|---|
InvalidRequestError | Empty, 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 |
RateLimitError | You exceed your key's requests per second (429). The SDK already retries twice honouring Retry-After | 429 with Retry-After |
InsufficientCreditsError | No credits left (402) | 503 to the user and an alert for you: top up in the dashboard |
AuthenticationError, PermissionError | Missing, revoked or wrong key (401/403) | 500: it is your configuration problem, not the user's |
APIError, APIConnectionError, APITimeoutError | Constaia 5xx or network error, after retries are exhausted | 502 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/.envWith CONSTAIA_API_KEY=ck_test_..., upload from the app a file named dni_valid.jpg, dni_expired.jpg or blurry.jpg:
| File | verdict.status |
|---|---|
dni_valid.jpg | valid (if the user's full_name is María García López or missing) |
dni_expired.jpg | invalid: not_expired with severity error |
blurry.jpg | review: 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:
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
kycbucket, 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: trueand the result through the webhook. -
ck_live_key only insupabase secrets. - Alert on
InsufficientCreditsErrorand thecredits.lowevent.
Next steps
Firebase Functions
Validate documents with Constaia on Cloud Functions for Firebase v2: a Storage trigger with a signed URL, base64 onRequest, defineSecret and webhook.
PHP
Validate documents from plain PHP 8.1+ with the constaia/constaia-php SDK: a $_FILES upload form, a signed webhook and a no-SDK cURL CURLFile variant.