Keys and rights

Every request to Pikoman is signed, and every key gets only what it needs. A key for statistics cannot publish, and a bot's key reaches only its own channels and stops working on the day you choose.

Two kinds of key

Key Signs Held by
Signing key Join tokens for your users, so their browsers may join private- and presence- channels. Your token endpoint
API key Requests to the HTTP API, and, with the subscribe right, its own join tokens to listen in channels. Backends, scripts, bots, MCP servers, LLMs

An app can have several of each. Make a new one, switch your code over, delete the old one, and nobody is locked out in between.

We never see your private keys. Both kinds are Ed25519 key pairs, made in your browser. Pikoman only ever receives the public half, which can check a signature but cannot make one, and no request ever carries a password. Someone who got hold of our stored data could not publish as your app or listen in its private channels.

The secret you get

When you make an API key in the dashboard, your browser makes the key pair, sends us only the public half, and shows you the private half once, as one string:

text
sk_<key id>.<seed>
  • <key id> is k_ and 16 hex characters, such as k_3f9a1c0e5b7d2468. It is not secret: it goes in every token so the server knows which public key to check with.
  • <seed> is the 32-byte Ed25519 private key in base64url, 43 characters. This is the secret.

Put it in PIKOMAN_API_KEY on your server. Our libraries read it from there and sign every request for you; in other languages it takes about ten lines. You can also make the key pair on your own computer and paste only the public key into the dashboard, then sign with it and the key id the dashboard shows.

Variable Holds
PIKOMAN_APP The app id. Public; it also goes in your frontend.
PIKOMAN_API_KEY An API key, sk_…, for HTTP requests and bots.
PIKOMAN_PRIVATE_KEY A signing key, one line of base64 starting MC4CAQAw, for your token endpoint.
PIKOMAN_SIGNING_KEY_ID Optional: that signing key's id, to put in the kid of join tokens.

Rights

An API key has one or more of these. Anything it was not given, it is refused with a 403.

Right Allows
publish POST /{channel}/{event}, POST /{channel} and POST /mass_events: send events to channels.
stats GET /{channel}/stats: how many connections are in a channel.
clients GET /{channel}/clients: who is in a channel, with their member data.
disconnect DELETE /connection/{id}: close one connection.
disconnect_all DELETE /disconnect-all: close every connection of the app.
subscribe Join channels over the WebSocket with a join token signed by this key, and receive what is sent there.
client_events Also send client events in those channels. Needs subscribe.

Limits

Each limit is optional. Left empty, it does not limit anything.

Limit Example What it does
Channel kinds private, presence Only channels of these kinds. Public channels are every name that starts with neither private- nor presence-; encrypted channels count as private.
Prefixes orders- Only channels whose whole name starts with one of these, up to 50. For a private channel include the kind: private-orders-. End a prefix with -, _ or ., or orders also matches ordersadmin.
IP addresses and networks 203.0.113.7, 10.0.0.0/8, 2001:db8::/32 Only requests, and bot connections, from these addresses, up to 50. IPv4 and IPv6, single addresses or networks.
Expiry 30 days, 90 days, a year, a date After it, every use is refused with a 403.

A channel must pass both the kinds and the prefixes. They apply to every right that names a channel: publish, stats, clients, subscribe and client_events. Disconnecting names a connection, not a channel, so those two rights reach the whole app. Changes in the dashboard take effect within half a minute.

Try the key form

This is the dashboard's form for a new API key. Pick a template or tick what you need, and see what the key would be allowed to do and what Pikoman would store for it.

This is a preview. It makes no key and sends nothing anywhere. To make a real one, sign in to the dashboard.

New API key

Template

Fills in the rights below. You can change them afterwards.

Rights
Channel kinds
Channels
Addresses
Expires

What this key may do

3 rights, every channel, never expires.

Can

  • Publish to every channel
  • Count the connections in every channel
  • See who is in every channel, with their member data
  • Be used from any address

Cannot

  • Disconnect a connection
  • Disconnect everyone at once
  • Join channels or listen
  • Send client events

Never expires. Delete it when you no longer need it.

What Pikoman stores
{
	  "id": "k_3f9a1c0e5b7d2468",
	  "public_key": "<public key, 32 bytes in base64>",
	  "scopes": ["publish", "stats", "clients"]
	}

