Writing & Deploying Functions
Write a function
A function is a Deno HTTP handler. The simplest form:
Deno.serve(async (req) => { const { name = 'world' } = await req.json().catch(() => ({})) return Response.json({ message: `Hello, ${name}!` })})A more realistic one that uses the injected project context to read the database:
Deno.serve(async (req) => { const base = req.headers.get('X-AnvilBase-URL')! const pid = req.headers.get('X-AnvilBase-Project-ID')! const key = req.headers.get('X-AnvilBase-Service-Role-Key')!
const res = await fetch( `${base}/v1/rest/${pid}/orders?order=created_at.desc&limit=10`, { headers: { apikey: key } }, ) const orders = await res.json() return Response.json({ orders })})Deploy
All three deploy paths — CLI, management API, and Console — write to
the store of record (the edge_functions table + the project’s MinIO bucket).
This is the canonical, durable deploy: the record persists across restarts,
appears in the functions listing, and carries the function’s verify_jwt flag.
The Deno runtime then lazily rehydrates the source from the store of record on the
function’s first invocation (a cold cache-miss), so the function goes live without
a separate runtime push.
CLI
Reads ./functions/<name>.ts or ./functions/<name>/index.ts (the Supabase
layout) — whichever exists (override the directory with --path):
# Deploys ./functions/hello.tsanvilbase functions deploy hello --project <id>
anvilbase functions deploy hello --project <id>
# Custom directory: deploys ./src/edge/webhook.tsanvilbase functions deploy webhook --project <id> --path ./src/edge
# List deployed functions (reads the durable management store)anvilbase functions list --project <id>The CLI deploys through the management API with UPSERT semantics: it first
tries PUT /api/v1/projects/<id>/functions/<name> (update) and falls back to
POST /api/v1/projects/<id>/functions (create) when the function doesn’t exist
yet. On success it prints the function’s invocation URL and confirms the deploy is
persisted to the canonical store.
Durable by default
anvilbase functions deploy persists to the store of record (MinIO + the
edge_functions table) just like the management API and Console — so a
CLI-deployed function survives container restarts, appears in the functions
listing, and carries its persisted verify_jwt flag. It becomes live on its
first invocation via lazy rehydration. (Earlier versions wrote only the
ephemeral Deno runtime filesystem and were lost on restart — that is no longer the
case.)
A new CLI deploy defaults verify_jwt to true (secure-by-default); set a
function public via the management API or Console (verify_jwt: false).
Management API
Create or update with the full code body:
# Create (verify_jwt defaults to true)curl -X POST http://localhost:39001/api/v1/projects/<id>/functions \ -H "Authorization: Bearer $ANVILBASE_TOKEN" -H "Content-Type: application/json" \ -d '{ "name": "hello", "code": "Deno.serve(() => Response.json({ ok: true }))", "description": "health probe", "verify_jwt": false }'
# Update code / settingscurl -X PUT http://localhost:39001/api/v1/projects/<id>/functions/hello \ -H "Authorization: Bearer $ANVILBASE_TOKEN" -H "Content-Type: application/json" \ -d '{"code":"Deno.serve(() => Response.json({ ok: true, v: 2 }))"}'
# List / get / deletecurl http://localhost:39001/api/v1/projects/<id>/functions -H "Authorization: Bearer $ANVILBASE_TOKEN"curl http://localhost:39001/api/v1/projects/<id>/functions/hello -H "Authorization: Bearer $ANVILBASE_TOKEN"curl -X DELETE http://localhost:39001/api/v1/projects/<id>/functions/hello -H "Authorization: Bearer $ANVILBASE_TOKEN"Rules: function name is 1–100 chars ([A-Za-z0-9_-]); code ≤ 10 MB; 409 if
the name already exists on create; quota breaches return 429.
Console
Project → Functions → New Function opens an editor. Write code, toggle Verify JWT, and Deploy. Logs and invocations appear in the same view.
Invoke
# verify_jwt: true → must send a valid tokencurl -X POST "http://localhost:39001/v1/functions/<id>/hello" \ -H "Authorization: Bearer $USER_JWT" -H "Content-Type: application/json" \ -d '{"name":"Ada"}'
# verify_jwt: false → publiccurl -X POST "http://localhost:39001/v1/functions/<id>/hello" \ -H "Content-Type: application/json" -d '{"name":"world"}'From an SDK: functions.invoke()
Every AnvilBase SDK ships a functions.invoke(name, options) that is
wire-compatible with supabase.functions.invoke() — it POSTs to
/functions/v1/{name} with the client’s live auth header, JSON-serializes the
body (unless it’s a string/binary), parses the response by content-type, and
returns { data, error } (a non-2xx becomes error).
// TypeScriptconst { data, error } = await db.functions.invoke('hello', { body: { name: 'Ada' } })# Pythonres = db.functions.invoke("hello", body={"name": "Ada"}) # res.data / res.error// Gores := client.Functions().Invoke("hello", &anvilbase.FunctionInvokeOptions{ Body: map[string]any{"name": "Ada"},})The Rust, Swift, Kotlin, and Elixir clients expose the same functions.invoke
shape (a JSON/text/raw body, optional per-call headers, and an optional method,
default POST).
Using project secrets (Deno.env)
Don’t hard-code third-party keys in function source. Store them in the project’s
Secrets Vault and read them at runtime as environment
variables via Deno.env.get(NAME) — the same shape as Supabase
supabase secrets set:
# Set a secret (CLI / management API). Name and value are two positional args.anvilbase secrets set STRIPE_KEY sk_live_... --project <id>Deno.serve(async () => { // Read the secret straight from the environment — no helper, no round-trip. const stripeKey = Deno.env.get('STRIPE_KEY')! const r = await fetch('https://api.stripe.com/v1/charges', { method: 'POST', headers: { Authorization: `Bearer ${stripeKey}` }, body: new URLSearchParams({ amount: '1000', currency: 'usd' }), }) return Response.json(await r.json())})How it works: a project’s secrets are decrypted and injected into that
project’s worker process when the worker cold-starts. Each project has its own
worker (see Runtime & Isolation), so a function only ever sees
its own project’s secrets — never another tenant’s. Reserved/system
environment names (PROJECT_ID, FUNCTIONS_DIR, DENO_*, …) cannot be
overridden by a secret.
Propagation latency
A changed secret takes effect on the worker’s next cold start. A long-lived warm worker keeps the values it started with; redeploy the function (or wait for the idle worker to be reaped) to pick up a new value immediately.
service_role for public functions
A public (verify_jwt: false) function invoked without a credential does
not receive the X-AnvilBase-Service-Role-Key header (see
Overview → Authentication). If such a
function needs service_role for a privileged DB write, store the service-role
key as a secret and read it with Deno.env.get('SERVICE_ROLE_KEY').
Keep secrets out of source and out of client bundles — the function runs server-side, which is exactly where they belong.
Viewing invocation logs
Every invocation is captured — its status, duration, method, and timestamp — into a bounded per-project ring store (capture is fire-and-forget, so it never slows the invocation). Read the most recent invocations for a function via the management API:
# Newest-first; ?limit= is clamped to 1..=500 (default 50)curl "http://localhost:39001/api/v1/projects/<id>/functions/hello/logs?limit=20" \ -H "Authorization: Bearer $ANVILBASE_TOKEN"# → [{ "function_name":"hello", "status":200, "method":"POST",# "duration_ms":12, "message":"", "created_at":"…" }, …]The function’s stdout/stderr is streamed to the container log (and the
console Functions view); this endpoint records the per-invocation
status/duration metadata. The richer logs API is tracked for a later phase and
reads the same store.
Receiving inbound webhooks
A function with verify_jwt: false is a clean way to receive third-party webhooks
(Stripe, GitHub, etc.). Verify the provider’s signature inside the function:
Deno.serve(async (req) => { const sig = req.headers.get('Stripe-Signature') const body = await req.text() // The signing secret comes from the project secrets (Deno.env), never source. const signingSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')! if (!verifyStripeSignature(body, sig, signingSecret)) { return new Response('bad signature', { status: 400 }) } // …handle the event… return new Response('ok')})Because the function is verify_jwt: false, Stripe can POST to it directly
with no AnvilBase credential — the request reaches your handler, which does its
own signature verification.
For AnvilBase → outbound webhooks (your own DB/auth events to external URLs), use the built-in Webhooks service instead.
Tips
- Return
Response/Response.json()— the runtime is a standard Deno HTTP handler. - Keep functions small and single-purpose; a project’s worker cold-starts fast but heavy dependencies add latency (warm reuse is a single loopback hop). See Runtime & Isolation.
- Use
verify_jwt: trueunless you have a specific reason for a public endpoint. - Log to stdout; view logs in the console Functions view.
Next: Queues (PGMQ).