Platform

Edge functions

Write TypeScript handlers that run in a sandboxed V8 isolate, call them over HTTPS from your apps, and manage their code, environment variables and logs.

Edge functions are single-file TypeScript handlers that OrbitNest runs on demand behind an authenticated HTTPS endpoint. Each call runs in a fresh V8 isolate with a small set of Web APIs, your project's environment variables, and the identity of the calling user when one is signed in.

How functions run

  • TypeScript in, JavaScript out. Your source is transpiled to ES2020 when it runs. Type annotations are fine; the compiled result is cached until you save new code.
  • A fresh V8 isolate per call. The isolate is not Node.js and not Deno. There is no file system, no process, no require and no module loading. The only additions to standard JavaScript are the runtime globals listed below.
  • One file, no imports. import statements are rejected with 400 (Imports are not supported in edge functions...) when you save the function, so you cannot npm install packages or import from URLs. Type-only imports are removed when the code is transpiled, so they are allowed. Put helpers in the same file and use fetch for external services.
  • Saving deploys. Creating or updating a function makes the new code live on the next call. There is no version history or rollback, so keep your source in git.

Write a handler

A function's default export is an async handler that receives a standard Request and a context object, and returns a Response:

hello.ts
type Ctx = {
  project: { id: string; slug: string; url: string };
  user: { id: string; email?: string } | null;
  executionId: string;
  env: Record<string, string> & { get(name: string): string | null };
  secrets: { get(name: string): string | null };
};

export default async function handler(req: Request, ctx: Ctx): Promise<Response> {
  const body = req.method === 'GET' ? null : await req.json();
  const name = body?.name ?? 'world';

  console.log('hello called by', ctx.user?.id ?? 'anonymous');

  return Response.json({ greeting: `Hello, ${name}!`, project: ctx.project.slug });
}

When you save, the code is checked:

  • It must contain export default and an async handler. export default async function handler(...), export default handler; after the declaration, and an anonymous export default async (req, ctx) => ... all work.
  • It must parse. Syntax errors are returned as Invalid function code: Syntax error: ....
  • eval(...), new Function(...) and import statements are rejected.

Studio's new-function template uses the same handler(request, context) signature.

Top-level code (outside the handler) runs on every call and must finish within 5 seconds.

The request

MemberNotes
req.methodGET, POST, PUT or DELETE
req.urlAbsolute URL string that ends with the invoke path and the query string
req.headersA Headers object with every incoming header (names are lowercase)
req.json(), req.text()Read the body. JSON and form-urlencoded bodies (up to 2 MB) are parsed by the API and handed to your function as JSON, so req.json() works for both. Bodies with other content types are not passed through, and an empty body reads as null
req.arrayBuffer(), req.bytes(), req.blob()Binary views of the same body

The isolate has no URL or URLSearchParams, so parse query strings yourself:

TypeScript
function getQuery(req: Request): Record<string, string> {
  const query: Record<string, string> = {};
  const qs = req.url.split('?')[1] ?? '';
  for (const pair of qs.split('&')) {
    if (!pair) continue;
    const [key, value = ''] = pair.split('=');
    query[decodeURIComponent(key)] = decodeURIComponent(value.replace(/\+/g, ' '));
  }
  return query;
}

The context

FieldValue
ctx.project{ id, slug, url } for the project that owns the function
ctx.user{ id, email } when the caller sent a valid user access token (see Pass the signed-in user), otherwise null
ctx.executionIdA unique ID for this call
ctx.envAll environment variables, as properties (ctx.env.MY_VAR) and through ctx.env.get('MY_VAR')
ctx.secrets.get(name)Reads one environment variable by name and returns null if it does not exist. Every read is recorded in the server's audit log, and there is no way to list names, so prefer it for credentials

The response

Return a Response. The status code and headers you set are sent to the caller.

TypeScript
return new Response('plain text', { status: 200, headers: { 'content-type': 'text/plain' } });
return Response.json({ ok: true }, { status: 201 });
return new Response({ ok: true }); // a plain object is sent as JSON
return new Response(pdfBytes, { headers: { 'content-type': 'application/pdf' } });

Bodies with a binary content type (images, PDFs, audio, archives) reach the client byte for byte. Text and JSON bodies are passed through as text, and JSON is re-serialized. Responses are buffered, not streamed.

Runtime globals

Besides the standard JavaScript built-ins (JSON, Promise, Map, Date, Math, typed arrays and so on), the isolate provides:

