← Back to API docs

Docs

Functions API

Functions run your server-side JavaScript next to your data, on a URL of their own. Each function is one bundled ES module that you deploy from the CLI or the console, and it reaches your other altengine services through capabilities you grant it explicitly — nothing else.

Why functions exist

The rest of altengine is built so a static front end can talk to your data directly: Auth issues identity tokens, and row-level rules decide what each end user may read and write. That covers most of an app — but not all of it.

Row rules scope to one collection per request. So the moment a single user action has to touch several collections at once — accept an invite: add a member, mark the invite used, bump a counter, all or nothing — a browser token structurally cannot do it, and transactions and query joins are organization-key-only for exactly that reason.

A function is where that logic goes. It runs inside the trust boundary with an organization-level grant, so it can do the multi-collection work safely, while your front end still calls one ordinary URL.

Your first function

A function is a module with a default export and a fetch method — the same shape as a Web/Service Worker handler.

// hello.js
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    return Response.json({ hello: "world", path: url.pathname });
  },
};

Deploy it with the CLI:

altengine deploy --instance prod --name hello ./hello.js

It is then live at its own hostname, and every path under the function name reaches it:

curl https://your-subdomain-fn.altengine.app/hello
curl https://your-subdomain-fn.altengine.app/hello/any/sub/path

Serve it from your own name instead with a custom domain.

The subdomain is chosen when you create the functions instance, and the -fn is added by us: each service has its own suffix, so a functions instance and a blob instance can share a name without ever colliding. A subdomain may not contain --. Change it later under Settings → Address: every URL already shared stops working, each version's address moves with it, and any custom domain follows automatically. The console shows the full URL — copy it from there rather than assembling one. Function URLs are public: anything a function does not check for itself, anyone can reach. If a function should be private, verify a token inside it — see Checking who is calling.

What a function is handed

fetch(request, env) gets a standard Request and an env object holding exactly two kinds of thing: your secrets, and one client per service you granted. A service you did not grant is absent from env — not present-but-refused — so a missing grant shows up immediately as env.datastore is undefined.

These globals are available: Request, Response, Headers, URL, URLSearchParams, fetch, console, crypto, atob/btoa, TextEncoder/TextDecoder, and the usual JavaScript built-ins. There is no filesystem, no process, and no npm resolution at runtime — your bundle must be self-contained, which is what the CLI's bundler produces.

Request headers we set

Every inbound x-ae-* header is stripped before your code sees it, so a caller cannot forge them. We then set:

Headers set on every invocation
HeaderValue
x-ae-triggerhttp for a request, cron for a scheduled run.
x-ae-request-idA unique id for this invocation, also returned in error responses.
x-ae-fnThe function name.
x-ae-versionThe deployed version running.

Granting access to your services

A function's grants use the same shape as an API key's: a map of service or service:instance to read, write, or full. An instance-specific grant overrides a service-wide one.

altengine deploy --instance prod --name invite \
  --grants datastore:appdb=write,auth:users=read ./invite.js

Grants are the blast radius of the function. A function granted datastore:appdb cannot reach datastore:otherdb at all, and one granted read cannot write. Grant the least it needs — deleting requires full, not write.

Row-level rules do not apply to a function. It runs at organization level, which is the entire point: it is the trusted half of your app, so the grants map is what bounds it.

Available clients

Every method takes a target object naming the instance (and namespace, where the service has one) as its first argument, and every method is async.

// env.datastore
await env.datastore.put({ instance, namespace }, collection, documents)
await env.datastore.get({ instance, namespace }, collection, keys)
await env.datastore.delete({ instance, namespace }, collection, keys)
await env.datastore.query({ instance, namespace }, collection, request)
await env.datastore.aggregate({ instance, namespace }, collection, request)
await env.datastore.transaction({ instance, namespace }, operations)
await env.datastore.listIndexes({ instance, namespace }, collection)
await env.datastore.createIndex({ instance, namespace }, collection, fields, unique)
await env.datastore.deleteIndex({ instance, namespace }, collection, indexId)

// env.search
await env.search.index({ instance, namespace }, index, documents)
await env.search.get({ instance, namespace }, index, ids)
await env.search.delete({ instance, namespace }, index, ids)
await env.search.search({ instance, namespace }, index, request)
await env.search.schema({ instance, namespace }, index)
await env.search.listIndexes({ instance, namespace }, { q, limit })
await env.search.deleteIndex({ instance, namespace }, index)

