Write the server
MarkdownThe code of an application is one HTTP server, and each of its routes is an action. This page shows what an action receives and how it reaches a site through the user’s browser.
The code: an entrypoint that answers HTTP
Section titled “The code: an entrypoint that answers HTTP”Your entrypoint exports default { fetch(request, env, ctx) }. Apigami calls it with the method and path
of the action it is running, so a small router is enough. env holds your variables and Apigami’s
(Environment variables).
import { searchProducts } from './actions/search.ts'import { readBasket } from './actions/basket.ts'
const routes = { 'POST /search': searchProducts, 'POST /basket': readBasket,}
export default { async fetch(request: Request, env) { const { pathname } = new URL(request.url)
// Every hosted application answers GET /healthz with a 200. Apigami calls it after each deploy and once a // day, and refuses to publish a version that does not answer: keep it cheap. if (request.method === 'GET' && pathname === '/healthz') { return Response.json({ ok: true }) }
const route = routes[`${request.method} ${pathname}`] // A 404 to a GET lets Apigami serve the file of public/ at that path, when there is one. if (!route) return Response.json({ error: 'no such action' }, { status: 404 })
try { return await route(request, env) } catch (error) { // Never a stack to the caller: a message they can act on, and the details in the log. console.error('action failed', error) return Response.json({ error: 'the action failed; see the application logs' }, { status: 500 }) } },}One action per module, imported by a relative path. Every file of the version is there, so
./actions/search.ts resolves the way it does on your own machine.
Schedules. The one way to run code on a schedule is
x-apigami.crons, which runs an action. An action of a cron
that must act for several people reads the version’s key, env.APIGAMI_KEY, and only after GET /v1/me
under its run token answered that action with user_ref null, caller_app_id equal to env.APIGAMI_APP_ID
and production true: every path of your address takes requests
from anyone, and the key acts for everyone connected (Emails shows
one). Only the version in production has a working key, and a version on trial has none.
What does not run here. The validation refuses Deno.serve, Deno.env, Deno.readTextFile,
Deno.readFile and Deno.cron (deno_api_refused) and imports of an https: URL (url_import_refused).
A node:http or Express server never starts (no_default_fetch), and an entrypoint without the default
export fails to load with [apigami] Export default { fetch(request, env, ctx) } from your entrypoint.
Apigami’s session stands in front of your code
(Who is behind a request).
Static files: public/
Section titled “Static files: public/”public/logo.svg is served at /logo.svg, without the prefix, but only when your code answers
404 to that GET or HEAD: a catch-all route that answers 200, as a single-page app does, hides every
file of public/. Answer 404 to the paths you do not serve. Anyone can download what is under public/,
so the server’s code never goes there (entrypoint_in_public). A version on trial, not deployed yet,
serves no file. A file your code reads is imported, never read from disk:
import page from './page.html' with { type: 'text' } works for .html, .txt, .md, .sql, .svg and
.css; a .bin imports as bytes, and a .wasm as a module.
No input or output while the module loads
Section titled “No input or output while the module loads”Nothing at the top level of a file may wait on the outside: no fetch, no query, no timer. The version
then fails to load, with your code does input or output while it loads. Do it inside fetch(). A
database client or a connection is created there too, for each request: one created outside and reused
by another request fails with a database client or a connection was created outside the request handler and reused.
Packages: npm:, jsr: and package.json
Section titled “Packages: npm:, jsr: and package.json”Import packages as npm:name@version or jsr:@scope/name, or list them in a package.json. Apigami
resolves them once, when the version is created, and keeps the lock with the version. Install scripts never
run, a native module (.node) is refused (native_module_refused), a package version must have been
published at least 3 days ago, and the modules of a version weigh 64 MB at most. Node’s built-in modules,
such as node:crypto and node:buffer, are there. What a version runs on is set when it is created and never
changes afterwards.
Limits
Section titled “Limits”| Limit | |
|---|---|
| Memory | 128 MB of memory per instance, shared by the requests it serves at once, not adjustable |
| Computing time | 300 seconds per request, page or action; time spent waiting on a fetch does not count |
| Duration of an action | 900 seconds of wall clock at most, less when another run calls it; its caller gets 202 { run_id, state: "running" } after 30 seconds |
| Outgoing connections | 6 open at once per request |
| A site that sends nothing | cut off after about 125 seconds without a first byte |
| Body of an action Apigami calls | 25 MB |
| Targets | no literal IP address, no port 25: a fetch to one rejects with a TypeError, from your code or any library it imports. Call another application through the API, never at its address: its pages would see you as an anonymous visitor |
Past the computing time or the memory, an action’s run fails with a sentence that names the limit, and a
page answers 503 This page took too long or This page was stopped. A page whose code throws answers
502 This page failed.
export const searchProducts = async (request: Request, env) => { const API = env.APIGAMI_API_URL // 1. Check the invocation. GET /v1/me tells you what the token you were handed is worth: which // application, which action, which version, and whether that version serves production. // Apigami hands an action its token in X-Apigami-Run. An action never gets x-apigami-user-ref: it // comes from Apigami's servers, not from the person's browser. const token = request.headers.get('X-Apigami-Run') const me = await fetch(`${API}/v1/me`, { headers: { Authorization: `Bearer ${token}` } }) if (!me.ok) return Response.json({ error: 'unauthorised' }, { status: 401 }) const run = await me.json() // A publication that landed mid-flight would otherwise run your new code under an old contract. if (run.version_id !== env.APIGAMI_VERSION_ID) { return Response.json({ error: 'this version is no longer the one being called' }, { status: 409 }) }
// 2. Validate the input yourself. The manifest documents the schema; it does not enforce it. const input = await request.json().catch(() => null) if (!input || typeof input.query !== 'string' || !input.query.trim()) { return Response.json({ error: '`query` is required and must be a non-empty string' }, { status: 400 }) } const limit = Math.min(Math.max(Number(input.limit ?? 10), 1), 50)
// 3. Do the work. Here, through the shopper's own browser: same token, same API. No `user_ref` nor // `install_ref` in the body: the token already names the browser, and naming one answers 400. const answer = await fetch(`${API}/call`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/octet-stream', 'Accept-Encoding': 'gzip', }, body: JSON.stringify({ op: 'request.fetch', params: { domain: 'example.com', url: `https://example.com/api/search?q=${encodeURIComponent(input.query)}&n=${limit}`, responseType: 'binary', }, }), }) if (!answer.ok) return Response.json({ error: await answer.text() }, { status: 502 })
// The site's own status and headers ride beside the body; the body is the site's bytes. const status = Number(answer.headers.get('X-Apigami-Status') ?? 0) if (status !== 200) return Response.json({ error: `the site answered ${status}` }, { status: 502 }) return Response.json({ products: await answer.json() })}Key per-person data on user_ref. An action that stores a third-party token per person selects it
by run.user_ref, never by a field of its input: whoever calls the action chooses its input. A full
example is under Its database.
The same token reaches everything else the browser offers. An identifier the site keeps in its own
tab, a store or a cart, is read with tabs.storage, from a tab open on the site:
const stored = await fetch(`${API}/call`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ op: 'tabs.storage', params: { domain: 'example.com', key: 'store_id', area: 'local' } }),})const { result } = await stored.json() // { tabId, url, area, key, value, length }With no tab open on the site it answers no open tab for example.com: open one with tabs.open
and pass the tabId it returns. A key whose name looks like a credential is never returned, see
tabs.storage.
What request.fetch answers your code
Section titled “What request.fetch answers your code”Your code sends POST {env.APIGAMI_API_URL}/call with the run token, as above. When the browser reached the
site, Apigami answers 200, whatever the site answered: the site’s status travels beside its body. So
check Apigami’s status first (answer.ok), then the site’s.
- Raw, with
Accept: application/octet-streamon the call andresponseType: "binary", as insearchProducts: the body is the site’s bytes, with no envelope.X-Apigami-Statusis the site’s status,X-Apigami-Headersits headers as a JSON object, andContent-Typeis alwaysapplication/octet-stream, even for HTML. WithAccept-Encoding: gziptoo, the body may come compressed, withContent-Encoding: gzip, whichfetchdecodes for you. - JSON, without that
Accept: the result in an envelope.bodyis the site’s text decoded as UTF-8 with the defaultresponseType: "text"; with"base64", or"binary"without theAccept, it is base64 andencoding: "base64"is added.headersnames the site’s headers in lowercase.
{ "result": { "status": 404, "headers": { "content-type": "text/html; charset=utf-8", "date": "Thu, 08 Oct 2026 09:00:00 GMT" }, "body": "<!doctype html><title>Not found</title>…" }}Redirects are followed inside the declared domain; neither shape gives the address the request ended on.
When Apigami refuses, the status is not 2xx and the body is JSON, { "error": "…" }, never the
site’s answer:
| Status | error |
What it means |
|---|---|---|
400 |
a run token already names the browser: … |
Remove user_ref and install_ref from the body |
401 |
bad run token |
Unknown, ended or expired: use the token of the run in progress |
403 |
example.com is not a domain this application declares as reached through the browser |
params.domain is not one of your x-apigami.domains with via: "extension" |
403 |
consent requested, with consentPending: true |
The approval window was open 45 seconds without an answer: run again once the person clicks Authorize |
403 |
no grant |
The person refused the site; only they can change it |
403 |
not paired |
The person is no longer connected to your application |
403 |
no install in this run |
The run has no browser: an application trigger, or a run started without one |
502 |
What the browser reported: url is outside the granted domain example.com: …, a network error, its 30-second timeout |
With "outcome": "unknown", the browser received the call and its answer was lost: a write may have reached the site |
503 |
extension offline or extension outdated, with online and last_seen_at |
The person’s browser is closed or asleep: Apigami waited up to 35 seconds for it to come back |
Environment variables: x-apigami.env
Section titled “Environment variables: x-apigami.env”Your code reads every variable on env, as env.NAME. Apigami sets APIGAMI_API_URL, APIGAMI_APP_ID and
APIGAMI_VERSION_ID; APIGAMI_KEY, the version’s own key, on a deployed version only, never on a trial; DB,
the SQLite database, with "database": "sqlite"; and DATABASE_URL and DIRECT_URL with "database": "postgres" only. The names your own code needs are declared in x-apigami.env; a human types each value on the
app’s Variables page of the dashboard, https://app.apigami.com/apps/{app_id}/environment-variables, and an
assistant never can — anything that passes through a conversation with a model has stopped being secret. Until a
declared name has a value, deploying a version and running an action both answer 409 with the names that are
missing and the link to fill them. Each entry is { "name": "API_KEY", "description": "…" }, with
"secret": true for a value the dashboard never shows again once typed, and "required": false for a name
your code does without: the page offers it, it never counts as missing, nothing waits for its value, and your
code handles its absence itself (env.NAME is then undefined). Every name starting with APIGAMI_ is reserved, like DATABASE_URL,
DIRECT_URL, DB and ASSETS. A value holds 5,120 bytes at most, and none may hold an Apigami key (apg_…)
or run token (rn_…).
How an action receives its input
Section titled “How an action receives its input”Every caller runs an action with POST /v1/apps/{app_id}/actions/{action}, and Apigami calls your code
with the method and the path the manifest declares for that operation, as written: a {id} in the path
is not filled in. The query string of that POST reaches your code, minus every apigami_ parameter
the caller sent; the run token comes in the X-Apigami-Run header only. Its body reaches your code byte for byte, with its Content-Type, unless the
operation is a get or a head, which receives no body: its input is the query string. app_run’s
input, apigami.call(action, input) and a trigger’s input are always sent as a JSON body, so a get
action never sees them: declare an action that takes input as a post.