Node.js Twitter WebSocket Client: Real-Time Tweets to Trades

This guide builds a Node.js consumer for the Xanguard real-time Twitter/X WebSocket that turns tweets from accounts you choose into contract-address trade signals. It handles the opcode handshake, heartbeats, reconnects and duplicate events. The code targets the live contract at wss://api.xanguard.tech/v1/dt/realtime/ws. Steps 4 to 6 concatenate into one runnable bot.mjs.

1. What you'll build

A dependency-light Node service that:

  1. Opens the Xanguard B2B WebSocket and logs in with your dt_ key.
  2. Reads tweet events and pulls out Solana and EVM contract addresses from the text, from expanded links, from quoted tweets and from image text (OCR, Enterprise plan).
  3. Heartbeats on the server's interval, drops the socket after two missed ACKs, and reconnects with jittered backoff.
  4. Stops for good on a dead key instead of retrying it forever.
  5. Filters by account, tweet type and lag, and passes each contract address to an executeTrade() stub exactly once.

Requirements: Node 18+ and the ws package (npm i ws). Node 22 ships a global WebSocket too, but ws gives you terminate() and the HTTP status of a refused upgrade, and both matter below.

2. Get an API key from @B2B_Xanguard_bot

  1. Message @B2B_Xanguard_bot on Telegram with /start.
  2. Choose a plan and pay in SOL. It starts at $49/month for 50 handles. Full table: B2B pricing.
  3. When the payment lands, the bot posts a quick-start message with your dt_ key. It's only shown once. /apikey issues a replacement and retires the old key.
  4. Tell it which accounts to follow, with /add handle1 handle2 in the bot or from your code:
curl -X POST https://api.xanguard.tech/v1/dt/targets \
  -H "Authorization: Bearer $XANGUARD_KEY" \
  -H "Content-Type: application/json" \
  -d '{"handle": "elonmusk"}'

Live connections pick up handle changes without reconnecting.

export XANGUARD_KEY=dt_your_key_here
npm init -y && npm i ws

3. Minimal connect

Every frame is JSON with an op and usually a d. The handshake goes like this:

  1. Server sends HELLO (op 10): {"op":10,"d":{"heartbeat_interval":30000}}.
  2. You send LOGIN (op 2) within 15 seconds: {"op":2,"d":"dt_..."}.
  3. Server replies READY (op 4) with client_id, modules, handles and max_handles. On failure it sends DISCONNECT (op 3) with d.reason.
  4. EVENT (op 0) frames follow. You send HEARTBEAT (op 1) each interval, and the server acknowledges with op 11.
import WebSocket from "ws";

const URL = "wss://api.xanguard.tech/v1/dt/realtime/ws";
const API_KEY = process.env.XANGUARD_KEY; // dt_...

const ws = new WebSocket(URL);
let heartbeat;

ws.on("message", (raw) => {
  const msg = JSON.parse(raw.toString());
  switch (msg.op) {
    case 10: // HELLO: log in, then heartbeat on the interval it gives us
      ws.send(JSON.stringify({ op: 2, d: API_KEY }));
      heartbeat = setInterval(
        () => ws.send(JSON.stringify({ op: 1 })),
        msg.d.heartbeat_interval
      );
      break;
    case 4: // READY
      console.log("READY:", msg.d);
      break;
    case 0: { // EVENT
      const d = msg.d;
      console.log(d.event, "@" + d.task_info.handle, (d.data.text || "").slice(0, 100));
      break;
    }
    case 3: // DISCONNECT
      console.error("disconnected:", msg.d.reason);
      break;
  }
});

ws.on("close", () => clearInterval(heartbeat));
ws.on("error", (err) => console.error("socket error:", err.message));

Save it as minimal.mjs and run node minimal.mjs. A bad key prints disconnected: Invalid or expired API key. A good one prints the READY payload and then live events. It doesn't reconnect yet.

4. Parse tweet events and extract the contract address

A tweet event, trimmed:

{"op": 0, "d": {
  "event": "twitter.post.new",
  "event_id": "evt_1234567890123456789",
  "task_info": {"handle": "somekol"},
  "data": {
    "id": "1234567890123456789",
    "created_at": 1758620000000,
    "type": "quote",
    "text": "this one",
    "entities": {"urls": []},
    "quoted_tweet": {"id": "...", "text": "launching 0x...", "author": {"handle": "dev"}},
    "author": {"handle": "somekol", "stats": {"followers": 50000}}
  }}}

