# HTTP API
URL: /docs/building-apps/capabilities/http-api
Markdown: /docs/building-apps/capabilities/http-api.md
Description: Serve HTTP endpoints under your app's own API path.

A Rome App can serve HTTP endpoints. The daemon exposes two surfaces and sends
each request to a single handler your app provides:

* `/api/apps/<appId>/...` is the dashboard/app-UI surface used by
  `fetchAppApi`.
* `/api/app-api/<appId>/...` is the public app-api surface used by third-party
  callbacks and webhooks when `api.noAuth` allows the subpath.

Handlers get a request object in a fixed shape and return a standard web
`Response`.

The HTTP API is declared in `app.yaml` and implemented in `src/api/index.ts`.

## Defining the handler [#defining-the-handler]

Declare the API entry in the manifest:

```yaml
# app.yaml
api:
  entry: api/index # path under the app root, no extension
```

The entry module must `export function createApiHandler(ctx)`, returning an
object that implements `RomeAppApiHandler`. The handler is built once with the
app's `RomeAppContext`, and its `handle` method is called once per request.

```ts
import type {
  RomeAppApiHandler,
  RomeAppApiRequest,
  RomeAppContext,
} from "@rome-os/app-runtime";

class MyApiHandler implements RomeAppApiHandler {
  constructor(private readonly ctx: RomeAppContext) {}

  async handle(request: RomeAppApiRequest): Promise<Response> {
    const route = request.path.join("/");

    if (request.method === "GET" && route === "status") {
      return Response.json({
        appId: this.ctx.app.id,
        version: this.ctx.app.version,
      });
    }

    return Response.json({ error: "not_found" }, { status: 404 });
  }
}

export function createApiHandler(ctx: RomeAppContext): RomeAppApiHandler {
  return new MyApiHandler(ctx);
}
```

The `RomeAppApiHandler` interface is a single method:

```ts
export interface RomeAppApiHandler {
  handle(request: RomeAppApiRequest): Promise<Response>;
}
```

### Mounting [#mounting]

The dashboard surface is mounted at `/api/apps/<appId>/[[...route]]`. The public
surface is mounted at `/api/app-api/<appId>/[[...route]]`. Both reach the same
handler. The prefix is removed before the request reaches you, so
`request.path` holds only the parts that are left and you route on those.

Some dashboard endpoints are reserved by the host and win before the app
dispatcher: `/api/apps/<appId>/manifest`, `/api/apps/<appId>/icon`,
`POST /api/apps/<appId>/publish`, and the bare app root itself —
`GET /api/apps/<appId>` is the host's deployment-status endpoint and
`DELETE` uninstalls the app, so `fetchAppApi("")` never reaches your handler.
Use a clear subroute such as `/api/apps/<appId>/state` or
`/api/app-api/<appId>/webhooks/stripe` for your own API.

The `ctx` passed in is the app's `RomeAppContext`. Fields you'll use often:

| Field                              | Use                                                                                                                   |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `ctx.app.id` / `ctx.app.version`   | App identity                                                                                                          |
| `ctx.runAction(canonicalId, args)` | Call an action by canonical `<app-id>:<local-name>` ID                                                                |
| `ctx.favors.requestAction(...)`    | Create a [favor request action](/docs/building-apps/capabilities/favor-request-actions) from an authenticated request |
| `ctx.db`                           | The app's database context (Drizzle)                                                                                  |
| `ctx.repositories`                 | Shared repositories (e.g. settings)                                                                                   |
| `ctx.log`                          | App logger                                                                                                            |

## Request and response [#request-and-response]

The request object is in a fixed shape that doesn't depend on how it arrived:

```ts
export interface RomeAppApiRequest {
  method: string;
  path: string[];
  headers: Record<string, string>;
  query: URLSearchParams;
  body?: Uint8Array;
  caller:
    | { kind: "guardian"; userId: string; via: "cookie" | "loopback" }
    | { kind: "visitor"; accountId: string; email: string }
    | { kind: "anonymous" };
}
```

| Field     | Notes                                                                                                                                                                                                                                                                                                                                                                                          |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `method`  | The HTTP method, e.g. `"GET"`, `"POST"`.                                                                                                                                                                                                                                                                                                                                                       |
| `path`    | Path parts **after** the app API prefix. `/api/apps/x/notes/42` and `/api/app-api/x/notes/42` both produce `["notes", "42"]`.                                                                                                                                                                                                                                                                  |
| `headers` | Request headers as a flat string map. Identity headers (`X-Rome-User-Id`, `X-Rome-Visitor-*`) are stripped by the host — use `caller` instead.                                                                                                                                                                                                                                                 |
| `query`   | A `URLSearchParams` instance — read with `.get()` / `.getAll()`.                                                                                                                                                                                                                                                                                                                               |
| `body`    | The raw request bytes as `Uint8Array`, or `undefined`. Not parsed for you.                                                                                                                                                                                                                                                                                                                     |
| `caller`  | Who is calling, resolved server-side by the host. The single trustworthy identity answer — gate owner-only routes with `caller.kind !== "guardian"`, and read the Rome Cloud visitor identity from the `visitor` variant. The visitor's favor token is kept server-side; create favor requests through `ctx.favors`. See [Public Access & Caller Identity](/docs/building-apps/public-access). |

The handler returns a standard web `Response`. `Response.json(...)` is the easy
way to send JSON; pass a status in the second argument when you need something
other than `200`.

### Reading query parameters [#reading-query-parameters]

