foghorn

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

  1. Sign up / log in to the foghorn dashboard.
  2. Click "New project", give it a name.
  3. 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 .env file that ships to the browser (e.g. never prefix it NEXT_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: true under the exact clientId the 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 a jti or clientId to invalidate emit rights immediately instead of waiting out the TTL. It reuses your FOGHORN_EMIT_URL — no separate endpoint to configure.
  • Rate limits stack. Client-emitted messages count against the channel's normal budget and a smaller per-clientId cap (15/sec by default) that protects the channel from one misbehaving client — you don't need to think about this unless you're hitting 429s.

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