Public API · Realtime

WebSockets (realtime)

The same events you can receive by webhook can be pushed to an open WebSocket instead. The server speaks the Pusher protocol, so the client is plain pusher-js (or any Pusher client): the app key is the id of your API key, private channels are authorized by your own server with the key secret. There is no URL to expose and no replay: events are a live feed, the API stays the source of truth. Delivery is charged like a webhook delivery.

websocketpusherrealtimeeventspaymentsblocks

← Public API

WebSocket or webhook

The WebSocket and the webhook are two separate transports of the same events. Each has its own switch, its own network lists and its own deliveries. Turning one on does not turn the other on, and a disabled webhook or an empty webhook URL does not affect the socket.

An event that both transports want produces two deliveries and is charged twice (see Credits below).

The socket needs no URL and no signature: you open the connection, subscribe to private channels and bind handlers. The socket belongs to the account of the API key it was opened with and only ever receives that account events.

You switch the socket on with PUT /v1/realtime/subscription (below) or on the WebSocket tab of the Webhooks page in the cabinet. The plan that allows webhooks allows the socket.

Connect

Base URL for the two HTTP routes: see Overview. The WebSocket is a different host: wss://<realtime host>. The realtime host is not part of the API host and is not fixed in this documentation. It is shown in the cabinet: Webhooks, WebSocket tab, the copy-ready code block under Live check (host and port are already filled in there).

A Pusher client connects to wss://<realtime host>/app/{key}?protocol=7. The path is /app/ followed by the app key. Pusher protocol 5 or newer is required, older protocols are refused with close code 4007. pusher-js sends protocol 7 by itself.

The app key (key) is the id of an API key: the same UUID you send as X-Api-Key to the HTTP API. The key must be active, and when the key has an IP allowlist, the address of the WebSocket client must be on it (otherwise the socket is refused with 4009). The key secret never goes to the browser or to the client: only your server uses it to sign channel subscriptions.

pusher-js options that match this server: wsHost, wssPort (or wsPort for plain ws), forceTLS, enabledTransports ["ws", "wss"], cluster (any value; the library requires one, the server ignores it) and disableStats true. Use channelAuthorization to point the library at your authorization endpoint.

After the socket opens, the server sends pusher:connection_established with socket_id and activity_timeout (30 seconds by default). The server also pings the socket every 20 seconds, and pusher-js sends its own ping after activity_timeout of silence, so an idle socket stays open behind a CDN or a proxy.

What the wire looks like (the client library does this for you)

wss://<realtime host>/app/<API key id>?protocol=7

← {"event":"pusher:connection_established","data":"{\"socket_id\":\"123456.789012\",\"activity_timeout\":30}"}

→ {"event":"pusher:subscribe","data":{"channel":"private-payments","auth":"<API key id>:<hex signature>"}}
← {"event":"pusher_internal:subscription_succeeded","channel":"private-payments","data":"{}"}

← {"event":"payment_order","channel":"private-payments","data":"{\"schema_version\":\"2\",\"type\":\"payment_order\", …}"}

Authorize private channels

All four channels are private. Before the server accepts a subscription, the client must present an auth string, and your server makes it, as with Pusher. For the socket id the library gives you and a channel name:

auth = "{key}:{signature}", where key is the API key id (the app key used to connect) and signature is the lowercase hex HMAC-SHA256 of the string "{socket_id}:{channel_name}" with the API key secret as the HMAC key.

pusher-js POSTs socket_id and channel_name (form-encoded) to the endpoint you configure in channelAuthorization and expects JSON {"auth": "…"} in reply. Check there that the current user is allowed to see the channel, then sign. Never put the secret into front-end code.

The server only verifies the signature. A subscription to an unknown channel is answered with pusher:subscription_error status 403. A wrong signature gets status 401 (Invalid auth signature). Because the socket_id changes on every connection, the signature is not reusable: after a reconnect the library asks your endpoint again.

Also not supported: public and presence channels, and client events (client-*). Sending a client event returns pusher:error code 4301.

Channels and events

Four channels. The Pusher event name that you bind to is the type of the event.