// env.auth
await env.auth.verifyToken({ instance }, token)
await env.auth.getUser({ instance }, uid)
await env.auth.listUsers({ instance }, { q, limit })
await env.auth.setClaims({ instance }, uid, claims)
await env.auth.setProfile({ instance }, uid, profile)
await env.auth.setDisabled({ instance }, uid, disabled)
await env.auth.deleteUser({ instance }, uid)
await env.auth.revokeSessions({ instance }, uid)

// env.channel
await env.channel.publish({ instance }, channel, data)
await env.channel.token({ instance }, { channels, ttlSeconds, publish, presenceId })
await env.channel.presence({ instance }, channel)

// env.blob
await env.blob.put({ instance }, name, content, { contentType, public, meta })
await env.blob.uploadUrl({ instance }, { name, size, contentType, public })
await env.blob.get({ instance }, id)
await env.blob.bytes({ instance }, id)
await env.blob.list({ instance }, { prefix, limit, cursor })
await env.blob.setPublic({ instance }, id, isPublic)
await env.blob.delete({ instance }, ids)

// env.container
await env.container.run({ instance }, { image, cmd, env, size, timeout_ms })
await env.container.get({ instance }, jobId)
await env.container.list({ instance }, { status, limit, before })
await env.container.cancel({ instance }, jobId)
await env.container.logs({ instance }, jobId, cursor)

Request and response shapes match the REST APIs exactly — see the Datastore, Search, Auth, Channel, Blob and Containers references. Omit namespace for the default one. Work done from a function is metered exactly as the same work over HTTP.

These are the only way a function reaches another service, and that is deliberate. A function cannot call altengine's own API over HTTP — its own hostnames are refused by the outbound allowlist — so there is no version of this that involves storing an API key in a secret and fetching yourself.

Which is the point: a key in a secret is a credential that can leak and that carries whatever access it was minted with. A grant carries exactly what you granted, cannot be exfiltrated, and is visible on the function's own page.

Three that are worth knowing about

env.blob.uploadUrl is how a browser uploads a file without your server touching the bytes. The size is signed into the URL, so it is the real limit on what whoever holds it can store — which is exactly the decision that should not be made in a page. A function decides whether this user may upload and how large, and hands back a URL the browser PUTs to directly.

env.container.run is the matching piece for jobs. Containers refuse end-user tokens outright, so a function is the only way a browser action can start one — and it is now a grant rather than a key.

env.channel.token is how you do channel authorization that rules cannot express. A browser normally mints its own subscriber token and the auth instance's access rules decide which channels it may join — which covers patterns like dm.$auth.uid. When the decision needs actual code — this user was invited to this room, this tier includes this feed, this document's ACL lists them — a function is where that code runs, and this is what it hands back:

export default {
  async fetch(request, env) {
    const { sub: uid } = await env.auth.verifyToken({ instance: "users" }, bearer(request));
    const rooms = await env.datastore.query({ instance: "appdb" }, "memberships", {
      where: [{ field: "uid", op: "=", value: uid }],
    });
    // Only the rooms this user is actually a member of, decided here rather than in the page.
    return Response.json(await env.channel.token({ instance: "live" }, {
      channels: rooms.documents.map((d) => `room.${d.data.roomId}`),
      ttlSeconds: 3600,
      presenceId: uid,
    }));
  },
};

The result carries token, expires_at, the channels it was granted, and a ready-to-use ws_url with the token already in it — so the browser needs one string and no knowledge of how the URL is built. Minting a subscriber token needs a read grant; passing publish: true needs write, because a publish-capable token can write.

The case functions were built for

export default {
  async fetch(request, env) {
    const { inviteId, uid } = await request.json();

    // All of this commits together, or none of it does.
    await env.datastore.transaction({ instance: "appdb" }, [
      { op: "put",    collection: "members", key: `m_${uid}`, data: { uid, role: "member" } },
      { op: "put",    collection: "invites", key: inviteId,   data: { used_by: uid } },
      { op: "delete", collection: "pending", key: inviteId },
    ]);

    await env.channel.publish({ instance: "live" }, "org-updates", { joined: uid });
    return Response.json({ ok: true });
  },
};

Checking who is calling

A function URL is public, so if the work is user-specific, verify the caller's identity token yourself. Pass it from your front end and check it with the auth client:

const token = (request.headers.get("authorization") || "").replace(/^Bearer /, "");
const identity = await env.auth.verifyToken({ instance: "users" }, token);
if (!identity) return new Response("unauthorized", { status: 401 });
// identity.sub is the end user's id — now do the privileged work on their behalf.

A bad or expired token returns null rather than throwing, so the check above is the whole of it. A good one returns the token's verified claims: sub (the end user's id — this is the value the other env.auth methods take as their uid), identifier, email, profile, claims and exp. Authorize on claims, which only you can set; profile is whatever the user typed at sign-up.

