Skip to main content

Socket.IO

A Socket.IO endpoint is a Socket.IO server that stock clients connect to without special options. Each endpoint has its own path:

io("https://app.hooklistener.com", { path: "/sio/<slug>" })

The client starts with HTTP long-polling and upgrades to WebSocket, as it would against your own server. Clients set to transports: ["websocket"] connect straight over WebSocket. This works for socket.io-client 2, 3 and 4 without a transports option; see Socket.IO v2 clients. See the overview for the console, rules, scenarios, limits and the REST API.

Connect​

These are the snippets from the endpoint's Connect tab, for an endpoint with an access token. Without a token, leave out auth.

Browser / Node (socket.io-client)

// npm i socket.io-client
import { io } from "socket.io-client";

const socket = io("https://app.hooklistener.com", {
path: "/sio/<slug>",
auth: { token: "<token>" },
});

socket.on("connect", async () => {
console.log("connected", socket.id);
const ack = await socket.emitWithAck("chat", { text: "hello" });
console.log("ack", ack);
});
socket.onAny((event, ...args) => console.log(event, args));
socket.on("connect_error", (error) => console.error(error.message));
socket.on("disconnect", (reason) => console.log("disconnected", reason));

Python (python-socketio)

# pip install "python-socketio[client]"
import socketio

sio = socketio.Client()

@sio.event
def connect():
print("connected", sio.sid)
print("ack", sio.call("chat", {"text": "hello"}))

@sio.on("*")
def catch_all(event, *args):
print(event, args)

sio.connect("https://app.hooklistener.com", socketio_path="/sio/<slug>", auth={"token": "<token>"})
sio.wait()

The REST API and the MCP tools return the same details in a socketio object on the endpoint:

{
"url": "https://app.hooklistener.com",
"path": "/sio/<slug>",
"transports": ["polling", "websocket"],
"token_via": "auth: { token } in the io() options, ?token= query or X-Hooklistener-Token header",
"snippet": "io(\"https://app.hooklistener.com\", { path: \"/sio/<slug>\" })"
}

The default /socket.io/ path​

io(origin) without a path requests /socket.io/, which no endpoint uses. That path answers 400 with an explanation instead of a bare 404:

{"code": 3, "message": "Each Hooklistener Socket.IO endpoint has its own path: connect with io(\"https://app.hooklistener.com\", { path: \"/sio/<endpoint-slug>\" }). The endpoint's connection details list the exact path."}

The client reports it as a connect_error; the message is visible in the browser's network tab.

Access token​

Socket.IO v5 clients (socket.io-client 3 and later, python-socketio 5) can send the token in the CONNECT packet: auth: { token }, as in the snippets above. The URL query (query: { token } or ?token=) and the X-Hooklistener-Token header also work. See Access token.

When the token travels in auth:

  • Nothing is recorded until a CONNECT carries the right token.
  • A missing or wrong token is refused with {"message": "Missing or invalid access token"}, which the client receives as a connect_error with that message.
  • After 5 refused CONNECTs the connection is closed with 1008 (too many invalid access tokens).
  • A client that doesn't send a CONNECT with the token within 20 seconds is closed with 1008 (authentication timeout).

Namespaces​

Clients can connect to any namespace (io("https://app.hooklistener.com/admin", { path })). Every namespace CONNECT is accepted and listed on the session.

  • On-connect messages are sent to each namespace once it connects.
  • Rule replies, echoes and acks go to the namespace of the message that triggered them.
  • Sends from the console, the API and MCP go to / unless you choose another namespace: the NS field in the composer, or namespace in the REST and MCP send. The client must have connected that namespace, or the send fails with Socket.IO namespace /admin is not connected on this session (connected: /). A broadcast skips sessions that haven't connected the namespace.

Events and arguments​

Each event the client emits is recorded with its event name. One argument is stored as that value's JSON, several as a JSON array, and none as an empty payload.

Messages you send are emitted as the event in the event name field (event_type in the API), or message when it's empty. The payload becomes the arguments:

PayloadClient receives
EmptyNo arguments
A JSON object, array or stringThat value, as one argument
Anything else (hello, 123, true)A string
A JSON array with spread args onOne argument per element: [1, true] → emit(event, 1, true)

To spread arguments, use the composer's 1 arg / ...args toggle, "spread_args": true in the REST and MCP send, or an args array in the REST body ({"event_type": "move", "args": [10, 20]}). An Echo rule sends back exactly the arguments it received.

