# foghorn — reference

WebSockets as a Service: a multi-tenant pub/sub layer over WebSockets. Any
project can broadcast events on named channels and any authorized client can
subscribe to them. This is a transport and routing layer, not an application
framework — it does not merge documents, resolve conflicts, or persist
messages. See [Non-goals](#non-goals) below.

## Contents

1. [Overview](#overview)
2. [Quickstart](#quickstart)
3. [Authentication](#authentication)
4. [WebSocket protocol reference](#websocket-protocol-reference)
5. [HTTP API reference](#http-api-reference)
6. [Errors reference](#errors-reference)
7. [Rate limits & payload limits](#rate-limits--payload-limits)
8. [Usage tracking](#usage-tracking)
9. [SDK reference](#sdk-reference)
10. [Recipes](#recipes)
11. [Changelog](#changelog)

---

## Overview

foghorn has four core primitives:

- **Project** — a tenant. Has a UUID identifier (`projectId`).
- **API key** — scoped to a project, issued in pairs:
  - **Public key** (`pk_live_...`) — connects and subscribes. Safe to ship to
    a browser.
  - **Secret key** (`sk_live_...`) — emits. **Never send this to a browser.**
    It must only be used from your own backend, calling the emit endpoint
    server-to-server.
- **Channel** — a string namespace within a project. Clients subscribe to a
  channel; your backend emits to it.
- **Connection** — a live WebSocket session, tied to a project and a set of
  subscribed channels.

The public/secret split is the one thing to get right as an integrator: if a
secret key ends up in client-side code, any visitor can impersonate your
backend and emit arbitrary events into your project's channels.

### Non-goals

foghorn does not do document/state merge logic (CRDT, OT, or otherwise),
does not implement a presence protocol (build it yourself on ordinary
channels), does not persist or replay messages (it is a relay, not a log),
and gives no cross-channel ordering guarantee (per-connection delivery order
from API Gateway is best-effort only). Every message payload is treated as an
opaque blob — there is no content-aware validation beyond size.

---

## Quickstart

This assumes you already have a project and a key pair (create one via the
dashboard, or `POST /api/projects` — see [HTTP API reference](#http-api-reference)).

**1. Subscribe from the browser** (public key only):

```ts
import { FoghornClient } from "@foghorn/sdk";

const client = new FoghornClient({
  url: "wss://<api-id>.execute-api.<region>.amazonaws.com/<stage>",
  publicKey: "pk_live_xxxxxxxx",
});

client.onOpen(() => client.subscribe("room-1"));
client.onMessage((msg) => console.log(msg.channel, msg.payload));
client.connect();
```

**2. Emit from your backend** (secret key only — never in browser code):

```ts
await fetch("https://<emit-api-id>.execute-api.<region>.amazonaws.com/<stage>/emit", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    authorization: "Bearer sk_live_xxxxxxxx",
  },
  body: JSON.stringify({
    channel: "room-1",
    payload: { text: "hello from the backend" },
  }),
});
```

The browser client receives `{ channel: "room-1", payload: { text: "hello from the backend" } }`
in its `onMessage` handler.

---

## Authentication

There are two independent auth layers:

| Layer | Credential | Used for |
|---|---|---|
| Control plane | Session cookie (`foghorn_session`, httpOnly JWT) | Managing your account, projects, and keys via the dashboard/API |
| Connection plane | Public key / secret key | Connecting, subscribing, and emitting |

### Public key

Passed as a query parameter on the WebSocket connection URL:

```
wss://<api-id>.execute-api.<region>.amazonaws.com/<stage>?publicKey=pk_live_xxxxxxxx
```

If the key is missing, unknown, or revoked, the `$connect` handler returns a
non-200 status, which causes the WebSocket **handshake itself** to fail —
the connection never opens. This is not a close code (no close frame is ever
sent, because the connection never completed); it surfaces to the client as
a generic connection error per the WebSocket spec, which does not expose the
HTTP status of a failed handshake. See [Errors reference](#errors-reference)
for the exact statuses `$connect` can return.

### Secret key

Passed as a bearer token on the HTTP emit endpoint:

```
Authorization: Bearer sk_live_xxxxxxxx
```

An invalid or revoked secret key gets a `401` JSON response (see
[Errors reference](#errors-reference)) — the request never reaches the
fan-out step.

### Key lifecycle

- Both keys are issued together when a project is created (`POST /api/projects`).
- Each key can be **revoked** independently (`POST .../keys/:keyId/revoke`) or
  **rotated** (`POST .../keys` — revokes the current key of that type and
  issues a new one in one call).
- Revoked keys fail auth immediately; there is no grace period.
- Keys are stored server-side as a SHA-256 hash. The raw key value is
  returned exactly once, at creation/rotation time — foghorn cannot show it
  to you again.

---

## WebSocket protocol reference

### Connection URL

```
wss://<api-id>.execute-api.<region>.amazonaws.com/<stage>?publicKey=<public key>
```

`publicKey` is the only required query parameter.

### Message envelope

Every client→server message is a JSON object with an `action` field:

```json
{ "action": "subscribe", "channel": "room-1" }
```

Supported `action` values: `subscribe`, `unsubscribe`. Any other value gets
an `unknown_action` error (see below).

### Client → server messages

| action | Fields | Effect |
|---|---|---|
| `subscribe` | `channel: string` | Subscribes this connection to `channel` |
| `unsubscribe` | `channel: string` | Unsubscribes this connection from `channel` |

Channel names must be 1–200 characters and match `^[a-zA-Z0-9_\-.:]+$`
(letters, digits, `_`, `-`, `.`, `:`). A `private-` prefix is reserved for a
future private-channel convention — not enforced in v1, but avoid it if you
want to adopt that convention later without a migration.

### Server → client messages

**Ack** (reply to a valid `subscribe`/`unsubscribe`):

```json
{ "action": "subscribe", "channel": "room-1", "ok": true }
```

**Protocol error** (reply to a malformed request — see
[Errors reference](#errors-reference) for the full list):

```json
{ "error": "invalid_channel", "channel": "bad channel!" }
```

**Emitted message** (pushed when your backend emits to a channel this
connection is subscribed to):

```json
{ "channel": "room-1", "payload": { "anything": "you sent" } }
```

`payload` is exactly what was sent to the emit endpoint — foghorn does not
interpret, validate, or transform it beyond checking total message size.

Distinguish message types on the client by shape: an emitted message has
`channel` + `payload` and no `action`; an ack has `action` + `ok`; an error
has `error`. `@foghorn/sdk` does this for you (see
[SDK reference](#sdk-reference)).

### Disconnection

- **Client-initiated**: closing the WebSocket triggers `$disconnect`, which
  deletes the connection record and all of its channel subscriptions.
- **Network-drop**: API Gateway calls `$disconnect` on a best-effort basis.
  As a safety net, connection records also carry a 24-hour DynamoDB TTL, so a
  missed `$disconnect` self-heals within a day even in the worst case (it
  does not linger indefinitely, but delivery attempts to a truly-dead
  connection during that window get pruned inline — see
  [stale connection pruning](#stale-connection-pruning)).

### Stale connection pruning

When emit attempts delivery to a connection that's actually gone, API
Gateway's `postToConnection` returns `410 Gone`. On that response, foghorn
deletes the stale connection record and its subscriptions inline — no
separate cleanup job needed. This happens per-connection as part of every
emit fan-out.

---

## HTTP API reference

Two separate HTTP surfaces exist: the **control plane** (session-cookie
auth, for managing your account/projects/keys) and the **connection plane's
emit endpoint** (secret-key auth, for sending messages). Base URLs differ —
the control plane is wherever you deploy `apps/web`; the emit endpoint is
the `EmitUrl` stack output from `services/ws`.

### Control plane

All control-plane routes below require the `foghorn_session` cookie
(obtained via login/signup) except signup and login themselves. Missing or
invalid session → `401 { "error": "unauthorized" }` on every route unless
noted otherwise.

#### `POST /api/auth/signup`

Auth: none (this creates the session). Body:

```json
{ "email": "you@example.com", "password": "at least 8 characters" }
```

| Status | Body | Cause |
|---|---|---|
| 201 | `{ "id": "<uuid>", "email": "..." }` | Created; session cookie set |
| 400 | `{ "error": "email_and_password_required" }` | Missing field |
| 400 | `{ "error": "password_too_short" }` | Password under 8 chars |
| 403 | `{ "error": "email_not_allowed" }` | Email not on `ALLOWED_SIGNUP_EMAILS` |
| 409 | `{ "error": "email_already_registered" }` | Email already has an account |

#### `POST /api/auth/login`

Auth: none. Body: `{ "email": "...", "password": "..." }`.

| Status | Body | Cause |
|---|---|---|
| 200 | `{ "id": "<uuid>", "email": "..." }` | Session cookie set |
| 400 | `{ "error": "email_and_password_required" }` | Missing field |
| 401 | `{ "error": "invalid_credentials" }` | Unknown email or wrong password |

#### `POST /api/auth/logout`

Auth: session. No body. Always `200 { "ok": true }` and clears the cookie.

#### `GET /api/projects`

Lists projects owned by the current user.
`200 { "projects": [{ "id", "name", "ownerUserId", "createdAt" }, ...] }`

#### `POST /api/projects`

Creates a project and issues its public/secret key pair in one call. Body:
`{ "name": "My project" }`.

| Status | Body | Cause |
|---|---|---|
| 201 | `{ "project": {...}, "keys": { "publicKey": "pk_live_...", "secretKey": "sk_live_..." } }` | Created — **this is the only response that ever contains the raw secret key** |
| 400 | `{ "error": "name_required" }` | Empty/missing name |

#### `GET /api/projects/:projectId`

`200 { "project": {...} }` or `404 { "error": "project_not_found" }` (also
returned if the project exists but isn't owned by the caller — foghorn does
not distinguish "not found" from "not yours").

#### `DELETE /api/projects/:projectId`

`200 { "ok": true }` or `404 { "error": "project_not_found" }`. Cascades to
the project's API keys and usage rows.

#### `GET /api/projects/:projectId/keys`

Lists keys for a project, masked. `404 { "error": "project_not_found" }` if
not owned/found.

```json
{
  "keys": [
    { "id": "<uuid>", "keyType": "public", "keyPrefix": "pk_live_ab12••••", "createdAt": "...", "revokedAt": null }
  ]
}
```

#### `POST /api/projects/:projectId/keys`

**Rotates** a key: revokes the current active key of the given type and
issues a new one. Body: `{ "keyType": "public" | "secret" }`.

| Status | Body | Cause |
|---|---|---|
| 201 | `{ "key": { "id", "keyType", "keyPrefix", "raw": "pk_live_..." } }` | `raw` is the new key, shown once |
| 400 | `{ "error": "invalid_key_type" }` | `keyType` missing or not `public`/`secret` |
| 404 | `{ "error": "project_not_found" }` | Not owned/found |

#### `POST /api/projects/:projectId/keys/:keyId/revoke`

`200 { "ok": true }`, `404 { "error": "project_not_found" }`, or
`404 { "error": "key_not_found" }`.

### Connection plane — emit endpoint

#### `POST <EmitUrl>/emit`

Auth: `Authorization: Bearer <secret key>`. Server-to-server only — see
[Authentication](#authentication). Body:

```json
{ "channel": "room-1", "payload": { "anything": "you want" } }
```

| Status | Body | Cause |
|---|---|---|
| 200 | `{ "ok": true, "channel": "...", "delivered": <n>, "subscriberCount": <n> }` | `subscriberCount` is how many connections were subscribed; `delivered` is how many actually received it (can be lower if some were already stale) |
| 401 | `{ "error": "missing_secret_key" }` | No `Authorization` header |
| 401 | `{ "error": "invalid_secret_key" }` | Key unknown or revoked |
| 400 | `{ "error": "invalid_json" }` | Body isn't valid JSON |
| 400 | `{ "error": "invalid_channel", "channel": "..." }` | Fails the channel name rules |
| 413 | `{ "error": "payload_too_large", "max_bytes": 131072 }` | Serialized `{channel,payload}` exceeds the limit |
| 429 | `{ "error": "rate_limited", "limit_per_minute": 600 }` | Project exceeded its per-minute emit budget |

---

## Errors reference

Every error code used anywhere in the system, in one place.

| Code | HTTP status | Surface | Meaning |
|---|---|---|---|
| `email_and_password_required` | 400 | control plane (signup, login) | Missing email or password |
| `password_too_short` | 400 | control plane (signup) | Password under 8 characters |
| `email_not_allowed` | 403 | control plane (signup) | Email isn't on `ALLOWED_SIGNUP_EMAILS` |
| `email_already_registered` | 409 | control plane (signup) | Account already exists |
| `invalid_credentials` | 401 | control plane (login) | Unknown email or wrong password |
| `unauthorized` | 401 | control plane (all authenticated routes) | Missing/invalid session cookie |
| `name_required` | 400 | control plane (create project) | Empty project name |
| `project_not_found` | 404 | control plane (project/key routes) | Doesn't exist, or isn't owned by the caller |
| `key_not_found` | 404 | control plane (revoke key) | Key ID doesn't belong to the project |
| `invalid_key_type` | 400 | control plane (rotate key) | `keyType` isn't `public` or `secret` |
| `missing_secret_key` | 401 | emit endpoint | No `Authorization` header |
| `invalid_secret_key` | 401 | emit endpoint | Key unknown or revoked |
| `invalid_json` | 400 | emit endpoint, WS subscribe/unsubscribe | Body/message isn't valid JSON |
| `invalid_channel` | 400 | emit endpoint, WS subscribe/unsubscribe | Fails channel name rules |
| `payload_too_large` | 413 | emit endpoint | Exceeds 128KB |
| `rate_limited` | 429 | emit endpoint | Exceeds per-project emit rate limit |
| `unknown_action` | — (WS message, not HTTP) | WS subscribe/unsubscribe | `action` isn't `subscribe`/`unsubscribe` |
| (non-200 from `$connect`) | 401 at handshake | WS connect | Missing/invalid/revoked public key — see [Authentication](#authentication) for why this isn't a close code |

---

## Rate limits & payload limits

| Limit | Value | Enforced by |
|---|---|---|
| Emit rate | 600 messages/minute per project (fixed 1-minute window) | `emit` Lambda, `EMIT_RATE_LIMIT_PER_MINUTE` env var |
| Max message payload | 131,072 bytes (128 KiB) — API Gateway's own hard cap | `emit` Lambda checks and rejects with `413` before attempting delivery |
| Channel name length | 1–200 characters | `emit` Lambda and WS subscribe/unsubscribe handler |
| Channel name characters | `^[a-zA-Z0-9_\-.:]+$` | same |
| Connection record TTL | 24 hours | DynamoDB TTL on the connections table (safety net for missed `$disconnect`) |

---

## Usage tracking

Every connect and every emit increments a per-project DynamoDB counter
(`connections#<date>`, `messages#<date>`). A scheduled Lambda rolls these
into Postgres `usage_daily` every 5 minutes — per-message activity never
writes to Postgres directly. Each row tracks:

| Column | Meaning |
|---|---|
| `connection_count` | Total connects that day |
| `message_count` | Total emits that day |
| `peak_concurrent_connections` | Highest live-connection count sampled during that day's rollups (a 5-minute-resolution sample, not a true instantaneous peak) |

**There is no public API for reading usage data in v1** — it's dashboard-only
(a project's page in the control plane renders it directly from Postgres).
If you need usage data programmatically, query `usage_daily` yourself for
now; a `GET /api/projects/:projectId/usage` endpoint may be added later.

---

## SDK reference

`@foghorn/sdk` is a thin wrapper over the native `WebSocket` for the
browser/client side (connect, subscribe, receive, reconnect). It
deliberately has no `emit()` — emitting needs the secret key, which must
never reach client code; call the [emit endpoint](#connection-plane--emit-endpoint)
from your own backend instead.

```ts
import { FoghornClient } from "@foghorn/sdk";
```

### `new FoghornClient(options)`

| Option | Type | Default | Notes |
|---|---|---|---|
| `url` | `string` | — required | `wss://...` connection URL, no query string |
| `publicKey` | `string` | — required | `pk_live_...` |
| `reconnect` | `boolean` | `true` | Auto-reconnect with backoff on unexpected close |
| `maxReconnectDelayMs` | `number` | `30000` | Cap on exponential backoff (with jitter) |

### Methods

| Method | Signature | Behavior |
|---|---|---|
| `connect()` | `(): void` | Opens the connection |
| `disconnect()` | `(): void` | Closes the connection; suppresses auto-reconnect |
| `subscribe(channel)` | `(channel: string): void` | Sends a subscribe message; remembers the channel so a future reconnect resubscribes automatically |
| `unsubscribe(channel)` | `(channel: string): void` | Sends an unsubscribe message; forgets the channel |
| `onMessage(handler)` | `(msg: {channel, payload}) => void` → unsubscribe fn | Fires for emitted messages on subscribed channels |
| `onAck(handler)` | `(ack: {action, channel, ok}) => void` → unsubscribe fn | Fires on subscribe/unsubscribe acks |
| `onErrorMessage(handler)` | `(err: {error, ...}) => void` → unsubscribe fn | Fires on server-sent protocol errors |
| `onOpen(handler)` | `() => void` → unsubscribe fn | Fires when the socket opens (including after a reconnect) |
| `onClose(handler)` | `({code, reason}) => void` → unsubscribe fn | Fires on every close, including ones followed by a reconnect |
| `onSocketError(handler)` | `(event: unknown) => void` → unsubscribe fn | Fires on the underlying WebSocket's `error` event |

Every `on*` method returns an unsubscribe function:

```ts
const stop = client.onMessage((msg) => console.log(msg));
stop(); // remove this listener
```

### Reconnection behavior

On an unexpected close (not a manual `disconnect()`), the client retries
with exponential backoff (`500ms * 2^attempt`, capped at
`maxReconnectDelayMs`, ±50% jitter). On reconnect, it automatically
re-sends `subscribe` for every channel you'd previously called `subscribe()`
on — the server has no memory of a dead connection's subscriptions, so the
client keeps that list itself.

---

## Recipes

### Broadcast an event from your backend

```ts
// Runs on your server — never in browser code.
async function notifyRoom(channel: string, payload: unknown) {
  const res = await fetch(`${process.env.FOGHORN_EMIT_URL}/emit`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${process.env.FOGHORN_SECRET_KEY}`,
    },
    body: JSON.stringify({ channel, payload }),
  });
  if (!res.ok) {
    const body = await res.json();
    throw new Error(`emit failed: ${body.error}`);
  }
  return res.json(); // { ok, channel, delivered, subscriberCount }
}
```

### Subscribe and handle reconnection

```ts
import { FoghornClient } from "@foghorn/sdk";

const client = new FoghornClient({
  url: process.env.NEXT_PUBLIC_FOGHORN_WS_URL!,
  publicKey: process.env.NEXT_PUBLIC_FOGHORN_PUBLIC_KEY!,
});

client.onOpen(() => client.subscribe("room-1")); // re-subscribes automatically after a reconnect too
client.onClose(({ code, reason }) => console.warn("disconnected", code, reason));
client.onMessage((msg) => {
  if (msg.channel === "room-1") renderIncoming(msg.payload);
});
client.connect();
```

### Check how many subscribers a channel currently has

There's no dedicated "list subscribers" endpoint in v1. The cheapest way to
find out is the `subscriberCount` field returned by every emit call:

```ts
const result = await notifyRoom("room-1", { ping: true });
console.log(`${result.subscriberCount} connections subscribed to room-1`);
```

### Rotate a compromised secret key

```ts
const res = await fetch(`/api/projects/${projectId}/keys`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ keyType: "secret" }),
});
const { key } = await res.json();
// key.raw is the new secret — the old one is revoked as of this call
```

---

## Changelog

- **2026-07-15** — Initial build: control-plane auth and project/key
  management (Phases 1–2); WebSocket connect/disconnect/subscribe/unsubscribe
  and the emit endpoint with rate limiting and stale-connection pruning
  (Phases 3–5); DynamoDB usage counters with a 5-minute EventBridge rollup
  into Postgres and a per-project usage dashboard (Phase 6); `@foghorn/sdk`
  client with reconnect/backoff and auto-resubscribe (Phase 7); this
  reference (Phase 8).
