# Documentation Thunderclap delivers realtime events to your users over WebSockets. Your frontend subscribes to channels with `@thunderclapjs/client` (thunderclapJS), and your backend publishes to them and authorizes private channels with `@thunderclapjs/server`. Both are open source (MIT) and have no dependencies. | Piece | Runs in | Uses | | --- | --- | --- | | `@thunderclapjs/client` | Browsers, React Native, Node.js 22+, Deno, Bun | Public key (`THUNDERCLAP_KEY`) | | `@thunderclapjs/server` | Your backend: Node.js, edge runtimes, anything with fetch and Web Crypto | App id and secret (`THUNDERCLAP_APP_ID`, `THUNDERCLAP_SECRET`) | | Dashboard | thunderclap.dev/app | Apps, keys, webhooks and a debug console | ## Getting started - [Quickstart](https://thunderclap.dev/docs/quickstart.md): From an app's keys to your first event, in five minutes. ## Guides - [Channels](https://thunderclap.dev/docs/guides/channels.md): Subscribe, publish, name and type channels. - [Private channels](https://thunderclap.dev/docs/guides/private-channels.md): Grant channels with connection tokens or per-channel authorization. - [Presence](https://thunderclap.dev/docs/guides/presence.md): Know who is in a channel. - [Client events](https://thunderclap.dev/docs/guides/client-events.md): Let subscribers message each other directly. - [Cache channels](https://thunderclap.dev/docs/guides/cache-channels.md): Give late joiners the latest state. - [Missed messages](https://thunderclap.dev/docs/guides/missed-messages.md): Sequence numbers, gaps and backfilling. - [Connection](https://thunderclap.dev/docs/guides/connection.md): States, reconnects and excluding the sender. - [Keys and secrets](https://thunderclap.dev/docs/guides/keys.md): Credentials, environments and rotation. - [Webhooks](https://thunderclap.dev/docs/guides/webhooks.md): Get told what happens on your channels. - [Local development](https://thunderclap.dev/docs/guides/local-development.md): Run Thunderclap in Docker for development, tests and CI. - [AI agents](https://thunderclap.dev/docs/guides/ai-agents.md): Give coding agents the docs: an MCP server, Markdown pages and llms.txt. - [Migrating from Pusher](https://thunderclap.dev/docs/guides/migrate-from-pusher.md): Change the host, then move at your own pace. ## Reference - [@thunderclapjs/client](https://thunderclap.dev/docs/reference/client.md): Thunderclap, Channel and PresenceChannel. - [@thunderclapjs/server](https://thunderclap.dev/docs/reference/server.md): ThunderclapServer and its errors. - [Webhook events](https://thunderclap.dev/docs/reference/webhooks.md): Payloads, event types and signatures. - [Error codes](https://thunderclap.dev/docs/reference/errors.md): What each code means and what to do. - [Limits](https://thunderclap.dev/docs/reference/limits.md): Sizes, counts and rates. --- # Quickstart Connect a browser to a channel and publish an event to it from your server. You need an app's credentials and Node.js 22 or later. ### Get your app's credentials [Sign in](https://thunderclap.dev/sign-in) and create an organisation. It comes with a first app, called Production, and shows its credentials once. Put them in your backend's environment: ```dotenv THUNDERCLAP_APP_ID=app_... THUNDERCLAP_KEY=pk_... THUNDERCLAP_SECRET=sk_... ``` The public key is safe in client code. The secret signs tokens and publishes events: keep it on your server and never ship it to browsers. Bundlers only expose variables with their prefix to the browser, so name the public key accordingly (`NEXT_PUBLIC_THUNDERCLAP_KEY` with Next.js, `VITE_THUNDERCLAP_KEY` with Vite). ### Install the SDKs ```sh npm install @thunderclapjs/client # in your frontend npm install @thunderclapjs/server # in your backend ``` ### Subscribe in the browser The client connects as soon as it's created, and keeps its subscriptions across network drops and reconnects. ```ts title="client.ts" import { Thunderclap } from "@thunderclapjs/client"; const thunderclap = new Thunderclap({ key: process.env.NEXT_PUBLIC_THUNDERCLAP_KEY }); const room = thunderclap.subscribe("room-42"); room.bind("message", (data, meta) => { console.log("new message", data, "sequence", meta.seq); }); ``` ### Publish from your server ```ts title="server.ts" import { ThunderclapServer } from "@thunderclapjs/server"; const thunderclap = new ThunderclapServer({ appId: process.env.THUNDERCLAP_APP_ID, secret: process.env.THUNDERCLAP_SECRET, }); await thunderclap.publish({ channel: "room-42", data: { text: "Hello!", author: "ada" }, }); ``` Every subscriber of `room-42` receives the event. Without an `event` name, it's called `message`, the one the client binds above. Watch it arrive in the dashboard's **Debug** tab, which can also publish test events. ## Next steps - [Private channels](https://thunderclap.dev/docs/guides/private-channels.md): restrict who can subscribe. - [Presence](https://thunderclap.dev/docs/guides/presence.md): show who is online. - [Missed messages](https://thunderclap.dev/docs/guides/missed-messages.md): backfill what a client missed while offline. - [API reference](https://thunderclap.dev/docs/reference/client.md): every option and method. --- # Channels A channel is a named stream of events. Clients subscribe to channels, and your server publishes events to them. Channels exist as soon as someone uses them: there is nothing to create. ## Channel types The prefix of a channel's name decides what it does: | Prefix | Type | Who can subscribe | | --- | --- | --- | | none | Public | Anyone with your public key | | `private-` | [Private](https://thunderclap.dev/docs/guides/private-channels.md) | Clients holding a token your server issued | | `presence-` | [Presence](https://thunderclap.dev/docs/guides/presence.md) | Like private, and members see each other | | `cache-`, `private-cache-`, `presence-cache-` | [Cache](https://thunderclap.dev/docs/guides/cache-channels.md) | As above, and new subscribers get the last event | Names use letters, digits and `_ - = @ , . ;`, from 1 to 164 characters. ## Subscribe ```ts title="client.ts" const room = thunderclap.subscribe("room-42"); room.bind("message", (data, meta) => render(data)); room.bindAll((event, data) => console.log(event, data)); // every event room.on("subscribed", () => console.log("ready")); room.on("error", ({ code, message }) => console.error(code, message)); thunderclap.unsubscribe("room-42"); ``` `bind` and `bindAll` return a function that removes the listener. Subscriptions made before the connection is ready are sent once it is, and every subscription is restored after a reconnect. ### Typed events With TypeScript, describe a channel's events and the data each one carries: ```ts title="client.ts" type RoomEvents = { message: { text: string; author: string }; typing: { user: string } }; const room = thunderclap.subscribe("room-42"); room.bind("message", (data) => data.text); // data is { text: string; author: string } ``` ## Publish ```ts title="server.ts" await thunderclap.publish({ channel: "room-42", data: { text: "Hello!" } }); ``` Each event has a name, which clients `bind` to. It's `message` unless you pass `event`: ```ts title="server.ts" await thunderclap.publish({ channel: "room-42", event: "typing", data: { user: "ada" } }); ``` A publish counts once however many subscribers receive it. Data must be JSON-serializable, up to 10 KB. Event names are up to 200 characters, and names starting with `tc:` are reserved. ### Several channels, several events Publish one event to up to 100 channels, or up to 10 events in order in one request: ```ts title="server.ts" await thunderclap.publish({ channel: ["room-42", "room-43"], event: "announcement", data }); await thunderclap.batch([ { channel: "room-42", data: { text: "first" } }, { channel: "room-42", data: { text: "second" } }, ]); ``` ### Sequence numbers Each publish returns the sequence number the event got on each channel: ```json { "seq": { "room-42": [1, 128] } } ``` Store it with your own copy of the message: it's how clients [backfill what they missed](https://thunderclap.dev/docs/guides/missed-messages.md). --- # Private channels Channels whose names start with `private-` need a token issued by your backend. A token lists the channels it grants: exact names, or prefixes ending in `*` (`private-team-7-*`). Your secret never leaves your server: the token is signed with it. There are two ways to hand out tokens. Use a **connection token** when you know a user's channels up front, and **per-channel authorization** when access is decided channel by channel. They combine: thunderclapJS asks for a per-channel token only when the connection token doesn't cover a channel. ## Connection tokens Your backend issues a token for the signed-in user, and thunderclapJS fetches it on every connect and reconnect: ```ts title="server.ts" app.get("/api/realtime-token", requireUser, async (req, res) => { const token = await thunderclap.token({ uid: req.user.id, channels: [`private-user-${req.user.id}`, "private-team-7-*"], ttlSeconds: 3600, }); res.send(token); }); ``` ```ts title="client.ts" const thunderclap = new Thunderclap({ key: process.env.NEXT_PUBLIC_THUNDERCLAP_KEY, token: () => fetch("/api/realtime-token").then((r) => r.text()), }); thunderclap.subscribe("private-user-7"); ``` ## Per-channel authorization For channels the connection token doesn't cover, thunderclapJS calls `authorize`, and your backend checks access to that one channel: ```ts title="client.ts" const thunderclap = new Thunderclap({ key: process.env.NEXT_PUBLIC_THUNDERCLAP_KEY, authorize: ({ channel, socketId }) => fetch("/api/realtime-authorize", { method: "POST", body: JSON.stringify({ channel, socketId }) }) .then((r) => r.text()), }); ``` ```ts title="server.ts" app.post("/api/realtime-authorize", requireUser, async (req, res) => { const { channel } = req.body; if (!(await canJoin(req.user, channel))) return res.sendStatus(403); res.send(await thunderclap.token({ uid: req.user.id, channels: [channel], ttlSeconds: 300 })); }); ``` ## Expiry > Tokens are checked when a client connects or subscribes. An expired token doesn't disconnect the channels it already joined; the client fetches a fresh one on its next reconnect. A refused subscription emits `error` on the channel with code `4302`. See [error codes](https://thunderclap.dev/docs/reference/errors.md). --- # Presence Channels whose names start with `presence-` are [private channels](https://thunderclap.dev/docs/guides/private-channels.md) that also know who is in them. Use them for "who's online", avatars in a document, or player lists. ## Identify members The token that grants a presence channel must carry the user's id (`uid`), and can carry public `info` that the other members see: ```ts title="server.ts" const token = await thunderclap.token({ uid: req.user.id, info: { name: req.user.name, avatar: req.user.avatarUrl }, channels: ["presence-room-*"], }); ``` `info` is at most 1 KB as JSON. A user connected from several tabs or devices counts as one member. ## Track members ```ts title="client.ts" const room = thunderclap.subscribe("presence-room-42"); room.on("subscribed", () => { console.log("me", room.me, "everyone", [...room.members.values()]); }); room.on("member_added", (member) => console.log(member.info?.name, "joined")); room.on("member_removed", (member) => console.log(member.info?.name, "left")); ``` - `members` is a `Map` keyed by user id, and includes the current user (`room.me`). - After a reconnect, the joins and leaves the client missed are reported as `member_added` and `member_removed`. - Presence changes don't use sequence numbers, so they never cause a gap. ## Limits A presence channel holds up to 100 members. Beyond that, new members are refused with error `4305`. To mirror presence in your own database, use the `member_added` and `member_removed` [webhooks](https://thunderclap.dev/docs/guides/webhooks.md). --- # Client events Subscribers of private and presence channels can send events straight to the channel's other subscribers, without going through your backend: typing indicators, cursors, reactions. ## Enable them Client events are off by default. Switch them on per app in the dashboard, under **Apps & keys**. The change applies to connections opened afterwards. ## Send and receive ```ts title="client.ts" const room = thunderclap.subscribe("private-room-42"); room.trigger("client-typing", { user: "ada" }); room.bind("client-typing", (data, meta) => { showTyping(data.user, meta.userId); // userId is set on presence channels }); ``` - Names must start with `client-`. - The channel must be private or presence, and subscribed. - `trigger` returns `false` when the event can't be sent (not subscribed, public channel, invalid name). - The sender doesn't receive its own event. ## Rules | Rule | Value | | --- | --- | | Channels | Private and presence only | | Rate | 10 events per second per connection | | Size | Up to 10 KB of data | | Sequence numbers | None: a missed client event is never reported as a gap | | Cache channels | Client events are never cached | Refused events are reported as an `error` on the channel with code `4306`. Your backend can receive every client event through the `client_event` [webhook](https://thunderclap.dev/docs/guides/webhooks.md). --- # Cache channels A cache channel keeps its last event for 30 minutes, and a new subscriber receives it straight away. Late joiners see the current state without asking your backend: a score, a room's topic, a price. ## Names | Prefix | Works like | | --- | --- | | `cache-` | A public channel | | `private-cache-` | A [private channel](https://thunderclap.dev/docs/guides/private-channels.md) | | `presence-cache-` | A [presence channel](https://thunderclap.dev/docs/guides/presence.md) | ## Use them ```ts title="client.ts" const score = thunderclap.subscribe("cache-match-7"); score.bind("score", (data) => render(data)); // also called with the cached event score.on("cache_miss", () => requestFreshScore()); // nothing cached yet ``` The cached event arrives just before `subscribed`. When nothing is cached, the channel emits `cache_miss` instead, and your server can publish a fresh value. Only events published by your server are cached, never [client events](https://thunderclap.dev/docs/guides/client-events.md). To be told on your server when a subscriber finds a cache channel empty, use the `cache_miss` [webhook](https://thunderclap.dev/docs/guides/webhooks.md). --- # Missed messages Thunderclap doesn't store messages: your app keeps its own history. Instead, every event carries a sequence number, and when a client may have missed events (for example after being offline), the channel reports a **gap** so you can fetch them from your own backend. ## Sequence numbers A sequence is `[epoch, n]`. `n` grows by one with each event published on the channel. The epoch changes when the channel's counter restarts, which happens rarely. - Your server gets each event's sequence back from `publish`: store it with the message. - Clients get it as `meta.seq` with every event, and `channel.lastSeq` holds the last one received. ## Handle gaps ```ts title="client.ts" room.onGap((gap) => { if (gap.reset) { // The channel restarted: reload recent history from your API. } else { // Events gap.from to gap.to (inclusive) were missed: fetch them from your API. } }); ``` | Gap | Means | Do | | --- | --- | --- | | `{ from, to }` | Sequences `from` to `to` were not received | Fetch those events from your store | | `{ reset: true }` | The channel's counter restarted | Reload recent history | Duplicate events are dropped automatically. Presence changes and [client events](https://thunderclap.dev/docs/guides/client-events.md) have no sequence, so they never cause a gap. --- # Connection thunderclapJS manages one WebSocket per client. It reconnects on its own with exponential backoff, and resubscribes every channel when it does. ## States ```ts title="client.ts" thunderclap.on("state", ({ previous, current }) => console.log(previous, "→", current)); thunderclap.on("error", ({ code, message }) => console.error(code, message)); ``` | State | Means | | --- | --- | | `initialized` | Created with `autoConnect: false`, not connected yet | | `connecting` | Opening the connection | | `connected` | Ready: subscriptions are active | | `reconnecting` | Lost the connection; a reconnect is scheduled | | `closed` | Closed by `disconnect()` | | `failed` | Refused for good, e.g. an unknown key or a suspended app: no automatic reconnect | Call `connect()` to connect after `autoConnect: false` or `disconnect()`, or to retry now instead of waiting for the backoff. See [error codes](https://thunderclap.dev/docs/reference/errors.md) for what makes a connection fail. ## Excluding the sender When a user's action triggers a broadcast, they usually shouldn't receive their own echo. Send `thunderclap.socketId` to your backend with the request, and pass it as `exclude`: ```ts title="client.ts" await fetch("/api/messages", { method: "POST", body: JSON.stringify({ text, socketId: thunderclap.socketId }), }); ``` ```ts title="server.ts" await thunderclap.publish({ channel: "room-42", data, exclude: req.body.socketId }); ``` ## Custom domains and local development The SDKs connect to `https://realtime.thunderclap.dev` unless you pass `url`. Set it only for a custom domain, for example from `THUNDERCLAP_URL`. --- # Keys and secrets Each app has three credentials. Keep them in environment variables, with these names: ```dotenv THUNDERCLAP_APP_ID=app_... THUNDERCLAP_KEY=pk_... THUNDERCLAP_SECRET=sk_... ``` | Variable | What it is | Where it goes | | --- | --- | --- | | `THUNDERCLAP_APP_ID` | Identifies the app | Your server | | `THUNDERCLAP_KEY` | Public key, for clients to connect | Your frontend (with your bundler's prefix) | | `THUNDERCLAP_SECRET` | Signs tokens and authenticates publishes | Your server only, never a browser | | `THUNDERCLAP_URL` | Optional: a custom domain | Both, only if you use one | ## Environments Use one app per environment, for example staging and production. Each has its own credentials and channels, so test traffic never reaches real users. ## Secrets are shown once The dashboard shows a secret only when it's created, and stores it encrypted. If you lose it, create a new one and revoke the old one. ## Rotate without downtime ### Create a second secret In the dashboard, under **Apps & keys**, choose **New secret**. An app has at most two active secrets, and both work. ### Deploy it Update `THUNDERCLAP_SECRET` on your backend and deploy. ### Revoke the old one Revoke the previous secret. It stops working within about a minute. Webhooks are signed with every active secret, so they verify throughout the rotation. --- # Webhooks Webhooks tell your backend what happens on your channels: when a channel gets its first subscriber or loses its last, when a user joins or leaves a presence channel, when a client sends an event, and when a subscriber finds a cache channel empty. Apps rely on them to mirror presence and occupancy in their own store. ## Add an endpoint In the dashboard's **Webhooks** tab (Starter plan and up), add up to 10 `https://` endpoints per app and choose the kinds of events each one receives. Changes apply within a minute. | Kind | Events | Sent | | --- | --- | --- | | Channel existence | `channel_occupied`, `channel_vacated` | When a channel gets its first subscriber, and when it has had none for 5 seconds | | Presence | `member_added`, `member_removed` | When a user joins a presence channel, and when their last connection leaves it | | Client events | `client_event` | For every client event. Off unless you choose it | | Cache misses | `cache_miss` | When a subscriber finds a cache channel empty, once until the next event | Every request is a `POST` of JSON carrying one or more events. See [webhook events](https://thunderclap.dev/docs/reference/webhooks.md) for each event's fields. ## Verify requests Verify every request with `@thunderclapjs/server` before trusting it. Pass the raw body, exactly as received, and the `Thunderclap-Signature` header; you get the events back, typed. ```ts title="server.ts" import { ThunderclapWebhookError } from "@thunderclapjs/server"; app.post("/thunderclap/webhooks", express.raw({ type: "application/json" }), async (req, res) => { try { const { events } = await thunderclap.verifyWebhook({ body: req.body, signature: req.get("Thunderclap-Signature"), }); for (const event of events) { if (event.type === "member_added") await markOnline({ user: event.userId, room: event.channel }); if (event.type === "member_removed") await markOffline({ user: event.userId, room: event.channel }); } res.sendStatus(200); } catch (err) { res.sendStatus(err instanceof ThunderclapWebhookError ? 401 : 500); } }); ``` With a standard `Request` (Next.js route handlers, Cloudflare Workers, Bun, Deno), it's one call: ```ts title="app/api/thunderclap/route.ts" export async function POST(request: Request) { const { events } = await thunderclap.verifyWebhookRequest(request); // ... return new Response(null, { status: 200 }); } ``` ## Delivery | Behaviour | Detail | | --- | --- | | Signatures | Each request is signed with every active secret, so it verifies during a [rotation](https://thunderclap.dev/docs/guides/keys.md#rotate-without-downtime) | | Replays | Requests signed more than 5 minutes earlier are refused by `verifyWebhook` | | Timeout | Answer with a `2xx` status within 10 seconds | | Retries | Failed requests are retried with increasing delays, up to 10 times | | Duplicates | A retry can deliver an event twice: skip events whose `id` you've already handled | | Order | Events can arrive out of order: rely on each event's `time` | ## Migrating from Pusher An endpoint can use Pusher's format instead, so an existing Pusher webhook handler keeps working. See [Migrating from Pusher](https://thunderclap.dev/docs/guides/migrate-from-pusher.md#webhooks). --- # Local development Thunderclap Local is the Thunderclap engine in a Docker image. Run it on your machine, in your tests and in CI instead of the managed service: it speaks the same protocol, so your code stays the same and only the address and credentials change. It works offline, and nothing you do with it counts towards your plan. > **For development, tests and CI only** > > Thunderclap Local is free to use while you build and test, for companies too, but its [licence](https://thunderclap.dev/legal/local) doesn't allow production: anything that serves real users runs on the managed service. ## Run it ```sh docker run --rm -p 6001:6001 ghcr.io/bside-tech/thunderclap-dev ``` It starts with a default app and prints its credentials: ```dotenv THUNDERCLAP_APP_ID=local THUNDERCLAP_KEY=local-key THUNDERCLAP_SECRET=local-secret THUNDERCLAP_URL=http://localhost:6001 ``` The default app has [client events](https://thunderclap.dev/docs/guides/client-events.md) switched on. The image runs on Intel and ARM machines, Apple silicon included. ## Point the SDKs at it Both SDKs take a `url`. Without one they connect to the managed service, so set `THUNDERCLAP_URL` in your development and test environments only, and the same code reaches production with production's credentials. ```ts title="client.ts" import { Thunderclap } from "@thunderclapjs/client"; const thunderclap = new Thunderclap({ key: process.env.NEXT_PUBLIC_THUNDERCLAP_KEY, url: process.env.NEXT_PUBLIC_THUNDERCLAP_URL, // http://localhost:6001 in development }); ``` ```ts title="server.ts" import { ThunderclapServer } from "@thunderclapjs/server"; const thunderclap = new ThunderclapServer({ appId: process.env.THUNDERCLAP_APP_ID, secret: process.env.THUNDERCLAP_SECRET, url: process.env.THUNDERCLAP_URL, }); ``` Everything else works as in the other guides: channels, [private channels](https://thunderclap.dev/docs/guides/private-channels.md) and their tokens, [presence](https://thunderclap.dev/docs/guides/presence.md), [cache channels](https://thunderclap.dev/docs/guides/cache-channels.md), [missed messages](https://thunderclap.dev/docs/guides/missed-messages.md) and [webhooks](#webhooks). ## Configure it | Variable | Default | What it sets | | --- | --- | --- | | `THUNDERCLAP_APPS` | The default app | The apps it serves, as a JSON array (below) | | `PORT` | `6001` | The port it listens on inside the container | | `HOST` | `0.0.0.0` | The address it listens on inside the container | ### Your own apps To use the same credentials as your `.env` files, or several apps, list them in `THUNDERCLAP_APPS`. The default app is then not served. ```sh docker run --rm -p 6001:6001 \ -e THUNDERCLAP_APPS='[{"id":"chat-dev","key":"chat-dev-key","secret":"chat-dev-secret","clientEvents":true}]' \ ghcr.io/bside-tech/thunderclap-dev ``` | Field | Required | What it is | | --- | --- | --- | | `id` | Yes | The app id (`THUNDERCLAP_APP_ID`) | | `key` | Yes | The public key (`THUNDERCLAP_KEY`) | | `secret` | Yes, or `secrets` | The secret (`THUNDERCLAP_SECRET`) | | `secrets` | No | Up to two secrets, both valid, to try a [rotation](https://thunderclap.dev/docs/guides/keys.md#rotate-without-downtime) | | `clientEvents` | No | `true` to allow [client events](https://thunderclap.dev/docs/guides/client-events.md) | | `webhooks` | No | Webhook endpoints (below) | Any values work: Thunderclap Local accepts whatever ids, keys and secrets you give it. A malformed `THUNDERCLAP_APPS` stops the container with an error saying what's wrong. ### Webhooks Each endpoint has a `url`, and optionally the `events` it receives, among `channel_existence`, `presence`, `client_events` and `cache_miss` (all but `client_events` by default). Requests are signed with the app's secrets, so verify them with `verifyWebhook` as in the [webhooks guide](https://thunderclap.dev/docs/guides/webhooks.md); plain `http://` URLs are fine locally. The container reaches your machine at `host.docker.internal`. Docker Desktop provides that name; on Linux, add `--add-host=host.docker.internal:host-gateway`. ```sh docker run --rm -p 6001:6001 --add-host=host.docker.internal:host-gateway \ -e THUNDERCLAP_APPS='[{"id":"local","key":"local-key","secret":"local-secret","webhooks":[{"url":"http://host.docker.internal:3000/thunderclap/webhooks"}]}]' \ ghcr.io/bside-tech/thunderclap-dev ``` ## Docker Compose ```yaml title="compose.yaml" services: thunderclap: image: ghcr.io/bside-tech/thunderclap-dev:0 ports: - "6001:6001" ``` Your other services reach it at `http://thunderclap:6001`, and browsers on your machine at `http://localhost:6001`. ## In CI Run it as a service container, so your tests have a realtime backend without credentials or network access. With GitHub Actions: ```yaml title=".github/workflows/test.yml" jobs: test: runs-on: ubuntu-latest services: thunderclap: image: ghcr.io/bside-tech/thunderclap-dev:0 ports: - 6001:6001 env: THUNDERCLAP_URL: http://localhost:6001 THUNDERCLAP_APP_ID: local THUNDERCLAP_KEY: local-key THUNDERCLAP_SECRET: local-secret steps: - uses: actions/checkout@v5 - run: npm ci && npm test ``` ## How it differs from the service Thunderclap Local runs the engine in one process, with everything in memory. That's what you want for development, but it isn't the service: - **Restarting it starts over.** Presence members and cache channels are gone, and channels start a new sequence, so clients that stayed connected get a gap with `reset: true` ([missed messages](https://thunderclap.dev/docs/guides/missed-messages.md)). - **No plans and no usage.** Nothing is metered, capped or rate limited, so it won't show you a plan's limits. The [size limits](https://thunderclap.dev/docs/reference/limits.md) of messages and channels are the same. - **The dashboard doesn't see it.** The Debug tab and usage pages show the managed service's apps only. - **It doesn't scale.** It's one process on your machine, so run load tests against the managed service. ## Versions Each release is tagged with its version (`0.1.0`), its minor and major versions (`0.1`, `0`) and `latest`. Pin a major version, such as `ghcr.io/bside-tech/thunderclap-dev:0`, to get fixes without breaking changes. ## Licence The [Thunderclap Local Licence](https://thunderclap.dev/legal/local) lets anyone, companies included, use the image for free on developers' machines, in automated tests and in CI. It doesn't allow serving real users, offering it as a service, or redistributing or modifying it. The SDKs stay MIT-licensed, whatever you connect them to. --- # AI agents Coding agents can read these docs as Markdown and search them through an MCP server, so they write Thunderclap code from the current docs rather than from memory. Nothing here needs an account. ## Connect the docs MCP server The server lives at `https://thunderclap.dev/mcp` (Streamable HTTP, no sign-in). It has three read-only tools: `list_docs` lists the pages, `search_docs` finds the sections that match a question, and `read_doc` returns a page in full. With Claude Code: ```sh claude mcp add --transport http thunderclap-docs https://thunderclap.dev/mcp ``` With Cursor, in your project or in `~/.cursor/mcp.json`: ```json title=".cursor/mcp.json" { "mcpServers": { "thunderclap-docs": { "url": "https://thunderclap.dev/mcp" } } } ``` With VS Code: ```json title=".vscode/mcp.json" { "servers": { "thunderclap-docs": { "type": "http", "url": "https://thunderclap.dev/mcp" } } } ``` Other clients take the same address: add it as a remote (HTTP) MCP server. ## Read the docs as Markdown Every page of these docs has a Markdown version at the same address with `.md` added, such as [/docs/quickstart.md](https://thunderclap.dev/docs/quickstart.md.md). For agents without MCP: - [`/llms.txt`](https://thunderclap.dev/llms.txt) lists every page with a one-line summary, following [llmstxt.org](https://llmstxt.org). - [`/llms-full.txt`](https://thunderclap.dev/llms-full.txt) holds every page in one file, to paste into a model's context. ## What to tell your agent Agents do best with the two rules most often got wrong: the browser uses `@thunderclapjs/client` with the public key only, and publishing and tokens happen on your server with `@thunderclapjs/server` and the secret. A line in your project's agent instructions (`AGENTS.md`, `CLAUDE.md`) is enough: ```md title="AGENTS.md" Realtime uses Thunderclap: look things up with the thunderclap-docs MCP server. Browsers use @thunderclapjs/client with THUNDERCLAP_KEY only; the backend publishes and signs tokens with @thunderclapjs/server and THUNDERCLAP_SECRET. ``` --- # Migrating from Pusher Thunderclap understands Pusher's protocol and REST API, so a migration takes two steps. First, point your existing Pusher libraries at Thunderclap by changing the host: nothing else changes, and your authorization endpoint keeps working as it is. Then move to thunderclapJS and `@thunderclapjs/server` at your own pace, page by page if you like: clients on both libraries share the same channels. ## Step 1: change the host ```ts title="pusher-js" const pusher = new Pusher("YOUR_PUBLIC_KEY", { cluster: "thunderclap", // required by pusher-js, not used by Thunderclap wsHost: "realtime.thunderclap.dev", forceTLS: true, enabledTransports: ["ws", "wss"], channelAuthorization: { endpoint: "/pusher/auth", transport: "ajax" }, }); ``` ```ts title="pusher" const pusher = new Pusher({ appId: process.env.THUNDERCLAP_APP_ID, key: process.env.THUNDERCLAP_KEY, secret: process.env.THUNDERCLAP_SECRET, host: "realtime.thunderclap.dev", useTLS: true, }); ``` What keeps working: - public, private, presence, encrypted and cache channels, and client events (once [enabled for the app](https://thunderclap.dev/docs/guides/client-events.md)); - `trigger`, `triggerBatch` and `socket_id` exclusion; - channel authorization; - channel queries (`/channels/room-42`, and `/users` on presence channels); - user sign-in with `sendToUser` and `terminateUserConnections`. Pusher's server libraries for other languages take the same host setting. ## Step 2: move to Thunderclap's SDKs ### In the browser Before, with Pusher's client: ```ts title="pusher-js" const pusher = new Pusher("APP_KEY", { cluster: "eu", channelAuthorization: { endpoint: "/pusher/auth", transport: "ajax" }, }); const channel = pusher.subscribe("private-room-42"); channel.bind("message", (data) => show(data)); pusher.connection.bind("state_change", ({ previous, current }) => log(previous, current)); ``` After, with thunderclapJS: ```ts title="@thunderclapjs/client" const thunderclap = new Thunderclap({ key: process.env.NEXT_PUBLIC_THUNDERCLAP_KEY, authorize: ({ channel, socketId }) => fetch("/realtime/authorize", { method: "POST", body: JSON.stringify({ channel, socketId }) }) .then((r) => r.text()), }); const channel = thunderclap.subscribe("private-room-42"); channel.bind("message", (data, meta) => show(data)); // meta.seq: the event's sequence number thunderclap.on("state", ({ previous, current }) => log(previous, current)); ``` ### On your server Before, with Pusher's server library: ```ts title="pusher" const pusher = new Pusher({ appId, key, secret, cluster: "eu" }); await pusher.trigger("room-42", "message", data, { socket_id: socketId }); // Channel authorization endpoint const auth = pusher.authorizeChannel(socketId, channelName); res.send(auth); ``` After, with the server SDK: ```ts title="@thunderclapjs/server" const thunderclap = new ThunderclapServer({ appId, secret }); await thunderclap.publish({ channel: "room-42", data, exclude: socketId }); // Channel authorization endpoint: answer with a token for that channel res.send(await thunderclap.token({ uid: user.id, channels: [channel], ttlSeconds: 300 })); ``` ## Equivalents | Pusher | Thunderclap | | --- | --- | | App key, app id, secret, `cluster` | Public key, app id, secret. No cluster: connections go to the nearest location | | `channel.bind_global(callback)` | `channel.bindAll(callback)` | | `pusher.connection.socket_id` | `thunderclap.socketId` | | Connection states `unavailable`, `disconnected` | `reconnecting`, `closed` | | `trigger(channels, event, data, { socket_id })` | `publish({ channel, event, data, exclude })` | | `triggerBatch(events)` | `batch(events)`, up to 10 events | | `authorizeChannel(socketId, channel)` | `token({ uid, channels: [channel] })`, or a connection token granting many channels at once | | `authorizeChannel(socketId, channel, { user_id, user_info })` | `token({ uid, info, channels })` | | `channel.members.each(…)`, `pusher:member_added` | `channel.members` (a `Map`), `member_added` on `channel.on` | | `channel.trigger("client-…", data)` | `channel.trigger("client-…", data)` | Two differences are worth knowing: - Thunderclap tokens grant channels to a user rather than to one socket, so a connection token can authorize every private channel up front instead of one request per channel. - Every event carries a sequence number. Where Pusher silently drops events sent while a client was offline, Thunderclap reports the gap (see [missed messages](https://thunderclap.dev/docs/guides/missed-messages.md)). ## Webhooks When you add an endpoint in the dashboard, choose the **Pusher-compatible** format and your existing webhook handler keeps working unchanged. Once your backend [verifies requests](https://thunderclap.dev/docs/guides/webhooks.md#verify-requests) with `@thunderclapjs/server`, switch the endpoint to Thunderclap's format to get event ids, timestamps, member info, and signatures that survive a secret rotation. In either format, `cache_miss` is sent once per channel until the next event rather than for every subscriber, and there is no `subscription_count` webhook. ## Not supported - Listing channels (`pusher.get("/channels")`) is refused: query channels by name instead. - `pusher:subscription_count` events. For who is in a channel, use a presence channel. - User watchlists. - Transports other than WebSockets: set `enabledTransports: ["ws", "wss"]` as shown above. Pusher is a trademark of its respective owner. Thunderclap is not affiliated with it. --- # @thunderclapjs/client thunderclapJS, for browsers, React Native, Node.js 22+, Deno and Bun. No dependencies. Install it with `npm install @thunderclapjs/client`. ## `Thunderclap` The client. It connects to Thunderclap, keeps subscriptions alive across reconnects and reports gaps so your app can backfill from its own store. ### new Thunderclap (constructor) Creates a client and, unless `autoConnect` is false, connects right away. ```ts new Thunderclap(options: ThunderclapOptions) ``` **Parameters** - `key`: `string`, required Your app's public key. - `token`: `string | (() => Promise)` A connection token granting private and presence channels up front, or a function fetching a fresh one on every connect. - `authorize`: `(input: { channel, socketId }) => Promise` Fetches a token for one private or presence channel the connection token doesn't cover. Called only when a subscription is refused. - `autoConnect`: `boolean`, default `true` Connect immediately. - `url`: `string`, default `"https://realtime.thunderclap.dev"` The Thunderclap endpoint. Set it only for a custom domain. - `reconnect.minDelayMs`: `number`, default `500` Base delay of the exponential backoff (with full jitter). - `reconnect.maxDelayMs`: `number`, default `30000` Backoff ceiling. - `pongTimeoutMs`: `number`, default `15000` How long to wait for a pong before considering the connection dead. - `WebSocket`: `WebSocketFactory` WebSocket implementation, for runtimes without a global one. **Example** ```ts const thunderclap = new Thunderclap({ key: process.env.NEXT_PUBLIC_THUNDERCLAP_KEY, token: () => fetch("/api/realtime-token").then((r) => r.text()), }); ``` ### subscribe (method) Subscribes to a channel, or returns the existing subscription. Subscriptions are restored after every reconnect. `presence-*` channels return a `PresenceChannel`. ```ts subscribe(name: string): Channel | PresenceChannel ``` **Parameters** - `name`: `string`, required Channel name. **Returns** The channel. **Example** ```ts const room = thunderclap.subscribe<{ message: { text: string } }>("room-42"); ``` ### unsubscribe (method) Unsubscribes from a channel and forgets it. ```ts unsubscribe(name: string): void ``` **Parameters** - `name`: `string`, required Channel name. ### channel (method) Finds an existing subscription without creating one. ```ts channel(name: string): Channel | undefined ``` **Parameters** - `name`: `string`, required Channel name. **Returns** The channel, or `undefined` when not subscribed. ### connect (method) Connects, or reconnects now if a reconnect is pending. Does nothing while connecting or connected. Needed after `autoConnect: false` or `disconnect()`. ```ts connect(): void ``` ### disconnect (method) Closes the connection and stops reconnecting. Subscriptions are kept for the next `connect()`. ```ts disconnect(): void ``` ### on (method) Listens to connection events. ```ts on(event: "state" | "error", listener): () => void ``` **Events** | Event | Listener | Description | | --- | --- | --- | | `state` | `({ previous, current }: StateChange) => void` | The connection state changed: initialized, connecting, connected, reconnecting, closed or failed. | | `error` | `({ code, message }: ThunderclapError) => void` | A connection-level error, such as a rejected key or token. See error codes. | **Returns** A function removing the listener. ### state (property) The current connection state. ```ts readonly state: ConnectionState ``` ### socketId (property) The current connection's id, `null` while not connected. Send it to your server and pass it as `exclude` to skip this client when publishing. ```ts readonly socketId: string | null ``` ## `Channel` A subscription, returned by `subscribe`. It survives reconnects. ### bind (method) Listens to one event. `meta` carries the event name, the channel and its sequence `seq`; for client events, `seq` is `null` and `userId` names the sender on presence channels. ```ts bind(event: K, listener: (data: Events[K], meta: EventMeta) => void): () => void ``` **Parameters** - `event`: `string`, required Event name. - `listener`: `(data, meta) => void`, required Called with each event. **Returns** A function removing the listener. ### bindAll (method) Listens to every event on the channel. ```ts bindAll(listener: (event: string, data: unknown, meta: EventMeta) => void): () => void ``` **Returns** A function removing the listener. ### trigger (method) Sends a client event to the channel's other subscribers. The name must start with `client-`, the channel must be private or presence, and client events must be enabled for the app. ```ts trigger(event: K, data: Events[K]): boolean ``` **Parameters** - `event`: `string`, required Event name, starting with `client-`. - `data`: `unknown`, required JSON-serializable data, up to 10 KB. **Returns** `true` when sent, `false` when the channel isn't subscribed or can't carry client events. Refusals by the server arrive as `error` events (code 4306). ### onGap (method) Called when events may have been missed: `{ from, to }` for a range of sequences, or `{ reset: true }` when the channel's counter restarted. ```ts onGap(listener: (gap: Gap) => void): () => void ``` **Returns** A function removing the listener. ### on (method) Listens to the subscription's status. ```ts on(event: "subscribed" | "error" | "gap" | "cache_miss", listener): () => void ``` **Events** | Event | Listener | Description | | --- | --- | --- | | `subscribed` | `() => void` | The server confirmed the subscription, again after each reconnect. | | `error` | `({ code, message }: ChannelError) => void` | The subscription was refused or failed, or a client event was refused. | | `gap` | `(gap: Gap) => void` | Same as `onGap`. | | `cache_miss` | `() => void` | Cache channels: nothing is cached, so your server may publish a fresh value. | **Returns** A function removing the listener. ### name (property) The channel's name. ```ts readonly name: string ``` ### subscribed (property) Whether the server confirmed the subscription on the current connection. ```ts readonly subscribed: boolean ``` ### lastSeq (property) The last sequence received, `null` before the first event or confirmation. ```ts readonly lastSeq: [epoch: number, n: number] | null ``` ## `PresenceChannel` A subscription to a `presence-*` channel: everything a `Channel` does, plus who is subscribed. A user connected from several devices is one member. ### members (property) Current members keyed by user id, the subscriber included. Empty until the first confirmation, and kept as last known while reconnecting. ```ts readonly members: ReadonlyMap> ``` **Fields** - `id`: `string` The user id, from the token's `uid`. - `info`: `Info | undefined` Public user info, from the token's `info`. ### me (property) The subscriber's own member entry, `null` before the first confirmation. ```ts readonly me: Member | null ``` ### on (method) Listens to status events, like `Channel.on`, and to membership changes. ```ts on(event: "member_added" | "member_removed" | ChannelEvent, listener): () => void ``` **Events** | Event | Listener | Description | | --- | --- | --- | | `member_added` | `(member: Member) => void` | A user joined, including joins missed while reconnecting. | | `member_removed` | `(member: Member) => void` | A user's last connection left. | **Returns** A function removing the listener. --- # @thunderclapjs/server For your backend: anything with `fetch` and Web Crypto, including Node.js 22+ and edge runtimes. No dependencies. Install it with `npm install @thunderclapjs/server`. ## `ThunderclapServer` Server-side access to one app: publish events, mint client tokens and verify webhooks. ### new ThunderclapServer (constructor) Creates an SDK instance for one app. ```ts new ThunderclapServer(options: ThunderclapServerOptions) ``` **Parameters** - `appId`: `string`, required The app id (`THUNDERCLAP_APP_ID`). - `secret`: `string`, required An active secret of the app (`THUNDERCLAP_SECRET`). Keep it on your server. - `url`: `string`, default `"https://realtime.thunderclap.dev"` The Thunderclap endpoint. Set it only for a custom domain. - `fetch`: `typeof fetch` Fetch implementation; the global one by default. **Example** ```ts const thunderclap = new ThunderclapServer({ appId: process.env.THUNDERCLAP_APP_ID, secret: process.env.THUNDERCLAP_SECRET, }); ``` ### publish (method) Publishes one event to one or more channels. Fan-out to subscribers is free. ```ts publish(input: PublishInput): Promise ``` **Parameters** - `channel`: `string | string[]`, required One channel, or up to 100 to publish the same event to. - `event`: `string` Event name, up to 200 characters. Defaults to `message`. - `data`: `unknown`, required JSON-serializable data, up to 10 KB. - `exclude`: `string` A client's `socketId` to skip, typically the one that triggered the event. **Returns** `{ seq }`: the sequence the event got on each channel, as `{ [channel]: [epoch, n] }`. Store it with your copy of the message. **Throws** `ThunderclapApiError` when the request is refused. **Example** ```ts const { seq } = await thunderclap.publish({ channel: "room-42", data: { text: "Hi" } }); ``` ### batch (method) Publishes up to 10 events in one request, in order. Cheaper than separate publishes. ```ts batch(events: BatchEvent[]): Promise ``` **Parameters** - `events`: `Array<{ channel, event?, data, exclude? }>`, required The events, each to a single channel. `event` defaults to `message`. **Returns** `{ results }`: one `{ seq }` per event, in order. **Throws** `ThunderclapApiError` when the request is refused. ### token (method) Mints a token: a connection token for thunderclapJS's `token` option, or a per-channel token for its `authorize` callback. ```ts token(options?: TokenOptions): Promise ``` **Parameters** - `uid`: `string` User id. Required to join presence channels, where it identifies the member. - `info`: `unknown` Public user info shown to the other members of presence channels, up to 1 KB as JSON. - `channels`: `string[]` Channel grants: exact names, or prefixes ending in `*` (`private-team-7-*`). - `ttlSeconds`: `number`, default `3600` Lifetime. Tokens are checked at connect and subscribe time only. **Returns** The signed token. **Throws** `RangeError` when `info` is over 1 KB. **Example** ```ts const token = await thunderclap.token({ uid: user.id, channels: [`private-user-${user.id}`] }); ``` ### verifyWebhook (method) Verifies a webhook request and returns its events. Requests are signed with every active secret, so this works with whichever one the instance holds, also during a rotation. ```ts verifyWebhook(input: VerifyWebhookInput): Promise ``` **Parameters** - `body`: `string | Uint8Array`, required The raw body, exactly as received: parsing and re-serializing it breaks the signature. - `signature`: `string | null | undefined`, required The `Thunderclap-Signature` header. - `toleranceSeconds`: `number`, default `300` Oldest signature accepted, against replayed requests. **Returns** The payload: `{ app, events }`, each event with a unique `id` for deduplication. **Throws** `ThunderclapWebhookError` when the signature is missing, wrong or too old, or the app differs. ### verifyWebhookRequest (method) Verifies a webhook delivered as a standard `Request` (Next.js route handlers, Workers, Bun, Deno), reading its body and signature header. ```ts verifyWebhookRequest(request: Request): Promise ``` **Returns** The payload, like `verifyWebhook`. **Throws** `ThunderclapWebhookError`, like `verifyWebhook`. ## `ThunderclapApiError` Thrown when the REST API refuses a request. ### status (property) The HTTP status: `400` for invalid input, `401` for a wrong secret or a suspended app. ```ts readonly status: number ``` ## `ThunderclapWebhookError` Thrown by `verifyWebhook` and `verifyWebhookRequest` when a request isn't a valid, recent one from Thunderclap for this app. Answer such requests with `401`. ## `Constants` Values exported by the package. ### WEBHOOK\_SIGNATURE\_HEADER (constant) The header carrying a webhook request's signature. ```ts const WEBHOOK_SIGNATURE_HEADER = "Thunderclap-Signature" ``` --- # Webhook events The request format of Thunderclap's webhooks. To set them up and verify them, see the [webhooks guide](https://thunderclap.dev/docs/guides/webhooks.md). ## Request | Part | Value | | --- | --- | | Method | `POST` | | `Content-Type` | `application/json` | | `Thunderclap-Signature` | `t=`, then one `v1=` per active secret of the app | | Body | A payload: `{ app, events }` | ```json title="request" { "app": "app_8f3k2m9q", "events": [ { "id": "4c1d…", "time": 1761040800000, "type": "channel_occupied", "channel": "room-42" }, { "id": "9a7e…", "time": 1761040800120, "type": "member_added", "channel": "presence-room-42", "userId": "ada", "info": { "name": "Ada" } } ] } ``` ## Common fields Every event has these fields: | Field | Type | Description | | --- | --- | --- | | `id` | `string` | Unique id. Retries may deliver an event twice: deduplicate on it | | `time` | `number` | When it happened, in milliseconds since epoch. Events can arrive out of order | | `type` | `string` | One of the types below | | `channel` | `string` | The channel it happened on | ## Event types | `type` | Kind | Extra fields | Sent when | | --- | --- | --- | --- | | `channel_occupied` | Channel existence | none | A channel got its first subscriber | | `channel_vacated` | Channel existence | none | A channel has had no subscribers for 5 seconds | | `member_added` | Presence | `userId: string`, `info?: unknown` | A user joined a presence channel | | `member_removed` | Presence | `userId: string` | A user's last connection left a presence channel | | `client_event` | Client events | `event: string`, `data: unknown`, `socketId: string`, `userId?: string` | A client sent an event; `userId` is set on presence channels | | `cache_miss` | Cache misses | none | A subscriber found a cache channel empty; once per channel until the next event | An endpoint receives only the kinds it was set up with in the dashboard. ## Signature `Thunderclap-Signature` carries a timestamp and one signature per active secret, each the HMAC-SHA256 of `.` in hex: ```text title="Thunderclap-Signature" t=1761040800,v1=5f2b…,v1=a91c… ``` `verifyWebhook` in `@thunderclapjs/server` checks it for you: it accepts any of the signatures and refuses timestamps older than 5 minutes. --- # Error codes ## Connection and channel errors thunderclapJS reports these codes through `error` events: connection-level errors on the client (`thunderclap.on("error")`), channel errors on the channel (`channel.on("error")`). | Code | Scope | Meaning | What happens | | --- | --- | --- | --- | | `4001` | Connection | Unknown app key | Fails for good: check `THUNDERCLAP_KEY` | | `4003` | Connection | The app is suspended | Fails for good until the app is reactivated | | `4009` | Connection | Connection token invalid or expired | Fails for good: check the token your backend issues | | `4100` | Connection | Over capacity, or too many connections opened at once for the app | Retries with backoff | | `4101` | Connection | The app reached its plan's connection limit (Spark) | Retries with backoff, connecting once a place frees up | | `4200` | Connection | Reconnect now (maintenance or rebalancing) | Reconnects at once, automatically | | `4300` | Channel | Malformed frame | The connection stays open | | `4301` | Channel | Invalid channel name | The subscription is refused | | `4302` | Channel | Not authorized for this private or presence channel (presence also needs a user id) | The subscription is refused | | `4303` | Channel | Too many subscriptions on one connection (100) | The subscription is refused | | `4304` | Channel | Internal error while handling the channel | Try subscribing again | | `4305` | Channel | The presence channel is full (100 members) | The subscription is refused | | `4306` | Channel | A client event was refused: disabled for the app, public channel, not subscribed, bad name, too large or too many | The event is dropped | Codes from `4000` to `4099` are final: the client moves to the `failed` state and stops reconnecting. Codes from `4100` to `4199` are retried with backoff, `4200` to `4299` reconnect at once, and `4300` and up concern one channel while the connection stays open. ## Server SDK errors `@thunderclapjs/server` throws a `ThunderclapApiError` with the HTTP `status` and a `message`: | Status | Meaning | | --- | --- | | `400` | Invalid input: channel or event name, data too large, too many channels or events | | `401` | Wrong app id or secret, or the app is suspended | | `429` | Too many requests: either the app's messages are capped for the month, because they're used up on Spark or the organisation reached a spending limit set to cap, which resumes next month or once the organisation upgrades or raises its limit, or it sent more than 1,000 requests a second, which resumes after the `Retry-After` delay | | `503` | Temporarily unavailable, usually for a few seconds while Cloudflare restarts part of the service: retry after the `Retry-After` delay. A retried publish can reach subscribers twice if the first attempt got through before failing | | other `5xx` | Temporary failure: retry | Webhook verification throws a `ThunderclapWebhookError` instead. See the [server reference](https://thunderclap.dev/docs/reference/server.md). --- # Limits These limits apply on every plan. Included connections and messages depend on your plan: see [pricing](https://thunderclap.dev/pricing) and your organisation's overview in the dashboard. ## Messages | Limit | Value | | --- | --- | | Event data | 10 KB, JSON-serializable | | Event name | 200 characters; names starting with `tc:` are reserved | | Channels per publish | 100 | | Events per batch | 10 | ## Channels and connections | Limit | Value | | --- | --- | | Channel name | 1 to 164 characters: letters, digits and `_ - = @ , . ;` | | Subscriptions per connection | 100 | | Presence members per channel | 100 | | Presence user info | 1 KB as JSON | | Client events | 10 per second per connection, 10 KB each | | Cache channels | The last event is kept for 30 minutes | ## Tokens | Limit | Value | | --- | --- | | Default lifetime | 3600 seconds | | When checked | At connect and subscribe time only | ## Apps and webhooks | Limit | Value | | --- | --- | | Active secrets per app | 2 | | Webhook endpoints per app | 10, `https://` only | | Webhook timeout | 10 seconds, then retried up to 10 times |