Documentation
Everything you need to add realtime channels to your app: connect from the browser, publish from your backend, lock channels down, and react to what happens on them. Examples are shown in every language Pulsely ships a server SDK for — pick the tab that matches your stack.
Getting started
An app is an isolated pub/sub namespace. Create one and three credentials appear immediately — there is no provisioning step and nothing to wait on.
| Credential | Visibility | Used for |
|---|---|---|
App ID | Public | Identifies the app in every server API URL |
App key | Public — ship it in your frontend bundle | The browser SDK's connection identity |
App secret | Server-side only | Signs every server-side request. Must never reach a browser. |
orders and the channel exists. It disappears again once the last
subscriber leaves.
From here: subscribe in the browser (next section), then publish from your backend with the trigger API. Both sides use the same app.
Connecting & subscribing
One script tag, hosted from Pulsely, bundles the SDK and its STOMP.js dependency together:
<script src="https://pulsely.dev/assets/js/pulsely.min.js"></script>
If you'd rather load STOMP.js and pulsely.js as two separate, unminified files
— for reading the source, debugging, or serving from your own domain — that
works too and always will. Either way, both StompJs and
Pulsely land as globals.
const bp = new Pulsely( 'your-app-key' ); // safe in the browser await bp.connect(); bp.subscribe( 'orders' ); bp.bind( 'created', ( data, meta ) => { console.log( 'new order:', data, meta.replayed ); } );
subscribe() can be called before or after connect()
resolves — it queues either way. bind(eventName, callback) only
fires for that exact event name; your callback receives
(data, meta), where meta is
{ channel, event, replayed, fromClient }. If the channel's plan has
message history on, subscribing replays recent messages (oldest → newest,
meta.replayed === true) before the live stream starts, so a page
that just loaded doesn't render empty.
To listen for everything without knowing event names ahead of time — a debug
console, an activity log — use bindGlobal((data, meta) => …)
instead; it fires alongside any bind() handlers, not in place of
them.
options.url, the
client defaults to wss://<the page's own host>/ws — the
origin the page is served from, not Pulsely's broker. Pass it
explicitly everywhere else, or connect() sits in a
connecting state against a WebSocket endpoint that doesn't exist:
new Pulsely('your-app-key', { url: 'wss://pulsely.dev/ws' }).
Private & presence channels
Channels prefixed private- or presence- require your
server to vouch for the connection. Two pieces: your backend mints a
short-lived connection token, and your backend answers an authorization
webhook when a subscribe comes in.
1. Mint a connection token
Every server SDK has the same authToken(userId, ttlSeconds) method:
const token = bp.authToken( user.id ); // "{expires}.{userId}.{hmac}"
token = bp.auth_token(user.id)
token = pulsely.authToken( user.id )
$token = $bp->authToken($user->id);
2. Present it from the browser
const bp = new Pulsely( 'your-app-key', { authToken: token } ); await bp.connect(); bp.subscribe( 'private-orders' );
3. Answer the auth webhook
Pulsely POSTs { channel_name, socket_id, user_token } to the
endpoint registered on your channel rule, and honors
{"authorized": true|false} back. Any timeout, non-200, or
malformed response denies. authorizeChannel(…) builds the
response shape for you:
app.post( '/pulsely/auth', ( req, res ) => { const user = userFromToken( req.body.user_token ); res.json( bp.authorizeChannel( { authorized: canSee( user, req.body.channel_name ), userId: user.id, userInfo: { name: user.name } } ) ); } );
@app.route("/pulsely/auth", methods=["POST"]) def pulsely_auth(): user = user_from_token(request.json["user_token"]) return bp.authorize_channel( authorized=can_see(user, request.json["channel_name"]), user_id=user.id, user_info={"name": user.name}, )
function answer( event, rc, prc ) { user = userFromToken( rc.user_token ); return pulsely.authorizeChannel( authorized = canSee( user, rc.channel_name ), userId = user.id, userInfo = { "name" : user.name } ); }
$input = json_decode(file_get_contents('php://input'), true); $user = userFromToken($input['user_token']); echo json_encode($bp->authorizeChannel( canSee($user, $input['channel_name']), $user->id, ['name' => $user->name] ));
Presence channels
Subscribing to a presence- channel joins a live roster. The auth
endpoint names the subscriber by returning user_id and optional
user_info alongside authorized; membership is tracked
per connection but reported per user — someone with three tabs
open is one member, and only their first arrival and last departure produce an
event.
bp.subscribe( 'presence-lobby' ); bp.bind( 'presence.subscription_succeeded', () => renderMembers( bp.members( 'presence-lobby' ) ) ); bp.bind( 'presence.member_added', ( d ) => addMember( d.member ) ); bp.bind( 'presence.member_removed', ( d ) => removeMember( d.member ) ); bp.members( 'presence-lobby' ); // [{ user_id, user_info }, …] bp.memberCount( 'presence-lobby' ); // distinct users
| Event | Sent to | Payload |
|---|---|---|
presence.subscription_succeeded | the joiner only | { members, me, count } |
presence.member_added | everyone else | { member, count } |
presence.member_removed | everyone else | { member, count } |
Presence requires a plan with allows_presence_channels and a matching rule in channel_auth_rules, the same as private channels.
Client-published events
By default only your server publishes, via the signed trigger API. Client events let browsers publish straight to each other — typing indicators, cursor positions, reactions — anywhere a round trip through your backend is wasted work. Because the app key is public, this is deliberately narrow:
| Guard | Why |
|---|---|
| Off unless the app opts in | The default cannot be "any visitor may broadcast" |
private- / presence- channels only | Public channels would let any visitor reach every other one |
| Must already be subscribed | Subscribing means your auth endpoint approved them |
Event name starts with client- | A client can never impersonate a server event your handlers trust |
| 10 events/second per connection | One abusive tab cannot flood a channel |
| 10 KB per payload | — |
bp.subscribe( 'private-chat-42' ); bp.trigger( 'private-chat-42', 'client-typing', { user: 'ada' } ); bp.bind( 'client-typing', ( data, meta ) => { // meta.fromClient === true — this came from a browser, not your server. showTypingIndicator( data.user ); } );
Client events are metered like any other message but are not written
to history — they're ephemeral, and persisting them would let any
connected browser write rows into your history table indefinitely. Senders
never receive their own events back, and meta.fromClient tells
your handler which messages came from a browser rather than your server —
validate those. Toggle client events per app from the app page in the
dashboard.
Watching the connection
Don't reach into bp.client — that's the underlying STOMP.js client,
and touching it directly breaks the SDK's own bookkeeping (its callback
properties hold one handler each, so setting your own silently replaces the
SDK's). Use bp.connection.bind(…) instead:
bp.connection.bind( 'state_change', ( { previous, current, error } ) => { if ( current === 'connected' ) showBanner( 'Live' ); if ( current === 'unavailable' ) showBanner( error || 'Reconnecting…' ); if ( current === 'disconnected' ) showBanner( 'Disconnected' ); } );
States: initialized → connecting →
connected / unavailable (dropped or refused — the
SDK retries automatically) / disconnected (you called
disconnect() — terminal, no retry). Call
bp.disconnect() on page unload for prompt server-side cleanup
rather than waiting for the broker to notice the socket died.
Publishing events (trigger API)
Your backend publishes with one signed HTTP call — no socket to hold open, no client library to keep alive:
POST /apps/{appId}/events
X-Pulsely-Timestamp: {epochSeconds}
X-Pulsely-Signature: {hex}
{"channel":"orders","event":"created","data":{...}}
Signed HMAC-SHA256 with your app secret, over four lines joined by real
newlines: POST\n/apps/{appId}/events\n{timestamp}\n{sha256(body)}.
The path and body hash are bound into the signature so a captured one can't be
replayed against another app or payload; timestamps outside the auth window
(600 seconds by default) are rejected. Every server SDK gets this right for
you:
BODY='{"channel":"orders","event":"created","data":{"id":42}}' TS=$(date +%s) BODY_HASH=$(printf '%s' "$BODY" | shasum -a 256 | cut -d' ' -f1) SIGNING=$(printf 'POST\n/apps/%s/events\n%s\n%s' "$APP_ID" "$TS" "$BODY_HASH") SIG=$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$APP_SECRET" | sed 's/^.*= //') curl -X POST "https://pulsely.dev/apps/$APP_ID/events" \ -H "Content-Type: application/json" \ -H "X-Pulsely-Timestamp: $TS" \ -H "X-Pulsely-Signature: $SIG" \ --data-binary "$BODY"
const bp = new Pulsely( { appId, appKey, appSecret, baseUrl } ); await bp.trigger( 'orders', 'created', { id: 42 } );
bp = Pulsely(app_id=app_id, app_key=app_key, app_secret=app_secret, base_url=base_url) bp.trigger("orders", "created", {"id": 42})
pulsely = new Pulsely( appId = "…", appKey = "…", appSecret = "…" ); pulsely.trigger( "orders", "created", { "id" : 42 } );
$bp = new Pulsely(['appId' => $appId, 'appKey' => $appKey, 'appSecret' => $appSecret, 'baseUrl' => $baseUrl]); $bp->trigger('orders', 'created', ['id' => 42]);
Non-2xx responses raise a typed error (PulselyError) carrying the
status and parsed body, rather than returning silently. Responses:
200 ok, 400 malformed, 401 bad signature,
404 unknown/inactive app, 429 daily message limit
reached.
"\n" is two
literal characters — use char(10)); lowercase hex for both the body
hash and the signature. The SDKs get all four right, which is the whole reason
to use them instead of rolling your own.
Listing channels
Channels are ephemeral occupancy state, not persistent objects — this is the only way to find out what's currently live without maintaining your own shadow registry. Signed the same way as the trigger API, with an empty body.
Every occupied channel
TS=$(date +%s) BODY_HASH=$(printf '%s' "" | shasum -a 256 | cut -d' ' -f1) SIGNING=$(printf 'GET\n/apps/%s/channels\n%s\n%s' "$APP_ID" "$TS" "$BODY_HASH") SIG=$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$APP_SECRET" | sed 's/^.*= //') curl "https://pulsely.dev/apps/$APP_ID/channels?filter_by_prefix=presence-&info=user_count" \ -H "X-Pulsely-Timestamp: $TS" \ -H "X-Pulsely-Signature: $SIG"
const channels = await bp.listChannels( { filterByPrefix: 'presence-', info: 'user_count' } ); // { "presence-lobby": { user_count: 3 } }
channels = bp.list_channels(filter_by_prefix="presence-", info="user_count") # {"presence-lobby": {"user_count": 3}}
channels = pulsely.listChannels( filterByPrefix = "presence-", info = "user_count" );
$channels = $bp->listChannels('presence-', 'user_count');
One channel's detail
# Same signing as above, path is /apps/{appId}/channels/{channelName} curl "https://pulsely.dev/apps/$APP_ID/channels/orders" \ -H "X-Pulsely-Timestamp: $TS" \ -H "X-Pulsely-Signature: $SIG" # {"occupied":true,"subscription_count":3}
const channel = await bp.getChannel( 'presence-lobby' ); // { occupied: true, subscription_count: 3, user_count: 2 }
channel = bp.get_channel("presence-lobby") # {"occupied": True, "subscription_count": 3, "user_count": 2}
channel = pulsely.getChannel( "presence-lobby" );
$channel = $bp->getChannel('presence-lobby');
GET /apps/{appId}/channels returns every occupied channel as
{ channel_name: {} }; add ?filter_by_prefix= to scope
by prefix, and ?info=user_count to include member counts inline for
presence channels. Query params are never part of the signing string — only the
route path is signed. GET /apps/{appId}/channels/{channelName}
always returns 200, since "not occupied" is a normal state, not a
missing resource. user_count (distinct presence members) only
appears for presence- channels; subscription_count
counts live connections, which can exceed user_count when one user
has several tabs open.
Event webhooks
Your backend doesn't need to hold a socket open to react to what happens in a channel. Register an endpoint from the app page in the dashboard and Pulsely POSTs to it.
| Event | Fires when |
|---|---|
channel_occupied | the first subscriber joins a channel |
channel_vacated | the last subscriber leaves |
member_added | someone joins a presence channel |
member_removed | they leave it |
client_event | a browser publishes a client event |
{
"event": "channel_occupied",
"data": { "channel": "orders" },
"app_id": "a3f1…",
"created_at": "2026-07-28T09:14:22"
}
Each request carries X-Pulsely-Timestamp and
X-Pulsely-Signature, signed with your app secret using the same
scheme as the trigger API — verify against the raw body, since
a re-serialized object will not match.
app.post( '/pulsely/webhook', express.raw( { type: 'application/json' } ), ( req, res ) => { if ( !bp.verifyWebhook( req.body, req.headers ) ) return res.sendStatus( 401 ); handle( JSON.parse( req.body ) ); res.sendStatus( 200 ); } );
@app.route("/pulsely/webhook", methods=["POST"]) def pulsely_webhook(): if not bp.verify_webhook(request.data, request.headers): return "", 401 handle(request.get_json()) return "", 200
verifyWebhook / verify_webhook currently ship on the
Node and Python SDKs only. The BoxLang and PHP SDKs don't yet expose it — verify
those deliveries by reimplementing the same signing scheme shown in
Publishing events against
X-Pulsely-Timestamp / X-Pulsely-Signature and the path
/webhook.
Delivery is queued, never inline — a customer endpoint that hangs must not stall the broker thread that produced the event. Non-2xx responses retry with backoff (10s, 1m, 5m, 30m) up to five attempts, after which the row stops retrying but stays visible for debugging on the app page. Endpoints only receive the event types they subscribed to, and registering the same URL twice updates it rather than creating a duplicate.
Browser SDK reference
public/assets/js/pulsely.js — everything on the Pulsely client:
| Method | What it does |
|---|---|
new Pulsely(appKey, options) | Create a client. options.authToken, options.url |
bp.connect() | Connect. Returns a promise |
bp.subscribe(channel) / bp.unsubscribe(channel) | Join / leave a channel |
bp.bind(event, cb) / bp.unbind(event, cb) | Listen for one event name |
bp.bindGlobal(cb) / bp.unbindGlobal(cb) | Listen for every event |
bp.trigger(channel, event, data) | Publish from the browser (client-* only) |
bp.signin(token) | Sign in as a user, app-wide |
bp.watch() | Subscribe to online/offline for the whole app |
bp.members(channel) / bp.memberCount(channel) | Local presence roster read |
bp.isSubscribed(channel) | Has the broker actually confirmed this channel |
bp.connection.bind('state_change', cb) | Watch connect/disconnect/retry |
bp.disconnect() | Close and stop retrying |
Pulsely.logToConsole = true | Turn on debug tracing |
Events you'll bind to
| Event | Fires when |
|---|---|
| (your own event names) | Something your server (or a client) published |
subscription_succeeded | A channel subscribe was accepted |
subscription_error | A channel subscribe was refused — data.reason |
subscription_count | A channel's live subscriber count changed |
presence.subscription_succeeded | You joined a presence channel — data.members |
presence.member_added / presence.member_removed | Someone else joined/left |
signin.succeeded / signin.failed | Result of bp.signin() |
watchlist.online / watchlist.offline | A signed-in user came online/went offline |
The full task-by-task walkthrough this reference is drawn from lives in pulsely-js-guide.md at the root of the repo.
Server SDK reference
Thin, dependency-free clients — publish, mint tokens, answer auth requests, and (Node/Python) verify webhooks. Each is small enough to read in one sitting.
| Language | File | Verified against a live broker |
|---|---|---|
| Node.js 18+ | sdks/node/pulsely.mjs | Yes |
| Python 3.8+ | sdks/python/pulsely.py | Yes |
| BoxLang / CFML | sdks/boxlang/Pulsely.bx | Yes |
| PHP 7.4+ | sdks/php/Pulsely.php | No — written to spec, not yet run against a live broker |
| Method | What it does |
|---|---|
trigger(channel, event, data) | Publish an event |
listChannels(filterByPrefix, info) | Every occupied channel |
getChannel(channelName) | One channel's occupancy detail |
authToken(userId, ttl) | Mint a browser connection token |
authorizeChannel(…) | Build an auth-endpoint response |
verifyWebhook(rawBody, headers) | Verify an inbound delivery — Node & Python only |
tests/sdk-check.mjs drives every verified SDK through a real
publish, a rejected publish, a channel list/detail lookup, and a connection
token that has to survive an actual STOMP CONNECT — and asserts all of them
produce byte-identical signatures for a fixed payload, so a divergence in any
one fails the build. The full breakdown, including the "why not roll your own"
case for the four signing gotchas, lives in sdks/readme.md.
Administration
An account with admin access reaches /admin, which lists every account with its plan, app count, and usage today, and can change a plan, suspend or reactivate an account, and grant or revoke administrator access. Authorization is checked server-side on every action — re-read from the database each request, not trusted from the session — so revoking access takes effect immediately rather than at next login. Three guards stop an administrator locking everyone out: you cannot suspend yourself, you cannot revoke your own access, and the last remaining administrator cannot be demoted.