AI answers drawn only from these docs, with links to the pages used.

WebSockets Overview

Last updated on October 15, 2026.

Insites instances support WebSocket channels for live updates. A browser opens a WebSocket connection and subscribes to a channel and a room. Your instance can then send a JSON message to everyone in that room, from a page or from a background job.

The most common use is showing the result of slow work without making the visitor wait on a page request. A page queues a background job, the job calls a slow service, such as an AI model, and sends the answer to the visitor's room. See the full example below.

Connecting

Open the connection to on your instance's own host, with the page's CSRF token in the address and the subprotocol. The browser sends the visitor's session cookie by itself.

const socket = new WebSocket(
  'wss://' + location.host + '/websocket?authenticity_token=' + encodeURIComponent(token),
  ['actioncable-v1-json']
);

Render the token into the page with . A connection opened without it receives a message with the reason Can't verify CSRF token authenticity. and is closed.

Subscribing to a channel

When the connection opens, the instance sends a message. Reply with a subscribe command. The is a string that holds JSON, not an object:

socket.send(JSON.stringify({
  command: 'subscribe',
  identifier: JSON.stringify({ channel: 'replies', room_id: room })
}));

The instance answers with , or refuses (see below). After that, each message sent to the room arrives with the same and the payload in . The instance also sends a message every few seconds, which you can ignore. If the connection closes, open it again and subscribe again.

Deciding who may subscribe

  • Each channel needs a partial at . It runs on every subscription. Output true to admit the subscriber. Any other output refuses them, and so does a missing partial.
  • The partial can read , and .
  • It cannot read the visitor's session: is empty, and so is for a visitor who is not signed in. So the room ID itself has to prove it was issued by your instance. The example signs it with and a secret constant.
  • Anyone who holds a valid room ID can subscribe to it. Treat it like a password: keep it out of addresses and logs.
  • On new instances, every message the client sends over the subscription must carry the CSRF token. The token the connection was opened with counts.
  • Clients on other domains can connect. The instance is found from the host name.

Sending a message

Send a message to everyone in a room on a channel with the mutation. It takes the channel name, the room ID and a JSON payload. See WebSocket Mutations.

The mutation answers dispatched. A hash you pass as the payload arrives in the browser as a JSON object.

When a subscription is refused

A refused subscription receives a message before it is closed. The message has a , a text message and a flag. The code is one of:

Example: Show a Slow API Call's Result

This example asks a slow service a question and shows the answer when it is ready. The page returns at once; a background job makes the call, which may take up to 180 seconds, and sends the answer to the visitor's room.

We ran it on an instance with the service address pointed at a test service that answers after 8 seconds. The answer appeared 9.6 s after the visitor pressed Ask. A made-up room ID and an unsigned room ID were both refused.

It needs two constants: REPLY_ROOM_SECRET, any long random string, and ANSWER_SERVICE_KEY, the API key for your service. Set each with .

app/views/pages/ask.liquid issues a signed room, keeps it in the session, renders the form and subscribes. The Ask button turns on once the subscription is confirmed.

---
slug: ask
---
{% liquid
  assign room = context.session.reply_room
  if room == blank
    assign id = '' | uuid
    assign signature = id | compute_hmac: context.constants.REPLY_ROOM_SECRET
    assign room = id | append: '.' | append: signature
    session reply_room = room
  endif
%}
<form id="ask-form">
  <label for="question">Your question</label>
  <input id="question" name="question" required>
  <button type="submit" disabled>Ask</button>
</form>
<p id="reply" aria-live="polite"></p>

<script>
  const token = {{ context.authenticity_token | json }};
  const identifier = JSON.stringify({ channel: 'replies', room_id: {{ room | json }} });
  const form = document.getElementById('ask-form');
  const reply = document.getElementById('reply');

  const socket = new WebSocket(
    'wss://' + location.host + '/websocket?authenticity_token=' + encodeURIComponent(token),
    ['actioncable-v1-json']
  );
  socket.onmessage = function (event) {
    const msg = JSON.parse(event.data);
    if (msg.type === 'welcome') {
      socket.send(JSON.stringify({ command: 'subscribe', identifier: identifier }));
    } else if (msg.type === 'confirm_subscription') {
      form.querySelector('button').disabled = false;
    } else if (msg.identifier === identifier && msg.message) {
      reply.textContent = msg.message.text || msg.message.error;
    }
  };

  form.addEventListener('submit', function (event) {
    event.preventDefault();
    reply.textContent = 'Working on it...';
    fetch('/ask-start', {
      method: 'POST',
      headers: { 'Accept': 'application/json' },
      body: new URLSearchParams({ question: form.question.value, authenticity_token: token })
    });
  });
</script>

app/views/pages/ask-start.liquid queues the job. The room comes from the session, not from the request, so a visitor can only queue work for their own room.

---
slug: ask-start
method: post
format: json
layout: ''
---
{% liquid
  assign room = context.session.reply_room
  if room == blank or context.params.question == blank
    response_status 422
    echo '{"queued": false}'
  else
    background job_id = 'ask/answer', room: room, question: context.params.question
    echo '{"queued": true}'
  endif
%}

app/views/partials/channels/replies/subscribed.liquid admits a room only when its signature matches.

{% liquid
  assign parts = context.params.room_id | split: '.'
  assign expected = parts[0] | compute_hmac: context.constants.REPLY_ROOM_SECRET
  if parts.size == 2 and parts[1] == expected
    echo 'true'
  else
    echo 'false'
  endif
%}

app/views/partials/ask/answer.liquid is the background job. It calls the service and always sends something back, so the visitor is never left waiting.

