# Socketly

A hosted WebSocket API. Publish from your server, subscribe from the browser; presence
rosters, signed private channels and per-app quotas without running a socket tier.

- API: https://api.socketly.co
- Dashboard: https://app.socketly.co
- Docs: https://docs.socketly.co

Packages: `@socketly/client` (browser, React Native, any JS runtime), `@socketly/react`
(hooks), `@socketly/server` (Node — signing and publishing).

---

# Quickstart

Socketly is a hosted WebSocket API. Your server publishes; browsers subscribe.

## Channels are named by their access rule

The prefix IS the permission. There is no separate config, and an unprefixed name is
rejected outright.

- `public-` — anyone holding the public key can subscribe and read. Only your server writes.
- `private-` — your backend signs each subscription with the secret key before the socket joins.
- `presence-` — everything private- does, plus a member roster pushed on join and leave.

**Default to `private-` or `presence-` for anything a user should not be able to read by
guessing the name.** Reach for `public-` only when the data is already on the page for
everyone.

## 1. Install

```bash
npm install @socketly/client @socketly/react @socketly/server
```

## 2. Environment

The public key belongs in the browser — that is what it is for. The secret key must never
reach a client bundle.

```bash
NEXT_PUBLIC_SOCKETLY_KEY=pk_app_xxxxxxxx_yyyyyyyyyyyyyyyyyyyyyy
SOCKETLY_SECRET=sk_app_xxxxxxxx_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz
```

## 3. Create the client

```ts
// lib/socketly.ts
import { Socketly } from '@socketly/client';

export const socketly = new Socketly({
  key: process.env.NEXT_PUBLIC_SOCKETLY_KEY!,
  authEndpoint: '/api/socketly/auth',
  url: process.env.NEXT_PUBLIC_SOCKETLY_URL, // omit in production
});
```

## 4. Provider (React)

```tsx
// app/providers.tsx
'use client';

import { SocketlyProvider } from '@socketly/react';
import { socketly } from '@/lib/socketly';

export function Providers({ children }: { children: React.ReactNode }) {
  return <SocketlyProvider client={socketly}>{children}</SocketlyProvider>;
}
```

## 5. The auth endpoint — required for private- and presence-

This is the only piece that asks anything of you. Your backend already knows who the user
is; this is where you say whether they may join.

`authorizeChannel` returns a promise. There is no synchronous HMAC that works in every
runtime, so it must be awaited — a missing `await` is a type error rather than a silently
serialized `{}`.

```ts
// app/api/socketly/auth/route.ts
import { authorizeChannel } from '@socketly/server';
import { getCurrentUser } from '@/lib/session';

export async function POST(request: Request) {
  const { socket_id, channel_name } = await request.json();

  // You decide who may subscribe. You already know who your user is.
  const user = await getCurrentUser();
  if (!user) return new Response('Unauthorized', { status: 401 });

  if (channel_name.startsWith('private-room-')) {
    const roomId = channel_name.replace('private-room-', '');
    if (!(await user.canAccessRoom(roomId))) {
      return new Response('Forbidden', { status: 403 });
    }
  }

  // Await it. There is no synchronous HMAC that works in every runtime, so this
  // returns a promise — the type system will remind you if you forget.
  return Response.json(
    await authorizeChannel({
      secret: process.env.SOCKETLY_SECRET!,
      socketId: socket_id,
      channel: channel_name,
      // Required for presence- channels only.
      userData: { user_id: user.id, user_info: { name: user.name } },
    }),
  );
}
```

## 6. Subscribe in a component

```tsx
'use client';

import { useState } from 'react';
import { useChannel, usePresence } from '@socketly/react';

export function Room({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<string[]>([]);
  const channel = `presence-room-${roomId}`;

  const { subscribed, error } = useChannel(channel, {
    'new-message': (data) => setMessages((m) => [...m, data.data.text]),
  });

  const { members, count } = usePresence(channel);

  if (error) return <p>{error}</p>;
  if (!subscribed) return <p>Connecting…</p>;

  return (
    <div>
      <p>{count} online</p>
      <ul>
        {messages.map((text, i) => (
          <li key={i}>{text}</li>
        ))}
      </ul>
    </div>
  );
}
```

