Build

Functions

A function is one TypeScript or JavaScript file that answers HTTP requests. It runs on Deno next to your app, with your app's env vars and a secret key already in its environment.

Write a function

Export a default handler that takes a Request and returns a Response:

cat > hello.ts <<'EOF'
export default async (req: Request): Promise<Response> => {
  const body = req.method === "POST" ? await req.json().catch(() => ({})) : {};
  const greeting = Deno.env.get("GREETING") ?? "Hello";
  return Response.json({ message: `${greeting}, ${body.name ?? "world"}` });
};
EOF

Or use Deno.serve, which is handy when you already have Deno code:

cat > whoami.ts <<'EOF'
Deno.serve((req) => {
  return Response.json({
    user_id: req.headers.get("Berth-User-Id"),
    email: req.headers.get("Berth-User-Email"),
  });
});
EOF

Imports

A function may import packages from npm:, jsr:, and https:// URLs such as esm.sh. It may not import other local files, because only the one file is deployed. If your code is spread over several files, bundle it into one first (for example with esbuild).

cat > digest.ts <<'EOF'
import { encodeHex } from "jsr:@std/encoding/hex";
export default async (req: Request) => {
  const data = new Uint8Array(await req.arrayBuffer());
  const hash = await crypto.subtle.digest("SHA-256", data);
  return Response.json({ sha256: encodeHex(hash) });
};
EOF

The other two forms look like this:

import { Chess } from "npm:chess.js";
import { nanoid } from "https://esm.sh/nanoid";

Deploy

hello.ts reads GREETING, which is not set yet. An unset env var reads as undefined, so the ?? "Hello" fallback applies.

berth functions deploy --app "$APP" hello hello.ts --verify key
berth functions deploy --app "$APP" whoami whoami.ts --verify user --timeout-ms 5000 --memory-mb 128
berth functions deploy --app "$APP" digest digest.ts
berth functions list --app "$APP"

Deploying the same name again replaces it. The HTTP form is a PUT with the source as a JSON string:

jq -n --rawfile source hello.ts '{source: $source, verify: "key", timeout_ms: 10000}' | \
  curl -sS -X PUT https://api.atberth.com/v1/apps/$APP/functions/hello/deployment \
    -H "Authorization: Bearer $SECRET_KEY" \
    -H "Content-Type: application/json" \
    --data-binary @-

Verify modes

verifyWho may call it
keyanyone with the app's publishable or secret key (the default)
usersigned-in users only; the function gets Berth-User-Id and Berth-User-Email
noneanyone on the internet, for example a payment provider's webhook. Check signatures yourself.

Invoke

A function answers any method at /v1/apps/{app}/functions/{name}, and its response is passed through as is.

curl -sS -X POST https://api.atberth.com/v1/apps/$APP/functions/hello \
  -H "Authorization: Bearer $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada"}'
berth functions invoke --app "$APP" hello --data '{"name":"Grace"}'
berth functions invoke --app "$APP" digest --data 'hello' --content-type text/plain | grep 2cf24dba

A user function needs a signed-in user's token:

ACCESS_TOKEN=$(curl -sS -X POST https://api.atberth.com/v1/apps/$APP/auth/signup \
  -H "Authorization: Bearer $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","password":"correct horse battery"}' | jq -er .access_token)
curl -sS https://api.atberth.com/v1/apps/$APP/functions/whoami \
  -H "apikey: $PUBLISHABLE_KEY" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Env vars

Set secrets such as third party API keys as env vars. Values are write only: the API lists names and updated_at, never values. Names are uppercase letters, digits, and underscores, and may not start with BERTH_ or DENO_.

berth env set --app "$APP" GREETING=Welcome
berth env list --app "$APP"
berth functions invoke --app "$APP" hello --data '{"name":"Ada"}'
curl -sS -X PUT https://api.atberth.com/v1/apps/$APP/env/GREETING \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value":"Hi"}'
berth env unset --app "$APP" GREETING

Every function also gets these, set by Berth:

NameValue
BERTH_API_URLthe API base URL
BERTH_APPthe app name
BERTH_SECRET_KEYa secret key for this app, so the function can call the API with full access

A function that counts notes with the injected key. The key stays on the server:

