HTTP triggers bind web requests directly to Flows. Every standard REST verb is available alongside RFC 10008 QUERY for safe body reads, multi-verb CRUD mounts, and live Server-Sent Events (SSE).

For developers building APIs on okengine — declare the route, attach gates, return typed data.

<Callout title="The one rule">
  An HTTP trigger parses the request, checks attached gates, and invokes `flow({ do })`.
  All business logic runs inside the Flow via `fx`. Declare `in`, `out`, and `errors` on
  the HTTP bag when the route has a body, path params, or domain failures.
</Callout>

## Smallest Example

<Steps>

<Step>
### Define the route

```typescript title="src/flows/main/ping.ts"
import { on, flow, http } from "okengine";

export const ping = on(
  http.get().public(),
  flow({
    do: () => ({ status: "ok" }),
  }),
);
```

</Step>

<Step>
### Call the endpoint

```bash
curl -X GET http://localhost:6530/ping -H "accept: application/json"
```

Response:

```json
{
  "data": { "status": "ok" },
  "error": null
}
```

</Step>

</Steps>

<Callout title="Omit path and name">
  Tree default: `http.get()` and `flow({ do })` — no path or name strings. The
  file stamps both. Pass either only for
  [control](/docs/elements/flow/routing#when-to-omit--when-to-pass).
</Callout>

## Progressive Patterns

Explore HTTP flow patterns from minimal handlers to schema-validated, error-handling, and gate-protected endpoints:

<Tabs items={["Minimal", "Validated", "Failures", "Gates"]}>

<Tab value="Minimal">

Return data directly with automatic JSON response enveloping and zero boilerplate:

```typescript title="src/flows/main/health.ts"
import { on, flow, http } from "okengine";

export const health = on(
  http.get().public(),
  flow({
    do: () => ({ ok: true }),
  }),
);
```

</Tab>

<Tab value="Validated">

Extract URL parameters and JSON body with runtime schema validation on the HTTP bag:

```typescript title="src/flows/notes/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const create = on(
  http.post({
    in: z.object({ title: z.string().min(1) }),
    out: z.object({ id: z.string(), title: z.string() }),
  }),
  flow({
    do: async ({ title }, fx) => {
      const id = fx.id();
      return { id, title };
    },
  }),
);
```

</Tab>

<Tab value="Failures">

Declare typed domain errors and return clean failure responses using `fx.fail`:

```typescript title="src/flows/orders/[id]/get.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const get = on(
  http.get({
    in: z.object({ id: z.string() }),
    out: z.object({ id: z.string(), sku: z.string(), qty: z.number() }),
    errors: { NotFound: z.object({ id: z.string() }) },
  }),
  flow({
    do: async ({ id }, fx) => {
      const [order] = await fx.store(db).select().from(orders).where(eq(orders.id, id));
      if (!order) return fx.fail("NotFound", { id });
      return order;
    },
  }),
);
```

</Tab>

<Tab value="Gates">

Chain policies and rate limits on the trigger with `.gate(...)`.
`fx.json.empty()` answers `204 No Content` with no body:

```typescript title="src/flows/account/delete.ts"
import { on, flow, http, gate } from "okengine";
import { eq } from "drizzle-orm";
import { db, users } from "@/schema";

const member = gate.policy("member", ({ auth }) => !!auth.verified);
const admin = gate.scope("admin");
const deleteRate = gate.rate({ max: 5, per: "1m", keyBy: "user" });

export const deleteAccount = on(
  http.delete().gate(member, admin, deleteRate),
  flow({
    do: async (_, fx) => {
      await fx.store(db).delete(users).where(eq(users.id, fx.auth.userId!));
      return fx.json.empty();
    },
  }),
);
```

</Tab>

</Tabs>

## Method Reference

| Method          | Signature                  | Purpose                                 | Body Allowed    | Idempotent     |
| --------------- | -------------------------- | --------------------------------------- | --------------- | -------------- |
| `http.get`      | `http.get(path?)`          | Fetch a resource or list                | No              | Yes            |
| `http.post`     | `http.post(path?)`         | Create resource / command               | Yes             | No             |
| `http.put`      | `http.put(path?)`          | Replace entire resource                 | Yes             | Yes            |
| `http.patch`    | `http.patch(path?)`        | Partial resource update                 | Yes             | No             |
| `http.delete`   | `http.delete(path?)`       | Remove a resource                       | Optional        | Yes            |
| `http.query`    | `http.query(path?)`        | Safe read with JSON body                | Yes (RFC 10008) | Yes            |
| `http.head`     | `http.head(path?)`         | Retrieve response headers               | No              | Yes            |
| `http.options`  | `http.options(path?)`      | Discover allowed methods                | No              | Yes            |
| `http.resource` | `http.resource(path, ops)` | Five CRUD verbs; live when on           | Verb-dependent  | Verb-dependent |
| `http.live`     | `http.live(signal)`        | Firehose SSE on `GET /_oke/live/{name}` | No              | Yes            |

## Path Conventions

**Default — omit the path.** Tree files stamp the URL from disk (`http.get()`).
Pass a path only for [control](/docs/elements/flow/routing#when-to-omit--when-to-pass).

**Control — explicit path** — pass the URL template when the folder should not own the route:

```typescript
http.get("/organizations/:orgId/members/:memberId");
```

**Pathless** — omit so the compiler stamps from disk location:

```typescript title="src/flows/users/[id]/get.ts"
import { on, flow, http } from "okengine";

// Stamped automatically to GET /users/:id · flow users.get
export const get = on(http.get(), flow({ do: async ({ id }) => ({ id }) }));
```

Full file-tree rules: [Routing](/docs/elements/flow/routing).

## Request Parsing

Before `flow({ do })`, the HTTP engine merges request parts into one object checked against
the trigger's `in`:

1. **Path parameters** — `:param` segments (e.g. `{ id: "123" }`).
2. **Query string** — `?sort=desc` keys at the root.
3. **JSON body** — object fields merged into the same root.
4. **Headers & cookies** — bags under `headers` / `cookie` when `do` reads them (declare the same keys in `in`).

```typescript title="src/flows/items/[id]/update.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, items } from "@/schema";

export const update = on(
  http.patch({
    // :id from the path merges with JSON body { title }
    in: z.object({
      id: z.string(),
      title: z.string().min(1),
    }),
  }),
  flow({
    do: async ({ id, title }, fx) => {
      await fx.store(db).update(items).set({ title }).where(eq(items.id, id));
      return { id, title };
    },
  }),
);
```

Read request metadata by naming `headers` / `cookie` in `in` and destructuring them in `do`
(header names are lower-cased):

```typescript title="src/flows/uploads/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";

export const create = on(
  http.post({
    in: z.object({
      name: z.string().min(1),
      headers: z.object({
        "content-type": z.string().optional(),
        "x-request-id": z.string().optional(),
      }),
      cookie: z.object({
        sid: z.string().optional(),
      }),
    }),
  }),
  flow({
    do: async ({ name, headers, cookie }, fx) => {
      return {
        id: fx.id(),
        name,
        contentType: headers["content-type"] ?? "application/octet-stream",
        session: cookie.sid ?? null,
      };
    },
  }),
);
```

## HTTP Methods

Each verb binds with `on(http.<method>(), flow({…}))`. Omit the path on tree
files; pass one only for [control](/docs/elements/flow/routing#when-to-omit--when-to-pass).

<Tabs items={["GET", "POST", "PUT", "PATCH", "DELETE", "QUERY", "HEAD", "OPTIONS"]}>

<Tab value="GET">

Fetch a resource or collection:

```typescript title="src/flows/notes/[id]/get.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";

export const get = on(
  http.get({
    in: z.object({ id: z.string() }),
    out: z.object({ id: z.string(), title: z.string() }),
    errors: { NotFound: z.object({ id: z.string() }) },
  }),
  flow({
    do: async ({ id }, fx) => {
      const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
      if (!note) return fx.fail("NotFound", { id });
      return note;
    },
  }),
);
```

</Tab>

<Tab value="POST">

Create a resource or run a command. `fx.json.create(value)` returns `201 Created` with
`{ data: value, error: null }`:

```typescript title="src/flows/notes/create.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db, notes } from "@/schema";

export const create = on(
  http.post({
    in: z.object({ title: z.string().min(1) }),
    out: z.object({ id: z.string(), title: z.string() }),
  }),
  flow({
    do: async ({ title }, fx) => {
      const id = fx.id();
      await fx.store(db).insert(notes).values({ id, title });
      return fx.json.create({ id, title });
    },
  }),
);
```

</Tab>

<Tab value="PUT">

Replace an entire resource (idempotent full write):

```typescript title="src/flows/notes/[id]/replace.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";

export const replace = on(
  http.put({
    in: z.object({
      id: z.string(),
      title: z.string().min(1),
      body: z.string(),
    }),
    out: z.object({ id: z.string(), title: z.string(), body: z.string() }),
  }),
  flow({
    do: async ({ id, title, body }, fx) => {
      await fx.store(db).update(notes).set({ title, body }).where(eq(notes.id, id));
      return { id, title, body };
    },
  }),
);
```

</Tab>

<Tab value="PATCH">

Apply a partial update (only declared fields change):

```typescript title="src/flows/notes/[id]/update.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";

export const update = on(
  http.patch({
    in: z.object({
      id: z.string(),
      title: z.string().min(1).optional(),
    }),
    out: z.object({ id: z.string(), title: z.string() }),
    errors: { NotFound: z.object({ id: z.string() }) },
  }),
  flow({
    do: async ({ id, title }, fx) => {
      if (title !== undefined) {
        await fx.store(db).update(notes).set({ title }).where(eq(notes.id, id));
      }
      const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
      if (!note) return fx.fail("NotFound", { id });
      return note;
    },
  }),
);
```

</Tab>

<Tab value="DELETE">

Remove a resource. `fx.json.empty()` returns `204 No Content` with no body:

```typescript title="src/flows/notes/[id]/remove.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";

export const remove = on(
  http.delete({ in: z.object({ id: z.string() }) }),
  flow({
    do: async ({ id }, fx) => {
      await fx.store(db).delete(notes).where(eq(notes.id, id));
      return fx.json.empty();
    },
  }),
);
```

</Tab>

<Tab value="QUERY">

Safe, idempotent read with a JSON body (RFC 10008) — filters that would overflow a URL:

```typescript title="src/flows/orders/search.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db } from "@/schema";

export const search = on(
  http.query({
    in: z.object({
      filters: z.array(z.string()),
      dateRange: z.object({ from: z.string(), to: z.string() }),
    }),
    out: z.array(z.object({ id: z.string(), total: z.number() })),
  }),
  flow({
    do: async ({ filters, dateRange }, fx) => {
      return await fx.store(db).queryOrders(filters, dateRange);
    },
  }),
);
```

Clients must send `Content-Type: application/json`.

Some browsers, HTTP libraries, and reverse proxies still reject or strip bodies on methods other
than `POST`/`PUT`/`PATCH`. Prefer modern clients, or fall back to `POST` for the same search
contract when you must support older stacks.

</Tab>

<Tab value="HEAD">

Probe existence / headers without returning a body. `head` is not a reserved
leaf, so pass the path when the URL must match GET (`/notes/:id`):

```typescript title="src/flows/notes/[id]/head.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";

export const head = on(
  http.head("/notes/:id", {
    in: z.object({ id: z.string() }),
    errors: { NotFound: z.object({ id: z.string() }) },
  }),
  flow({
    do: async ({ id }, fx) => {
      const [note] = await fx
        .store(db)
        .select({ id: notes.id })
        .from(notes)
        .where(eq(notes.id, id));
      if (!note) return fx.fail("NotFound", { id });
      return;
    },
  }),
);
```

</Tab>

<Tab value="OPTIONS">

Advertise allowed verbs. Same idea — pass the collection path explicitly when
the leaf name would otherwise add a segment:

```typescript title="src/flows/notes/options.ts"
import { on, flow, http } from "okengine";

export const options = on(
  http.options("/notes"),
  flow({
    do: () => ({
      allow: ["GET", "POST", "PUT", "PATCH", "DELETE", "QUERY", "HEAD", "OPTIONS"],
    }),
  }),
);
```

</Tab>

</Tabs>

## Resources

<Callout title="Detailed section">
  If you only need the basic mount, jump to the example below. `on(http.resource(path, ops))` takes
  **no Flow as a second argument** — `.all()` is the bag; options live on `store.resource`. A second
  argument throws `on(http.resource(...)) takes no second argument`.
</Callout>

`http.resource(path, ops)` mounts five CRUD Flows in one `on()` call. Pass
`store.resource(…).all()` or any bag with `list` · `create` · `get` · `update` · `remove`.

Chain `.gate(...)` / `.public()` once — every verb (and live, when present) gets the same gates.

<Tabs items={["Define", "Mount"]}>

<Tab value="Define">

`store.resource` builds the five Flows. The factory registers no routes.

```typescript title="src/flows/notes/resource.ts"
import { store } from "okengine";
import { z } from "zod";
import { db, notesTable } from "@/schema";

export const notesResource = store.resource(db, notesTable, {
  in: z.object({ title: z.string().min(1) }),
  out: z.object({ id: z.string(), title: z.string() }),
});
```

</Tab>

<Tab value="Mount">

`.gate(member)` stamps every verb. The client sees `api.notes.list` / `.create` / `.get` /
`.update` / `.remove` after `oke({ name: "app" }).adopt({ notes })`.

```typescript title="src/flows/notes/index.ts"
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { notesResource } from "./resource";

export const notes = on(http.resource("/notes", notesResource.all()).gate(member));
```

</Tab>

</Tabs>

The URL id segment is always `:id`. Update is **PATCH**, not PUT. There is no
pathless `http.resource()` — pass an explicit base path.

| Op       | Method   | Path          | Typical status                     |
| -------- | -------- | ------------- | ---------------------------------- |
| `list`   | `GET`    | `/notes`      | `200` + `{ data, error, meta }`    |
| `create` | `POST`   | `/notes`      | `201 Created` (`fx.json.create`)   |
| `get`    | `GET`    | `/notes/:id`  | `200`, or `NotFound`               |
| `update` | `PATCH`  | `/notes/:id`  | `200`, or `NotFound`               |
| `remove` | `DELETE` | `/notes/:id`  | `204 No Content` (`fx.json.empty`) |
| `live`   | `GET`    | `/notes/live` | SSE — only when live is on         |

<Accordions>

<Accordion title="Resource Options">
  Third argument to `store.resource(db, table, options)`:

| Option     | Type      | Default                          | Meaning                                                                  |
| ---------- | --------- | -------------------------------- | ------------------------------------------------------------------------ |
| `in`       | Schema    | _(required)_                     | Create body (`POST`)                                                     |
| `out`      | Schema    | _(required)_                     | Item shape (get / list / update return)                                  |
| `update`   | Schema    | `in`                             | Patch fields. Wire body is `{ id, ...patch }`                            |
| `idSchema` | Schema    | `update`/`in` + `{ id: string }` | Replaces the update Flow `in` when set (include the id key)              |
| `errors`   | error map | `{ NotFound }`                   | Typed failures on get / update / remove                                  |
| `id`       | column    | table PK                         | Column bound to `:id`                                                    |
| `list`     | object    | see List Options                 | List query grammar (`GET /notes`)                                        |
| `breaking` | `boolean` | `false`                          | Marks the five Flows `breaking: true` (handwritten → resource migration) |
| `live`     | `boolean` | omitted                          | Live query surface; see Resource Live                                    |

</Accordion>

<Accordion title="List Options">
  Nested on `store.resource(…, { list: { … } })`. Search / filter / order / select
  use a column scope: `"all"` · column array · `"none"`.

| Option      | Type                     | Default                                          | Meaning                        |
| ----------- | ------------------------ | ------------------------------------------------ | ------------------------------ |
| `mode`      | `"cursor"` \| `"offset"` | `"cursor"` when `cursor` is set, else `"offset"` | Pagination                     |
| `cursor`    | columns                  | `[]`                                             | Keyset columns                 |
| `direction` | `"asc"` \| `"desc"`      | `"desc"`                                         | Default sort when no `?order=` |
| `limit`     | `number`                 | `20`                                             | Default page size              |
| `maxLimit`  | `number`                 | `100`                                            | Cap on `?limit=`               |
| `count`     | `"exact"` \| `"none"`    | `"exact"`                                        | Offset-only `COUNT(*)`         |
| `search`    | column scope             | `"none"`                                         | `?search=` / `?q=`             |
| `filter`    | column scope             | `"none"`                                         | `?col=eq.x` grammar            |
| `order`     | column scope             | cursor columns, else `"all"`                     | `?order=`                      |
| `select`    | column scope             | `"all"`                                          | `?select=` projection          |

</Accordion>

<Accordion title="Resource Members">

| Member                                          | Kind                | Meaning                                                                                   |
| ----------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------- |
| `all()`                                         | method, no args     | Bag for `http.resource(path, notesResource.all())` — five Flows, plus `live` when enabled |
| `list` · `create` · `get` · `update` · `remove` | Flow                | One verb. Bind with `http.get` / `http.post` / `http.patch` / `http.delete`               |
| `page(input)`                                   | method              | Compile list-query input for a handwritten `fx.store(db).page`                            |
| `live`                                          | `{ signal, flow }?` | Live surface when `live: true` (or the project default drained on)                        |

</Accordion>

<Accordion title="Resource Live">
  A sixth route appears only when the resource is live. It is **not** a signal
  firehose — each subscriber gets classified row events (RLS + list filters).

| `live` on the resource           | Result                                                                                   |
| -------------------------------- | ---------------------------------------------------------------------------------------- |
| `{ live: true }`                 | Mount `GET <path>/live` now                                                              |
| omitted                          | Mount only if `oke({ store: { live: true } })`                                           |
| `{ live: false }`                | Never mount live for this resource                                                       |
| table `store.schema.live(false)` | Opts that table out of the project default. `{ live: true }` on the resource still wins. |

```typescript title="src/flows/notes/resource.ts"
const notesResource = store.resource(db, notesTable, {
  in: z.object({ title: z.string().min(1) }),
  out: z.object({ id: z.string(), title: z.string() }),
  live: true,
});

export const notes = on(http.resource("/notes", notesResource.all()).gate(member));
```

**Consequence:** `GET /notes/live` rides the same `.gate(...)` chain as list/get.

Query-string filters use the resource list grammar; pagination cursors do **not**
gate membership — a row enters or leaves the window when filters / RLS change.

Wire events (consumed with `useLiveQuery` on the [typed client](/docs/client/react)):

| `kind`    | Meaning                                                     |
| --------- | ----------------------------------------------------------- |
| `upsert`  | Row visible under stamp + query — merge by primary key      |
| `revoked` | Row left visibility (`reason: "rls"` or `"query"`) — remove |
| `delete`  | Row deleted — remove                                        |

Live queries need an RLS-capable SQL driver (`postgres` / `pglite`) and a gated
identity on the request. Extract fails without a primary key:

```text
extract: live: true on table "notes" requires a primary key column (upsert/revoked/delete address rows by PK)
```

Missing `updatedAt` / `updated_at`, or no RLS policies, warn at extract — they
do not fail the build.

</Accordion>

<Accordion title="Subset & Override">
  `http.resource` always mounts all five CRUD keys. To expose only some verbs,
  bind those Flows on individual triggers. To replace one verb, spread `.all()`
  and override that key — the other four stay:

```typescript title="src/flows/notes/list.ts"
import { on, http, store } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";
import { db, notesTable } from "@/schema";

const notesResource = store.resource(db, notesTable, {
  in: z.object({ title: z.string().min(1) }),
  out: z.object({ id: z.string(), title: z.string() }),
});

export const list = on(http.get().gate(member), notesResource.list);
```

```typescript title="src/flows/notes/[id]/get.ts"
export const get = on(http.get().gate(member), notesResource.get);
```

Override one verb on a resource mount — path is required on `http.resource`:

```typescript title="src/flows/notes/index.ts"
import { on, flow, http } from "okengine";
import { eq } from "drizzle-orm";

export const notes = on(
  http
    .resource("/notes", {
      ...notesResource.all(),
      remove: flow({
        do: async ({ id }, fx) => {
          await fx
            .store(db)
            .update(notesTable)
            .set({ archived: true })
            .where(eq(notesTable.id, id));
          return fx.json.empty();
        },
      }),
    })
    .gate(member),
);
```

A handwritten bag works the same way — each value must be a `flow(…)`:

```typescript
on(
  http.resource("/notes", {
    list: flow("notes.list", { do: () => [] }),
    create: flow("notes.create", { do: () => ({ id: "n1" }) }),
    get: flow("notes.get", { do: () => ({ id: "n1" }) }),
    update: flow("notes.update", { do: () => ({ id: "n1" }) }),
    remove: flow("notes.remove", { do: (_, fx) => fx.json.empty() }),
  }),
);
```

Missing or non-Flow keys throw `on(http.resource(...)) expects the five CRUD FlowDefs`.
A `GET /notes` you also declared by hand collides at boot (**OKE1041**).

</Accordion>

</Accordions>

## Live Streams

<Callout title="Detailed section">
  If you only need the basic firehose, jump to the example below. `.live(…)` is GET-only —
  `on(http.post("/x").live(signal))` throws `on(http.*.live(signal)): live exposure must be GET`.
</Callout>

`http.live(signal)` is one-arg `on()` — the engine synthesizes the stream Flow
(`fx.live` + `effects.reads: ["signal:<name>"]`). Chain `.gate(...)` like any GET.

```typescript title="src/flows/orders/firehose.ts"
import { on, http, signal } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";

export const orderStatus = signal.live("order-status", {
  optional: true,
  schema: z.object({
    orderId: z.string(),
    status: z.enum(["placed", "fulfilling", "shipped"]),
  }),
});

export const firehose = on(http.live(orderStatus).gate(member));
```

```bash
curl -N http://localhost:6530/_oke/live/order-status \
  -H "accept: text/event-stream" \
  -H "authorization: Bearer …"
```

Response `Content-Type` is `text/event-stream`. Frames are JSON `data:` lines
(optional `id:` for resume), then `data: [DONE]`.

<Accordions>

<Accordion title="Exposure Shapes">
  Three GET shapes expose a live SSE body. Pick the physics first, then the path.

| Declaration                                        | Path                    | Physics                                        |
| -------------------------------------------------- | ----------------------- | ---------------------------------------------- |
| `on(http.live(signal))`                            | `GET /_oke/live/{name}` | Signal tape — every event                      |
| `on(http.get(path).live(signal))`                  | Your path               | Signal tape — auto-match on `:params`          |
| `on(http.get(path).live(table), flow)`             | Your path               | Live **query** — `liveQuery(fx, table, input)` |
| `store.resource({ live: true })` + `http.resource` | `GET <path>/live`       | Same live-query physics as `.live(table)`      |

Signal names in the default firehose path are `encodeURIComponent`'d
(`chat.message` stays readable; slashes in internal names are escaped).

</Accordion>

<Accordion title="Filtered Paths">
  Path params become a filter: an event is forwarded when each `:param` that
  **exists on the payload** equals the request value. Params missing from the
  payload are skipped (the event still flows). No params = firehose.

```typescript title="src/flows/orders/events.ts"
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";

export const events = on(http.get("/orders/:orderId/events").gate(member).live(orderStatus));
```

`GET /orders/ord_1/events` receives `{ orderId: "ord_1", status: "shipped" }`
and drops events for other orders.

</Accordion>

<Accordion title="Custom Match">
  Pass your own Flow as the second argument to `on()` when auto-match is not
  enough. Return `fx.live(signal, { match })` from `do` — do not wrap it with
  `fx.json.stream`.

```typescript title="src/flows/orders/vip-feed.ts"
import { on, flow, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";

export const vipFeed = on(
  http.get("/orders/vip/events").gate(member).live(orderStatus),
  flow("orders.vipFeed", {
    do: (_input, fx) =>
      fx.live(orderStatus, {
        match: (payload) => payload.status === "shipped",
      }),
  }),
);
```

**Consequence:** a custom Flow stamps a distinct match key, so it can coexist
with the auto-match route for the same signal (different path). Two synthesized
firehoses that share signal **and** gates fail uniqueness — see Uniqueness.

</Accordion>

<Accordion title="Live Queries">
  For a handwritten list that should stream the same classified CDC as
  `store.resource({ live: true })`, bind the table on GET and open the window
  with `liveQuery`:

```typescript title="src/flows/tasks/live.ts"
import { on, flow, http, liveQuery } from "okengine";
import { member } from "@/core/gate";
import { tasks } from "@/schema";

export const tasksLive = on(
  http.get("/tasks/live").gate(member).live(tasks),
  flow("tasks.live", {
    do: async (input, fx) =>
      liveQuery(fx, tasks, input, {
        filter: [tasks.status],
        search: [tasks.title],
        order: "all",
      }),
  }),
);
```

Same driver, identity, and extract guardrails as Resource Live. Prefer
`http.resource` + `{ live: true }` when you already mount the five CRUD ops.

</Accordion>

<Accordion title="Uniqueness">
  Boot keys each live HTTP route as `(signal, gates, match)`. Match is the
  sorted path-param names, or `custom:<flow>` when you passed a Flow, or
  `(firehose)` when there are no params.

| Pair                                    | Boots?                                    |
| --------------------------------------- | ----------------------------------------- |
| Member `:orderId` + admin firehose      | Yes — gates and match differ              |
| Same params, different gates            | Yes — the client disambiguates with `via` |
| Two member firehoses on different paths | No — **OKE1050**                          |
| Same method + path twice                | No — **OKE1041** first                    |

**OKE1050** cause: `Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}).`
Fix: a different gate, a path-param filter, or drop the extra route.

</Accordion>

<Accordion title="Client Subscription">
  `signal.live` is HTTP SSE. `for await` stays on the server; the browser
  uses a callback.

The client picks the unique exposure whose `matchKey` fields are a subset of
the input, preferring the largest match (`{ orderId }` beats firehose). A tie
needs `via: "unit.flow"`.

```typescript
const stop = api.live(
  orderStatus,
  { orderId: "ord_1" },
  {
    onEvent: (event) => {
      /* { orderId, status } */
    },
    onError: (err) => {
      /* 4xx, envelope, or drop */
    },
    autoResubscribe: false,
  },
);
stop();
```

`api.orders.events({ orderId }, { onEvent })` is the same shape on the exposing
Flow. Reconnects send `Last-Event-ID` from the last `id:` received.

A **410** `LiveResumeGap` (**OKE1210**) means that cursor is gone — drop it
and replay the remaining tape (`autoResubscribe: true`).

Resource live queries use `useLiveQuery` (snapshot + classified events), not
`api.live`. See [Client · Live](/docs/client/live).

</Accordion>

</Accordions>

## Trigger Modifiers

Every HTTP trigger supports fluent modifier chaining before binding to `on()`.
Resource mounts accept `.gate(...)` and `.public()` only — live on a resource
comes from `store.resource({ live: true })`, not `.live()`.

**Gates** — attach policy and rate handles. They evaluate in declaration order; first denial wins:

```typescript
import { gate } from "okengine";
import { member } from "@/core/gate";

http.post().gate(member, gate.scope("editor"), gate.rate({ max: 100, per: "1m", keyBy: "user" }));
```

**Public** — explicitly marks the endpoint as open without authentication:

```typescript
http.get().public();
```

## Response Envelopes

Every HTTP flow returns the same envelope shape. You choose status and optional `meta` — not a
custom wrapper.

<Callout title="Envelope is fixed">
  Success and failure always use `{ data, error }` (optional top-level `meta`). There is no API to
  replace that shape. Use `fx.json.*` for status codes and `meta`; use `fx.fail` for typed errors.
</Callout>

**Success** — returning a value from `do` produces `200 OK`:

```json
{ "data": { "id": "123" }, "error": null }
```

Returning `undefined` produces a `204 No Content` response with an empty body.

**Custom status** — `fx.json.create` for `201 Created`, or `fx.json.ok` with optional `meta`:

```typescript
return fx.json.create({ id: "ord_1" });
// or
return fx.json.ok({ id: "ord_1" }, { meta: { traceId: fx.runId } });
```

**Typed failures** — `fx.fail(code, data)` formats the error envelope and maps status:

```typescript
return fx.fail("NotFound", { id: "123" });
```

```json
{
  "data": null,
  "error": {
    "code": "NotFound",
    "message": "Resource not found",
    "data": { "id": "123" }
  }
}
```

Standard status code mappings:

- `ValidationError` → `422 Unprocessable Entity`
- `Unauthorized` → `401 Unauthorized`
- `Forbidden` → `403 Forbidden`
- `RateLimited` → `429 Too Many Requests`
- Custom error codes → `400 Bad Request`

## Troubleshooting

<Accordions>

<Accordion title="404 Not Found — route missing">
  No Flow is bound to that method + path. Check the explicit path, or for pathless routes the
  file-tree stamp (`src/flows/users/[id]/get.ts` → `GET /users/:id`). A bare `404` with body `Not
  Found` means the router found no match.
</Accordion>

<Accordion title="405 Method Not Allowed on valid route">
  The path exists but has not been bound to the requested HTTP verb. The response contains an
  `Allow` header listing valid methods for that path.
</Accordion>

<Accordion title="415 Unsupported Media Type on QUERY">
  RFC 10008 requires `Content-Type: application/json` for `http.query` requests. Ensure your client
  sends this header with a valid JSON payload.
</Accordion>

<Accordion title="422 ValidationError on request">
  The merged input payload failed validation against the trigger's `in` schema. Check the
  `error.data.issues` array for the specific field validation failure.
</Accordion>

<Accordion title="Browser blocked by CORS / missing Access-Control-*">
  Cross-origin access is closed until you plug the [`cors`](/docs/plugins/cors) plugin with an
  explicit `origin`. Same-origin calls need no CORS headers. Preflight `OPTIONS` is answered by the
  plugin even when the path is bound to other methods.
</Accordion>

<Accordion title="TypeError: on(http.resource(...)) takes no second argument">
  The ops bag already holds the five Flows. Call `on(http.resource("/notes",
  notesResource.all()).gate(member))` — do not pass a `flow(...)` as the second argument.
</Accordion>

<Accordion title="TypeError: on(http.resource(...)) expects the five CRUD FlowDefs">
  The bag must include `list`, `create`, `get`, `update`, and `remove`, each a `flow(...)`. To
  expose fewer verbs, bind those Flows on `http.get` / `http.post` yourself instead of
  `http.resource`.
</Accordion>

<Accordion title="OKE1041 — method + path bound twice">
  Cause: `{method} {path} is bound twice (flow "{flow}").` A resource mount plus a handwritten
  `http.get("/notes")` (or two mounts on the same base path) collide. Drop one binding.
</Accordion>

<Accordion title="TypeError: live exposure must be GET">
  Live SSE feeds declared via `.live(signal)` can only be attached to `GET` triggers
  (`http.get(...)` or `http.live(...)`). Other verbs reject live stream synthesis.
</Accordion>

<Accordion title="OKE1050 — live signal exposed twice">
  Cause: `Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}
  ).` Two firehoses (`http.live` or param-less `.live`) that share the signal and gates cannot boot.
  Change the gate, add a path-param filter, or remove a route.
</Accordion>

<Accordion title="OKE1210 — 410 LiveResumeGap">
  Cause: `Cursor "{afterId}" missing on "{signal}".` That `Last-Event-ID` is gone from the tape.
  Reconnect without it; remaining events replay. `autoResubscribe: true` does this after backoff.
</Accordion>

<Accordion title="live query requires a primary key / RLS driver">
  Extract: `live: true on table "…" requires a primary key column`. Runtime: `live query for "…"
  requires an RLS-capable SQL driver (postgres / pglite)` or `requires a gated identity`. Attach
  `.gate(...)` and declare a PK.
</Accordion>

</Accordions>

## Learn more

- [Store](/docs/elements/store) — `store.resource`, list query grammar, SQL facet
- [Signal · Live](/docs/elements/signal/live) — `signal.live` tapes
- [Client](/docs/client/live) — `api.live`, `useLive`, `useLiveQuery`
- [fx](/docs/reference/fx) — `fx.live`, `fx.json.stream`, `fx.json.create`
- [Gate](/docs/elements/gate) — `.gate(...)` / `.public()` on triggers
- [Errors](/docs/reference/errors) — OKE1041 · OKE1050 · OKE1210

## Next

<Cards>
  <Card
    title="Gate Element"
    description="Configure authentication, authorization, and rate limiting."
    href="/docs/elements/gate"
  />
  <Card
    title="Consumers"
    description="Signal workers, named Clock jobs, and SQL CDC — one Flow species."
    href="/docs/elements/flow/consumers"
  />
  <Card
    title="Durable Workflows"
    description="Step journaling and multi-step distributed execution."
    href="/docs/elements/flow/workflows"
  />
</Cards>