## 7. Publish from your server

```ts
// Anywhere on your server
await fetch(`https://api.socketly.co/v1/apps/${APP_ID}/events`, {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    authorization: `Bearer ${process.env.SOCKETLY_SECRET}`,
  },
  body: JSON.stringify({
    channels: ['presence-room-42'],
    event: 'new-message',
    data: { text: 'Hello from the server' },
  }),
});
```

## Things that bite

- **Origins are matched exactly.** A public key is public; the origin allow-list on your app
  is what scopes it. No wildcards unless you write `*.example.com` explicitly. React Native
  and other non-browser clients send no Origin header and are therefore not origin-checked.
- **Client events** (publishing from the browser) are only allowed on `private-` and
  `presence-` channels, must be named `client-*`, and must be enabled per app.
- **Quotas** are per app, per minute and per day. A refused publish returns 429.


---

# Any language

Two things a backend must do, neither needing an SDK: sign a subscription, and publish an
event. Every signing snippet below is executed against a real gateway by the test suite.

**The one rule: sign the exact string you return.** The gateway verifies against the exact
`channel_data` bytes the client sends back, so JSON key order, slash escaping and separator
spacing are all free — Go sorts map keys, PHP escapes slashes, and both verify. What breaks
is signing one serialization and then returning the *object* for your framework to serialize
again. Build the string once, use it twice.

The construction: HMAC-SHA256 over `socketId:channel`, or `socketId:channel:channelData`
for presence channels, hex-encoded, returned as `{appId}:{hex}`. The app id is the middle
segment of the secret key (`sk_app_<id>_<rest>` → `app_<id>`).

## Authorize a subscription

### Node

```
// app/api/socketly/auth/route.ts — Next.js
import { createHmac } from 'node:crypto';

export function socketly_authorize(secret, socketId, channel, userData) {
  const appId = 'app_' + secret.split('_')[2];
  let channelData = null;
  let payload = socketId + ':' + channel;

  if (channel.startsWith('presence-')) {
    channelData = JSON.stringify(userData);
    payload += ':' + channelData;
  }

  const signature = createHmac('sha256', secret).update(payload).digest('hex');
  const result = { auth: appId + ':' + signature };
  if (channelData) result.channel_data = channelData;
  return result;
}

export async function POST(request: Request) {
  const { socket_id, channel_name } = await request.json();

  // You decide who may subscribe. You already know who your user is.
  const user = await getCurrentUser();
  if (!user) return new Response('Unauthorized', { status: 401 });

  return Response.json(
    socketly_authorize(process.env.SOCKETLY_SECRET, socket_id, channel_name, {
      user_id: user.id,
      user_info: { name: user.name },
    }),
  );
}
```

### Python

```
# Django, Flask or FastAPI — the handler differs, the signing does not.
import hmac, hashlib, json

def socketly_authorize(secret, socket_id, channel, user_data=None):
    app_id = "app_" + secret.split("_")[2]
    channel_data = None
    payload = f"{socket_id}:{channel}"

    if channel.startswith("presence-"):
        # Any valid JSON works. What matters is that this exact string is the one
        # returned as channel_data — sign what you return.
        channel_data = json.dumps(user_data, separators=(",", ":"))
        payload += f":{channel_data}"

    signature = hmac.new(
        secret.encode(), payload.encode(), hashlib.sha256
    ).hexdigest()
    result = {"auth": f"{app_id}:{signature}"}
    if channel_data is not None:
        result["channel_data"] = channel_data
    return result


# --- FastAPI ---------------------------------------------------------------
from fastapi import FastAPI, Request, HTTPException
import os

app = FastAPI()

@app.post("/api/socketly/auth")
async def socketly_auth(request: Request):
    body = await request.json()

    # You decide who may subscribe. You already know who your user is.
    user = await get_current_user(request)
    if user is None:
        raise HTTPException(status_code=401)

    return socketly_authorize(
        os.environ["SOCKETLY_SECRET"],
        body["socket_id"],
        body["channel_name"],
        {"user_id": str(user.id), "user_info": {"name": user.name}},
    )
```

### Ruby

```
# Rails — config/routes.rb: post "/api/socketly/auth" => "socketly#auth"
require "openssl"
require "json"