private-payments: event payment_order. A Payments order changed state (event inside the body: check, underpaid, expired, failed, cancelled, aml_check, paid). Arrives on every network for as long as the socket is enabled, whatever the network lists say.

private-address-events: events payment (value received by a watched address), payout (value sent from it) and address (any other touch: confirmations, contract calls, self transfers). Filtered by the payment and payout network lists. The webhook-only Send test event is not pushed to sockets.

private-tx-watch: event tx, the final result of a watched hash (event confirmed, failed or cancelled). Delivered when the payment list or the payout list allows the network.

private-blocks: event block, a new block on a network. Filtered by the block network list. Blocks come every few seconds per network, so turn them on only for the networks you need.

Each socket receives only the channels it subscribed to. Several sockets of one account all get every event on the channels they subscribed to.

Event body

The data of an event is the JSON text of the same body a webhook would receive (pusher-js parses it for you, so the handler gets an object). Every body starts with the same header: schema_version ("2"), delivery_id, type, event, network. delivery_id is the idempotency key: the same event always has the same delivery_id. Field-by-field formats of payment, payout, address, tx and payment_order bodies are the webhook bodies, see Webhooks.

Exception 1 is block. A block event on the socket is always the same short description, for every network, and does not contain the block itself (blocks are large). Fetch the block by block_height or block_hash from the RPC endpoint or from your own node. block_time is Unix seconds. The HTTP webhook for block is different: it still carries the raw node block (see Webhooks).

Exception 2 is size. A body longer than 256 KB is not sent in full. You receive a short reference instead: delivery_id, kind (the event name), network, block_height, optionally tx_hash, address, payment_id and event, and truncated: true. Check for truncated and fetch the full object from the API.

block event (data)

{
  "schema_version": "2",
  "delivery_id": "…",
  "type": "block",
  "event": "new",
  "network": "tron-nile",
  "block_height": 71734001,
  "block_hash": "0000000004…",
  "block_time": 1760270400
}

// a body over the size cap: short reference
{
  "delivery_id": "…",
  "kind": "payment",
  "network": "ethereum",
  "block_height": 21500000,
  "tx_hash": "0x…",
  "address": "0x…",
  "event": "confirmed",
  "truncated": true
}

Recent events (rpcnode:recent)

Events are not replayed. While a socket is closed, events for it are lost; the state of a payment, an address or a hash is always available from the API. To show real data right after connecting, you can ask the server for recent events of your account.

After your subscriptions succeeded, send the client event rpcnode:recent (pusher.send_event("rpcnode:recent", {})). The server answers on the same socket with an rpcnode:recent frame whose data is a JSON array, newest first, only from the channels this socket subscribed to. Each item: channel, event, data (the JSON text of the body, as the socket would have got it), at (Unix milliseconds) and delivery_id.

The list is best-effort: it can be short or empty. Use the frame for display, never for reconciliation.

Bind it with pusher.bind("rpcnode:recent", handler) on the Pusher instance (not on a channel). Deduplicate by delivery_id.

Reconnect and reconcile

pusher-js reconnects by itself and subscribes again with a fresh authorization request. Close codes follow the Pusher convention: 4000-4099 do not reconnect (fix the cause first), 4100-4199 reconnect after a pause, 4200-4299 reconnect at once.

4001: the app key is unknown, the API key is not active or has been revoked (a revoked or deleted key closes its open sockets within a minute and new ones are refused). 4007: protocol older than 5. 4009: the client address is not on the IP allowlist of the key. 4100: too many connections (see Limits) or the service is temporarily unavailable, try again later. 4200: reconnect: the server let go of a client that could not keep up, or the key was changed.

Because nothing is replayed, treat the socket as a hint and the API as the truth. After every reconnect (and on application start), read the current state through the API: list payments you are waiting for, the address watch events since your last seen item, the tx watches. Process events idempotently by delivery_id (or by payment_id / tx_hash and status).

Limits

These limits can change, so handle the close codes instead of counting on exact numbers.

Connections: 50 open sockets per account, 200 per client IP, and 60 new sockets per minute from one IP. Over a limit the socket gets pusher:error and closes with 4100. A client that reconnects in a loop hits the per-minute limit, so use a growing pause.