{% liquid
  assign payload = { "question": question }
  assign body = payload | json
  assign headers = { "Content-Type": "application/json" }
  assign headers['Authorization'] = 'Bearer ' | append: context.constants.ANSWER_SERVICE_KEY

  graphql r = 'ask/call_service', body: body, headers: headers

  assign reply = {}
  if r.api_call_send.response.status == 200
    assign reply['text'] = r.api_call_send.response.body
  else
    assign reply['error'] = 'No answer this time. Please try again.'
  endif
  graphql sent = 'ask/send_reply', room: room, reply: reply
%}

app/graphql/ask/call_service.graphql

mutation call_service($body: String!, $headers: HashObject) {
  api_call_send(
    api_call: { url: "https://api.example.com/answer", method: "POST", headers: $headers, body: $body }
    options: { timeout: 120 }
  ) {
    response { status body }
  }
}

app/graphql/ask/send_reply.graphql

mutation send_reply($room: String!, $reply: JSONPayload!) {
  channel_send_message(channel_name: "replies", room_id: $room, payload: $reply)
}

Server-Sent Events

See it working first: the Live Features Demo runs Server-Sent Events, background job progress, a shared live feed, a file upload and the log query on this site.

A page that only listens can subscribe with an at instead of a WebSocket. It uses the same channels, the same partial and the same , and every message reaches both kinds of subscriber.

const identifier = JSON.stringify({ channel: 'replies', room_id: room });
const source = new EventSource('/anycable-events?identifier=' + encodeURIComponent(identifier)
  + '&authenticity_token=' + encodeURIComponent(token));
source.onmessage = function (event) { show(JSON.parse(event.data)); };
  • The identifier goes in the address as URL-encoded JSON, and the CSRF token goes in the same address as .
  • The connection is read-only. There is no way to send, and a partial is never called.
  • is exactly the payload you sent. Over the WebSocket it arrives wrapped as .
  • A refused subscription answers with an error status (400 for a room your partial refuses, 401 with no session or token) and closes the stream. The browser reports it as .
  • Don't rely on the browser's automatic reconnect. It reuses the same address and CSRF token, which stops working when the session changes. Open a new instead.

Server-Sent Events are available on Insites instances from 15 October 2026. To check an instance, run . A response of text/event-stream means it is available. A 401 is expected there, because the request has no session.

Example: Server-Sent Events with a WebSocket fallback

The example above only listens, so it can use Server-Sent Events. This version of its script opens an first. It opens the WebSocket only if the stream fails before the subscription is confirmed, so the page also works where Server-Sent Events are not available. Replace the script in ask.liquid with this one, and add an empty paragraph with the id transport under the reply. The other five files stay as they are.

<script>
  const token = {{ context.authenticity_token | json }};
  const identifier = JSON.stringify({ channel: 'replies', room_id: {{ room | json }} });
  const form = document.getElementById('ask-form');
  const reply = document.getElementById('reply');
  const transport = document.getElementById('transport');

  function ready(name) {
    document.body.dataset.transport = name;
    transport.textContent = 'Connected over ' + name;
    form.querySelector('button').disabled = false;
  }
  function show(message) {
    reply.textContent = message.text || message.error;
    document.body.dataset.replied = 'true';
  }

  // Server-Sent Events first: read-only, which is all this page needs.
  function connectEventSource() {
    let confirmed = false;
    const source = new EventSource('/anycable-events?identifier=' + encodeURIComponent(identifier)
      + '&authenticity_token=' + encodeURIComponent(token));
    source.addEventListener('confirm_subscription', function () { confirmed = true; ready('Server-Sent Events'); });
    source.onmessage = function (event) { show(JSON.parse(event.data)); };
    source.onerror = function () {
      source.close();
      if (!confirmed) {
        // Never subscribed: this instance does not serve SSE (or refused the room). Use the WebSocket.
        document.body.dataset.sse = 'unavailable';
        connectWebSocket();
      } else {
        // Dropped after it worked: reconnect with a fresh EventSource, never the browser's own retry.
        setTimeout(connectEventSource, 2000);
      }
    };
  }

  function connectWebSocket() {
    const socket = new WebSocket(
      'wss://' + location.host + '/websocket?authenticity_token=' + encodeURIComponent(token),
      ['actioncable-v1-json']
    );
    socket.onmessage = function (event) {
      const msg = JSON.parse(event.data);
      if (msg.type === 'welcome') {
        socket.send(JSON.stringify({ command: 'subscribe', identifier: identifier }));
      } else if (msg.type === 'confirm_subscription') {
        ready('WebSocket');
      } else if (msg.identifier === identifier && msg.message) {
        show(msg.message);
      }
    };
    socket.onclose = function () { setTimeout(connectWebSocket, 2000); };
  }

  if (window.EventSource) { connectEventSource(); } else { connectWebSocket(); }

  form.addEventListener('submit', function (event) {
    event.preventDefault();
    reply.textContent = 'Working on it...';
    fetch('/ask-start', {
      method: 'POST',
      headers: { 'Accept': 'application/json' },
      body: new URLSearchParams({ question: form.question.value, authenticity_token: token })
    });
  });
</script>
  • Only an error before means Server-Sent Events are not available. An error after it is a dropped connection, so the script opens a new .

What to Expect

  • Most jobs started within a second of being queued. In our test, two jobs queued at the same moment as a third waited 20.5 and 22.2 seconds before they started.
  • The timeout on an API call is the longest wait for the next part of the response. A service that streams its answer keeps the call going for as long as data keeps arriving. See System Limitations.
  • The job receives the whole answer when the call ends, so it sends the answer in one message, not word by word.
Have a suggestion for this page?

Didn't quite find what you are looking for or have feedback on how we can make the content better then we would love to hear from you. Please provide us feedback and we will get back to you shortly.