def socketly_authorize(secret, socket_id, channel, user_data = nil)
  app_id = "app_#{secret.split('_')[2]}"
  channel_data = nil
  payload = "#{socket_id}:#{channel}"

  if channel.start_with?("presence-")
    channel_data = JSON.generate(user_data)
    payload += ":#{channel_data}"
  end

  signature = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
  result = { "auth" => "#{app_id}:#{signature}" }
  result["channel_data"] = channel_data if channel_data
  result
end


class SocketlyController < ApplicationController
  def auth
    # You decide who may subscribe. You already know who your user is.
    return head :unauthorized unless current_user

    render json: socketly_authorize(
      ENV.fetch("SOCKETLY_SECRET"),
      params.require(:socket_id),
      params.require(:channel_name),
      { "user_id" => current_user.id.to_s, "user_info" => { "name" => current_user.name } }
    )
  end
end
```

### PHP

```
<?php
// Laravel — routes/api.php: Route::post('/socketly/auth', SocketlyController::class);
function socketly_authorize(string $secret, string $socketId, string $channel, ?array $userData = null): array {
    $appId = 'app_' . explode('_', $secret)[2];
    $channelData = null;
    $payload = "$socketId:$channel";

    if (str_starts_with($channel, 'presence-')) {
        // json_encode escapes "/" by default. That is fine: this exact string is what
        // gets returned and what the gateway verifies against.
        $channelData = json_encode($userData);
        $payload .= ":$channelData";
    }

    $signature = hash_hmac('sha256', $payload, $secret);
    $result = ['auth' => "$appId:$signature"];
    if ($channelData !== null) {
        $result['channel_data'] = $channelData;
    }
    return $result;
}

class SocketlyController extends Controller
{
    public function __invoke(Request $request)
    {
        // You decide who may subscribe. You already know who your user is.
        $user = $request->user();
        if (!$user) {
            return response('Unauthorized', 401);
        }

        return response()->json(socketly_authorize(
            env('SOCKETLY_SECRET'),
            $request->input('socket_id'),
            $request->input('channel_name'),
            ['user_id' => (string) $user->id, 'user_info' => ['name' => $user->name]],
        ));
    }
}
```

### Go

```
package socketly

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"strings"
)

func socketly_authorize(secret, socketID, channel string, userData any) (map[string]string, error) {
	appID := "app_" + strings.Split(secret, "_")[2]
	channelData := ""
	payload := socketID + ":" + channel

	if strings.HasPrefix(channel, "presence-") {
		// encoding/json sorts map keys and escapes &, < and >. None of that matters —
		// this exact string is what gets returned and what the gateway verifies against.
		b, err := json.Marshal(userData)
		if err != nil {
			return nil, err
		}
		channelData = string(b)
		payload += ":" + channelData
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(payload))

	result := map[string]string{"auth": appID + ":" + hex.EncodeToString(mac.Sum(nil))}
	if channelData != "" {
		result["channel_data"] = channelData
	}
	return result, nil
}

// net/http — mux.HandleFunc("POST /api/socketly/auth", socketlyAuth)
func socketlyAuth(w http.ResponseWriter, r *http.Request) {
	var body struct {
		SocketID string `json:"socket_id"`
		Channel  string `json:"channel_name"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	// You decide who may subscribe. You already know who your user is.
	user, ok := currentUser(r)
	if !ok {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}

	result, err := socketly_authorize(
		os.Getenv("SOCKETLY_SECRET"),
		body.SocketID,
		body.Channel,
		map[string]any{"user_id": user.ID, "user_info": map[string]any{"name": user.Name}},
	)
	if err != nil {
		http.Error(w, "could not authorize", http.StatusInternalServerError)
		return
	}

	w.Header().Set("content-type", "application/json")
	json.NewEncoder(w).Encode(result)
}
```

## Publish an event

### Node

```
const appId = 'app_' + process.env.SOCKETLY_SECRET.split('_')[2];

await fetch(`https://api.socketly.co/v1/apps/${appId}/events`, {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    authorization: `Bearer ${process.env.SOCKETLY_SECRET}`,
  },
  body: JSON.stringify({
    channels: ['presence-room-42'],
    event: 'new-message',
    data: { text: 'Shipped.' },
  }),
});
```

### Python

```
import os, requests

