Getting started with foghorn
A step-by-step walkthrough for integrating foghorn — as a human reading this,
or as an AI agent building the integration on someone's behalf. For the
exhaustive API/error/limits reference, see reference.md
(or the rendered version at /docs/reference) — this guide is the "how do I
actually wire this up" version.
The four things to know first
- Project — your tenant. Everything (keys, channels, usage) is scoped to one.
- API key pair — every project gets a public key (
pk_live_...) and a secret key (sk_live_...), issued together, shown once. - Public key = client-side. Safe to ship to a browser. Can only connect and subscribe — never emit.
- Secret key = server-side only. Never send it to a browser. It's the only thing that can emit a message into a channel.
If you remember nothing else: the secret key never touches client code. Every example below reflects that split.
Step 1 — Create a project and get your keys
- Sign up / log in to the foghorn dashboard.
- Click "New project", give it a name.
- You'll see both keys exactly once — copy them now:
pk_live_...→ goes in your client-side app config.sk_live_...→ goes in your server's environment variables/secrets manager. Never in a.envfile that ships to the browser (e.g. never prefix itNEXT_PUBLIC_).
The WebSocket URL and emit URL are the same for every project on this deployment — only your keys differ. They're already filled in in the examples below; see Base URLs for the concrete values.
Step 2 — Client-side: connect and subscribe
This runs in the browser (or anywhere with a WebSocket global — a mobile
webview, Node with the ws polyfill, etc.). Only the public key is
involved here.
Install the SDK
npm install @foghorn/sdk
Connect and subscribe to a channel
import { FoghornClient } from "@foghorn/sdk";
const client = new FoghornClient({
url: "wss://y8ymlv721c.execute-api.us-east-1.amazonaws.com/prod",
publicKey: "pk_live_xxxxxxxx",
});
// Fires once the socket is open — including after an automatic reconnect,
// so re-subscribing here means you never lose your subscriptions.
client.onOpen(() => {
client.subscribe("room-1");
});
// Fires for every message emitted to a channel you're subscribed to.
client.onMessage((msg) => {
console.log(`[${msg.channel}]`, msg.payload);
});
client.connect();
That's the whole client integration. Reconnection with backoff and re-subscribing after a reconnect happen automatically — you don't write that logic yourself.
What you get in onMessage
Whatever was sent to the emit endpoint, verbatim:
client.onMessage((msg) => {
// msg.channel -> "room-1"
// msg.payload -> whatever JSON your backend sent (see Step 3)
// msg.seq -> per-channel monotonic counter, useful for detecting gaps
});
Cleaning up
client.unsubscribe("room-1"); // stop receiving messages on this channel
client.disconnect(); // close the socket, no auto-reconnect after this
Listening for problems
client.onClose(({ code, reason }) => {
// the socket dropped - the SDK will auto-reconnect unless you called disconnect()
});
client.onErrorMessage((err) => {
// the server rejected something you sent (bad channel name, etc.)
console.warn(err.error);
});
Full method-by-method reference: SDK reference.
Presence — who else is in this channel?
Opt-in per subscription: pass presence: true and a clientId (any string
you choose — a user ID, a tab ID, whatever identifies this connection to your
app) to subscribe(). Presence is not a channel-level setting — one client
subscribing with presence: true doesn't turn it on for anyone else; each
client opts in independently.
client.subscribe("template:42", { presence: true, clientId: "user-123" });
client.onPresence((event) => {
if (event.event === "sync") {
// members: string[] — everyone else in the channel with presence enabled,
// sent once, right after you subscribe.
console.log("currently in the room:", event.members);
} else {
// event.event is "join" or "leave", event.clientId is who
console.log(event.event, event.clientId);
}
});
There's no per-user metadata beyond clientId — attach your own payload
(cursor position, display name, whatever) by emitting it yourself on the same
channel; presence only tracks membership, not application state.
Presence events don't count against your project's emit rate limit (they're
not developer-initiated emits), and unsubscribing or disconnecting (including
an unclean tab close/network drop, once API Gateway's $disconnect fires)
triggers a leave for every presence-enabled channel that connection was in.
Client-emit — let the browser emit directly
Presence tells you who's in the room, but attaching your own data to it (cursor position, current selection, focus/blur) normally means calling your own backend for every mouse move, which stops scaling once you have a lot of concurrent viewers. Client-emit is the escape hatch: the browser emits directly over its WebSocket connection, no backend round trip per event.
It's not the secret key. A client-emit token is short-lived and scoped
to exactly one channel, one clientId, and a list of message types you
choose — a client holding one can't emit anywhere else, under any other
identity, or with any other message type. Document edits and anything that
needs a durable, revision-checked write still go through the emit endpoint
from Step 3 below, with the secret key — this is only for the ephemeral
stuff.
Mint a token on your server (never in browser code):
// e.g. a Next.js route handler your client calls after it knows its own clientId
import { mintClientEmitToken } from "@foghorn/sdk/server";
const token = mintClientEmitToken({
secret: process.env.FOGHORN_CLIENT_EMIT_SECRET!, // from the dashboard
projectId: process.env.FOGHORN_PROJECT_ID!,
channel: "template:42",
clientId: "user-123", // same clientId this connection subscribes with
types: ["cursor", "selection"], // this token can emit these, and only these
ttlSeconds: 300,
});
Emit from the browser, once subscribed with presence under the same clientId:
client.subscribe("template:42", { presence: true, clientId: "user-123" });
document.addEventListener("mousemove", (e) => {
client.emit("template:42", "cursor", { x: e.clientX, y: e.clientY }, token);
});
Everyone else subscribed to template:42 gets it in onMessage, same as a
backend-emitted message, plus type and clientId so you know it's a
cursor event and whose it is:
client.onMessage((msg) => {
if (msg.type === "cursor") {
renderCursor(msg.clientId, msg.payload);
}
});
A few things worth knowing before you reach for this:
- The token requires presence. The connection must already be
subscribed to the channel with
presence: trueunder the exactclientIdthe token was minted for — a valid token alone isn't enough to claim someone else's identity. - Reissue tokens on your normal reconnect cadence. They're deliberately short-lived (10 minutes max, regardless of what you request) — mint a fresh one whenever you'd otherwise be reconnecting or refreshing a session.
- Revoke on logout/ban. Call
revokeClientEmitToken(also from@foghorn/sdk/server) with ajtiorclientIdto invalidate emit rights immediately instead of waiting out the TTL. It reuses yourFOGHORN_EMIT_URL— no separate endpoint to configure. - Rate limits stack. Client-emitted messages count against the
channel's normal budget and a smaller per-
clientIdcap (15/sec by default) that protects the channel from one misbehaving client — you don't need to think about this unless you're hitting429s.
Full details: Client-emit reference.
Reconnects and the replay buffer
Every emitted message is held in a short-lived buffer (20 seconds by
default) and stamped with seq. The SDK tracks the highest seq it's seen
per channel and automatically requests replay from that point whenever it
reconnects — you don't write any of this yourself:
client.onMessage((msg) => {
if (msg.replay) {
// delivered from the buffer during a reconnect, not live
}
});
client.onReplayGap((event) => {
// the buffer had already expired past what we needed — some messages
// between event.since and now are gone for good. Fall back to a full
// resync over your own REST API instead of trusting the channel alone.
refetchFullState(event.channel);
});
This makes brief reconnects (a network blip, a tab backgrounding) cheap —
you don't lose messages sent during the gap. It does not make the channel
a durable log: the buffer is seconds-scale, not minutes, and a client that's
been gone longer than that will get a replay_gap and needs its own resync
path regardless. Treat the replay buffer as smoothing over short drops, not
as a substitute for your own periodic full-state resync as a backstop.
That automatic tracking only lasts for the life of the FoghornClient
instance — a full page reload starts a fresh one with no memory of what
seq you'd last seen. If you want replay to survive a reload too, read it
out yourself before the page unloads and pass it back in:
const seq = client.getLastSeq("room-1");
if (seq !== undefined) localStorage.setItem("room-1:seq", String(seq));
// ...on the next page load:
const saved = localStorage.getItem("room-1:seq");
client.subscribe("room-1", saved ? { since: Number(saved) } : undefined);
Step 3 — Server-side: emit a message
This runs on your backend only — wherever your server-side code lives.
The secret key is required here, and this is a plain HTTP POST, so it
works from any language, not just JavaScript.
The request
POST https://q19yjb9xd0.execute-api.us-east-1.amazonaws.com/prod/emit
Authorization: Bearer sk_live_xxxxxxxx
Content-Type: application/json
{ "channel": "room-1", "payload": { "text": "hello from the backend" } }
Node / TypeScript example
async function notifyRoom(channel: string, payload: unknown) {
const res = await fetch(process.env.FOGHORN_EMIT_URL as string, {
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 }
}
await notifyRoom("room-1", { text: "hello from the backend" });
From any other language
It's a plain HTTP POST with a JSON body and a bearer token — call it however
your language calls HTTP. Example with curl, useful for testing:
curl -X POST "$FOGHORN_EMIT_URL" \
-H "authorization: Bearer $FOGHORN_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"channel":"room-1","payload":{"text":"hello from curl"}}'
Reading the response
{ "ok": true, "channel": "room-1", "delivered": 3, "subscriberCount": 3 }
subscriberCount is how many connections were subscribed at emit time;
delivered is how many actually received it (can be lower if a connection
had already dropped — foghorn cleans those up automatically, no action
needed on your end).
Full request/response/error details: HTTP API reference.
Putting both sides together
A typical flow: your backend does something (a job finishes, a comment gets posted, a price changes), calls the emit endpoint, and every browser tab subscribed to that channel updates instantly.
┌─────────────┐ POST /emit (secret key) ┌──────────────┐
│ Your │ ─────────────────────────────────────▶ │ foghorn │
│ backend │ │ connection │
└─────────────┘ │ plane │
└──────┬───────┘
│ pushes to every
│ subscribed connection
▼
┌──────────────┐
│ Browser │
│ (public key, │
│ @foghorn/sdk)│
└──────────────┘
Nothing in the middle needs your attention — no polling, no manual fan-out logic. Subscribe once on the client, emit once on the server.
Next steps
- Full API/protocol reference — every endpoint, every error, exact limits
- Recipes — reconnection handling, rotating a compromised key, checking subscriber counts
- Changelog — what's shipped and when