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 or resolve conflicts, and its replay buffer is a seconds-scale reconnect aid, not durable message storage. See Non-goals below.

Contents

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

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

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, emit, ping. Any other value gets an unknown_action error (see below).

Client → server messages

actionFieldsEffect
subscribechannel: string, presence?: boolean, clientId?: string, since?: numberSubscribes this connection to channel. If presence: true, clientId is required — see Presence. If since is set, replays buffered messages first — see Replay buffer
unsubscribechannel: stringUnsubscribes this connection from channel
emitchannel: string, type: string, payload: unknown, token: stringEmits directly to channel over this connection, authorized by a scoped token — see Client-emit (scoped)
pingnoneLiveness 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" }
  • sync is 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/leave are 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; leave fires on $disconnect the same as an explicit unsubscribe).
  • Presence is per-subscription, not per-channel: connections that didn't opt in with presence: true neither appear in members nor receive join/leave broadcasts.
  • 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 $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).
  • When $disconnect does fire, foghorn logs disconnectStatusCode / disconnectReason (as sent by API Gateway) alongside connectionId and projectId — 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, $disconnect may 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 $disconnect does 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" }
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").

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.

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

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

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

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

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/rename 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
channel_rate_limited429emit endpointExceeds that channel's own emit rate limit
project_rate_limited429emit endpointExceeds the project-wide backstop (summed across all channels)
unknown_action— (WS message, not HTTP)WS subscribe/unsubscribe/emitaction isn't subscribe/unsubscribe/emit
client_id_required— (WS message, not HTTP)WS subscribepresence: true sent without a clientId
invalid_since— (WS message, not HTTP)WS subscribesince was sent but isn't a number
token_and_type_required— (WS message, not HTTP)WS emitemit sent without a token or a type
client_emit_not_configured— (WS message, not HTTP)WS emitProject has no clientEmitSecret generated yet
invalid_emit_token— (WS message, not HTTP)WS emitToken's signature, exp, pid, ch, or types allowlist didn't check out
emit_identity_mismatch— (WS message, not HTTP)WS emitThis connection isn't subscribed with presence: true under the token's clientId
emit_token_revoked— (WS message, not HTTP)WS emitToken's jti or clientId was revoked — see revoke endpoint
client_emit_rate_limited— (WS message, not HTTP)WS emitThis clientId exceeded its per-second cap
payload_too_large (WS)— (WS message, not HTTP)WS emitSerialized {channel,payload} exceeds 128 KiB
channel_rate_limited (WS)— (WS message, not HTTP)WS emitSame per-channel budget as the HTTP emit endpoint
project_rate_limited (WS)— (WS message, not HTTP)WS emitSame project-wide backstop as the HTTP emit endpoint
jti_or_client_id_required400revoke endpointNeither jti nor clientId was sent
(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 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 projectemit Lambda, PROJECT_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)
Replay buffer window20 seconds by defaultREPLAY_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 aboveWS emit action, CLIENT_EMIT_RATE_LIMIT_PER_SECOND env var — see Client-emit
Client-emit token max TTL600 seconds (10 minutes) by default, enforced at verification time regardless of what was requested at mint timeCLIENT_EMIT_TOKEN_MAX_TTL_SECONDS env var
WebSocket idle timeout10 minutes of no data in either direction — a fixed API Gateway platform ceiling, not configurable by foghornAWS API Gateway; see Heartbeat for how @foghorn/sdk avoids it
SDK heartbeat interval240 seconds (4 min) by default, comfortably under the 10-minute idle ceiling above@foghorn/sdk, heartbeatIntervalMs client option — see Heartbeat
SDK heartbeat ack timeout15 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:

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). 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)

OptionTypeDefaultNotes
urlstring— requiredwss://... connection URL, no query string
publicKeystring— requiredpk_live_...
reconnectbooleantrueAuto-reconnect with backoff on unexpected close
maxReconnectDelayMsnumber30000Cap on exponential backoff (with jitter)
heartbeatIntervalMsnumber240000How often to send ping; 0 disables the heartbeat — see Heartbeat
heartbeatAckTimeoutMsnumber15000How long to wait for pong before closing the socket and letting reconnect logic take over

Methods

MethodSignatureBehavior
connect()(): voidOpens the connection
disconnect()(): voidCloses the connection; suppresses auto-reconnect
subscribe(channel, options?)(channel: string, options?: {presence?, clientId?, since?}): voidSends 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): voidSends an unsubscribe message; forgets the channel (and its tracked seq)
getLastSeq(channel)(channel: string): number | undefinedHighest 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): voidEmits directly to channel over this connection using a scoped token — see Client-emit
onMessage(handler)(msg: {channel, payload, seq, replay?, type?, clientId?}) => void → unsubscribe fnFires 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 fnFires for join/leave/sync events on channels subscribed with presence: true — see Presence
onReplayGap(handler)(event: {type: "replay_gap", channel, since}) => void → unsubscribe fnFires when a replay request couldn't fully catch up — see Replay buffer
onAck(handler)(ack: {action, channel, ok, seq?}) => void → unsubscribe fnFires on subscribe/unsubscribe/emit acks; seq is present for emit
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.

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)

OptionTypeNotes
secretstringThe project's client-emit secret (from the dashboard, or PATCH /api/projects/:projectId/client-emit-secret) — never the public or secret API key
projectIdstring
channelstringThe one channel this token authorizes emit on
clientIdstringMust match the clientId the client will subscribe with (presence: true) — see Client-emit
typesstring[]Allowlisted message-type tags, e.g. ["cursor", "selection"] — no bare "emit anything"
ttlSecondsnumberRequested 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-23Heartbeat, in response to integrator feedback: a new ping/pong WS action (handled for free by the existing $default route, no new AWS resources) lets @foghorn/sdk detect a connection API Gateway's 10-minute idle timeout (or any mid-path drop) closed without ever delivering a close frame — previously invisible, since readyState stays OPEN on a connection like that indefinitely. The client pings every heartbeatIntervalMs (default 4 min) and closes the socket itself if a pong doesn't arrive within heartbeatAckTimeoutMs (default 15s), which triggers the existing reconnect path the same as any other unexpected close. disconnect.ts also now logs disconnectStatusCode/disconnectReason (previously nothing app-level was logged on $disconnect at all) and relays both fields on the "disconnect" webhook when API Gateway supplies them.
  • 2026-07-18@foghorn/sdk/server revokeClientEmitToken: wraps the revoke endpoint so integrators never need a separate revoke URL — it derives one from the same emitUrl already used for backend emit() calls. FOGHORN_REVOKE_EMIT_TOKEN_URL is 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/sdk getLastSeq(channel): reads the highest seq seen on a channel this session, so you can persist it yourself (localStorage, etc.) and resume via since after a full page reload — previously there was no way to read this out of the client, only track it yourself in onMessage.
  • 2026-07-17Scoped 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 (mintClientEmitToken in the new @foghorn/sdk/server entry 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-clientId rate limit independent of the existing per-channel/project caps, and a revoke endpoint for invalidating a token (or all of a clientId's tokens) ahead of its TTL.
  • 2026-07-16 — Collaborative-editing feature set, in response to integrator feedback: per-channel monotonic seq on every emitted message; per-channel emit rate limits (with a project-wide backstop) replacing the old project-only limit, plus rate_limit/project_rate_limit visibility on every emit response; opt-in presence (join/leave/sync) per subscription; a short-lived replay buffer (since on subscribe, replay_gap on 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/sdk client with reconnect/backoff and auto-resubscribe (Phase 7); this reference (Phase 8).