secret = os.environ["SOCKETLY_SECRET"]
app_id = "app_" + secret.split("_")[2]

requests.post(
    f"https://api.socketly.co/v1/apps/{app_id}/events",
    headers={"authorization": f"Bearer {secret}"},
    json={
        "channels": ["presence-room-42"],
        "event": "new-message",
        "data": {"text": "Shipped."},
    },
    timeout=10,
)
```

### Ruby

```
require "net/http"
require "json"

secret = ENV.fetch("SOCKETLY_SECRET")
app_id = "app_#{secret.split('_')[2]}"

uri = URI("https://api.socketly.co/v1/apps/#{app_id}/events")
Net::HTTP.post(
  uri,
  JSON.generate({
    channels: ["presence-room-42"],
    event: "new-message",
    data: { text: "Shipped." }
  }),
  "content-type" => "application/json",
  "authorization" => "Bearer #{secret}"
)
```

### PHP

```
<?php

$secret = getenv('SOCKETLY_SECRET');
$appId  = 'app_' . explode('_', $secret)[2];

$ch = curl_init("https://api.socketly.co/v1/apps/$appId/events");
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'content-type: application/json',
        "authorization: Bearer $secret",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'channels' => ['presence-room-42'],
        'event'    => 'new-message',
        'data'     => ['text' => 'Shipped.'],
    ]),
]);
curl_exec($ch);
curl_close($ch);
```

### Go

```
secret := os.Getenv("SOCKETLY_SECRET")
appID := "app_" + strings.Split(secret, "_")[2]

body, _ := json.Marshal(map[string]any{
	"channels": []string{"presence-room-42"},
	"event":    "new-message",
	"data":     map[string]any{"text": "Shipped."},
})

req, _ := http.NewRequest("POST",
	"https://api.socketly.co/v1/apps/"+appID+"/events",
	bytes.NewReader(body))
req.Header.Set("content-type", "application/json")
req.Header.Set("authorization", "Bearer "+secret)

resp, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()
```

## Frontends

`@socketly/client` touches no browser-only global, so the same package runs in a browser,
in React Native and Expo, and in any other JS runtime. Only `@socketly/react` is
React-specific.

### React

```
'use client';
import { useChannel, usePresence } from '@socketly/react';

export function Room({ roomId }: { roomId: string }) {
  const channel = `presence-room-${roomId}`;

  const { subscribed } = useChannel(channel, {
    'new-message': ({ data }) => append(data.text),
  });
  const { members, count } = usePresence(channel);

  if (!subscribed) return <p>Connecting…</p>;
  return <p>{count} online</p>;
}
```

### React Native / Expo

```
import { useEffect, useState } from 'react';
import { Socketly } from '@socketly/client';

const socketly = new Socketly({
  key: process.env.EXPO_PUBLIC_SOCKETLY_KEY!,
  authEndpoint: 'https://your-api.example.com/api/socketly/auth',
  // React Native has no XHR long-polling worth falling back to.
  transports: ['websocket'],
});

export function useRoom(roomId: string) {
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    const channel = socketly.subscribe(`presence-room-${roomId}`);
    channel.bind('new-message', ({ data }) => {
      setMessages((m) => [...m, data.text]);
    });
    return () => socketly.unsubscribe(`presence-room-${roomId}`);
  }, [roomId]);

  return messages;
}
```

> React Native sends no Origin header, so the origin allow-list on your app does not constrain a mobile build the way it constrains a website. That is not a downgrade — a public key was always public, and anything not sending an Origin was never origin-checked — but it does mean the allow-list is not what protects a native app. Keep real authorization on private- and presence- channels, where your backend signs.

### Vue

```
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { Socketly } from '@socketly/client';

const props = defineProps<{ roomId: string }>();
const messages = ref<string[]>([]);
const socketly = new Socketly({
  key: import.meta.env.VITE_SOCKETLY_KEY,
  authEndpoint: '/api/socketly/auth',
});

onMounted(() => {
  socketly
    .subscribe(`presence-room-${props.roomId}`)
    .bind('new-message', ({ data }) => messages.value.push(data.text));
});

