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.

CredentialVisibilityUsed for
App IDPublicIdentifies the app in every server API URL
App keyPublic — ship it in your frontend bundleThe browser SDK's connection identity
App secretServer-side onlySigns every server-side request. Must never reach a browser.
Channels are created implicitly. There is nothing to register — publish to 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:

index.html
<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.

app.js
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.

Default broker URL. With no 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:

server.js
const token = bp.authToken( user.id ); // "{expires}.{userId}.{hmac}"

2. Present it from the browser

app.js
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:

server.js
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 }
  } ) );
} );

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.

app.js
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
EventSent toPayload
presence.subscription_succeededthe joiner only{ members, me, count }
presence.member_addedeveryone else{ member, count }
presence.member_removedeveryone 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:

GuardWhy
Off unless the app opts inThe default cannot be "any visitor may broadcast"
private- / presence- channels onlyPublic channels would let any visitor reach every other one
Must already be subscribedSubscribing 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 connectionOne abusive tab cannot flood a channel
10 KB per payload
app.js
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:

app.js
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: initializedconnectingconnected / 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:

wire format
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:

publish.sh
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"

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.

Four sharp edges, each an unexplained 401 if you miss one: serialize the body once and sign those exact bytes — re-serializing risks a different key order; use epoch seconds, not milliseconds, in UTC; join the signing string with real newlines (in CFML/BoxLang, "\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

list-channels.sh
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"

One channel's detail

get-channel.sh
# 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}

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.

EventFires when
channel_occupiedthe first subscriber joins a channel
channel_vacatedthe last subscriber leaves
member_addedsomeone joins a presence channel
member_removedthey leave it
client_eventa browser publishes a client event
delivery payload
{
  "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.

server.js
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 );
} );
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:

MethodWhat 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 = trueTurn on debug tracing

Events you'll bind to

EventFires when
(your own event names)Something your server (or a client) published
subscription_succeededA channel subscribe was accepted
subscription_errorA channel subscribe was refused — data.reason
subscription_countA channel's live subscriber count changed
presence.subscription_succeededYou joined a presence channel — data.members
presence.member_added / presence.member_removedSomeone else joined/left
signin.succeeded / signin.failedResult of bp.signin()
watchlist.online / watchlist.offlineA 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.

LanguageFileVerified against a live broker
Node.js 18+sdks/node/pulsely.mjsYes
Python 3.8+sdks/python/pulsely.pyYes
BoxLang / CFMLsdks/boxlang/Pulsely.bxYes
PHP 7.4+sdks/php/Pulsely.phpNo — written to spec, not yet run against a live broker
MethodWhat 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.