Messages: the server pushes frames up to 1 MiB; a frame that you send must be shorter than 10,000 characters (longer client frames are ignored). Supported client messages: pusher:ping, pusher:subscribe, pusher:unsubscribe and rpcnode:recent.

Slow clients: a client that falls more than 256 frames or 8 MiB behind is disconnected with 4200 and must reconnect.

Credits

A WebSocket delivery is charged like a webhook delivery, at the same prices, once per event and subscription, when the delivery is accepted for the socket (a payment, an address event, a tx result or a block). Opening a socket and the number of sockets cost nothing. An event is charged also when no socket is open at that moment.

For the prices per network and event type, see Webhooks and Networks.

Switch the socket on

GET /v1/realtime/subscription reads the current state, PUT /v1/realtime/subscription changes it. Both are signed like every other route, see Authentication. Errors use { ok: false, error_code, error, errors? }.

events has three keys: payment, payout and block. Each is true (all networks), false (off) or an array of network slugs. A bare slug such as "tron" means the mainnet; use the full environment slug for a testnet ("tron-nile"). Slugs are lowercased and deduplicated, and are not checked against the network catalogue. A key that is missing from events counts as off.

If you omit events, the saved lists stay as they are. One exception: if you switch the socket on while payment, payout and block are all off (a new subscription), payments and payouts are subscribed on all networks and blocks stay off. payment_order events are delivered whenever the socket is enabled and do not depend on any list.

Enabling requires a plan that includes webhooks (otherwise HTTP 403, watch.webhook_unavailable).

GET /v1/realtime/subscription

Read whether the WebSocket is on and which networks it receives for each kind of event.

Response fields

NameTypeDescription
okbooleantrue on success.
subscription.enabledbooleanWhether the WebSocket transport is on.
subscription.events.paymentstring[]Networks for payment events (incoming value). Empty with payment_all true means all networks.
subscription.events.payoutstring[]Networks for payout events (outgoing value).
subscription.events.blockstring[]Networks for block events.
subscription.events.payment_allbooleanTrue when payment is on for every network.
subscription.events.payout_allbooleanTrue when payout is on for every network.
subscription.events.block_allbooleanTrue when block is on for every network.
  • Signed headers required, see Authentication. A new account gets enabled: false and empty lists.
  • The response shape of events differs from the request: payment, payout and block are arrays of network slugs, and payment_all, payout_all and block_all tell whether the whole kind is on for every network (the array is then empty).

Request example

import { createHash, createHmac } from 'node:crypto'

const API_KEY = process.env.PUBLIC_API_KEY! // cabinet UUID
const SECRET = process.env.PUBLIC_API_SECRET! // sk_live_…
const BASE = 'https://api.rpcnode.dev'