Field notes:

  1. The tweet ID is data.id, and data.created_at is milliseconds since epoch.
  2. data.type is post, reply, quote or repost.
  3. A twitter.post.update with the same event_id can arrive shortly after. It carries fuller reply or quote context, or the rest of a long body. Merge it rather than treating it as a new tweet.
  4. The address in a tweet is often a link, so check entities.urls[].expanded_url. entities may be null.
  5. extracted_cas only appears if OCR is enabled on your key, which needs the Enterprise plan.

First part of bot.mjs:

import WebSocket from "ws";

const URL = "wss://api.xanguard.tech/v1/dt/realtime/ws";
const API_KEY = process.env.XANGUARD_KEY;

const SOL_RE = /\b[1-9A-HJ-NP-Za-km-z]{32,44}\b/g; // Solana base58
const EVM_RE = /\b0x[a-fA-F0-9]{40}\b/g;           // EVM hex

const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
const caKey = (ca) => (ca.startsWith("0x") ? ca.toLowerCase() : ca);

// Contract addresses from a tweet payload, first-seen order, no dupes.
function extractCAs(data) {
  const sources = [data.text || ""];
  // t.co links hide the address; entities.urls has the expanded URL
  // (e.g. https://pump.fun/coin/<mint>). entities can be null.
  for (const u of data.entities?.urls ?? []) sources.push(u.expanded_url || "");
  // On quotes, quoted_tweet is the tweet being quoted. (On replies it holds
  // the parent tweet, someone else's words, so skip it.)
  if (data.type === "quote" && data.quoted_tweet) sources.push(data.quoted_tweet.text || "");

  const found = [];
  const seen = new Set();
  const add = (ca) => {
    if (!seen.has(caKey(ca))) { seen.add(caKey(ca)); found.push(ca); }
  };
  for (const ca of data.extracted_cas ?? []) add(ca); // read from images (OCR, Enterprise plan)
  for (const s of sources) {
    for (const m of s.matchAll(SOL_RE)) add(m[0]);
    for (const m of s.matchAll(EVM_RE)) add(m[0]);
  }
  return found;
}

function handleEvent(d) {
  if (d.event !== "twitter.post.new" && d.event !== "twitter.post.update") return;
  const data = d.data;
  const lagMs = Date.now() - (data.created_at || 0);
  const cas = extractCAs(data);
  log(`${d.event} @${d.task_info.handle} ${data.type} id=${data.id} lag=${lagMs}ms cas=${JSON.stringify(cas)}`);
  onTweet(d.event_id, d.task_info.handle, data, cas, lagMs);
}

The g flag is required for matchAll. EVM addresses are compared in lower case so a checksummed copy and a lower-case copy count as one token.

5. Reconnect with backoff, heartbeat

These are the connection rules your client has to live with:

  1. No HEARTBEAT for 90 seconds closes the socket, and no inbound traffic for 180 seconds does too.
  2. Backend deploys can drop the socket for a few seconds with no DISCONNECT frame. That's routine, so reconnect straight away.
  3. Tweets are live-only. Anything older than 30 seconds is dropped and nothing is replayed. Follow and unfollow events from the last 15 minutes are replayed after READY.
  4. A key can hold up to 5 connections. Past that you get Too many connections (max 5).
  5. Repeated failed logins get the source IP a temporary HTTP 429 at upgrade. A bot that retries a revoked key in a tight loop locks itself out.

Second part of bot.mjs:

const FATAL = ["Invalid or expired API key", "Subscription expired", "Invalid login payload"];

