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 or resolve conflicts, and its replay buffer is a seconds-scale reconnect aid, not durable message storage. See Non-goals below.
Contents
- Overview
- Base URLs
- Quickstart
- Authentication
- WebSocket protocol reference
- HTTP API reference
- Webhooks
- 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.
For ephemeral, high-frequency events (cursor/selection/presence-adjacent
signals) that don't need your backend in the loop, see
Client-emit (scoped) — a narrower capability than the
secret key, bound to one channel, one clientId, and an explicit allowlist
of message types.
Non-goals
foghorn does not do document/state merge logic (CRDT, OT, or otherwise) —
bring your own conflict resolution on top of ordinary channels. Its
presence support tracks membership only, not per-user
application state. Its replay buffer is a seconds-scale
reconnect aid, not durable message storage or a general-purpose event log —
don't rely on it as your only source of truth for anything older than a few
seconds. There is no cross-channel ordering guarantee (per-connection
delivery order from API Gateway is best-effort only, though seq
gives you a per-channel gap-detection signal). 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 |
| HTTP client-emit token revoke endpoint | https://q19yjb9xd0.execute-api.us-east-1.amazonaws.com/prod/emit-tokens/revoke — a sibling path on the same host as the emit endpoint above, not a separately configured URL. @foghorn/sdk/server's revokeClientEmitToken derives it from your emit URL automatically. |
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, emit, ping. Any
other value gets an unknown_action error (see below).
Client → server messages
| action | Fields | Effect |
|---|---|---|
subscribe | channel: string, presence?: boolean, clientId?: string, since?: number | Subscribes this connection to channel. If presence: true, clientId is required — see Presence. If since is set, replays buffered messages first — see Replay buffer |
unsubscribe | channel: string | Unsubscribes this connection from channel |
emit | channel: string, type: string, payload: unknown, token: string | Emits directly to channel over this connection, authorized by a scoped token — see Client-emit (scoped) |
ping | none | Liveness probe, replied to immediately with pong — no channel, no DynamoDB touch. See Heartbeat |
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!" }
Pong (reply to ping):
{ "action": "pong", "ok": true }
Emitted message (pushed when your backend emits to a channel this connection is subscribed to):
{ "channel": "room-1", "payload": { "anything": "you sent" }, "seq": 42 }
payload is exactly what was sent to the emit endpoint — foghorn does not
interpret, validate, or transform it beyond checking total message size.
seq is a per-channel monotonically increasing counter, stamped by the
server on every emit (independent of anything in your payload). It's a cheap,
generic way to detect gaps or out-of-order delivery (expected seq N, got N+2) without needing application-specific revision numbers. It is not a
durable sequence: a channel that goes fully dormant for an extended period may
have its counter reset, so don't treat it as a permanent identifier — treat it
as a hint for the current connection's session.
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; a presence event has type: "presence". @foghorn/sdk does this
for you (see SDK reference).
Presence
Opt in per subscription by sending presence: true and a clientId (opaque,
client-supplied, no server-side metadata beyond it) on subscribe. A
client_id_required error is returned if presence: true is sent without a
clientId.
{ "action": "subscribe", "channel": "template:42", "presence": true, "clientId": "user-123" }
Three presence event shapes, all with type: "presence":
{ "type": "presence", "channel": "template:42", "event": "sync", "members": ["user-456"] }
{ "type": "presence", "channel": "template:42", "event": "join", "clientId": "user-789" }
{ "type": "presence", "channel": "template:42", "event": "leave", "clientId": "user-789" }
syncis sent once, only to the subscriber who just opted into presence, with the current member list (every other presence-enabled subscriber on that channel at that moment).join/leaveare broadcast to every other presence-enabled subscriber of the channel when someone with presence enabled subscribes, unsubscribes, or disconnects (including an unclean disconnect — see Disconnection below;leavefires on$disconnectthe same as an explicitunsubscribe).- Presence is per-subscription, not per-channel: connections that didn't opt
in with
presence: trueneither appear inmembersnor receivejoin/leavebroadcasts. - Presence broadcasts do not count against the emit rate limit — they aren't developer-initiated emits.
Client-emit (scoped)
Lets a browser client emit directly to a channel over its own WebSocket connection, without a round trip through your backend — built for high-frequency, non-persisted signals (cursor position, selection, focus/ blur, a display identity) where routing every event through your server scales with concurrent viewers rather than with anything your backend actually needs to do. Document edits, or anything requiring durable, revision-checked writes, should keep going through the HTTP emit endpoint with the secret key — this is not a replacement for that path, only an escape hatch for the traffic that doesn't need it.
This is not the secret key. A client-emit token authorizes exactly one
channel, one clientId, and an explicit allowlist of message-type tags, for
a short, capped TTL. It cannot be used to emit on any other channel, under
any other identity, or with any message type outside its allowlist.
1. Mint a token, server-side (never in browser code — see
mintClientEmitToken):
import { mintClientEmitToken } from "@foghorn/sdk/server";
const token = mintClientEmitToken({
secret: process.env.FOGHORN_CLIENT_EMIT_SECRET!, // from the dashboard, never the public/secret API key
projectId: "...",
channel: "template:42",
clientId: "user-123", // must match the clientId this connection subscribes with (presence: true)
types: ["cursor", "selection"],
ttlSeconds: 300,
});
2. Subscribe with presence, then emit (browser):
client.subscribe("template:42", { presence: true, clientId: "user-123" });
client.emit("template:42", "cursor", { x: 120, y: 84 }, token);
The connection must already be subscribed to channel with
presence: true and the same clientId the token was minted for — a
structurally valid token alone isn't enough to claim an identity this
connection hasn't already established. This also means presence's leave
event (and the connect/disconnect webhook) double as your signal that a
client's emit rights just went away, the same as for any other presence
membership change.
Every other client-emit-enabled subscriber (and the HTTP-emit path's
existing subscribers) receives the relayed message the same way a
backend-emitted one arrives, plus type and clientId:
{ "channel": "template:42", "type": "cursor", "payload": { "x": 120, "y": 84 }, "seq": 481, "clientId": "user-123" }
The emitting connection gets its own ack, including the seq assigned:
{ "action": "emit", "channel": "template:42", "ok": true, "seq": 481 }
Independent per-clientId rate limit. The existing per-channel/project
rate limits still apply to client-emitted
messages (they're additive, not a replacement) — but there's also a
per-clientId cap (15/sec by default, CLIENT_EMIT_RATE_LIMIT_PER_SECOND)
so one misbehaving or compromised client can't exhaust a shared channel's
budget for everyone else in it.
Revocation. Revoke a specific token or every outstanding token for a
clientId ahead of its TTL — useful on logout, ban, or any session-end
where you don't want emit rights to outlive the session:
import { revokeClientEmitToken } from "@foghorn/sdk/server";
await revokeClientEmitToken({
emitUrl: process.env.FOGHORN_EMIT_URL!, // the same one you already use for backend emit() calls
secretKey: process.env.FOGHORN_SECRET_KEY!,
clientId: "user-123", // and/or jti to revoke one specific token
});
There's no separate revoke URL to look up — it's a sibling path on the
same host as the emit endpoint, and revokeClientEmitToken derives it from
emitUrl automatically. Non-JS stacks can call the
raw HTTP endpoint directly instead.
Configuring the signing secret. Generate (or rotate) a project's client-emit secret from the dashboard, or:
PATCH /api/projects/:projectId/client-emit-secret
Body: { "regenerate": true }
→ { "clientEmitSecret": "..." }
Rotating it immediately invalidates every token minted against the old secret — there's no overlap window, so coordinate a rotation with a reissue on your side rather than doing it under live traffic you care about.
Replay buffer
Every emitted message is held for a short, configurable window (20 seconds
by default, REPLAY_BUFFER_TTL_SECONDS) and can be replayed by passing
since: <seq> on subscribe — get everything with seq > since on that
channel, delivered before the subscribe ack:
{ "action": "subscribe", "channel": "template:42", "since": 42 }
Each replayed message has the same shape as a live one, plus replay: true:
{ "channel": "template:42", "payload": { "...": "..." }, "seq": 43, "replay": true }
If some messages between since and now are missing from the buffer
(because they expired, or because since predates anything ever buffered
for that channel), a gap event is sent instead of pretending you're caught
up:
{ "type": "replay_gap", "channel": "template:42", "since": 42 }
A replay_gap doesn't mean nothing was replayed — you may get a partial
run of messages plus a gap for the rest; the client decides whether a
partial catch-up is good enough or a full resync is warranted. @foghorn/sdk
tracks the last seq seen per channel and automatically requests replay
from it on every reconnect, so you don't manage since by hand for the
common case (see SDK reference).
This is a best-effort reconnect aid, not durable storage: the window is
seconds-scale, and a client gone longer than that will always get a
replay_gap. Keep your own periodic full-state resync as the backstop for
correctness; treat the replay buffer as smoothing over brief drops, not as
a message log.
Heartbeat
API Gateway WebSocket connections are force-closed after 10 minutes of no
data in either direction — a fixed platform ceiling, not something foghorn
configures. Whether that closure reaches your client as a clean close
event depends on the close frame actually making it back over the network:
sleep/wake, a wifi handoff, a NAT/proxy timeout, or a backgrounded tab can
all eat that frame, leaving readyState reporting OPEN on a connection
that's actually dead — a "zombie" your client has no way to detect on its
own without periodically asking "are we still actually connected?"
Send { "action": "ping" } and expect { "action": "pong", "ok": true }
back. A ping costs nothing server-side — no DynamoDB read or write, just
an immediate reply — and, like any data on the connection, also resets API
Gateway's 10-minute idle clock, so a ping well under that interval keeps
the connection from ever hitting the idle ceiling in the first place.
@foghorn/sdk does this for you automatically: heartbeatIntervalMs
(default 240000, 4 min) controls how often it pings, and
heartbeatAckTimeoutMs (default 15000, 15s) is how long it waits for the
pong before treating the connection as dead and closing the socket itself
— which is the actual fix for the zombie case, since it forces resolution
(successful close, or the browser's own bounded closing-handshake timeout)
instead of waiting indefinitely on a close event that may never arrive.
That in turn fires the existing reconnect path (see
Reconnection behavior) the same as any other
unexpected close. Set heartbeatIntervalMs: 0 to disable. 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). - When
$disconnectdoes fire, foghorn logsdisconnectStatusCode/disconnectReason(as sent by API Gateway) alongsideconnectionIdandprojectId— CloudWatch-only, not exposed via any API. The same two fields are also included in the disconnect webhook payload when present. Neither helps with the zombie-connection case above by definition: if the close frame never reached your client,$disconnectmay not have fired promptly (or at all, short of the 24-hour TTL sweep) on foghorn's side either — there's no reason to relay in that scenario on either end. Heartbeat is what actually surfaces that case to your client; this logging is for foghorn's/your own debugging once$disconnectdoes fire, clean or not.
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").
PATCH /api/projects/:projectId
Renames a project. Body: { "name": "New name" }. Doesn't touch keys, usage,
or the project's id — only ever a cosmetic label.
| Status | Body | Cause |
|---|---|---|
| 200 | { "project": { "id", "name" } } | Renamed |
| 400 | { "error": "name_required" } | Empty/missing name |
| 404 | { "error": "project_not_found" } | Doesn't exist, or isn't owned by the caller |
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" }.
PATCH /api/projects/:projectId/client-emit-secret
Generates (first call) or rotates (regenerate: true) the project's
client-emit signing secret — used to mint
scoped client-emit tokens server-side via
@foghorn/sdk/server. Rotating invalidates every token minted against the
previous secret immediately, with no overlap window. Body:
{ "regenerate"?: boolean }.
| Status | Body | Cause |
|---|---|---|
| 200 | { "clientEmitSecret": "..." } | Existing secret returned unchanged, or a new one generated/rotated |
| 404 | { "error": "project_not_found" } | Not owned/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": "...", "seq": <n>, "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); seq is the same per-channel counter stamped on the delivered message |
| 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": "channel_rate_limited", "rate_limit": {...}, "project_rate_limit": {...} } | This channel exceeded its own per-minute budget |
| 429 | { "error": "project_rate_limited", "rate_limit": {...}, "project_rate_limit": {...} } | The project-wide backstop was exceeded (traffic summed across all channels) |
Every response (200 or 429) includes rate_limit (the per-channel budget:
{ limit, remaining, resetAt }) and project_rate_limit (the project-wide
backstop, same shape) — poll these instead of guessing how close you are to
either ceiling.
Connection plane — revoke endpoint
If you're on Node, use revokeClientEmitToken from @foghorn/sdk/server
(see Client-emit) instead of calling this directly —
it derives the URL below for you. This raw HTTP form is for other
languages/stacks.
POST https://q19yjb9xd0.execute-api.us-east-1.amazonaws.com/prod/emit-tokens/revoke
Auth: Authorization: Bearer <secret key>. Revokes a
client-emit token by jti, or every outstanding
token for a clientId, ahead of its TTL. Body (at least one of jti /
clientId; both may be sent together):
{ "jti": "...", "clientId": "user-123" }
| Status | Body | Cause |
|---|---|---|
| 200 | { "ok": true } | Revoked. Idempotent — revoking an already-revoked or never-issued jti/clientId still returns 200 |
| 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": "jti_or_client_id_required" } | Neither jti nor clientId was sent |
Webhooks
Optional per-project notification of connect/disconnect events, configured from the project's dashboard page (URL + a generated signing secret — not yet settable via API). Best-effort, single attempt, no retry queue — if your endpoint is down or slow, that delivery is simply lost. This is a deliberate v1 scope cut, not an oversight: build reliable state (e.g. releasing a stale editing lock) around presence instead, which tells you the current membership state directly rather than relying on every individual event having been delivered.
On connect and on disconnect (including an unclean drop, once $disconnect
fires — see Disconnection), if a webhook URL is configured,
foghorn POSTs:
{ "event": "connect", "projectId": "...", "connectionId": "...", "timestamp": "2026-07-16T12:00:00.000Z" }
The "disconnect" event additionally carries disconnectStatusCode /
disconnectReason when API Gateway supplied them on $disconnect (see
Disconnection):
{ "event": "disconnect", "projectId": "...", "connectionId": "...", "disconnectStatusCode": 1006, "disconnectReason": "Idle timeout", "timestamp": "2026-07-16T12:00:00.000Z" }
event is "connect" or "disconnect". The request is signed:
X-Foghorn-Signature: sha256=<hex-encoded HMAC-SHA256 of the exact request body, keyed with your webhook secret>
Verify it by recomputing the same HMAC over the raw request body and
comparing (constant-time compare, not ===):
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyFoghornSignature(rawBody: string, header: string | undefined, secret: string): boolean {
if (!header?.startsWith("sha256=")) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const given = header.slice("sha256=".length);
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
A request is given a 2–3 second timeout — a slow endpoint can add that latency to the connect handshake or to disconnect cleanup, but never blocks longer than that.
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/rename 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 |
channel_rate_limited | 429 | emit endpoint | Exceeds that channel's own emit rate limit |
project_rate_limited | 429 | emit endpoint | Exceeds the project-wide backstop (summed across all channels) |
unknown_action | — (WS message, not HTTP) | WS subscribe/unsubscribe/emit | action isn't subscribe/unsubscribe/emit |
client_id_required | — (WS message, not HTTP) | WS subscribe | presence: true sent without a clientId |
invalid_since | — (WS message, not HTTP) | WS subscribe | since was sent but isn't a number |
token_and_type_required | — (WS message, not HTTP) | WS emit | emit sent without a token or a type |
client_emit_not_configured | — (WS message, not HTTP) | WS emit | Project has no clientEmitSecret generated yet |
invalid_emit_token | — (WS message, not HTTP) | WS emit | Token's signature, exp, pid, ch, or types allowlist didn't check out |
emit_identity_mismatch | — (WS message, not HTTP) | WS emit | This connection isn't subscribed with presence: true under the token's clientId |
emit_token_revoked | — (WS message, not HTTP) | WS emit | Token's jti or clientId was revoked — see revoke endpoint |
client_emit_rate_limited | — (WS message, not HTTP) | WS emit | This clientId exceeded its per-second cap |
payload_too_large (WS) | — (WS message, not HTTP) | WS emit | Serialized {channel,payload} exceeds 128 KiB |
channel_rate_limited (WS) | — (WS message, not HTTP) | WS emit | Same per-channel budget as the HTTP emit endpoint |
project_rate_limited (WS) | — (WS message, not HTTP) | WS emit | Same project-wide backstop as the HTTP emit endpoint |
jti_or_client_id_required | 400 | revoke endpoint | Neither jti nor clientId was sent |
(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 (per channel) | 600 messages/minute per channel by default (fixed 1-minute window) | emit Lambda, EMIT_RATE_LIMIT_PER_MINUTE env var default; overridable per project |
| Emit rate (project backstop) | 6,000 messages/minute summed across every channel in the project | emit Lambda, PROJECT_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) |
| Replay buffer window | 20 seconds by default | REPLAY_BUFFER_TTL_SECONDS env var — see Replay buffer |
| Client-emit rate (per clientId) | 15 messages/second by default (fixed 1-second window), independent of the per-channel/project limits above | WS emit action, CLIENT_EMIT_RATE_LIMIT_PER_SECOND env var — see Client-emit |
| Client-emit token max TTL | 600 seconds (10 minutes) by default, enforced at verification time regardless of what was requested at mint time | CLIENT_EMIT_TOKEN_MAX_TTL_SECONDS env var |
| WebSocket idle timeout | 10 minutes of no data in either direction — a fixed API Gateway platform ceiling, not configurable by foghorn | AWS API Gateway; see Heartbeat for how @foghorn/sdk avoids it |
| SDK heartbeat interval | 240 seconds (4 min) by default, comfortably under the 10-minute idle ceiling above | @foghorn/sdk, heartbeatIntervalMs client option — see Heartbeat |
| SDK heartbeat ack timeout | 15 seconds by default | @foghorn/sdk, heartbeatAckTimeoutMs client option — see Heartbeat |
Limits are scoped per channel, not per project — a busy channel that hits its own ceiling doesn't affect other channels in the same project. The project-wide backstop exists only to cap total abuse from one project provisioning unbounded channels; it's set well above the per-channel default so well-behaved multi-channel usage shouldn't hit it in normal operation.
A project's per-channel budget can be raised (or lowered) from the 600/minute default directly from the project's dashboard page, under Limits — no need to contact support.
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). There's still
no way to emit with the secret key from here, and never will be — that
stays a server-to-server call to the
emit endpoint. It does support emit()
for scoped client-emit, authorized by a short-lived
token minted server-side (see @foghorn/sdk/server below) — a narrower
capability than the secret key, not a replacement for it.
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) |
heartbeatIntervalMs | number | 240000 | How often to send ping; 0 disables the heartbeat — see Heartbeat |
heartbeatAckTimeoutMs | number | 15000 | How long to wait for pong before closing the socket and letting reconnect logic take over |
Methods
| Method | Signature | Behavior |
|---|---|---|
connect() | (): void | Opens the connection |
disconnect() | (): void | Closes the connection; suppresses auto-reconnect |
subscribe(channel, options?) | (channel: string, options?: {presence?, clientId?, since?}): void | Sends a subscribe message; remembers the channel (and options) so a future reconnect resubscribes automatically, requesting replay from the last seq actually seen |
unsubscribe(channel) | (channel: string): void | Sends an unsubscribe message; forgets the channel (and its tracked seq) |
getLastSeq(channel) | (channel: string): number | undefined | Highest seq seen on channel this session, or undefined if none yet — reconnects within the same page load resume from this automatically; persist it yourself (e.g. localStorage) to resume via since after a full page reload |
emit(channel, type, payload, token) | (channel: string, type: string, payload: unknown, token: string): void | Emits directly to channel over this connection using a scoped token — see Client-emit |
onMessage(handler) | (msg: {channel, payload, seq, replay?, type?, clientId?}) => void → unsubscribe fn | Fires for emitted messages on subscribed channels; replay: true if delivered from the replay buffer rather than live; type/clientId are present when the message came from client-emit |
onPresence(handler) | (event: {type: "presence", channel, event, ...}) => void → unsubscribe fn | Fires for join/leave/sync events on channels subscribed with presence: true — see Presence |
onReplayGap(handler) | (event: {type: "replay_gap", channel, since}) => void → unsubscribe fn | Fires when a replay request couldn't fully catch up — see Replay buffer |
onAck(handler) | (ack: {action, channel, ok, seq?}) => void → unsubscribe fn | Fires on subscribe/unsubscribe/emit acks; seq is present for emit |
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.
The heartbeat (see Heartbeat) is what makes "unexpected close"
actually fire for a zombie connection whose close frame never arrived: on a
missed pong, the client closes the socket itself, which triggers this same
reconnection path exactly as a server-initiated close would.
@foghorn/sdk/server
A separate, Node-only entry point — never import this from browser code, the
same way you'd never ship a secret key to one. Mints
scoped client-emit tokens. Dependency-free (only
node:crypto): token verification on the connection-plane side is an
independent implementation, not a shared import, so this stays safe to
depend on without pulling in anything else.
import { mintClientEmitToken } from "@foghorn/sdk/server";
mintClientEmitToken(options)
| Option | Type | Notes |
|---|---|---|
secret | string | The project's client-emit secret (from the dashboard, or PATCH /api/projects/:projectId/client-emit-secret) — never the public or secret API key |
projectId | string | — |
channel | string | The one channel this token authorizes emit on |
clientId | string | Must match the clientId the client will subscribe with (presence: true) — see Client-emit |
types | string[] | Allowlisted message-type tags, e.g. ["cursor", "selection"] — no bare "emit anything" |
ttlSeconds | number | Requested TTL; the connection plane also enforces its own hard ceiling (CLIENT_EMIT_TOKEN_MAX_TTL_SECONDS, default 600) regardless of what's requested here |
Returns the token string, to be passed straight to client.emit(channel, type, payload, token) in the browser.
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, seq, delivered, subscriberCount }
}
Emit directly from the browser (scoped client-emit)
// Server-side (Next.js route handler, etc.) — mints a token, never sends
// your clientEmitSecret itself to the browser.
import { mintClientEmitToken } from "@foghorn/sdk/server";
export async function POST(req: Request) {
const { channel, clientId } = await req.json();
const token = mintClientEmitToken({
secret: process.env.FOGHORN_CLIENT_EMIT_SECRET!,
projectId: process.env.FOGHORN_PROJECT_ID!,
channel,
clientId,
types: ["cursor", "selection", "focus", "blur"],
ttlSeconds: 300,
});
return Response.json({ token });
}
// Browser — reissue the token alongside your normal reconnect flow (it's
// short-lived by design), then emit without touching your backend per event.
client.subscribe("template:42", { presence: true, clientId: "user-123" });
const { token } = await fetch("/api/client-emit-token", {
method: "POST",
body: JSON.stringify({ channel: "template:42", clientId: "user-123" }),
}).then((r) => r.json());
document.addEventListener("mousemove", (e) => {
client.emit("template:42", "cursor", { x: e.clientX, y: e.clientY }, token);
});
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-23 — Heartbeat, in response to integrator
feedback: a new
ping/pongWS action (handled for free by the existing$defaultroute, no new AWS resources) lets@foghorn/sdkdetect a connection API Gateway's 10-minute idle timeout (or any mid-path drop) closed without ever delivering acloseframe — previously invisible, sincereadyStatestaysOPENon a connection like that indefinitely. The client pings everyheartbeatIntervalMs(default 4 min) and closes the socket itself if apongdoesn't arrive withinheartbeatAckTimeoutMs(default 15s), which triggers the existing reconnect path the same as any other unexpected close.disconnect.tsalso now logsdisconnectStatusCode/disconnectReason(previously nothing app-level was logged on$disconnectat all) and relays both fields on the"disconnect"webhook when API Gateway supplies them. - 2026-07-18 —
@foghorn/sdk/serverrevokeClientEmitToken: wraps the revoke endpoint so integrators never need a separate revoke URL — it derives one from the sameemitUrlalready used for backendemit()calls.FOGHORN_REVOKE_EMIT_TOKEN_URLis no longer a configured value anywhere (docs generation derives it the same way). Dashboard now shows the WebSocket/emit URLs and a client-emit-secret generator directly, rather than requiring a trip through this reference to find them. - 2026-07-18 —
@foghorn/sdkgetLastSeq(channel): reads the highestseqseen on a channel this session, so you can persist it yourself (localStorage, etc.) and resume viasinceafter a full page reload — previously there was no way to read this out of the client, only track it yourself inonMessage. - 2026-07-17 — Scoped client-emit, in response to
integrator feedback: browser clients can now
emit()directly to a channel over their existing WebSocket connection, authorized by a short-lived, channel-scoped,clientId-bound, type-allowlisted token (mintClientEmitTokenin the new@foghorn/sdk/serverentry point) — removes the app-server round trip for high-frequency, non-persisted signals (cursor/selection/focus/blur/identity) without touching the secret-key trust model. Includes a per-clientIdrate limit independent of the existing per-channel/project caps, and a revoke endpoint for invalidating a token (or all of aclientId's tokens) ahead of its TTL. - 2026-07-16 — Collaborative-editing feature set, in response to
integrator feedback: per-channel monotonic
seqon every emitted message; per-channel emit rate limits (with a project-wide backstop) replacing the old project-only limit, plusrate_limit/project_rate_limitvisibility on every emit response; opt-in presence (join/leave/sync) per subscription; a short-lived replay buffer (sinceon subscribe,replay_gapon expiry) with automatic reconnect support in@foghorn/sdk; optional signed connect/disconnect webhooks (best-effort, no retry queue), configurable from the project dashboard. - 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).