Skip to content

Write the server

Markdown

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

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

main.ts
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).

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.

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.

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.

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.

actions/search.ts
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.

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-stream on the call and responseType: "binary", as in searchProducts: the body is the site’s bytes, with no envelope. X-Apigami-Status is the site’s status, X-Apigami-Headers its headers as a JSON object, and Content-Type is always application/octet-stream, even for HTML. With Accept-Encoding: gzip too, the body may come compressed, with Content-Encoding: gzip, which fetch decodes for you.
  • JSON, without that Accept: the result in an envelope. body is the site’s text decoded as UTF-8 with the default responseType: "text"; with "base64", or "binary" without the Accept, it is base64 and encoding: "base64" is added. headers names the site’s headers in lowercase.
200, the JSON envelope
{
"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

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_…).

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.