One Server

Docs

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 secret

The 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: role is anon, authenticated or service_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 (or Outbound requests in the console's functions screen) and fetch() 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_functions shows it), all recorded in the function logs).
  • Connections (sockets): Deno.connect, Deno.connectTls and Deno.startTls work 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's createClient(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 names SUPABASE_SECRET_KEYS and SUPABASE_PUBLISHABLE_KEYS (JSON shaped {"default": "<key>"}) are set too. SUPABASE_DB_URL is not: a function runs where there is no network and cannot connect to the database directly, so use createClient or the sql its second argument hands it.
  • npm packages: send dependencies ({"zod": "^3.23.8"}) or package_json (+ package_lock) with deploy_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 as files ({"lib/util.ts": "..."}) and import them by relative path. When you change a bundled function, send back the package_json and files that get_function returned.
  • 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_function has not finished in 40 seconds, it answers status: "building" at once and the deploy carries on on the server. When build.status in get_function is deployed, the new version takes calls; when it is failed, build.error says why.
  • A whole folder: send a folder with index.ts and other modules (and optionally deno.json) with oneend functions deploy <name> <folder>, or put everything, index.ts included, in the files of deploy_function. As on Supabase, the imports of deno.json and imports like npm:zod@3 work; they are turned into npm packages and installed at deploy time.
  • jsr: and URL imports: jsr:@supabase/supabase-js@2 is installed as the same package from npm, any other jsr: package from JSR's npm-compatible registry. URL imports such as https://deno.land/[email protected]/http/server.ts or https://esm.sh/... are fetched once, at deploy time, and bundled in (each address, where it ended up and its SHA-256 are kept in remote_imports of get_function; with a deno.lock the 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.ts imports ../_shared/cors.ts, oneend functions deploy <name> sends that folder along. Over MCP, put it in files under the path ../_shared/cors.ts as 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 (or oneend functions limits, or the console's function screen) changes them per function, up to timeout_cap_seconds from get_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, REST POST /v1/projects/<ref>/function-secrets). They never reach a site build, win over an environment variable of the same name in process.env, and are used from the next call. A value can be saved but never read back by any tool or API: list_function_secrets gives names and when they changed, and delete_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..., REST POST /v1/projects/<ref>/function-secrets/copy). The value moves inside the server, a name already there is replaced only with overwrite: true, and the answer gives names only, as copied, skipped and missing. 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:

FunctionNo keyPublishable keyUser token · secret key
Private (the default)401yes (claims.role = anon)yes (authenticated · service_role)
Public (public: true)yes (claims = null)yesyes

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 is 0 0 * * *.
  • A job does one of two things: kind: "function" (calls a deployed function with a POST whose body is {}) or kind: "sql" (one statement as the service role).
  • Outside URLs cannot be called with save_job (from SQL use net.http_post). A run at a given time happens only once, even with several servers. A SQL job has to finish within timeout_seconds (120 by default). When its SQL raises its own limit, as set 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 limit and used in list_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 blackquant becomes this project's own app_<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, or oneend db schemas expose), then use the Accept-Profile / Content-Profile headers or supabase-js db: { schema: 'blackquant' }. list_schemas lists them. A schema that is not exposed answers PGRST106 (406).
  • Search path and row cap: read them with get_data_api_settings and change them with set_data_api_settings (Settings, then Data API in the console). A schema on search_path (one made with create 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_settings and change them with set_database_settings (Settings, then Database in the console). When a scheduled job or function fails with too many connections for role, the API login (<ref>_authenticator) is held at its limit. If enforced.api_connections is lower than limits.api_connections, raise api_connections to 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.job and cron.job_run_details (kept 7 days). One Server's scheduler runs the jobs as the service role, and list_jobs shows them with source: "sql". Once a minute at most (a schedule in seconds such as 30 seconds is refused); shorthands like @hourly and @daily work. create extension pg_cron passes without an error.
  • pg_net: net.http_post(url, body, params, headers, timeout_milliseconds), net.http_get and net.http_delete queue the request and return its id. It is sent within seconds after the transaction ends, and the answer stays in net._http_response for 6 hours. https only, internal addresses refused, the requested timeout_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 needs set_function_egress.
  • There is no http extension (synchronous http_get / http_post). A call that waits for the answer in the same transaction becomes net.http_get with the answer read later from net._http_response, or a fetch() in a function. A call whose answer is not used works as net.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_secret and vault.decrypted_secrets open 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 delivery to 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 only signing_secret_set and 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).