GlobalNotes
fetch(url, init?)Outbound HTTP through the API server. See Outbound requests
Request, Response, Headers, BlobMinimal Web-compatible implementations. Response.json() is available, and res.body.getReader() returns the whole body in one chunk
TextEncoder, TextDecoderUTF-8 only
atob, btoaBase64
console.log, console.info, console.warn, console.errorCaptured into the function's logs
Deno.env.get(name), Deno.env.toObject()Environment variables. get returns null for a missing name
globalAn alias for globalThis

Not available: URL, URLSearchParams, setTimeout, setInterval, crypto, FormData, ReadableStream, AbortController, process, require and import.

Outbound requests

fetch accepts a string, a plain object (sent as JSON), a Uint8Array, an ArrayBuffer or a Blob as the body, and returns a Response whose body you can read as text, JSON or bytes. To protect the platform, every request is checked first:

  • Only http: and https: URLs are allowed.
  • Loopback, private (RFC 1918), link-local (including cloud metadata addresses), carrier-grade NAT, multicast and reserved addresses are blocked, as are hostnames such as localhost and names ending in .local, .internal, .corp or .lan. Hostnames are resolved first, and the request goes to the checked address.
  • Up to 5 redirects are followed, and each one is checked again.
  • Response bodies larger than 10 MB are cut off.
  • A request, including all its redirects, must finish before the function's own timeout runs out (counted from the start of the call). A request still running at that point is cancelled.

Blocked or failed requests do not throw. fetch resolves with an error response instead, so always check res.ok:

SituationStatusBody
Destination blocked403{"error":"Request blocked"}
Response over 10 MB502{"error":"Response too large"}
More than 5 redirects502{"error":"Too many redirects"}
Function timeout reached504{"error":"Fetch timed out"}
Network or other failure500{"error":"Fetch failed"}

Console output

console.* calls are stored with each execution and shown in the function's logs. Objects are logged as JSON. Before anything is stored, values that look like credentials are masked: bearer tokens, JWTs, Stripe, GitHub and AWS keys, password=/token=/api_key=-style pairs, connection strings with passwords, and long base64-like strings. Masking is pattern-based, so avoid logging secrets.

Each execution log also stores the request headers. The authorization, apikey, x-api-key, cookie and proxy-authorization headers are stored as [REDACTED]. Your function still receives their real values in req.headers.

Environment variables

Environment variables belong to the project. Every edge function and background job in the project sees the same set, read with Deno.env.get('NAME'), ctx.env.NAME or ctx.secrets.get('NAME'). Values are read when each call starts, so changes apply to the next call without redeploying.

Each function call also gets these built-in values:

NameValue
PROJECT_IDThe project's ID
PROJECT_URLThe project's API base URL, for example https://api.orbitnest.io/api/project/my-app
FUNCTION_NAMEThe name the function was invoked with
ORBITNEST_ANON_KEY, ANON_KEYThe project's anon (public) API key

New projects are also seeded with ORBITNEST_URL (the same value as PROJECT_URL) and ORBITNEST_ANON_KEY as regular environment variables. A variable you create with the same name as a built-in replaces it.

The service role key is not injected

ORBITNEST_SERVICE_ROLE_KEY is not available by default, because it bypasses row-level security. If a function needs privileged access, copy the service role key from API Keys and add it yourself as an environment variable named ORBITNEST_SERVICE_ROLE_KEY. Every function and job in the project can then read it.

Manage variables

  • Studio: open the project, select Functions, then Environment Variables. Mark credentials as secret.
  • MCP: orbitnest_get_env_variables, orbitnest_create_env_variable, orbitnest_update_env_variable and orbitnest_delete_env_variable.
  • REST: see Management API.

Rules:

  • Names use uppercase letters, digits and underscores (^[A-Z0-9_]+$), up to 100 characters. Names cannot be renamed; delete and recreate instead.
  • Values can be up to 10,000 characters. Descriptions up to 500.
  • A project can have up to 100 variables.
  • All values are encrypted at rest. Secret values are never returned by the API (they come back as ***HIDDEN***). Non-secret values are returned only when you fetch a single variable; the list endpoint masks them as ***ENCRYPTED***. ORBITNEST_ANON_KEY and ORBITNEST_SERVICE_ROLE_KEY are always stored as secrets.
  • Secret and non-secret variables behave the same at runtime. The flag only controls what the dashboard and API reveal.

Call your database from a function

There is no built-in database client in the isolate. Call the project's REST API with fetch instead. With the anon key, row-level security applies:

