The answer. Edge Functions are small Deno handlers deployed next to your project and invoked at /functions/v1/<name>. They are the right place for work that should not run in a Postgres transaction and should not need a separate server: enrich a new row, call a third-party API, generate an embedding. Our upload-asset function exists for exactly that reason: it streams up to 10 MB of image bytes to a CMS, work we did not want inside the API Worker's CPU budget. Wired to a Database Webhook, the shape is: the insert commits, pg_net POSTs the record to the function, the function verifies a shared secret, does the work keyed on the row id so a duplicate delivery is harmless, and writes the result back with a secret-key client.
The pattern.
npx supabase functions new enrich-ticket && npx supabase secrets set HOOK_SECRET=… OPENAI_API_KEY=…
npx supabase functions serve enrich-ticket # local: http://localhost:54321/functions/v1/enrich-ticket
npx supabase functions deploy enrich-ticket
// supabase/functions/enrich-ticket/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
if (req.headers.get("x-hook-secret") !== Deno.env.get("HOOK_SECRET")) return new Response("forbidden", { status: 403 });
const { type, record } = await req.json();
if (type !== "INSERT") return new Response("ignored", { status: 204 });
const db = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SECRET_KEY")!); // set as a secret
const { data: done } = await db.from("tickets").select("summary").eq("id", record.id).single();
if (done?.summary) return new Response("already", { status: 200 }); // idempotent
const summary = await summarize(record.title, record.description);
await db.from("tickets").update({ summary }).eq("id", record.id);
return new Response("ok");
});
create trigger tickets_enrich after insert on public.tickets for each row execute function supabase_functions.http_request(
'https://<ref>.supabase.co/functions/v1/enrich-ticket', 'POST',
'{"Content-Type":"application/json","Authorization":"Bearer <publishable key>","x-hook-secret":"…"}', '{}', '10000');
Watch out.
- The gateway rejects calls without an
Authorization: Bearer <key>header, even from a webhook or a cron job; the publishable key satisfies it, your own secret header does the real check. Setverify_jwt = falsefor the function only when you verify something else. - Function secrets (
supabase secrets set) are separate from Vault and from your Worker secrets; three stores, three rotations. - Functions are for seconds of work with modest memory; image processing and long batches belong in a queue consumer.
Related: database-webhooks-are-pg-net-triggers-make-receivers-idempotent · pg-cron-inside-postgres-vs-a-worker-cron-outside · signed-urls-and-image-transforms-for-private-files