Secrets

Store API keys and tokens as instance secrets rather than in your source. Secrets are write-only: the console and API return names, never values, so once saved a secret can be replaced but not read back.

Each secret has one of two exposures, and it is worth choosing deliberately:

Secret exposures
ExposureWhere the value livesUse when
envIn your function's env.NAME.Your code itself needs the value — signing a webhook payload, decrypting something.
Outbound onlyNowhere your code can read it — it is filled in on the outbound request.The value is only ever an outbound header or query parameter. Write {{SECRET_NAME}} and we substitute it on the way out.
// STRIPE_KEY is stored as "Outbound only" — this code never holds the value.
const res = await fetch("https://api.stripe.com/v1/charges", {
  method: "POST",
  headers: { authorization: "Bearer {{STRIPE_KEY}}" },
});

Prefer Outbound only where you can: a value that never enters your function cannot be logged by accident, cannot appear in an error message, and cannot be read by a compromised dependency in your bundle. Substitution happens in request headers and query-string values only, never the host or path, and only for hosts on your allowlist.

Outbound requests

By default a function cannot make outbound requests at all — fetch() throws. To call an external API, add its hostname to the instance's outbound allowlist (Functions → your instance → Settings).

api.stripe.com
*.githubusercontent.com

Rules worth knowing before you debug a blocked call:

  • HTTPS only. Plaintext http:// is refused.
  • Hostnames only — IP addresses are always refused, and so are localhost, .internal and .local.
  • *.example.com matches subdomains but not example.com itself; add the apex separately if you need it.
  • Redirects are re-checked at every hop against the same list, and credentials are dropped when following one.
  • altengine's own hostnames are always refused, even if listed. Use the env clients to reach your services — they are faster and need no credential.

A blocked request throws with a message naming the reason. Keep the list short: it is your function's entire reach into the outside world.

Calling a function from a browser

By default a function sends no CORS headers, which suits server-to-server use. To call one from a web page, set the allowed origins on the instance: either * or an exact list. Preflight OPTIONS requests are answered without running your function, so they cost you nothing.

Credentials are never allowed, so call a function with an explicit token in a header rather than relying on cookies.

Running on a schedule

A function can run on a timer as well as on request. Give it one or more five-field cron expressions, in UTC, in the Schedule field when you deploy — or with the CLI:

# with ALTENGINE_URL / _KEY / _INSTANCE set, as under CLI below
altengine deploy --schedule "0 3 * * *" --name nightly ./nightly.js
altengine deploy --schedule "0 9 * * 1-5" --schedule "0 12 * * 6" --name digest ./digest.js
altengine deploy --unschedule --name nightly ./nightly.js

Why more than one? Within a single expression the hour and day-of-week fields are ANDed, and cron's only OR is the fixed day-of-month/day-of-week rule. So “09:00 on weekdays and 12:00 on Saturday” genuinely cannot be written as one expression — it is two. You can give a function up to five.

Supported in each field: *, a number, ranges (1-5), lists (9,17) and steps (*/15). Names like MON, and the L/W/# extensions, are not supported. There is no seconds field: the scheduler ticks once a minute, so accepting one would be a lie.

A scheduled run arrives as a POST with x-ae-trigger: cron and this body:

{ "crons": ["0 9 * * 1-5", "0 12 * * 6"], "scheduled_for": 1767225600000 }

crons is always a list, so a function that later gains a second schedule does not see the shape of its own payload change.

The behaviour worth knowing before you rely on it:

  • One at a time. If a run is still going when the next is due, that occurrence is skipped, not queued. A function never overlaps itself, and a job that is permanently slower than its interval cannot build a backlog it will never drain. It runs again on the first tick after it finishes.
  • At or after, never early. A busy minute defers the overflow, so a run can start late.
  • No retries. A run that fails is not retried; it appears in Logs like any other failure.
  • Billed normally. A scheduled run is an invocation like any other.
  • Omitting the schedule keeps it. Deploying without --schedule leaves the existing schedules in place, so a routine code push never silently unschedules a job. Use --unschedule (or clear the field) to remove them.

Schedules are paused for suspended or closed organizations: there is no caller waiting for a refusal, so a timer would otherwise keep burning invocations nobody is watching.

Errors and logs

When a function throws, or returns a 5xx, we record it under Functions → your instance → Logs, grouped by error rather than logged one line per failure. Each group shows the message, how often it has happened, when it started and last happened, the stack, and the console output from the most recent failure. Occurrence counts are approximate, and groups are kept for 14 days.

