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, norequireand no module loading. The only additions to standard JavaScript are the runtime globals listed below. - One file, no imports.
importstatements are rejected with400(Imports are not supported in edge functions...) when you save the function, so you cannotnpm installpackages 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 usefetchfor 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:
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 defaultand anasynchandler.export default async function handler(...),export default handler;after the declaration, and an anonymousexport default async (req, ctx) => ...all work. - It must parse. Syntax errors are returned as
Invalid function code: Syntax error: .... eval(...),new Function(...)andimportstatements 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
| Member | Notes |
|---|---|
req.method | GET, POST, PUT or DELETE |
req.url | Absolute URL string that ends with the invoke path and the query string |
req.headers | A 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:
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
| Field | Value |
|---|---|
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.executionId | A unique ID for this call |
ctx.env | All 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.
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:
| Global | Notes |
|---|---|
fetch(url, init?) | Outbound HTTP through the API server. See Outbound requests |
Request, Response, Headers, Blob | Minimal Web-compatible implementations. Response.json() is available, and res.body.getReader() returns the whole body in one chunk |
TextEncoder, TextDecoder | UTF-8 only |
atob, btoa | Base64 |
console.log, console.info, console.warn, console.error | Captured into the function's logs |
Deno.env.get(name), Deno.env.toObject() | Environment variables. get returns null for a missing name |
global | An 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:andhttps: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
localhostand names ending in.local,.internal,.corpor.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:
| Situation | Status | Body |
|---|---|---|
| Destination blocked | 403 | {"error":"Request blocked"} |
| Response over 10 MB | 502 | {"error":"Response too large"} |
| More than 5 redirects | 502 | {"error":"Too many redirects"} |
| Function timeout reached | 504 | {"error":"Fetch timed out"} |
| Network or other failure | 500 | {"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:
| Name | Value |
|---|---|
PROJECT_ID | The project's ID |
PROJECT_URL | The project's API base URL, for example https://api.orbitnest.io/api/project/my-app |
FUNCTION_NAME | The name the function was invoked with |
ORBITNEST_ANON_KEY, ANON_KEY | The 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_variableandorbitnest_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_KEYandORBITNEST_SERVICE_ROLE_KEYare 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:
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
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,PUTandDELETE.OPTIONSpreflight requests are answered automatically, and CORS headers are added to the response unless your function sets its ownAccess-Control-Allow-*headers. - Function names are matched case-insensitively.
Every call must authenticate with one of:
| Credential | How to send it |
|---|---|
| Project anon or service role key | apikey: <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 workspace | Authorization: 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:
| Status | Cause |
|---|---|
401 | No credentials, an invalid or revoked key, an unknown project, or an admin token for an inactive admin or someone outside the workspace |
402 | The 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"} |
400 | The 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:
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
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.statusanddata.headersare the function's real status code and response headers.data.datais the body: parsed JSON for JSON responses, a string fortext/*, and aBlobfor anything else.- A non-2xx response is returned as
error, witherror.statusset and the parsed body inerror.details.
The Node.js SDK (@orbitneststudio/node) has the same client.functions.invoke method.
Flutter
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.
- 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).
- Select a function to open it. On the Code tab, choose Edit Code, make changes and Save. The new code is live immediately.
- 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 path | Purpose |
|---|---|
POST /api/projects/:projectId/functions | Create a function |
GET /api/projects/:projectId/functions | List functions (without source code) |
GET /api/projects/:projectId/functions/:name | Get one function, including source code and executionConfig |
PUT /api/projects/:projectId/functions/:name | Update description, sourceCode, status or executionConfig |
DELETE /api/projects/:projectId/functions/:name | Delete a function |
GET /api/projects/:projectId/functions/:name/logs?limit=50 | Recent executions with console output |
GET /api/projects/:projectId/environment-variables | List variables (values masked) |
GET /api/projects/:projectId/environment-variables/:name | Get one variable |
POST /api/projects/:projectId/environment-variables | Create { name, value, description?, is_secret? } |
POST /api/projects/:projectId/environment-variables/bulk | Create several: { variables: { NAME: "value" }, all_secrets? } |
PUT /api/projects/:projectId/environment-variables/:name | Update value, description or is_secret |
DELETE /api/projects/:projectId/environment-variables/:name | Delete a variable |
{
"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 }
}| Field | Rules |
|---|---|
name | 3–50 characters: letters, digits, - and _. Unique per project, ignoring case. invoke and logs are reserved (400) |
description | Optional, up to 500 characters |
sourceCode | Up to 1 MB |
executionConfig.timeout | Milliseconds: 10000, 30000 (default), 60000 or 120000 |
executionConfig.memory | Isolate memory limit in MB, 16–1024 (400 otherwise). Default 128 |
executionConfig.enableLogs | Default true. Set to false to stop recording executions and console output |
status | Update 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
| Limit | Value |
|---|---|
| Functions per project | Free: 20. Starter: 100. Pro: unlimited. Creating one more returns 402 QUOTA_EXCEEDED |
| Invocations per month | Free: 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 |
| Timeout | 10, 30, 60 or 120 seconds. Default 30 |
| Memory | 16–1024 MB, 128 MB by default. Studio offers 128, 256, 512 and 1024 MB |
| Source code | 1 MB |
| Request body | 2 MB, JSON or form-urlencoded |
| Top-level code | 5 seconds |
Outbound fetch | 10 MB per response, 5 redirects, must finish within the function's timeout |
| Concurrent executions | 50 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 |