← 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.

Closed beta. Functions are enabled per organization. If the Functions tab isn't in your console, ask us to switch it on.

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

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. 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 allfetch() 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:

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 size1 MiB per function, after bundling
Functions per instance100
Versions kept10 per function (older ones are pruned)
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 alerted against, not a hard stop: an invocation that overruns your configured value is reported 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.

export ALTENGINE_URL=https://api.altengine.net
export ALTENGINE_KEY=ak_...            # needs 'full' on the functions instance

altengine deploy --instance prod --name hello ./hello.js
altengine functions list      --instance prod
altengine functions versions  --instance prod --name hello
altengine functions rollback  --instance prod --name hello --version 3
altengine functions pull      --instance prod --name hello        # print deployed source

Deploying needs a full grant, not write: a deploy replaces the code that runs with your instance's capabilities, which is more powerful than writing data through them.

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.

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
Deployed functions$0.10 per function per month, prorated

A deployed function costs its monthly charge whether or not it is ever called, so delete the ones you are no longer using. 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.