foghorn

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

  1. Overview
  2. Base URLs
  3. Quickstart
  4. Authentication
  5. WebSocket protocol reference
  6. HTTP API reference
  7. Errors reference
  8. Rate limits & payload limits
  9. Usage tracking
  10. SDK reference
  11. Recipes
  12. 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.


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 connectionwss://y8ymlv721c.execute-api.us-east-1.amazonaws.com/prod
HTTP emit endpointhttps://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:

LayerCredentialUsed for
Control planeSession cookie (foghorn_session, httpOnly JWT)Managing your account, projects, and keys via the dashboard/API
Connection planePublic key / secret keyConnecting, 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

actionFieldsEffect
subscribechannel: stringSubscribes this connection to channel
unsubscribechannel: stringUnsubscribes 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 $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

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" }
StatusBodyCause
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": "..." }.

StatusBodyCause
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" }.

StatusBodyCause
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" }.

StatusBodyCause
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" } }
StatusBodyCause
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.

CodeHTTP statusSurfaceMeaning
email_and_password_required400control plane (signup, login)Missing email or password
password_too_short400control plane (signup)Password under 8 characters
email_not_allowed403control plane (signup)Email isn't on ALLOWED_SIGNUP_EMAILS
email_already_registered409control plane (signup)Account already exists
invalid_credentials401control plane (login)Unknown email or wrong password
unauthorized401control plane (all authenticated routes)Missing/invalid session cookie
name_required400control plane (create project)Empty project name
project_not_found404control plane (project/key routes)Doesn't exist, or isn't owned by the caller
key_not_found404control plane (revoke key)Key ID doesn't belong to the project
invalid_key_type400control plane (rotate key)keyType isn't public or secret
missing_secret_key401emit endpointNo Authorization header
invalid_secret_key401emit endpointKey unknown or revoked
invalid_json400emit endpoint, WS subscribe/unsubscribeBody/message isn't valid JSON
invalid_channel400emit endpoint, WS subscribe/unsubscribeFails channel name rules
payload_too_large413emit endpointExceeds 128KB
rate_limited429emit endpointExceeds per-project emit rate limit
unknown_action— (WS message, not HTTP)WS subscribe/unsubscribeaction isn't subscribe/unsubscribe
(non-200 from $connect)401 at handshakeWS connectMissing/invalid/revoked public key — see Authentication for why this isn't a close code

Rate limits & payload limits

LimitValueEnforced by
Emit rate600 messages/minute per project (fixed 1-minute window)emit Lambda, EMIT_RATE_LIMIT_PER_MINUTE env var
Max message payload131,072 bytes (128 KiB) — API Gateway's own hard capemit Lambda checks and rejects with 413 before attempting delivery
Channel name length1–200 charactersemit Lambda and WS subscribe/unsubscribe handler
Channel name characters^[a-zA-Z0-9_\-.:]+$same
Connection record TTL24 hoursDynamoDB 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:

ColumnMeaning
connection_countTotal connects that day
message_countTotal emits that day
peak_concurrent_connectionsHighest 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)

OptionTypeDefaultNotes
urlstring— requiredwss://... connection URL, no query string
publicKeystring— requiredpk_live_...
reconnectbooleantrueAuto-reconnect with backoff on unexpected close
maxReconnectDelayMsnumber30000Cap on exponential backoff (with jitter)

Methods

MethodSignatureBehavior
connect()(): voidOpens the connection
disconnect()(): voidCloses the connection; suppresses auto-reconnect
subscribe(channel)(channel: string): voidSends a subscribe message; remembers the channel so a future reconnect resubscribes automatically
unsubscribe(channel)(channel: string): voidSends an unsubscribe message; forgets the channel
onMessage(handler)(msg: {channel, payload}) => void → unsubscribe fnFires for emitted messages on subscribed channels
onAck(handler)(ack: {action, channel, ok}) => void → unsubscribe fnFires on subscribe/unsubscribe acks
onErrorMessage(handler)(err: {error, ...}) => void → unsubscribe fnFires on server-sent protocol errors
onOpen(handler)() => void → unsubscribe fnFires when the socket opens (including after a reconnect)
onClose(handler)({code, reason}) => void → unsubscribe fnFires on every close, including ones followed by a reconnect
onSocketError(handler)(event: unknown) => void → unsubscribe fnFires 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/sdk client with reconnect/backoff and auto-resubscribe (Phase 7); this reference (Phase 8).