`request.query` is a `URLSearchParams`, not a plain object. Use `.get(key)` for
the first value (or `null`) and `.getAll(key)` for every value when a key
appears more than once.

```ts
// GET /api/apps/<appId>/notes?limit=20&cursor=abc&tag=foo&tag=bar
// or GET /api/app-api/<appId>/notes?limit=20&cursor=abc&tag=foo&tag=bar
const limit = Number(request.query.get("limit") ?? "20");
const cursor = request.query.get("cursor") ?? undefined;
const tags = request.query.getAll("tag"); // ["foo", "bar"]
```

> **Watch out:** `request.query.limit` is &#x2A;*always `undefined`**. `URLSearchParams`
> does not expose keys as properties. Always call `.get("limit")`.

### Decoding raw bodies [#decoding-raw-bodies]

The daemon does **not** parse the request body for you. `request.body` is the
raw bytes (`Uint8Array | undefined`), so casting it straight to an object type
compiles but gives `undefined` for every property at runtime — you'd be casting
a `Uint8Array`. Decode and parse it yourself.

```ts
type JsonBody<T> =
  | { ok: true; value: T }
  | { ok: false; reason: "empty" | "parse_error" };

function readJsonBody<T>(request: RomeAppApiRequest): JsonBody<T> {
  if (!request.body || request.body.byteLength === 0) {
    return { ok: false, reason: "empty" };
  }
  try {
    return { ok: true, value: JSON.parse(new TextDecoder().decode(request.body)) as T };
  } catch {
    return { ok: false, reason: "parse_error" };
  }
}
```

The two-part result lets callers tell an empty body from broken JSON and return
the right error. Note that `JSON.parse(...) as T` is only a cast — add a runtime
schema check (zod, a manual `typeof`, etc.) before you trust the value on write
paths.

> **Watch out:**
>
> * `request.body` is `undefined` for empty bodies and for GET requests.
>   Null-check before decoding.
> * For binary uploads, pass the `Uint8Array` to a binary parser instead of
>   `TextDecoder`.

## Routing and auth [#routing-and-auth]

There is one handler per app; routing is path-based and you write it inside
`handle`. Match on `request.method` and `request.path` (or the joined
`request.path.join("/")`), and return a `404` `Response` for anything that
doesn't match. For path parameters, look at the individual parts and the array
length.

```ts
async handle(request: RomeAppApiRequest): Promise<Response> {
  const route = request.path.join("/");

  // GET /api/apps/<appId>/notes/:id
  if (request.method === "GET" && request.path[0] === "notes" && request.path.length === 2) {
    const id = request.path[1];
    return Response.json({ id });
  }

  // POST /api/apps/<appId>/notes — JSON write
  if (request.method === "POST" && route === "notes") {
    const parsed = readJsonBody<{ title?: string; content?: string }>(request);
    if (!parsed.ok) {
      const error = parsed.reason === "empty" ? "request body required" : "invalid JSON body";
      return Response.json({ error }, { status: 400 });
    }
    const body = parsed.value;
    if (!body.title || !body.content) {
      return Response.json({ error: "title and content required" }, { status: 400 });
    }
    // ...
  }

  return Response.json({ error: "not_found" }, { status: 404 });
}
```

For more involved routing, branch further inside the handler or pull in a small
router.

### Calling actions from a route [#calling-actions-from-a-route]

Routes should hand domain work to actions instead of redoing it.
`ctx.runAction(canonicalId, args)` returns an `ActionResult`; the ID is always
`<app-id>:<local-name>`, even for your own app. Check `status` before
reading `.data`:

```ts
const result = await this.ctx.runAction("notes:notes_list", { limit, cursor, tags });
if (result.status !== "ok") {
  const error =
    result.status === "error"
      ? result.error
      : `notes:notes_list returned ${result.status}`;
  return Response.json({ error }, { status: 500 });
}
return Response.json({ items: result.data });
```

`ActionResult` is a union of `ok`, `error`, `pending_approval`,
`pending_interaction`, `handoff`, and `place_widget`. Decide how a route turns
each non-`ok` status into an HTTP response; the example above folds everything
that is not `ok` into a `500`.

### Public (`noAuth`) endpoints [#public-noauth-endpoints]

By default, the public `/api/app-api/<appId>/*` surface is locked behind the
same session check as the rest of the instance. To open endpoints to the public
internet (third-party webhooks, public callbacks), opt those public subpaths out
of the session check in `app.yaml`:

```yaml
api:
  entry: api/index
  # Open every sub-path of this app:
  # noAuth: true
  # Or open only specific sub-paths (trailing /* makes the entry a prefix match):
  noAuth:
    - /webhooks/stripe
    - /public/*
```

`noAuth: true` opens every public app-api subpath; an array opens only the
listed subpaths, where a trailing `/*` makes the entry a prefix match.
Everything else stays behind the session check. Treat `noAuth` routes as
untrusted input: check the body and verify any webhook signature yourself
inside the handler.

The dashboard `/api/apps/<appId>/*` surface remains the app UI surface. Use
`fetchAppApi` from your web bundle for signed-in dashboard calls, and give
third parties the `/api/app-api/<appId>/...` URL for public callbacks.

`noAuth` is one of three access tiers. If human visitors should open the app's
*page* anonymously (not just call webhook endpoints), or you need to tell the
guardian apart from public visitors inside your handler, see
[Public Access & Caller Identity](/docs/building-apps/public-access).