// path: as the server receives it, with the query; body: the exact string that is sent
async function call(method: string, path: string, body = '') {
  const timestamp = Math.floor(Date.now() / 1000)
  const bodyHash = createHash('sha256').update(body).digest('hex')
  const signature = createHmac('sha256', SECRET)
    .update([timestamp, method, path, bodyHash].join('\n'))
    .digest('hex')
  const res = await fetch(BASE + path, {
    method,
    headers: {
      'X-Api-Key': API_KEY,
      'X-Timestamp': String(timestamp),
      'X-Signature': signature,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body || undefined,
  })
  return res.json()
}

console.log(await call('GET', '/api/public/v1/realtime/subscription'))

The request is signed in the snippet itself — see Authentication for the signing rules.

Response example

{
  "ok": true,
  "subscription": {
    "enabled": true,
    "events": {
      "payment": [],
      "payout": ["tron-nile"],
      "block": [],
      "payment_all": true,
      "payout_all": false,
      "block_all": false
    }
  }
}

PUT /v1/realtime/subscription

Switch the WebSocket on or off and optionally replace the network lists. Returns the saved state.

Request body

NameTypeDescription
enabledbooleanTurn the WebSocket on or off.
eventsobjectKeys payment, payout, block; each true (all networks), false (off) or an array of network slugs. Replaces the saved lists when present. (optional)

Response fields

NameTypeDescription
okbooleantrue on success.
subscriptionobjectSaved state, same shape as GET: enabled and events (arrays plus payment_all / payout_all / block_all).
  • Signed headers required, see Authentication.
  • Omit events to keep the saved lists (switching on with nothing chosen subscribes payments and payouts on all networks, blocks stay off).
  • When events is sent, it replaces all three lists; a missing key counts as false.
  • Errors: 400 validation_failed with errors[] (field events.<name>) when events has a key other than payment, payout or block, or a value that is not true, false or an array of strings; 403 watch.webhook_unavailable when enabling on a plan without webhooks; watch.no_plan when the account has no plan.

Request example

import { createHash, createHmac } from 'node:crypto'

const API_KEY = process.env.PUBLIC_API_KEY! // cabinet UUID
const SECRET = process.env.PUBLIC_API_SECRET! // sk_live_…
const BASE = 'https://api.rpcnode.dev'

// path: as the server receives it, with the query; body: the exact string that is sent
async function call(method: string, path: string, body = '') {
  const timestamp = Math.floor(Date.now() / 1000)
  const bodyHash = createHash('sha256').update(body).digest('hex')
  const signature = createHmac('sha256', SECRET)
    .update([timestamp, method, path, bodyHash].join('\n'))
    .digest('hex')
  const res = await fetch(BASE + path, {
    method,
    headers: {
      'X-Api-Key': API_KEY,
      'X-Timestamp': String(timestamp),
      'X-Signature': signature,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body || undefined,
  })
  return res.json()
}

console.log(await call('PUT', '/api/public/v1/realtime/subscription', '{"enabled":true}'))

The request is signed in the snippet itself — see Authentication for the signing rules.

Response example

{
  "ok": true,
  "subscription": {
    "enabled": true,
    "events": {
      "payment": [],
      "payout": [],
      "block": ["tron-nile", "ethereum"],
      "payment_all": true,
      "payout_all": true,
      "block_all": false
    }
  }
}

Enable the socket (PUT)

JSON bodies

// switch on; payments and payouts on every network, blocks off (when nothing was chosen before)
{ "enabled": true }

// explicit lists: all payments, payouts only on the Tron Nile testnet, blocks of two networks
{
  "enabled": true,
  "events": {
    "payment": true,
    "payout": ["tron-nile"],
    "block": ["tron", "ethereum"]
  }
}

// switch off, keep the lists
{ "enabled": false }

Browser: pusher-js client

JavaScript

import Pusher from 'pusher-js'

const pusher = new Pusher('<API key id>', {
  wsHost: '<realtime host>', // shown in the cabinet, WebSocket tab
  wssPort: 443, // use the port shown in the cabinet
  forceTLS: true,
  enabledTransports: ['ws', 'wss'],
  cluster: 'mt1', // any value, the library requires one
  disableStats: true,
  channelAuthorization: {
    endpoint: 'https://your-server.example/pusher/auth', // see the server code
  },
})

const seen = new Set()
function once(body) {
  // delivery_id is the same for the same event
  if (!body || !body.delivery_id) return true
  if (seen.has(body.delivery_id)) return false
  seen.add(body.delivery_id)
  return true
}

// Payments: always delivered while the socket is enabled
pusher.subscribe('private-payments').bind('payment_order', (body) => {
  if (!once(body)) return
  if (body.truncated) return refetchPayment(body.payment_id) // too big, ask the API
  console.log('payment_order', body.event, body.payment_id, body.status)
})

// Address Watch hits: payment (in), payout (out), address (other)
const addresses = pusher.subscribe('private-address-events')
for (const name of ['payment', 'payout', 'address']) {
  addresses.bind(name, (body) => {
    if (!once(body)) return
    console.log(name, body.event, body.tx_hash, body.amount, body.currency)
  })
}

// Result of a watched transaction hash
pusher.subscribe('private-tx-watch').bind('tx', (body) => {
  if (!once(body)) return
  console.log('tx', body.event, body.tx_hash)
})

// New block: only a short description, the block is not inside
pusher.subscribe('private-blocks').bind('block', (body) => {
  if (!once(body)) return
  console.log('block', body.network, body.block_height, body.block_hash, body.block_time)
  // fetch the block yourself by height or hash when you need its contents
})

// Optional: recent events of this account (newest first)
pusher.bind('rpcnode:recent', (items) => {
  if (!Array.isArray(items)) return
  for (const item of items.reverse()) {
    const body = JSON.parse(item.data)
    if (once(body)) console.log('recent', item.channel, item.event, new Date(item.at))
  }
})
let ready = 0
for (const name of ['private-payments', 'private-address-events', 'private-tx-watch', 'private-blocks']) {
  pusher.channel(name).bind('pusher:subscription_succeeded', () => {
    if (++ready === 4) pusher.send_event('rpcnode:recent', {})
  })
}

// Reconnect: nothing is replayed, so read the current state from the API
let wasConnected = false
pusher.connection.bind('state_change', ({ current }) => {
  if (current === 'connected') {
    if (wasConnected) reconcileFromApi() // list pending payments / watch events since last seen
    wasConnected = true
  }
})
pusher.connection.bind('error', (err) => console.error('socket error', err))

Server: sign the channel (Node.js)

Node.js, no dependencies

import crypto from 'node:crypto'

const KEY_ID = process.env.RPCNODE_KEY_ID // the API key id (UUID), same as X-Api-Key
const SECRET = process.env.RPCNODE_SECRET // the API key secret, server side only

const CHANNELS = new Set([
  'private-payments',
  'private-address-events',
  'private-tx-watch',
  'private-blocks',
])

// auth = "{key}:{hex HMAC-SHA256(secret, "{socket_id}:{channel}")}"
export function authorizeChannel(socketId, channelName) {
  const signature = crypto
    .createHmac('sha256', SECRET)
    .update(socketId + ':' + channelName)
    .digest('hex')
  return { auth: KEY_ID + ':' + signature }
}

// Express handler for pusher-js (it POSTs socket_id and channel_name as a form)
import express from 'express'

const app = express()

app.post('/pusher/auth', express.urlencoded({ extended: false }), (req, res) => {
  const socketId = String(req.body.socket_id || '')
  const channelName = String(req.body.channel_name || '')

  // check here that the signed-in user may see this channel
  if (!/^[0-9]+[.][0-9]+$/.test(socketId) || !CHANNELS.has(channelName)) {
    return res.status(403).json({ error: 'forbidden' })
  }
  res.json(authorizeChannel(socketId, channelName))
})

app.listen(3000)

Server: the same with the pusher SDK

Node.js, pusher package

import Pusher from 'pusher'
import express from 'express'

// appId can be anything; key and secret are the id and the secret of your API key
const pusher = new Pusher({
  appId: 'rpcnode',
  key: process.env.RPCNODE_KEY_ID,
  secret: process.env.RPCNODE_SECRET,
  cluster: 'mt1',
})

const app = express()

app.post('/pusher/auth', express.urlencoded({ extended: false }), (req, res) => {
  // check here that the user is allowed to see this channel
  res.send(pusher.authorizeChannel(req.body.socket_id, req.body.channel_name))
})

Tips

  • The realtime host is not the API host. Take it from the cabinet (Webhooks, WebSocket tab) and keep it in configuration instead of hard-coding it.
  • The app key is the API key id (X-Api-Key). The secret stays on your server: it only signs "{socket_id}:{channel_name}" in the authorization endpoint.
  • Events are a live feed with no replay. After every reconnect, reconcile with the API, and deduplicate by delivery_id.
  • block events carry only block_height, block_hash and block_time. Load the block itself from your RPC endpoint or node.
  • Check body.truncated: a body over 256 KB arrives as a short reference, fetch the full object from the API.
  • Do not reconnect in a tight loop: 60 new sockets per minute per IP is the limit, and 4100 means wait. 4001, 4007 and 4009 mean fix the key, protocol or IP allowlist first.
  • Test with the Live check in the cabinet: it opens a real socket in the page and shows the real events of the account.