Skip to main content
The legacy Ably-based WebSocket API is being deprecated. This guide covers everything you need to migrate to the Centrifugo-based API.
Deprecation deadline: July 1, 2026. The legacy Ably WebSocket API will be shut down on this date. All integrations must complete migration to Centrifugo before then.
The core concepts are the same — channels, subscriptions, and message payloads are structurally identical. There are six concrete changes to make.
1

Swap the SDK

Uninstall ably and install centrifuge:
npm uninstall ably
npm install centrifuge
2

Update authentication

The auth endpoint and callback pattern have both changed.
Ably (old)Centrifugo (new)
EndpointGET /user/tokenGET /user/realtime-token/api-key
CallbackauthCallback(tokenParams, callback)getToken: async () => token
WS URLManaged by SDKwss://realtime.sx.bet/connection/websocket
Before:
import * as ably from "ably";

const realtime = new ably.Realtime.Promise({
  authCallback: async (tokenParams, callback) => {
    try {
      const response = await fetch("https://api.sx.bet/user/token", {
        headers: { "X-Api-Key": process.env.SX_BET_API_KEY },
      });
      callback(null, await response.json());
    } catch (error) {
      callback(error, null);
    }
  },
});

await realtime.connection.once("connected");
After:
import { Centrifuge } from "centrifuge";

const client = new Centrifuge("wss://realtime.sx.bet/connection/websocket", {
  getToken: async () => {
    const res = await fetch("https://api.sx.bet/user/realtime-token/api-key", {
      headers: { "x-api-key": process.env.SX_BET_API_KEY },
    });
    if (!res.ok) throw new Error(`Token endpoint returned ${res.status}`);
    const { token } = await res.json();
    return token;
  },
});

client.connect();
getToken is called automatically on initial connect and on token refresh — no manual token management needed. To signal a permanent auth failure (so the client stops retrying), throw UnauthorizedError from centrifuge on a 401/403 response. Any other thrown error is treated as transient and retried with backoff. See Real-time Data → Auth for the full pattern.
3

Update channel subscriptions

The subscription API has changed. Message data moves from message.data to ctx.data.Before:
const channel = realtime.channels.get("order_book_v2:0x6629...:0xabc...", {
  params: { rewind: "10s" },
});

channel.subscribe((message) => {
  console.log(message.data);
});
After:
const sub = client.newSubscription("order_book:market_0xabc...", {
  positioned: true,
  recoverable: true,
});

sub.on("subscribed", async (ctx) => {
  if (!ctx.wasRecovering || (ctx.wasRecovering && !ctx.recovered)) {
    // Seed state from REST on fresh connect or failed recovery
    const res = await fetch(`https://api.sx.bet/orders?marketHashes=0xabc...`);
    applySnapshot(await res.json());
  }
});

sub.on("publication", (ctx) => {
  applyUpdate(ctx.data);
});

sub.subscribe();
rewind is replaced by positioned + recoverable. These two flags together give you at-least-once delivery with automatic gap recovery on reconnect — equivalent to what rewind was doing, but more reliable. See Real-time Data → Recovery & reliability for how to handle the three reconnect states.
4

Update channel names

All channel names have changed. Update every channel string in your code:
Old channel (Ably)New channel (Centrifuge)Notes
active_orders_v2:{baseToken}:{maker}active_orders:{maker}baseToken removed, _v2 dropped
order_book_v2:{baseToken}:{marketHash}order_book:market_{marketHash}baseToken removed
order_book:event_{sportXEventId}New — alternative to order_book:market_ that covers every market in an event with a single subscription
best_odds:{baseToken}best_odds:globalbaseToken removed from channel name
marketsmarkets:globalRenamed
recent_tradesrecent_trades:globalRenamed
recent_trades_consolidatedrecent_trades_consolidated:globalRenamed
main_linemain_line:globalRenamed
fixtures:fixture_updatefixtures:globalRenamed
live_scores:{sportXEventId}fixtures:live_scoresNow global — filter by sportXEventId client-side
ce_refunds:{user}ce_refunds:{bettor}Parameter renamed userbettor
markets:parlayparlay_markets:globalRenamed
All message payloads are unchanged.live_scores is now a global channel. The old live_scores:{sportXEventId} channel was per-event. The new fixtures:live_scores delivers all live score updates globally — filter the sportXEventId field in the payload to isolate the events you care about.
5

Replace rewind with the snapshot + subscribe pattern

The Ably rewind parameter (which replayed recent messages automatically on subscribe) has no direct equivalent in Centrifugo. The replacement is to set positioned: true and recoverable: true on the subscription, then seed from REST inside the subscribed handler — which fires on every connect and tells you whether history replay already filled the gap.Before:
const channel = realtime.channels.get(`order_book_v2:${token}:${marketHash}`, {
  params: { rewind: "10s" },
});
channel.subscribe((message) => {
  applyUpdate(message.data);
});
After:
let ready = false;
const buffer = [];

const sub = client.newSubscription(`order_book:market_${marketHash}`, {
  positioned: true,
  recoverable: true,
});

sub.on("publication", (ctx) => {
  if (!ready) {
    buffer.push(ctx.data);
  } else {
    applyUpdate(ctx.data);
  }
});

sub.on("subscribed", async (ctx) => {
  if (ctx.wasRecovering && ctx.recovered) {
    // Centrifugo replayed all missed messages — no REST call needed.
    ready = true;
    return;
  }

  // Fresh connect or failed recovery (history gap too large / expired).
  // Reset and re-seed from REST.
  ready = false;
  buffer.length = 0;

  const res = await fetch(`https://api.sx.bet/orders?marketHashes=${marketHash}`);
  applySnapshot(await res.json());

  // Drain buffered publications on top of the snapshot.
  // applyUpdate deduplicates by entity ID so any
  // overlap between the snapshot and the buffer is handled safely.
  for (const data of buffer) applyUpdate(data);
  buffer.length = 0;
  ready = true;
});

sub.subscribe();
For channels with no history (best_odds:global, parlay_markets:global), omit the flags and always seed from REST in the subscribed handler — recovery is not available for those channels. See Real-time Data → Recovery & reliability for the full three-state logic.
6

Handle duplicate messages

Centrifugo provides at-least-once delivery: during history recovery a message may be replayed more than once. Use ctx.tags.messageId to deduplicate:
const seen = new Set();
const MAX_SEEN = 10_000;

sub.on("publication", (ctx) => {
  const id = ctx.tags?.messageId;
  if (id !== undefined) {
    if (seen.has(id)) return;
    seen.add(id);
    if (seen.size > MAX_SEEN) {
      seen.delete(seen.values().next().value);
    }
  }
  applyUpdate(ctx.data);
});
For long-running processes, bound the set size (e.g., keep only the last 1,000 IDs) to avoid unbounded memory growth.

Further reading