Acknowledgements​

When a client emits with a callback (emitWithAck, or a function as the last argument), the server must acknowledge it:

  • If a Reply as the event's ack rule matches the event, its payload is the ack value. It goes out after the rule's delay and through the network simulation. In the snippet above, await socket.emitWithAck("chat", …) then resolves to the rule's payload.
  • Otherwise the server acknowledges at once with no arguments, so emitWithAck resolves to undefined.

An ack rule that matches an event without a callback sends its payload as a regular reply.

Binary attachments​

Binary data works in both directions:

  • Buffers and ArrayBuffers the client sends are recorded inline as {"_binary": true, "base64": "...", "size": 3}.
  • Binary (b64) in the composer, or "opcode": "binary" in the API, sends the decoded bytes as one binary argument (a Buffer in Node).
  • Inside a JSON payload, any {"_binary": true, "base64": "..."} object is sent as a binary attachment in its place.

Heartbeats​

Socket.IO runs its own heartbeat on top of the WebSocket keepalive. The handshake announces pingInterval: 25000 and pingTimeout: 20000. The server pings every 25 seconds. A client that doesn't answer within 20 seconds is closed with 1001 (ping timeout: no pong within 20s). Socket.IO v2 clients drive the heartbeat themselves: they ping, and the server answers. A v2 client whose pings stop for 45 seconds is closed with 1001 (ping timeout: no ping for 45s). The handshake's maxPayload is 1 MB. With Record ping/pong control frames on, the Engine.IO heartbeats are recorded too.

Disconnects and reconnection​

socket.io-client reconnects on its own after a network failure, but not after the server disconnects it on purpose. Hooklistener follows the same split:

How the session endsWhat the client seesReconnects?
You close it from the console or API, a Close rule or scenario step fires, or the endpoint is deletedA Socket.IO disconnect packet for each connected namespace: io server disconnectNo
Simulated disconnect, rate limit, keepalive or ping timeoutThe transport drops: transport close or ping timeoutYes

The disconnect packet (41) is sent only for those deliberate closes. Use Disconnect in the network simulation to test reconnection.

Socket.IO v2 clients​

Socket.IO v2 clients (socket.io-client 2.x, Engine.IO 3) connect with their default transports, like newer clients. They start with HTTP long-polling and upgrade to WebSocket. Polling only (transports: ["polling"]) and WebSocket only (transports: ["websocket"]) work too.

v2 has no auth option in its CONNECT packet, so send the token in the query string or as a header:

// socket.io-client@2
const socket = io("https://app.hooklistener.com", {
path: "/sio/<slug>",
query: { token: "<token>" },
});
  • Namespace: the server connects v2 clients to / right after the handshake, as v2 expects.
  • Heartbeats: the client pings, and the server answers. See Heartbeats.
  • Binary: works in both directions. Node clients send binary packets base64-encoded inside the text payload; browsers use XHR2 binary payloads.
  • Unchanged from v4: namespaces, acks and rules behave as on newer clients.

Two kinds of long-polling request are refused with an Engine.IO error (400):

  • JSONP polling (a j= parameter): JSONP polling is not supported: use XHR polling (the default) or connect with transports: ["websocket"].
  • Any Engine.IO version other than 3 or 4: Unsupported protocol version: long-polling needs Engine.IO 3 or 4 (socket.io-client 2 or later).

Proxy mode​

In proxy mode your own Socket.IO server answers the handshake, CONNECTs and acks, and Hooklistener records both directions.

  • Set the Upstream URL to your server including its Socket.IO path, for example wss://api.example.com/socket.io/. The client's query string (EIO, transport and your own parameters) is appended.

  • Clients of every Socket.IO version must use the WebSocket transport. A long-polling handshake gets 400: This endpoint proxies to an upstream server, which needs the websocket transport: connect with transports: ["websocket"].

  • The access token goes in the query or a header, because the CONNECT packet's auth belongs to your server. The endpoint's Connect tab switches its snippets to this form:

    const socket = io("https://app.hooklistener.com", {
    path: "/sio/<slug>",
    transports: ["websocket"],
    query: { token: "<token>" },
    });
  • Frames are relayed byte for byte, and events are recorded with their names, namespaces and ack ids. Handshakes and heartbeats are recorded only with Record ping/pong control frames on.

  • The on-connect message is sent once your server accepts a namespace CONNECT.

See Proxy mode for headers, credentials and close codes.