onUnmounted(() => socketly.disconnect());
</script>
```

### Svelte

```
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import { Socketly } from '@socketly/client';

  export let roomId: string;
  let messages: string[] = [];

  const socketly = new Socketly({
    key: import.meta.env.VITE_SOCKETLY_KEY,
    authEndpoint: '/api/socketly/auth',
  });

  onMount(() => {
    socketly
      .subscribe(`presence-room-${roomId}`)
      .bind('new-message', ({ data }) => (messages = [...messages, data.text]));
  });

  onDestroy(() => socketly.disconnect());
</script>
```

### Plain JS

```
import { Socketly } from '@socketly/client';

const socketly = new Socketly({
  key: 'pk_app_…',
  authEndpoint: '/api/socketly/auth',
});

socketly
  .subscribe('presence-room-42')
  .bind('new-message', ({ data }) => {
    document.querySelector('#log').append(data.text);
  });

// Every event on the channel, when you do not know the names in advance.
socketly.channel('presence-room-42')?.bindGlobal((event, payload) => {
  console.debug(event, payload);
});
```


---

# MCP server

`@socketly/mcp` gives a coding agent five tools and the docs as a resource, so it can wire
Socketly into a codebase and then **check its own work** — publish an event, watch the
channel, see the message arrive. An agent that publishes blind and reports success is how
generated realtime code ends up quietly wrong.

## Install

Claude Code:

```bash
claude mcp add socketly \
  --env SOCKETLY_SECRET_KEY=sk_app_… \
  --env SOCKETLY_PUBLIC_KEY=pk_app_… \
  -- npx -y @socketly/mcp
```

Anything that reads `mcp.json` — Claude Desktop, Cursor, Windsurf, VS Code:

```json
{
  "mcpServers": {
    "socketly": {
      "command": "npx",
      "args": ["-y", "@socketly/mcp"],
      "env": {
        "SOCKETLY_SECRET_KEY": "sk_app_…",
        "SOCKETLY_PUBLIC_KEY": "pk_app_…"
      }
    }
  }
}
```

Both keys are minted together when you create an app. The secret is shown exactly once.

## Configuration

| Variable | Required | Purpose |
|---|---|---|
| `SOCKETLY_SECRET_KEY` | yes | Publishing and channel inspection. The app id is read out of it. |
| `SOCKETLY_PUBLIC_KEY` | no | Needed to watch channels. Without it the three subscription tools are not registered at all, rather than present and always failing. |
| `SOCKETLY_URL` | no | Defaults to https://api.socketly.co |
| `SOCKETLY_DOCS_URL` | no | Defaults to https://docs.socketly.co |

## Tools

- `socketly_publish` (no) — Publishes an event to one or more channels, as your server would. Real clients receive it.
- `socketly_channel_info` (read-only) — A channel's kind, and for presence- channels who is currently in it.
- `socketly_subscribe` (read-only) — Opens a connection and starts buffering. Returns a handle.
- `socketly_receive` (read-only) — Drains a handle. Long-polls for the first message if you ask it to.
- `socketly_unsubscribe` (yes) — Closes the subscription and its connection.

`socketly_publish` is annotated as not read-only so a client can gate it behind
confirmation. It is the only tool that changes anything.

## Watching a channel is a buffer, not a stream

MCP cannot push a message into a model's turn — the specification's only server-to-client
mechanism carries four notification types, all about lists changing, and none reaches the
model. So the server holds the socket and buffers; the agent drains with a tool call.

- A subscription closes after **5 minutes** without a receive. An expired handle returns an
  error telling the agent to resubscribe, rather than an empty result that reads exactly
  like a quiet channel.
- The buffer holds **256 messages**; past that the oldest are dropped and `receive` reports
  how many, because a silently truncated buffer is a lie an agent would reason from.
- An open subscription is a connection and counts against your concurrent connection limit.

Only `public-` channels can be watched. `private-` and `presence-` need a signature from
your backend, which is the point of them.

## Not yet

**Creating apps and rolling keys.** Those sit behind a session cookie and exposing them
needs a personal access token the API does not have. They are also a dashboard click, and
rare.

**An agent as a channel member.** Replying in `presence-support-4821` as a participant
needs a credential scoped to one channel and one identity; a secret key grants far too
much.