berth tables create --app "$APP" notes title:text:notnull
berth rows add --app "$APP" notes title="From a function"
cat > count_notes.ts <<'EOF'
export default async () => {
  const base = Deno.env.get("BERTH_API_URL");
  const app = Deno.env.get("BERTH_APP");
  const res = await fetch(`${base}/apps/${app}/tables/notes/rows?count=exact&limit=1`, {
    headers: { Authorization: `Bearer ${Deno.env.get("BERTH_SECRET_KEY")}` },
  });
  const { count } = await res.json();
  return Response.json({ notes: count });
};
EOF
berth functions deploy --app "$APP" count_notes count_notes.ts
berth functions invoke --app "$APP" count_notes --method GET

Schedules

Give a function a cron schedule and Berth runs it on its own: nightly cleanups, reminders, syncing from another API. A schedule is five cron fields in UTC (minute hour day month weekday), or one of @hourly, @daily, @weekly, @monthly.

Each minute that matches, Berth sends the function a POST with the body {"scheduled_at": "2026-09-22T10:15:00Z"} and these headers:

HeaderValue
Berth-Key-Typeschedule. Berth sets this header itself on every call (publishable, secret, user, or none for other callers), so a caller cannot fake it.
Berth-Schedulethe cron expression, for your logs. Do not use it to decide who called; check Berth-Key-Type.

Scheduled runs do not go through the function's verify mode, and they show up in the function's logs like any other run.

cat > cleanup.ts <<'EOF'
export default async (req: Request) => {
  if (req.headers.get("Berth-Key-Type") !== "schedule") {
    return new Response("Only the scheduler runs this.", { status: 403 });
  }
  const { scheduled_at } = await req.json();
  const base = Deno.env.get("BERTH_API_URL");
  const app = Deno.env.get("BERTH_APP");
  const res = await fetch(`${base}/apps/${app}/tables/notes/rows?title=eq.expired`, {
    method: "DELETE",
    headers: { Authorization: `Bearer ${Deno.env.get("BERTH_SECRET_KEY")}` },
  });
  return Response.json({ scheduled_at, cleaned: await res.json() });
};
EOF
berth functions deploy --app "$APP" cleanup cleanup.ts --schedule "*/15 * * * *"

The deployment response includes "schedule". A redeploy that leaves the schedule out keeps it. --no-schedule, or "schedule": null over HTTP, removes it.

berth functions deploy --app "$APP" cleanup cleanup.ts --json | jq -e '.function.schedule == "*/15 * * * *"'
jq -n --rawfile source cleanup.ts '{source: $source, schedule: "@daily"}' | \
  curl -sS -X PUT https://api.atberth.com/v1/apps/$APP/functions/cleanup/deployment \
    -H "Authorization: Bearer $SECRET_KEY" \
    -H "Content-Type: application/json" \
    --data-binary @- | jq -e '.function.schedule == "@daily"'
jq -n --rawfile source cleanup.ts '{source: $source, schedule: null}' | \
  curl -sS -X PUT https://api.atberth.com/v1/apps/$APP/functions/cleanup/deployment \
    -H "Authorization: Bearer $SECRET_KEY" \
    -H "Content-Type: application/json" \
    --data-binary @- | jq -e '.function.schedule == null'
berth functions deploy --app "$APP" cleanup cleanup.ts --schedule @hourly
berth functions deploy --app "$APP" cleanup cleanup.ts --no-schedule --json | jq -e '.function.schedule == null'

A bad expression is rejected with 400 invalid_body:

jq -n --rawfile source cleanup.ts '{source: $source, schedule: "every day"}' | \
  curl -sS -X PUT https://api.atberth.com/v1/apps/$APP/functions/cleanup/deployment \
    -H "Authorization: Bearer $SECRET_KEY" \
    -H "Content-Type: application/json" \
    --data-binary @- | jq -e '.error == "invalid_body"'

Sandbox rules

  • Network: the public internet only. No localhost, no private ranges, no cloud metadata addresses.
  • No file system and no subprocesses.
  • Only this app's env vars are visible. A name that is not set reads as undefined; the host's own variables are never reachable.
  • Imports from npm:, jsr:, and https:// URLs work. Relative imports of local files do not.
  • Timeout: 10 seconds by default, 30 at most (--timeout-ms). Past it the caller gets 504 timeout.
  • Heap: 128 MB by default, 256 MB at most (--memory-mb).
  • Four runs at once per app, 20 functions per app, 1 MB of source each.
  • A crash or an empty response returns 502 function_error.

Logs

Each run of a function is logged. Read the recent entries:

berth functions logs --app "$APP" hello
curl -sS "https://api.atberth.com/v1/apps/$APP/functions/hello/logs?limit=20" \
  -H "Authorization: Bearer $SECRET_KEY"

Show and delete

berth functions show --app "$APP" hello
berth functions delete --app "$APP" whoami