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 below.
Contents
- Overview
- Base URLs
- Quickstart
- Authentication
- WebSocket protocol reference
- HTTP API reference
- Errors reference
- Rate limits & payload limits
- Usage tracking
- SDK reference
- Recipes
- 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.
- Public key (
- 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.
Base URLs
foghorn is multi-tenant by key, not by URL — every project on this
deployment shares the same two endpoints below; your pk_live_.../sk_live_...
keys are what scope a connection or emit call to your specific project.
| URL | |
|---|---|
| WebSocket connection | wss://y8ymlv721c.execute-api.us-east-1.amazonaws.com/prod |
| HTTP emit endpoint | https://q19yjb9xd0.execute-api.us-east-1.amazonaws.com/prod/emit |
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).
1. Subscribe from the browser (public key only):
import { FoghornClient } from "@foghorn/sdk";
const client = new FoghornClient({
url: "wss://y8ymlv721c.execute-api.us-east-1.amazonaws.com/prod",
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):
await fetch("https://q19yjb9xd0.execute-api.us-east-1.amazonaws.com/prod/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://y8ymlv721c.execute-api.us-east-1.amazonaws.com/prod?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
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) — 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://y8ymlv721c.execute-api.us-east-1.amazonaws.com/prod?publicKey=<public key>
publicKey is the only required query parameter.
Message envelope
Every client→server message is a JSON object with an action field:
{ "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):
{ "action": "subscribe", "channel": "room-1", "ok": true }
Protocol error (reply to a malformed request — see Errors reference for the full list):
{ "error": "invalid_channel", "channel": "bad channel!" }
Emitted message (pushed when your backend emits to a channel this connection is subscribed to):
{ "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).
Disconnection
- Client-initiated: closing the WebSocket triggers
$disconnect, which deletes the connection record and all of its channel subscriptions. - Network-drop: API Gateway calls
$disconnecton a best-effort basis. As a safety net, connection records also carry a 24-hour DynamoDB TTL, so a missed$disconnectself-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
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:
{ "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.
{
"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 https://q19yjb9xd0.execute-api.us-east-1.amazonaws.com/prod/emit
Auth: Authorization: Bearer <secret key>. Server-to-server only — see
Authentication. Body:
{ "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 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
from your own backend instead.
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:
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
// 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
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:
const result = await notifyRoom("room-1", { ping: true });
console.log(`${result.subscriberCount} connections subscribed to room-1`);
Rotate a compromised secret key
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/sdkclient with reconnect/backoff and auto-resubscribe (Phase 7); this reference (Phase 8).