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:
sk_<key id>.<seed>-
<key id>isk_and 16 hex characters, such ask_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.
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.
{
"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 dashboardBots, 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.
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;
-
accessis0(listen), or3if the key also hasclient_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:
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.