// One connection's life. Resolves (never rejects) when the socket is gone.
function session() {
  return new Promise((resolve) => {
    const ws = new WebSocket(URL, { handshakeTimeout: 10_000 });
    const st = { ready: false, fatal: false, slow: false, reason: "socket closed" };
    let heartbeat = null;
    let lastAck = Date.now();
    // No READY within 20s of opening? Give up on this attempt.
    const loginTimer = setTimeout(() => { st.reason = "no READY"; ws.terminate(); }, 20_000);

    ws.on("message", (raw) => {
      let msg;
      try { msg = JSON.parse(raw.toString()); } catch { return; }
      switch (msg.op) {
        case 10: { // HELLO
          const interval = msg.d.heartbeat_interval;
          // d may be the bare key string, or an object with the key + filters,
          // e.g. { api_key: KEY, onlyCA: true, types: ["tweet", "quote"] }
          ws.send(JSON.stringify({ op: 2, d: { api_key: API_KEY } }));
          heartbeat = setInterval(() => {
            if (Date.now() - lastAck > 2 * interval + 5_000) {
              st.reason = "two heartbeat ACKs missed";
              return ws.terminate();
            }
            ws.send(JSON.stringify({ op: 1 }));
          }, interval);
          break;
        }
        case 4: // READY
          clearTimeout(loginTimer);
          st.ready = true;
          lastAck = Date.now();
          log(`READY: ${msg.d.handles} handles, modules=${msg.d.modules}`);
          break;
        case 11: // HEARTBEAT_ACK
          lastAck = Date.now();
          break;
        case 0: // EVENT
          try { handleEvent(msg.d); }
          catch (e) { log("event handler error:", e); } // one bad event must not kill the feed
          break;
        case 3: // DISCONNECT
          st.reason = `refused: ${msg.d.reason}`;
          st.fatal = FATAL.includes(msg.d.reason);
          st.slow = !st.fatal; // e.g. "Too many connections (max 5)"
          break;
      }
    });

    ws.on("unexpected-response", (_req, res) => {
      st.reason = `HTTP ${res.statusCode}`;
      st.slow = res.statusCode === 429; // temporary block after repeated bad keys
      ws.terminate();
    });
    ws.on("error", (err) => {
      if (st.reason === "socket closed") st.reason = err.message;
    });
    ws.on("close", () => {
      clearTimeout(loginTimer);
      clearInterval(heartbeat);
      resolve(st);
    });
  });
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function runForever() {
  const backoff = [1, 2, 5, 10, 30];
  let attempt = 0;
  for (;;) {
    const st = await session();
    if (st.fatal) {
      log(`fatal: ${st.reason}. Fix the key or renew in @B2B_Xanguard_bot.`);
      process.exit(1);
    }
    if (st.slow) attempt = Math.max(attempt, backoff.length - 1);
    if (st.ready) attempt = 0; // we were live; come back fast
    const delay = backoff[Math.min(attempt, backoff.length - 1)] * (0.8 + Math.random() * 0.4);
    attempt++;
    log(`disconnected (${st.reason}); reconnecting in ${delay.toFixed(1)}s`);
    await sleep(delay * 1000);
  }
}

Design notes:

  1. session() always resolves, never rejects, so the loop in runForever() is the only place that makes decisions.
  2. ws.terminate() destroys the socket right away. That's what you want for a half-open connection, where a polite close would just wait.
  3. The backoff goes 1, 2, 5, 10, 30 seconds with ±20% jitter. It resets once a session reaches READY and jumps to 30 seconds on refusals and 429s.

6. Filter and trigger a trading action (stub)

The last part picks what to act on and runs trades one at a time on a promise chain. The message handler never awaits a trade, so a slow order can't back up the socket:

const WATCH = new Set((process.env.WATCH || "").split(",").map((h) => h.trim().toLowerCase()).filter(Boolean));
const TYPES = new Set(["post", "quote"]); // skip replies and reposts
const MAX_LAG_MS = 5_000;                 // too late to trade? skip
const DRY_RUN = process.env.DRY_RUN !== "0";

const acted = new Set();                  // CAs already sent to the trader
let queue = Promise.resolve();            // runs trades one at a time

function onTweet(eventId, handle, data, cas, lagMs) {
  if (WATCH.size && !WATCH.has(handle.toLowerCase())) return;
  if (!TYPES.has(data.type) || lagMs > MAX_LAG_MS) return;
  for (const ca of cas) {
    if (acted.has(caKey(ca))) continue;   // already handled (post.update repeats, reposted CAs)
    acted.add(caKey(ca));
    const sig = { ca, handle, tweetId: data.id, eventId, lagMs };
    // Chain, don't await: the socket keeps reading while the trade runs.
    queue = queue.then(() => executeTrade(sig)).catch((e) => log("trade failed:", e.message));
  }
}

// STUB: replace with your swap / order code.
async function executeTrade(sig) {
  if (DRY_RUN) {
    log(`DRY RUN buy ${sig.ca} (@${sig.handle}, https://x.com/${sig.handle}/status/${sig.tweetId})`);
    return;
  }
  throw new Error("wire up your exchange or DEX client here");
}

if (!API_KEY) {
  console.error("Set XANGUARD_KEY");
  process.exit(1);
}
runForever();

Put the three parts together and run it in dry-run mode:

cat part1.mjs part2.mjs part3.mjs > bot.mjs   # or paste them into one file
WATCH=somekol,otherkol node bot.mjs
# 12:01:07 READY: 42 handles, modules=realtime
# 12:03:55 twitter.post.new @somekol post id=19... lag=412ms cas=["9BB6...pump"]
# 12:03:55 DRY RUN buy 9BB6...pump (@somekol, https://x.com/somekol/status/19...)

Only set DRY_RUN=0 once executeTrade checks what a real strategy needs: liquidity, token age, position limits, slippage. A pattern match is a candidate, not a verified token. Base58 strings and DEX pair addresses can look like a contract.

You can also filter on the server. Send LOGIN with an object instead of a string: { op: 2, d: { api_key: KEY, onlyCA: true, types: ["tweet", "quote"] } }. onlyCA keeps tweets with an address in the text, or in an image if OCR is on. It won't match addresses that only appear inside a link, which is why the client-side extractor stays.

7. Deploy tips

  1. Use a process manager. systemd (Restart=always, key in an EnvironmentFile with mode 600) or pm2 start bot.mjs --name xg. Don't commit the key.
  2. Run two instances on separate hosts. Missed tweets aren't replayed, so redundancy is your backfill. The key allows 5 connections. Make the "already traded" check shared (for example Redis SET ca 1 NX EX 86400) so only the first instance buys.
  3. Keep the handler synchronous and light. Parse, filter, enqueue. Anything slow (RPC calls, database writes, notifications) goes on the queue or a worker.
  4. Sync your clock with NTP. Your lag figure is your clock minus created_at.
  5. Host near your execution venue, your RPC node or exchange API. That leg usually adds more delay than the feed.
  6. Log disconnects in UTC and include the exact window if you contact support about a gap.
  7. Add process.on("unhandledRejection", ...) logging so a bug in a trade adapter shows up instead of failing silently.

8. Frequently Asked Questions

Can I use Node's built-in WebSocket instead of ws?

On Node 22+, yes. The protocol is plain JSON text frames. You lose terminate() and the HTTP status on a refused upgrade, so replace them with close() and a generic error path.

Will I get tweets that were posted while I was disconnected?

No. The feed is real-time, and tweets older than 30 seconds are dropped. Follow and unfollow events are the exception: the last 15 minutes are replayed after READY. Two connections on two hosts are the answer for zero gaps.

Why is the same tweet ID showing up twice?

The second frame is twitter.post.update, which has the same event_id and fuller data. Merge on event_id. The bot's acted set makes sure it only trades once.

What does each DISCONNECT reason mean?

Invalid or expired API key, Subscription expired and Invalid login payload are fatal, so fix the key or renew. Too many connections (max 5) means close another connection. Login timeout means LOGIN wasn't sent within 15 seconds.

Is it TweetCatcher-compatible?

The opcodes and handshake match: HELLO 10, LOGIN 2, READY 4, EVENT 0, HEARTBEAT 1, ACK 11, DISCONNECT 3. Change the URL and the key, then check the event field names above.

How fast are events delivered?

Tweet detection speed on production traffic is published live at xanguard.tech/speed. The lag column in your own logs is the number that counts for your setup.

Prefer Python? The same bot is in the Python WebSocket tutorial. The protocol reference is at docs.xanguard.tech.

← Back to Blog

Get an API Key (60 seconds)

Get Telegram notifications for new tweets from any X account, in a median of about 270 ms on paid plans. Free for 1 account. Paid plans from $19 a month for 10 accounts.