For everything else there is live tail: open the Logs tab, start a tail, and console.log output from invocations streams in while you watch. Nothing is stored — close the page and collection stops. Only invocations that happen while the tail is open appear.

An uncaught error returns a JSON body with the request_id we set on the invocation, so you can match a report to a specific call.

Limits

Function limits
LimitValue
Bundle size5 MB per function, after bundling; the console editor loads source up to 1 MB
Functions per instance100
Versions kept3 per function by default, 1–50 — see Versions
Secrets32 per instance, 4 KB each
Outbound allowlist20 hostnames per instance
Subrequests50 per invocation by default
Invocation rate6,000/minute per instance by default; lower it in Settings

Functions also carry a per-invocation CPU setting. Treat it as a budget you are measured against, not a hard stop: an invocation that overruns your configured value is counted (the instance's Usage tab shows CPU overruns) rather than killed at it, and a separate hard ceiling of a second or two is what ends a runaway. Don't build anything that depends on a function being stopped at a precise CPU figure.

CLI

The altengine CLI bundles and deploys. Bundling happens on your machine, so what you tested is byte-for-byte what runs, and an import that cannot be resolved fails locally with a filename and line number rather than after deploy.

alt install altlimit/altengine         # the CLI, if you do not have it

export ALTENGINE_URL=https://api.altengine.net
export ALTENGINE_KEY=ae_...            # needs 'full' on the functions instance
export ALTENGINE_INSTANCE=prod         # or pass --instance to each command

altengine deploy --name hello ./hello.js
altengine functions list
altengine functions versions  hello    # the function NAME is positional here
altengine functions rollback  --version 3 hello
altengine functions pull      hello    # print deployed source

Deploying needs a full grant on the functions instance. It also needs the access it hands out: a key can only give a function grants it holds itself, so a key with no datastore access cannot deploy a function that reaches the datastore. The refusal names the grant that is missing.

On redeploy, anything you omit is kept — pushing new code without --grants or --schedule does not clear the grants or schedules the function already has.

To delete a version, use the function's version history in the console, the functions_delete_version MCP tool, or the API. The version being served cannot be deleted. Deleting a function removes its schedules and every version.

DELETE /v1/functions/{instance}/{name}/versions/{version}   # one version
DELETE /v1/functions/{instance}/{name}                      # the function

Versions

Each deploy stores a new version. The newest 3 per function are kept — Versions to keep in Settings, or keepVersions (1–50) on the config. Older ones are deleted after each deploy, never the active one, and lowering the setting deletes the extras when you save. Every retained version is billed, so this setting is the dial on that charge.

Every stored version runs at its own URL:

curl https://your-subdomain--v3-fn.altengine.app/hello

An old version runs with the function's current grants and secrets, so deploying a fix does not take the old one offline — delete the version to remove its URL. A deleted or pruned version returns 404. Versioned URLs work on the platform hostname only, not on a custom domain.

GET /v1/functions/{instance}/{name}/versions

{ "active_version": 4,
  "versions": [
    { "version": 4, "size_bytes": 812, "sha256": "…", "created_at": …,
      "url": "https://your-subdomain--v4-fn.altengine.app/hello" },
    { "version": 3, … } ] }

Running locally

The same CLI runs an emulator with every service, functions included:

altengine dev                      # http://127.0.0.1:9191
altengine deploy --url http://127.0.0.1:9191 --key dev \
  --instance main --name hello ./hello.js
curl http://127.0.0.1:9191/fn/main/hello

Locally there are no per-instance subdomains, so a function is served from /fn/{instance}/{function} instead. Read paths off request.url rather than assuming a fixed prefix and the same code works in both places. console.log output goes to the terminal running the emulator.

The emulator runs the scheduler too, ticking each minute, with the same one-at-a-time behaviour. Nothing fires merely because you restarted it — a function runs because its expression came due while the emulator was watching.

The emulator enforces the same grants, secret exposures, outbound allowlist and CORS rules, so a call that production will refuse fails locally too. It is a development tool, not a security sandbox — run only code you trust, and keep it bound to localhost.

Pricing

Functions bill on three axes:

Functions rates
MetricRate
Invocations$2.00 per million
CPU time$0.10 per million milliseconds
Retained versions$0.10 per version per month, prorated

Every retained version costs its monthly charge whether or not it is ever called — each one is callable at its own URL. Delete functions you have finished with, and lower Versions to keep to hold less history. Calls your function makes to Datastore, Search, Auth or Channel are metered on those services exactly as the equivalent REST calls would be — a function is not a way to avoid their pricing, nor a second charge on top of it.

See Pricing for these rates alongside every other service, and how the $3/month free tier applies.