recent-posts.ts
export default async function handler(req: Request): Promise<Response> {
  const res = await fetch(
    `${Deno.env.get('PROJECT_URL')}/database/tables/posts/data?limit=10&sortBy=created_at&sortOrder=desc`,
    { headers: { Authorization: `Bearer ${Deno.env.get('ORBITNEST_ANON_KEY')}` } },
  );
  if (!res.ok) {
    console.error('database request failed', res.status, await res.text());
    return Response.json({ error: 'Could not load posts' }, { status: 502 });
  }
  return Response.json(await res.json());
}

To bypass row-level security, send Deno.env.get('ORBITNEST_SERVICE_ROLE_KEY') instead, after you add it as described above. See Database and REST API for the available endpoints and filters.

Invoke a function

Over HTTP

Terminal
curl -X POST "https://api.orbitnest.io/api/projects/my-app/functions/v1/hello" \
  -H "apikey: $ORBITNEST_ANON_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada"}'
  • Path: /api/projects/:slug/functions/v1/:name. The same function is also reachable by project ID at /api/projects/:projectId/functions/:name/invoke.
  • Methods: GET, POST, PUT and DELETE. OPTIONS preflight requests are answered automatically, and CORS headers are added to the response unless your function sets its own Access-Control-Allow-* headers.
  • Function names are matched case-insensitively.

Every call must authenticate with one of:

CredentialHow to send it
Project anon or service role keyapikey: <key> (preferred), x-api-key: <key>, or Authorization: Bearer <key> when neither key header is sent
Studio admin access token of a member of the project's workspaceAuthorization: Bearer <token>

The API key is read from apikey first, then x-api-key, then the bearer token. It must be the project's current key; keys replaced by Regenerate keys are rejected. A Studio admin token is subject to the workspace's two-factor policy: if the workspace requires 2FA and the admin hasn't set it up, the call fails with 403 and "code": "MFA_ENROLLMENT_REQUIRED".

Error responses from the invoke endpoint:

StatusCause
401No credentials, an invalid or revoked key, an unknown project, or an admin token for an inactive admin or someone outside the workspace
402The workspace has used its monthly invocation allowance. The body has "code": "QUOTA_EXCEEDED" and "quota": "invocations" (see Limits)
403"code": "MFA_ENROLLMENT_REQUIRED", described above
404{"error":"Function not found or inactive"}
400The handler threw, timed out (Function timed out after 30000ms), or the server was already running its maximum number of concurrent executions. The body is {"error":"...","timestamp":"..."} with credential-like values masked

Pass the signed-in user

To make the end user available as ctx.user, send the anon key in apikey and the user's access token as the bearer token:

Terminal
curl -X POST "https://api.orbitnest.io/api/projects/my-app/functions/v1/hello" \
  -H "apikey: $ORBITNEST_ANON_KEY" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN"

ctx.user is filled only from a project user access token signed by the project. Refresh tokens and the project's own API keys never become a user. A user access token alone is rejected with 401. If the token is expired or invalid, the call still runs with ctx.user set to null, so check it in functions that require a user.

JavaScript and Node.js

TypeScript
import { createClient, localStorageAdapter } from '@orbitneststudio/js';

const orbit = createClient({ apiKey: ORBITNEST_ANON_KEY, storage: localStorageAdapter });

const { data, error } = await orbit.functions.invoke('hello', {
  body: { name: 'Ada' },
});
if (error) console.error(error.status, error.message, error.details);
else console.log(data.status, data.data.greeting);

