Protocol 2.0

The next version of Pikoman is ready in code and being tested. It changes what goes over the socket and how joins are signed. Protocol 1.x, which the rest of these docs describe, is deprecated.

2.0 runs now, next to 1.x. Apps you create in the dashboard get 2.0 keys and use wss://next.pikoman.se for websockets (the client's ws_address option) and https://next-api.pikoman.se for the HTTP API. Existing 1.x apps keep working on ws.pikoman.se and api.pikoman.se until they move; we contact every app owner. When every app has moved, ws. and api.pikoman.se switch to 2.0 and the next names go away.

What changes

  • JSON on the socket. Every frame is a JSON object with an op field, in both directions. Payloads and member data are real JSON, not strings encoded twice.
  • New keys for every app. An API key for HTTP calls, and a signing key for presence and private joins. Our servers keep only a hash of the API key and the public half of the signing key, so nothing stored on our side can be used to act as your app.
  • Join tokens are standard JWTs. Presence and private joins are signed with EdDSA (Ed25519) instead of HMAC. A token is tied to one connection and one channel, and is valid for at most ten minutes.
  • Private channels. Channels starting with private- need a token, like presence, but have no member list.
  • Random connection ids, such as c_8f3a1c9e2b7d4a6051e0c3b9, instead of 1, 2, 3.
  • @pikoman/ws 1.0.0, which speaks the new protocol. Its API stays almost the same.

In your frontend

Upgrade the client when the switch happens. Most code keeps working as it is:

terminal
npm install @pikoman/ws@1
  • listen, listenPresence, on, off, send, members and getMembers are unchanged.
  • New: listenPrivate(name) for private channels, and refused(fn) when a join is turned down.
  • Member data arrive as objects. If you call JSON.parse on them today, remove it.
  • You can send objects with send directly; there is no need to JSON.stringify them first.
  • clientMessage(fn) gets (clientId, event, data), and serverMessage(fn) gets (data, event).

Tabs still running the old client when the server switches are closed with code 4400 ("protocol outdated"). They connect again after a reload.

In your auth endpoint

The request is the same, GET {auth_url}/{clientId}-{channelName}. The answer is a token instead of a join string:

JSON
{ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJjb25uZWN0aW9uIjo….3d9e…" }

The token is a standard JWT signed with EdDSA (Ed25519): a header, a payload and a signature, each base64url and joined by dots. Any JWT library that supports EdDSA can make one. The server only accepts "alg": "EdDSA", whatever else the header says.

payload
{
  "connection": "c_8f3a1c9e2b7d4a6051e0c3b9",
  "channel": "presence-room",
  "access": 3,
  "data": { "name": "Ada" },
  "exp": 1761210300
}
  • connection and channel must match the join, so a token is useless anywhere else.
  • access is the same permission number as today: 3 for a full member.
  • data is the member data that everyone in a presence channel sees.
  • exp is required, and at most ten minutes ahead. Other claims, such as iat, are ignored.

Inside a token

Change the token and see what it says, and whether the signature still holds. Change the payload and it is signed again. The key pair is made in your browser for this page only; nothing is sent anywhere.

Header

				
Signature

Signing on your backend

Node has Ed25519 built in, so no library is needed:

pikoman-token.js
import { createPrivateKey, sign } from "node:crypto";

const signingKey = createPrivateKey(process.env.PIKOMAN_SIGNING_KEY); // PEM

const part = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");

export function pikomanToken({ clientId, channel, access = 3, data }) {
  const signed = part({ alg: "EdDSA", typ: "JWT" }) + "." + part({
    connection: clientId,
    channel,
    access,
    data,
    exp: Math.floor(Date.now() / 1000) + 300,
  });
  const signature = sign(null, Buffer.from(signed), signingKey).toString("base64url");
  return `${signed}.${signature}`;
}

// res.json({ token: pikomanToken({ clientId, channel, data: { name: user.name } }) });

Or with a JWT library, such as jose:

JavaScript
import { SignJWT, importPKCS8 } from "jose";

const signingKey = await importPKCS8(process.env.PIKOMAN_SIGNING_KEY, "EdDSA");

const token = await new SignJWT({ connection: clientId, channel, access: 3, data: { name: user.name } })
  .setProtectedHeader({ alg: "EdDSA" })
  .setExpirationTime("5m")
  .sign(signingKey);

PHP has Ed25519 built in through sodium:

auth.php
function part(string $s): string {
    return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
}

// The signing key is a PEM file; its last 32 bytes are the Ed25519 seed.
$der = base64_decode(preg_replace('/-----[^-]+-----|\s+/', '', getenv('PIKOMAN_SIGNING_KEY')));
$secretKey = sodium_crypto_sign_secretkey(sodium_crypto_sign_seed_keypair(substr($der, -32)));

$signed = part(json_encode(['alg' => 'EdDSA', 'typ' => 'JWT'])) . '.' . part(json_encode([
    'connection' => $clientId,
    'channel'    => $channel,
    'access'     => 3,
    'data'       => ['name' => $name],
    'exp'        => time() + 300,
]));
$token = $signed . '.' . part(sodium_crypto_sign_detached($signed, $secretKey));

echo json_encode(['token' => $token]);

The signing key belongs on your server, next to your other secrets. Never put it in frontend code or in a VITE_ or similar variable that ends up in the bundle.

Publishing over HTTP

Nothing changes in the calls: the same paths and basic auth, with the new API key as the password. JSON bodies are passed on as JSON.

terminal
curl -X POST https://next-api.pikoman.se/orders/shipped \
  -u your-app-id:your-api-key \
  -d '{"id": 1042}'
  • New: GET /{channel}/clients lists the members of a channel with their data.
  • DELETE /kick-all always applies to the app you authenticate as.
  • A request body may be at most 256 KB, and one /mass_events call reaches at most 1,000 deliveries.

On the wire

Only needed if you write your own client. A presence join, then a client message:

JSON
{"op":"join","channel":"presence-room","token":"eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJjb25uZWN0aW9uIjo….3d9e…"}
{"op":"members","channel":"presence-room","members":{"c_8f3a…":{"name":"Ada"}}}
{"op":"send","channel":"presence-room","event":"typing","data":{"on":true}}
1.x 2.0 op
0:{id}connected
1:{channel}:{event}:{data}event
2:{channel}join / joined
2:presence-…:{signature}:…join with token
3:{channel}error, code join_refused
4:{channel}leave / left / member_left
8:…send / client_event
9:RATE_LIMITerror, code rate_limited
16:{channel}:{members}members
64:{time}ping / pong

To do now

  1. Find where your backend signs presence joins and where it publishes, so you know what to change.
  2. Check that no secret is in your frontend bundle.
  3. if something in your setup does not fit what is described here.

Previous: wire protocol 1.x

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