Functions, scheduled jobs and database webhooks
What function code looks like, what it can and cannot do (outbound requests must be turned on), how to call it, cron schedules (UTC), and webhooks that tell outside services about table changes.
Functions
Code that takes a single request and handles it, with no server to run. Use them to receive payment provider webhooks, for calculations that need a secret key, and as the body of scheduled jobs.
list_functions deployed functions and their call addresses
get_function the current code (read it before you change it)
deploy_function deploy (the same name replaces it entirely, from the next call)
set_function_public change only whether it is public
set_function_limits change the time limits (one call, one SQL statement)
remove_function delete
list_function_secrets function secret names (values are never returned)
set_function_secrets save function secrets (the same name is overwritten)
copy_schema_from_project build another project's tables and structure (functions, views, policies included) here
copy_data_from_project copy another project's table rows here (missing ones only, or changed ones too)
copy_users_from_project copy another project's sign-in users (hashes included, inside the server)
compare_projects_data compare two projects' tables by row count and hash (is the copy complete)
get_project_forward see whether another project answers this one's public function calls
set_project_forward let another project answer this one's public function calls (so webhooks survive a split)
set_project_write_lock hold a project read only for a while (before the last copy of a split; it ends by itself)
get_project_write_lock see the write lock
copy_functions_from_project copy another project's functions, code included (as private)
copy_function_secrets copy function secrets by name from another project
delete_function_secret delete one function secretThe code is a single file:
export default async (request, { sql }) => {
const body = request.json() as { id?: string } | null
const rows = await sql('select * from orders where id = $1', [body?.id])
return { order: rows[0] ?? null }
}request: method, headers, claims (the claims of the key it was called with:roleisanon,authenticatedorservice_role; null for a public function called with no key), text(), json()sql(text, values): runs one statement with this project's service_role rights. It does not go through RLS, so check claims yourself.- Response: an object (JSON), a string, or
{ status, headers, body } - Network access is off by default. Turn it on with
set_function_egress(orOutbound requestsin the console's functions screen) andfetch()can call public HTTPS addresses (the server sends them on your behalf, internal addresses are blocked, up to the plan's limit a minute (Free 300, Pro 3,000, Enterprise 10,000;list_functionsshows it), all recorded in the function logs). - Connections (sockets):
Deno.connect,Deno.connectTlsandDeno.startTlswork too, so mail libraries (denomailer) and database drivers run as they are. The server opens them for the function under the rules of outside requests: network access must be on, each connection counts once against the per-minute limit, and only public addresses are allowed. Ports from 1024 up are open, and below that only web (80, 443) and mail (110, 143, 465, 587, 993, 995); other low ports such as 25 are blocked. The server checks the TLS certificate against the name. A call can hold 4 at once, one unused for 60 seconds is closed, and they close when the call ends. - The project's own API (
https://<ref>.<zone>) is reachable even with network access off, and the automatic variables (SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY; blackwall://env) are set, so a Supabase function'screateClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)works as it is. Those calls do not count toward the limit a minute, and are logged. Supabase's newer namesSUPABASE_SECRET_KEYSandSUPABASE_PUBLISHABLE_KEYS(JSON shaped{"default": "<key>"}) are set too.SUPABASE_DB_URLis not: a function runs where there is no network and cannot connect to the database directly, so usecreateClientor thesqlits second argument hands it. - npm packages: send
dependencies({"zod": "^3.23.8"}) orpackage_json(+package_lock) withdeploy_function, and they are installed at deploy time and bundled with your code into one ESM file (up to 5MB). Install scripts are not run, and native modules (.node) cannot be used. Send several files asfiles({"lib/util.ts": "..."}) and import them by relative path. When you change a bundled function, send back thepackage_jsonandfilesthatget_functionreturned. - Install time: a package set (package.json and lockfile) installed once is reused, so from the second deploy on it takes seconds. If MCP
deploy_functionhas not finished in 40 seconds, it answersstatus: "building"at once and the deploy carries on on the server. Whenbuild.statusinget_functionisdeployed, the new version takes calls; when it isfailed,build.errorsays why. - A whole folder: send a folder with
index.tsand other modules (and optionallydeno.json) withoneend functions deploy <name> <folder>, or put everything,index.tsincluded, in thefilesofdeploy_function. As on Supabase, theimportsofdeno.jsonand imports likenpm:zod@3work; they are turned into npm packages and installed at deploy time. - jsr: and URL imports:
jsr:@supabase/supabase-js@2is installed as the same package from npm, any otherjsr:package from JSR's npm-compatible registry. URL imports such ashttps://deno.land/[email protected]/http/server.tsorhttps://esm.sh/...are fetched once, at deploy time, and bundled in (each address, where it ended up and its SHA-256 are kept inremote_importsofget_function; with adeno.lockthe hashes must match). A function never fetches them while running. An address that cannot be reached fails the deploy, naming the address and why. - The _shared folder: as on Supabase, when
supabase/functions/<name>/index.tsimports../_shared/cors.ts,oneend functions deploy <name>sends that folder along. Over MCP, put it infilesunder the path../_shared/cors.tsas it is. - Time limits: one call may take 150 seconds by default (as on Supabase's edge functions), and one SQL statement inside a function 8 seconds.
set_function_limits(oroneend functions limits, or the console's function screen) changes them per function, up totimeout_cap_secondsfromget_function. A call past its limit is cut off with 504. - Production environment variables (blackwall://env) arrive as
process.env. When you change a value, the next call gets the new one. - Function secrets: values only functions use, such as a webhook signing key or a mail service's API key, are saved with
set_function_secrets { ref, secrets: { "RESEND_API_KEY": "re_…" } }(Function secrets in the console,oneend secrets set, RESTPOST /v1/projects/<ref>/function-secrets). They never reach a site build, win over an environment variable of the same name inprocess.env, and are used from the next call. A value can be saved but never read back by any tool or API:list_function_secretsgives names and when they changed, anddelete_function_secret(DELETE /v1/projects/<ref>/function-secrets/<name>) deletes one. A key already saved in another of your projects is not pasted again:copy_function_secrets { ref, from: "<other project ref>", names: ["ANTHROPIC_API_KEY"] }copies it (oneend secrets copy --from <ref> NAME..., RESTPOST /v1/projects/<ref>/function-secrets/copy). The value moves inside the server, a name already there is replaced only withoverwrite: true, and the answer gives names only, ascopied,skippedandmissing. You must be an owner or admin of both projects. Only owners and admins can see and change them. - Functions can read production secrets, so only owners and admins can deploy them.
Calling:
POST https://<ref>.<zone>/functions/v1/<name>
Authorization: Bearer <publishable key, user token or secret key>Who can call it:
| Function | No key | Publishable key | User token · secret key |
|---|---|---|---|
| Private (the default) | 401 | yes (claims.role = anon) | yes (authenticated · service_role) |
Public (public: true) | yes (claims = null) | yes | yes |
Private is what Supabase's verify_jwt checks, so a fetch with the publishable key and supabase.functions.invoke work as they are.
The publishable key ships inside your app where anyone can read it, so to accept signed-in users only, check request.claims.role === 'authenticated' in the function.
The caller's Authorization and apikey headers reach the function as they came (as on Supabase), so createClient(url, anonKey, { global: { headers: { Authorization: req.headers.get('Authorization') } } }) inside it queries as that user. Only values verified against this project's keys and tokens are passed; an unverified apikey, cookies and the platform's own credentials are not.
Set public: true only for payment provider webhooks that arrive without a key, and verify the signature yourself inside. Output (console.log) shows up in get_logs under source "functions".
Scheduled jobs
save_job create or change (the same name replaces it)
list_jobs list, next run, last result
run_job run once now (within a minute)
list_job_runs run history and errors
set_job_enabled turn off and on
remove_job delete
set_job_retention days of run history kept (1 | 3 | 7 | 14 | 30 | 90, null for the default)- Schedules are five-field cron in UTC. Korea Standard Time is UTC + 9 hours: midnight in Korea is
0 15 * * *, 9 a.m. in Korea is0 0 * * *. - A job does one of two things:
kind: "function"(calls a deployed function with a POST whose body is {}) orkind: "sql"(one statement as the service role). - Outside URLs cannot be called with
save_job(from SQL usenet.http_post). A run at a given time happens only once, even with several servers. A SQL job has to finish withintimeout_seconds(120 by default). When its SQL raises its own limit, asset statement_timeout to '15min'does, it is given that long; either way it stops at its plan's maximum (Free 300 seconds, Pro and Enterprise 3600). - The number of jobs per project depends on the plan (Free 50, Pro 200, Enterprise 500, jobs made in SQL included). See
limitandusedinlist_jobs. One project's jobs piling up does not hold up another project's (at most 4 run at once per project).
Supabase SQL as it is: schemas, pg_cron, pg_net, Vault
SQL written for Supabase runs without changes. The SQL editor, run_sql, apply_migrations, oneend db push and SQL scheduled jobs all go through the same name translation.
create schema if not exists blackquant; -- made as app_<ref>_blackquant
create table blackquant.prices (...); -- SQL uses the name as written
select cron.schedule('collect', '* * * * *', $$ select net.http_post(url := 'https://<ref>.<zone>/functions/v1/collect') $$);
select decrypted_secret from vault.decrypted_secrets where name = 'service_role_key';- Schemas:
create schema blackquantbecomes this project's ownapp_<ref>_blackquant. New tables are made with row level security on, as in public, and the anon, authenticated and service_role privileges are the same as in public. Another project's schemas can be neither named nor read. - Exposed schemas: the data API answers for public only. Expose more with
set_exposed_schemas(Database, then Schemas in the console, oroneend db schemas expose), then use theAccept-Profile/Content-Profileheaders or supabase-jsdb: { schema: 'blackquant' }.list_schemaslists them. A schema that is not exposed answers PGRST106 (406). - Search path and row cap: read them with
get_data_api_settingsand change them withset_data_api_settings(Settings, then Data API in the console). A schema onsearch_path(one made withcreate schema, or extensions) comes after the one asked for, so functions and views find what is in it without the schema name. - Connection limits: read them with
get_database_settingsand change them withset_database_settings(Settings, then Database in the console). When a scheduled job or function fails withtoo many connections for role, the API login (<ref>_authenticator) is held at its limit. Ifenforced.api_connectionsis lower thanlimits.api_connections, raiseapi_connectionsto it. It applies at once and closes no open connection. A project that has a server to itself can go up to the server's connections less the direct login's (30 by default) and 10 reserved; on a server shared with other projects it is a tenth of the server's connections (20 to 80). A scheduled job that finds the login at its limit waits up to a minute and connects again. - pg_cron:
cron.schedule(name, schedule, command),cron.unschedule,cron.alter_job,cron.jobandcron.job_run_details(kept 7 days). One Server's scheduler runs the jobs as the service role, andlist_jobsshows them withsource: "sql". Once a minute at most (a schedule in seconds such as30 secondsis refused); shorthands like@hourlyand@dailywork.create extension pg_cronpasses without an error. - pg_net:
net.http_post(url, body, params, headers, timeout_milliseconds),net.http_getandnet.http_deletequeue the request and return its id. It is sent within seconds after the transaction ends, and the answer stays innet._http_responsefor 6 hours. https only, internal addresses refused, the requestedtimeout_milliseconds(5 seconds by default) honoured up to the function time limit's maximum (150 seconds by default, up to 900 set by the operator), a called function that runs on to its end even when the call stops waiting, and the same limit a minute as functions' outbound requests. The project's own address (https://<ref>.<zone>) works even with functions' outbound requests off; any other address needsset_function_egress. - There is no http extension (synchronous
http_get/http_post). A call that waits for the answer in the same transaction becomesnet.http_getwith the answer read later fromnet._http_response, or afetch()in a function. A call whose answer is not used works asnet.http_post. It is left out because a database server sending on its own would get around the private-address refusal and the limit a minute. The migration tool names the functions and jobs that call it. - Vault:
vault.create_secretandvault.decrypted_secretsopen only for the service role (the SQL editor,run_sql, the secret key, SQL scheduled jobs). It turns on the first time it is used in a project with its own database.
Database webhooks
Notify when a row in a table changes. Unless you have turned on outbound requests for functions, this is how you notify outside services (Slack, email sending APIs and so on).
create_db_webhook create: table, events (insert/update/delete), destination (url or function)
list_db_webhooks list with recent results (signing key: last four characters only)
list_db_webhook_deliveries delivery history and reasons for failure
set_db_webhook_enabled turn off and on
remove_db_webhook delete- Body:
{ type: "INSERT"|"UPDATE"|"DELETE", table, schema: "public", record, old_record, hook, delivery, occurred_at } - Only committed changes are sent. If the server was restarting, they are sent later. Order is not guaranteed, so use
deliveryto drop duplicates. - Anything other than a 2xx, or no response within 10 seconds, is a failure. It is retried after 10 seconds, 1 minute, 5 minutes and 30 minutes, and dropped on the fifth attempt.
- The url must be a public https address. Private and internal addresses and redirects are refused.
- Verifying on the receiving side:
x-blackwall-signature: t=<seconds>,v1=<signature>, where the signature is the hex of HMAC-SHA256(signing_secret, "<t>.<body>"). - signing_secret is shown once, when the webhook is created. The list (
list_db_webhooks) shows onlysigning_secret_setand the last four characters (signing_secret_last4). If it is lost, remove the webhook and create it again to get a new key.
Web Response, Deno.serve
Deno.serve(async (req) => {
const { name } = await req.json()
return new Response(JSON.stringify({ hello: name }), { headers: { "content-type": "application/json" } })
})export default {
async fetch(req, ctx) {
const hits = await ctx.kv.incr("hits")
const on = await ctx.flags.isEnabled("new-checkout", req.claims?.sub ?? null)
return Response.json({ hits, on })
},
}Functions written for Supabase run as they are: new Response(...) (streams included), Deno.serve(handler), export default { fetch }, Deno.env.get(). The path after the function's name (/functions/v1/<name>/a/b) arrives in req.url. Work handed to EdgeRuntime.waitUntil(promise) runs on after the answer is sent, within the function's time limit, and may still query and make outside requests.
A Response made from a stream is sent as it is written (server-sent events, a model's answer). ctx.kv, ctx.config and ctx.flags reach the KV store and edge config (blackwall://edge).