invoke(name, { method?, body?, headers? }) defaults to POST. It sends the client's API key in apikey and, while a user is signed in, the user's access token as the bearer token, so ctx.user is filled. Without a session the bearer token is the API key.

  • data.status and data.headers are the function's real status code and response headers.
  • data.data is the body: parsed JSON for JSON responses, a string for text/*, and a Blob for anything else.
  • A non-2xx response is returned as error, with error.status set and the parsed body in error.details.

The Node.js SDK (@orbitneststudio/node) has the same client.functions.invoke method.

Flutter

Dart
final res = await orbitnest.functions.invoke('hello', body: {'name': 'Ada'});
print(res.status);
print(res.data['greeting']);

// Shortcut that returns only the response body
final data = await orbitnest.function('hello', params: {'name': 'Ada'});

invoke accepts method, body, headers and queryParameters, and get, post, put and delete helpers are also available. Failures throw a FunctionException. While a user is signed in, the Flutter SDK sends the anon key in apikey and the user's access token as the bearer token, so ctx.user is filled.

Create and deploy functions

In Studio

Open the project and select Functions. The page has three sections: Functions, Background Jobs and Environment Variables.

  1. In Functions, create a function with a name, an optional description, the code, a memory limit (128, 256, 512 or 1024 MB) and a timeout (10 s, 30 s, 60 s or 2 minutes).
  2. Select a function to open it. On the Code tab, choose Edit Code, make changes and Save. The new code is live immediately.
  3. The Logs tab shows recent executions. See Logs.

Studio does not have a button to deactivate a function. Use the management API ("status": "inactive") for that.

With the MCP server

The MCP server exposes orbitnest_create_function, orbitnest_update_function, orbitnest_get_function, orbitnest_list_functions, orbitnest_delete_function and orbitnest_get_function_logs. Create and update accept name, description, sourceCode and timeout (10s, 30s, 60s or 120s). Pass the project ID, not the slug.

The CLI does not manage functions.

Management API

Management endpoints take a Studio admin access token, not a project API key, and identify the project by ID. Errors use standard status codes: 400 for invalid input or code, 404 for an unknown function or variable, 409 for a duplicate name, and 402 with "code": "QUOTA_EXCEEDED" when creating a function would exceed the plan's function limit.

Method and pathPurpose
POST /api/projects/:projectId/functionsCreate a function
GET /api/projects/:projectId/functionsList functions (without source code)
GET /api/projects/:projectId/functions/:nameGet one function, including source code and executionConfig
PUT /api/projects/:projectId/functions/:nameUpdate description, sourceCode, status or executionConfig
DELETE /api/projects/:projectId/functions/:nameDelete a function
GET /api/projects/:projectId/functions/:name/logs?limit=50Recent executions with console output
GET /api/projects/:projectId/environment-variablesList variables (values masked)
GET /api/projects/:projectId/environment-variables/:nameGet one variable
POST /api/projects/:projectId/environment-variablesCreate { name, value, description?, is_secret? }
POST /api/projects/:projectId/environment-variables/bulkCreate several: { variables: { NAME: "value" }, all_secrets? }
PUT /api/projects/:projectId/environment-variables/:nameUpdate value, description or is_secret
DELETE /api/projects/:projectId/environment-variables/:nameDelete a variable
POST /api/projects/:projectId/functions
{
  "name": "hello",
  "description": "Greets the caller",
  "sourceCode": "export default async function handler(req) { return Response.json({ ok: true }); }",
  "executionConfig": { "timeout": 30000, "memory": 128, "enableLogs": true }
}
FieldRules
name3–50 characters: letters, digits, - and _. Unique per project, ignoring case. invoke and logs are reserved (400)
descriptionOptional, up to 500 characters
sourceCodeUp to 1 MB
executionConfig.timeoutMilliseconds: 10000, 30000 (default), 60000 or 120000
executionConfig.memoryIsolate memory limit in MB, 16–1024 (400 otherwise). Default 128
executionConfig.enableLogsDefault true. Set to false to stop recording executions and console output
statusUpdate only: active or inactive. Inactive functions return 404

Note

executionConfig is replaced as a whole on update. Send every field you want to keep; omitted fields fall back to their defaults.

Logs

Each execution records its status (success or error), duration, request method, response status, error message, the calling user's ID, and the console output.

  • Studio: open the function and select the Logs tab. Logs are loaded when you open the tab; select Refresh to load new executions.
  • REST: GET /api/projects/:projectId/functions/:name/logs?limit=50 (default 50).
  • MCP: orbitnest_get_function_logs.

Executions also appear as edge_function events in the project-wide logs. See Logs & observability.

Limits

LimitValue
Functions per projectFree: 20. Starter: 100. Pro: unlimited. Creating one more returns 402 QUOTA_EXCEEDED
Invocations per monthFree: 50,000. Starter: 100,000. Pro: 1,000,000. Counted across every project in the workspace for the calendar month. Calls over the allowance return 402 QUOTA_EXCEEDED
Timeout10, 30, 60 or 120 seconds. Default 30
Memory16–1024 MB, 128 MB by default. Studio offers 128, 256, 512 and 1024 MB
Source code1 MB
Request body2 MB, JSON or form-urlencoded
Top-level code5 seconds
Outbound fetch10 MB per response, 5 redirects, must finish within the function's timeout
Concurrent executions50 per API server, and the memory limits of running executions can add up to at most 3072 MB. Further calls fail until one finishes

A project's plan is its workspace's plan: the highest plan held by a workspace admin, or the creator's plan for a project outside a workspace. See Plans & billing. | Environment variables | 100 per project, 10,000 characters per value |