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 aconnect_errorwith 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, ornamespacein the REST and MCP send. The client must have connected that namespace, or the send fails withSocket.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:
| Payload | Client receives |
|---|---|
| Empty | No arguments |
| A JSON object, array or string | That value, as one argument |
Anything else (hello, 123, true) | A string |
| A JSON array with spread args on | One 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
emitWithAckresolves toundefined.
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 (aBufferin 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 ends | What the client sees | Reconnects? |
|---|---|---|
| You close it from the console or API, a Close rule or scenario step fires, or the endpoint is deleted | A Socket.IO disconnect packet for each connected namespace: io server disconnect | No |
| Simulated disconnect, rate limit, keepalive or ping timeout | The transport drops: transport close or ping timeout | Yes |
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,transportand 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
authbelongs 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.