Only the public half is stored. A limit left empty is left out, which means no limit.

Open the dashboard

Bots, MCP servers and LLMs

A program that should follow what happens in a channel, such as an order bot, an MCP server or an LLM agent, needs no web page and no token endpoint. Give it an API key with subscribe, limited to its channels, and it signs its own join token: the usual claims, with the key id as kid in the header.

join token
header  {"alg": "EdDSA", "typ": "JWT", "kid": "k_3f9a1c0e5b7d2468"}
payload {"connection": "c_8f3a1c9e2b7d4a6051e0c3b9", "channel": "private-orders-eu", "access": 0,
         "data": {"name": "Order bot"}, "exp": 1761300300}

When a join token names an API key, the server checks that:

  • the key has subscribe, and the channel passes its kinds and prefixes;
  • the key has not expired, and the WebSocket comes from an allowed address;
  • access is 0 (listen), or 3 if the key also has client_events.

In a presence channel the server names the bot next to its member data, never in it: bot in member_joined and bots with the member list hold the id of the API key it joined with, so people can see it is not a person and which key it is. The member data is yours and goes out exactly as the token has it; data that says "bot": true makes nobody a bot. See who is a bot. Here is a bot in Node 22 or later, with nothing to install:

order-bot.mjs
import { createPrivateKey, sign } from "node:crypto";

// PIKOMAN_API_KEY is sk_<key id>.<seed>; the seed is the 32-byte Ed25519 private key.
const [keyId, seed] = process.env.PIKOMAN_API_KEY.slice(3).split(".");
const privateKey = createPrivateKey({
  key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.from(seed, "base64url")]),
  format: "der",
  type: "pkcs8",
});
const part = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
const jwt = (header, payload) => {
  const signed = `${part(header)}.${part(payload)}`;
  return `${signed}.${sign(null, Buffer.from(signed), privateKey).toString("base64url")}`;
};

const socket = new WebSocket(`wss://next.pikoman.se/${process.env.PIKOMAN_APP}`);
socket.onmessage = ({ data }) => {
  const message = JSON.parse(data);
  if (message.op === "connected") {
    const token = jwt(
      { alg: "EdDSA", typ: "JWT", kid: keyId },
      { connection: message.id, channel: "private-orders-eu", access: 0, data: { name: "Order bot" },
        exp: Math.floor(Date.now() / 1000) + 300 },
    );
    socket.send(JSON.stringify({ op: "join", channel: "private-orders-eu", token }));
  } else if (message.op === "event") {
    console.log(message.event, message.data); // hand it to your agent here
  }
};
// The server drops a connection that is silent for 90 seconds.
setInterval(() => socket.send(JSON.stringify({ op: "ping" })), 30_000);

The messages are described in Protocol 2.0. A refused join is answered with {"op":"error","code":"join_refused"}.

Signing keys and kid

Your token endpoint signs join tokens with a signing key, as in the quickstart. With more than one, a token may name the key it was signed with in kid; set PIKOMAN_SIGNING_KEY_ID and put it in the header. A token without kid is checked against each of the app's signing keys, so tokens made before you had several keep working.

To replace a signing key: add a new one in the dashboard, switch your token endpoint to it, then delete the old one. Tokens live at most ten minutes, so nobody is refused while you switch.

Compared with Pusher, Stripe and Ably

How we read their public documentation in October 2026. Tell us if we have something wrong, and we will fix it.

Pusher Stripe Ably Pikoman
How a request is proven HMAC signature The key is sent Key or token Ed25519 signature
The service stores The shared secret The key The shared key Public keys only
Several keys with limited rights No Yes Yes, per channel Yes, per endpoint and channel
A program listens with its own key No Not a realtime service Yes Yes
Try it with one line of curl No Yes Yes With a token from the dashboard

The last row is the price of signing: there is no curl -u. The dashboard makes a ten-minute token for you to paste, and our libraries sign without you noticing.

Prices, PubNub and code for moving from Pusher are on Pusher alternative.

Previous: HTTP API Next: protocol and tokens

We are rebuilding the dashboard

Self-service sign-up is closed while we do. Until the new dashboard is ready we set up apps by hand: write to us with a line about what you are building, and we will set up your app and get back to you.

Write to hello@pikoman.se