1---
2title: Effect HTTP
3description: Schema-validated REST endpoints with an rpc-like typed interface — for trust boundaries where consumers want a plain HTTP client.
4sidebar:
5 order: 4
6---
7
8Effect HTTP (`effect/http-api`) is the same idea as
9[Effect RPC](/apis/effect-rpc): define a Schema, construct handler
10Layers, return an `HttpEffect` from `fetch`, call it through an
11rpc-like typed interface. The difference is what goes on the wire —
12real HTTP endpoints, with URLs, path params, query strings, headers,
13bodies, and content types.
14
15The use case is the same trust boundary — data that needs validating
16and sanitizing on the way in, exposed to a web app or an external
17service. Choose Effect HTTP over Effect RPC when your consumers
18aren't Effect (or TypeScript) programs and want a plain HTTP client.
19The cost is also the same: every request pays for a schema decode
20and encode, so it's equally discouraged for internal
21service-to-service calls — that's what
22[schemaless RPC](/apis/schemaless) is for.
23
24## The pieces
25
26Four files. Three are the API — domain model, endpoint
27declarations, and the host that serves them (a Cloudflare Worker or
28Lambda Function) — and the fourth is a client that derives
29everything from the second:
30
31```sh
32src/
33├── Task.ts # domain model + typed 404 (Schema.Class, TaggedErrorClass)
34├── ApiSchema.ts # endpoint declarations — one HttpApi value, imported by server AND clients
35├── ApiService.ts # the host — a Worker or Lambda Function; wired on the hub pages — returns { fetch }
36└── ApiClient.ts # typed client derived from ApiSchema — no codegen, no string URLs
37```
38
39The load-bearing file is `src/ApiSchema.ts`: the server implements
40it, every typed client is derived from it, and plain HTTP consumers
41get its validation for free.
42
43## Schema
44
45`src/Task.ts` owns the value that crosses the wire and the error
46that crosses with it:
47
48```typescript
49// src/Task.ts
50import * as Schema from "effect/Schema";
51
52export class Task extends Schema.Class<Task>("Task")({
53 id: Schema.String,
54 title: Schema.String,
55 completed: Schema.Boolean,
56}) {}
57
58export class TaskNotFound extends Schema.TaggedErrorClass<TaskNotFound>()(
59 "TaskNotFound",
60 { id: Schema.String },
61 { httpApiStatus: 404 },
62) {}
63```
64
65`Schema.Class` gives you a runtime-validated class with an inferred
66TypeScript type — one definition imported by both sides — and
67`httpApiStatus: 404` maps the tagged error to a real 404 over plain
68HTTP instead of a generic 500.
69
70## Endpoints
71
72`src/ApiSchema.ts` declares the contract — endpoints, a group, and
73the API value:
74
75```typescript
76// src/ApiSchema.ts
77import * as Schema from "effect/Schema";
78import * as HttpApi from "effect/http-api/HttpApi";
79import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint";
80import * as HttpApiGroup from "effect/http-api/HttpApiGroup";
81import { Task, TaskNotFound } from "./Task.ts";
82
83export const getTask = HttpApiEndpoint.get("getTask", "/:id", {
84 params: Schema.Struct({ id: Schema.String }),
85 success: Task,
86 error: TaskNotFound,
87});
88
89export const createTask = HttpApiEndpoint.post("createTask", "/", {
90 payload: Schema.Struct({ title: Schema.String }),
91 success: Task,
92});
93
94export class TasksGroup extends HttpApiGroup.make("Tasks")
95 .add(getTask)
96 .add(createTask) {}
97
98export class TaskApi extends HttpApi.make("TaskApi").add(TasksGroup) {}
99```
100
101Endpoints are pure declarations of `(method, path, schemas)`: the
102`/:id` path parameter declared under `params` is what makes
103`params.id` a typed `string` in both the handler and the client,
104request bodies are decoded against the `payload` Schema before your
105handler runs (anything that doesn't match gets an automatic 400 with
106a structured validation error), and `TaskApi` is a plain value —
107importable by the server and every client without dragging any
108runtime code across.
109
110## Handlers
111
112`HttpApiBuilder.group` wires handlers into the spec:
113
114```typescript
115// src/ApiService.ts — inside the host's Construction phase (a Cloudflare Worker or Lambda Function)
116import * as Effect from "effect/Effect";
117import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
118import { TaskApi } from "./ApiSchema.ts";
119import { Task, TaskNotFound } from "./Task.ts";
120
121const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) =>
122 handlers
123 .handle("getTask", ({ params }) =>
124 // look up the task in storage — the hub guides wire R2/DynamoDB
125 Effect.fail(new TaskNotFound({ id: params.id })),
126 )
127 .handle("createTask", ({ payload }) =>
128 Effect.succeed(
129 new Task({
130 id: crypto.randomUUID(),
131 title: payload.title,
132 completed: false,
133 }),
134 ),
135 ),
136);
137```
138
139`HttpApiBuilder.group` *constructs* a handler Layer — it runs
140nothing, which is what makes it safe at Construction/plan time
141([Phases](/infrastructure-as-effects/phases)) — and each
142handler receives the typed request its endpoint declared:
143`params.id` and `payload.title` are `string`s, the handler must
144return a `Task`, and the only allowed failure is `TaskNotFound` — a
145wrong shape or an undeclared error is a compile error, not a runtime
146surprise.
147
148## Serve it
149
150Assemble the API Layer and convert it into the `HttpEffect` that
151`fetch` expects:
152
153```typescript
154// src/ApiService.ts — end of the Construction phase
155import * as Layer from "effect/Layer";
156import * as HttpRouter from "effect/http/HttpRouter";
157
158// platform = the host's platform service Layers — differs per host
159return {
160 fetch: yield* HttpRouter.toHttpEffect(
161 HttpApiBuilder.layer(TaskApi).pipe(
162 Layer.provide(tasksGroup),
163 Layer.provide(platform),
164 ),
165 ),
166};
167```
168
169`HttpRouter.toHttpEffect` builds the Layer once at boot and yields
170the request handler — the same `{ fetch }` shape a Worker or a
171Lambda Function with `functionUrl: true` serves. The `platform` Layers are
172the host-specific part of the wiring — see
173[Workers](/cloudflare/apis/effect-http-api) and
174[Lambda](/aws/apis/effect-http-api) for the exact set, plus storage
175bindings and deploy (and CORS, on Workers).
176
177## Plain HTTP for everyone else
178
179Deploy it and the payoff over Effect RPC is immediate: the wire
180format is ordinary REST, so consumers need nothing but an HTTP
181client.
182
183```sh
184curl -X POST https://<your-api-url>/ \
185 -H "Content-Type: application/json" \
186 -d '{"title": "Write docs"}'
187# → {"id":"...","title":"Write docs","completed":false}
188
189curl https://<your-api-url>/<id>
190# → {"id":"...","title":"Write docs","completed":false}
191```
192
193Invalid payloads get automatic 400 responses with structured
194validation errors, and a missing task comes back as a real 404 — the
195`httpApiStatus` declared on `TaskNotFound` — in any language, with
196any HTTP library, against the same validated contract.
197
198## A typed client
199
200For consumers that *are* Effect programs, the same `TaskApi` value
201drives a fully typed client — no codegen, no string URLs:
202
203```typescript
204// src/ApiClient.ts
205import * as Effect from "effect/Effect";
206import * as HttpApiClient from "effect/http-api/HttpApiClient";
207import { TaskApi } from "./ApiSchema.ts";
208
209const program = Effect.gen(function* () {
210 const client = yield* HttpApiClient.make(TaskApi, {
211 baseUrl: process.env.TASK_API_URL!,
212 });
213
214 const created = yield* client.Tasks.createTask({
215 payload: { title: "Write docs" },
216 });
217
218 const task = yield* client.Tasks.getTask({
219 params: { id: created.id },
220 });
221});
222```
223
224Request keys mirror the declarations — `payload` for bodies,
225`params` for path parameters — and `client.Tasks.getTask` returns
226`Effect<Task, TaskNotFound | HttpClientError>`: the 404 arrives as a
227typed value you pattern-match on, not a status code you interpret.
228The client can also ride a Binding instead of a URL — see the
229[Durable Object bridge](/cloudflare/apis/effect-http-api#bridge-the-do-into-a-typed-client)
230on the Workers page.
231
232## Where next
233
234- [Effect HTTP on Workers](/cloudflare/apis/effect-http-api) — full
235 Worker wiring: R2 storage, the `HttpPlatform` stub, CORS, and the
236 DO sub-API bridge.
237- [Effect HTTP on Lambda](/aws/apis/effect-http-api) — full Lambda
238 wiring: DynamoDB bindings, IAM, and a typed end-to-end test.
239- [Effect RPC](/apis/effect-rpc) — same trust boundary, leaner wire
240 protocol, for consumers that are Effect programs.
241- [Schemaless RPC](/apis/schemaless) — the default for internal
242 calls: typed clients with no schema and no per-request validation.
243- [REST API (API Gateway v1)](/aws/apis/api-gateway) — put API
244 Gateway in front instead of a Function URL.