# Write the server

> The HTTP server whose routes are your actions — what an action receives, how it learns who it serves and how it reaches a site through the browser.

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.

## 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](#environment-variables-x-apigamienv)).

```ts title="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`](/docs/app-database/#triggers-x-apigamicrons), 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](/docs/emails/#who-sends-and-to-whom) 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](/docs/app-page-sessions/#who-is-behind-a-request-x-apigami-user-ref)).

### 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

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`

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

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

```ts title="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](/docs/app-database/#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:

```ts
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`](/docs/operations/#tabsstorage--a-tabs-web-storage).

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

```json title="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 |

### 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

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