Webhooks
The realtime server can call your backend when something happens in your app's channels: a channel gets its
first member or loses its last, someone joins or leaves a presence channel, or (if you ask) a member sends a
client event. Each call is a signed POST with a batch of events, in Pusher's shape.
Events
| name | When | Fields |
|---|---|---|
channel_occupied | A channel's first member joined. | channel |
channel_vacated | Its last member left. | channel |
member_added |
Someone joined a presence channel: a user's first connection there, when their signed member data has a user_id; any connection when it has none. |
channel, user_id (when there is one), connection |
member_removed |
A user's last connection left (or one without a user_id). When a moderator removed it, reason is "kicked". |
channel, user_id, connection; after a kick also reason and kicked: by (the API key), by_connection (a bot's), reason, ban_seconds |
client_event |
A member sent a client event. Opt in only if a bot needs every one; it can be a lot. | channel, event, data, connection, user_id, bot, to (for one sent to one member) |
user_id is the one in the member data your backend signed in the join token
({"user_id": "42", "name": "Ada"}), so Pikoman tells you who, as you said it. Two tabs of the same
user are one member: member_added comes for the first and member_removed for the last.
Setting them up
In the dashboard, open the app and add a webhook under
Webhooks: an https:// URL, the events it wants, and which channels it may hear
of. Up to five per app. Like an API key, a webhook can be held to channel kinds,
prefixes and exact names: one for someone else's bot that may only see chat- channels never hears
of admin-x. The dashboard signs the webhooks into the app's key list, so the realtime server takes
them from nowhere else. Changes reach the server within about 30 seconds.
The page also shows what came of the calls: how many events were delivered, waiting, given up and dropped, when the last one went through and the last error.
What your backend gets
POST /pikoman/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Pikoman-Webhooks/1 (+https://pikoman.se/docs/webhooks/)
webhook-id: msg_4f1c9e0b7a2d48e6b3c5a9d1e7f20864
webhook-timestamp: 1761400000
webhook-signature: v1a,Q2JpZ3JhbnQ…
pikoman-key-id: wh_9a3f1c0e5b7d2468
{"time_ms":1761400000123,"app_id":"app-yourapp","events":[
{"name":"channel_occupied","channel":"presence-room"},
{"name":"member_added","channel":"presence-room","user_id":"42","connection":"c_8f3a1c9e2b7d4a6051e0c3b9"},
{"name":"client_event","channel":"chat-1","event":"client-message","data":{"text":"hi"},"connection":"c_8f3a…","user_id":"42"}
]}- Events are batched per URL: whatever happened in about a tenth of a second goes in one request, at most 100 events and 256 KB.
- Answer with any
2xxwithin 5 seconds, and do slow work afterwards. Anything else, a timeout or a redirect counts as failed. - A failed batch is tried again after 1, 2, 4, 8, 16 and 32 seconds and then every minute, about five minutes in all, with the same
webhook-id. Then it is given up and counted on the dashboard. - Batches to one URL go one at a time, in order. Up to 1 000 events can wait per URL, and as many bytes as your plan gives (about 400 KB on Free, 4 MB on Pro); past that they are dropped and counted.
- A URL that has not answered through a whole batch's tries counts as down: each batch then gets one try until one gets through, and then they are tried again as usual. Your app has at most 4 requests on their way at once.
- Webhooks are best effort, as Pusher's are: after a restart of the server, what was waiting is gone. For something that must not be missed, have your backend ask (
GET /channels) as well.
Checking the signature
Every request is signed with an Ed25519 key of your app's own, made by the realtime server, following the
Standard Webhooks spec for public-key signatures
(v1a): the signature covers <webhook-id>.<webhook-timestamp>.<the raw body>.
Check it before you trust anything in the body, and:
- refuse a
webhook-timestampmore than 5 minutes from your clock (an old request played back); - handle each
webhook-idonce (a retry, or a replay within the 5 minutes); - check the raw bytes: parse the JSON only after;
- check that the body's
app_idis your app's.
Each app has its own key, so a request signed for someone else's app never verifies with yours, even if it is passed on to your URL unchanged. Your app's public key, with its id, is published at (also shown in the dashboard, under Webhooks):
GET https://next-api.pikoman.se/.well-known/pikoman-webhook-key/app-yourapp{"app_id":"app-yourapp","keys":[{"kid":"wh_9a3f1c0e5b7d2468","alg":"Ed25519","public_key":"<base64, 32 bytes>","whpk":"whpk_<the same>"}]}
Put its public_key in your backend's settings (PIKOMAN_WEBHOOK_PUBLIC_KEY below), and
your app id as PIKOMAN_APP. Neither is a secret. When the key is ever changed, the server signs with
both keys for a while and both are listed, so a request keeps verifying with the key you have;
pikoman-key-id says which keys signed it, in the order of the signatures. The key also works with the
Standard Webhooks libraries, as its whpk_ form.
import { createPublicKey, verify } from "node:crypto";
// Your app's public key from GET https://next-api.pikoman.se/.well-known/pikoman-webhook-key/<your app id>
// ("public_key", base64). Not a secret: it can only check, never sign.
const PIKOMAN_KEY = createPublicKey({
key: Buffer.concat([
Buffer.from("302a300506032b6570032100", "hex"), // Ed25519 public key header (SPKI)
Buffer.from(process.env.PIKOMAN_WEBHOOK_PUBLIC_KEY, "base64"),
]),
format: "der",
type: "spki",
});
const seen = new Map(); // webhook-id -> when, to drop a batch you already handled
/** The events of a webhook request, or null when it is not from Pikoman. `body` is the raw bytes. */
export function pikomanEvents(headers, body) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = (headers["webhook-signature"] ?? "").split(" ");
if (!id || !timestamp) return null;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return null; // older than 5 minutes
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), body]);
const valid = signatures.some((s) =>
s.startsWith("v1a,") && verify(null, signed, PIKOMAN_KEY, Buffer.from(s.slice(4), "base64")));
if (!valid || seen.has(id)) return null;
const batch = JSON.parse(body);
if (batch.app_id !== process.env.PIKOMAN_APP) return null; // for another app: not yours
seen.set(id, Date.now());
return batch.events;
}
// Express: the raw body, not parsed JSON, since the signature covers the exact bytes.
app.post("/pikoman/webhook", express.raw({ type: "application/json" }), (req, res) => {
const events = pikomanEvents(req.headers, req.body);
if (!events) return res.sendStatus(401);
res.sendStatus(204); // answer at once; do the work after
for (const event of events) console.log(event.name, event.channel);
});<?php
// Your app's public key from https://next-api.pikoman.se/.well-known/pikoman-webhook-key/<your app id>, base64.
$publicKey = base64_decode(getenv('PIKOMAN_WEBHOOK_PUBLIC_KEY'));
$body = file_get_contents('php://input'); // the raw body
$id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$signatures = explode(' ', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '');
$valid = false;
if ($id !== '' && abs(time() - (int) $timestamp) <= 300) {
foreach ($signatures as $signature) {
if (str_starts_with($signature, 'v1a,')) {
$raw = base64_decode(substr($signature, 4), true);
if ($raw !== false && strlen($raw) === SODIUM_CRYPTO_SIGN_BYTES
&& sodium_crypto_sign_verify_detached($raw, "$id.$timestamp.$body", $publicKey)) {
$valid = true;
}
}
}
}
$batch = $valid ? json_decode($body, true) : null;
if (!$valid || ($batch['app_id'] ?? '') !== getenv('PIKOMAN_APP')) {
http_response_code(401);
exit;
}
// Drop an id you have already handled (a retry), e.g. in a table with a unique key.
http_response_code(204);
foreach ($batch['events'] as $event) {
error_log($event['name'] . ' ' . $event['channel']);
}# pip install cryptography
import base64, json, os, time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
PIKOMAN_KEY = Ed25519PublicKey.from_public_bytes(
base64.b64decode(os.environ["PIKOMAN_WEBHOOK_PUBLIC_KEY"]))
def pikoman_events(headers, body: bytes):
"""The events of a webhook request, or None when it is not from Pikoman."""
msg_id = headers.get("webhook-id", "")
timestamp = headers.get("webhook-timestamp", "0")
if not msg_id or abs(time.time() - int(timestamp)) > 300:
return None
signed = f"{msg_id}.{timestamp}.".encode() + body
for signature in headers.get("webhook-signature", "").split(" "):
if signature.startswith("v1a,"):
try:
PIKOMAN_KEY.verify(base64.b64decode(signature[4:]), signed)
batch = json.loads(body)
if batch.get("app_id") != os.environ["PIKOMAN_APP"]:
return None # for another app: not yours
return batch["events"]
except (InvalidSignature, ValueError):
pass
return None
# Flask: request.get_data() is the raw body.
# events = pikoman_events(request.headers, request.get_data())// ed25519-dalek = "2", base64 = "0.22"
use base64::{Engine, engine::general_purpose::STANDARD};
use ed25519_dalek::{Signature, VerifyingKey};
/// Whether a webhook request is from Pikoman: `key` is your app's 32-byte public key from
/// `/.well-known/pikoman-webhook-key/<your app id>`, `now` Unix seconds, `body` the raw bytes.
/// Parse the body after, and check that its `app_id` is your app's.
pub fn from_pikoman(key: &VerifyingKey, id: &str, timestamp: &str, signatures: &str,
body: &[u8], now: u64) -> bool {
let Ok(sent) = timestamp.parse::<u64>() else { return false };
if id.is_empty() || now.abs_diff(sent) > 300 {
return false;
}
let mut signed = format!("{id}.{timestamp}.").into_bytes();
signed.extend_from_slice(body);
signatures.split(' ').filter_map(|s| s.strip_prefix("v1a,")).any(|s| {
STANDARD.decode(s).ok()
.and_then(|bytes| Signature::from_slice(&bytes).ok())
.is_some_and(|signature| key.verify_strict(&signed, &signature).is_ok())
})
}Why a key of ours, not a shared secret
Pusher signs webhooks with HMAC and your app's secret, which Pusher therefore has to keep. Pikoman never holds a secret of yours: your API keys and signing keys are key pairs whose private halves stay with you. So webhooks are signed with a key pair of ours instead, one for each app. Anyone can check a webhook with the public key, nobody but the realtime server can make one, a webhook for one app is worthless to another, and nothing you hold has to be shared with us or rotated because of us.
What the server will and will not call
Only https:// URLs on a host name (not an address), on port 443 or 1024 and up, with no user name
or password. The name has to resolve to public addresses: private, loopback, link-local (and so cloud metadata
services), shared, multicast and reserved ranges are refused, for IPv4 and IPv6, and the check is made again for
every connection, so a name that changes its answer later is still caught. Redirects are not followed, no proxy
is used, only a few KB of an answer are read, and the request carries nothing but the headers above.
Next: build a bot with webhooks, the channel list and messages to one member.