Functions
Scribase edge functions are server-side TypeScript or JavaScript modules that
run on the open-source Deno edge-runtime
— the same runtime Supabase uses — next to your database. They are served on
the Supabase-compatible /functions/v1/<name> path, so existing function code
and the clients that call it move over by changing the URL and keys.
Writing a function
A function is a directory with an index.ts that serves requests:
// functions/hello/index.ts
Deno.serve(async (req: Request) => {
const { name } = await req.json();
return Response.json({ message: `Hello, ${name}!` });
});Environment available to a function
Every function receives the Supabase-compatible variables plus every secret set on its environment (see Secrets below):
| Variable | Value |
|---|---|
SUPABASE_URL |
The environment's public API URL |
SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY |
The environment's API keys |
SUPABASE_DB_URL |
The Postgres connection string (scribase dev also sets DATABASE_URL and SCRIBASE_DB_URL) |
| Your secrets | Each secret set with scribase secrets set or the console, by name |
The JWT signing secret and the runtime's own admin credential are never passed
to a deployed function. To verify a caller, forward their Authorization
header to auth (supabase.auth.getUser(token)) rather than checking the
signature yourself.
Query Postgres directly with any Deno Postgres driver:
import postgres from 'https://deno.land/x/postgresjs@v3.4.4/mod.js';
const sql = postgres(Deno.env.get('SUPABASE_DB_URL')!);
Deno.serve(async () => {
const orders = await sql`select id, total from orders order by id desc limit 10`;
return Response.json({ orders });
});Or use supabase-js against your environment's API:
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const client = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);Deploying to a hosted environment
With the CLI, point functions deploy at the function's directory. It sends
index.ts, every sibling module (.ts, .tsx, .js, .jsx, .mjs, .mts,
.json) and an import_map.json if one is present, as one bundle:
scribase functions deploy acme store production hello ./functions/hello
scribase functions deploy acme store production hello ./functions/hello --import-map ./import_map.jsonEach deploy is a new immutable version. The runtime boots it once and makes it active only if the boot succeeds; otherwise the previous version keeps serving and the command fails with the boot error. Manage versions with:
scribase functions list acme store production
scribase functions get acme store production hello [--version 3]
scribase functions rollback acme store production hello 3
scribase functions delete acme store production hello
scribase functions invoke acme store production hello --data '{"name":"Alice"}'From CI without the CLI, send the function's name and source to the environment's functions route. A deploy creates or replaces the function; a runtime that rejects it surfaces a real error rather than a silent success.
jq -n --arg source "$(cat functions/hello/index.ts)" '{name: "hello", source: $source}' \
| curl -X POST "$SCRIBASE_API_URL/v1/organizations/acme/projects/store/environments/production/functions" \
-H "Authorization: Bearer $SCRIBASE_ACCESS_TOKEN" \
-H 'Content-Type: application/json' --data-binary @-With the SDK:
import { readFile } from 'node:fs/promises';
const ref = { organizationId: 'acme', projectId: 'store', environmentId: 'production' };
await scribase.functions.deploy(ref, {
name: 'hello',
source: await readFile('functions/hello/index.ts', 'utf8'),
});Function names are letters, digits, hyphens, or underscores, starting with a letter or digit.
Logs
Each function's log holds one line per invocation (method, path, status,
duration) plus the function's own output: console.log, console.info,
console.warn and console.error, uncaught exceptions, and CPU, wall-clock or
memory limit terminations. Read it on the function's page in the console or
with:
scribase functions logs acme store production hello --limit 50Lines are also shipped to the environment's logs page, where they are kept after the runtime restarts.
Listing and invoking through the control API
const { functions, notes } = await scribase.functions.list(ref);
const { result } = await scribase.functions.invoke(ref, 'hello', { name: 'Alice' });curl -X POST "$SCRIBASE_API_URL/v1/organizations/acme/projects/store/environments/production/functions/hello/invoke" \
-H "Authorization: Bearer $SCRIBASE_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name": "Alice"}'list degrades to an empty array with a notes explanation if the runtime is
unreachable, so a dashboard never breaks; invoke and deploy surface errors.
Calling a function from your app
Clients call functions on the compatible path, with a user's JWT or the anon key:
curl -X POST 'https://<your-environment-api-url>/functions/v1/hello' \
-H 'Authorization: Bearer <anon-key-or-user-jwt>' \
-H 'Content-Type: application/json' \
-d '{"name": "Alice"}'With supabase-js: await supabase.functions.invoke('hello', { body: { name: 'Alice' } }).
Local development
scribase dev starts the edge-runtime (port 9000 by default) with
<SCRIBASE_HOME>/functions/main as its main service — .scribase/functions/main
unless you set SCRIBASE_HOME. The main service is a small router that maps
/<name> to a sibling function directory. Create it once:
// .scribase/functions/main/index.ts
declare const EdgeRuntime: {
userWorkers: {
create(options: Record<string, unknown>): Promise<{ fetch(req: Request): Promise<Response> }>;
};
};
const FUNCTIONS_ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
Deno.serve(async (req) => {
const segments = new URL(req.url).pathname.split('/').filter(Boolean);
// Accept both /hello and /functions/v1/hello.
const name = segments[0] === 'functions' && segments[1] === 'v1' ? segments[2] ?? '' : segments[0] ?? '';
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
return Response.json({ error: 'invalid function name' }, { status: 400 });
}
try {
const worker = await EdgeRuntime.userWorkers.create({
servicePath: `${FUNCTIONS_ROOT}/${name}`,
memoryLimitMb: 150,
workerTimeoutMs: 30_000,
envVars: Object.entries(Deno.env.toObject()),
});
return await worker.fetch(req);
} catch {
return Response.json({ error: `function "${name}" is not deployed` }, { status: 404 });
}
});Then put each function next to it and start the stack:
.scribase/functions/
├── main/
│ └── index.ts # the router above
└── hello/
└── index.tsscribase dev --services functions
curl -X POST http://localhost:9000/hello -H 'Content-Type: application/json' -d '{"name":"Alice"}'The self-hosted Docker Compose stack ships the same router at
deploy/compose/functions/main/index.ts.
Secrets
Values your function needs beyond the injected variables, such as third-party
API keys, are environment secrets. They are encrypted at rest, bound into every
function in the environment, and read with Deno.env.get('NAME'):
scribase secrets set acme store production STRIPE_KEY --from-env STRIPE_KEY
printf %s "$WEBHOOK_SECRET" | scribase secrets set acme store production WEBHOOK_SECRET --stdin
scribase secrets list acme store production
scribase secrets unset acme store production STRIPE_KEYPrefer --from-env or --stdin over a literal value so the secret stays out of
your shell history. Secrets can also be managed on the environment's Secrets
page in the console.
Setting, rotating or removing a secret reaches deployed functions without a redeploy: the next request after the change boots a fresh worker with the new values, while requests already in flight finish on the old one.
CORS
Return CORS headers from functions called by browsers:
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, content-type',
};
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
return Response.json({ ok: true }, { headers: corsHeaders });
});