Skip to docs content
Docs

WebSocket quickstart

Open one server-side WebSocket connection, pass the v1 protocol and API-key protocol, then switch on the envelope fields `t` and `op`.

5-minute smoke test

Start by adding one monitored account in the dashboard, then open a socket from a terminal. A quiet socket usually means the watchlist is empty or the monitored account has not posted since you connected.

TypeScript smoke testtypescript
import WebSocket from "ws";
 
const apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) {
  throw new Error("Missing TWEETSTREAM_API_KEY");
}
 
const ws = new WebSocket("wss://ws-global.tweetstream.io/ws", [
  "tweetstream.v1",
  `tweetstream.auth.token.${apiKey}`,
]);
 
ws.on("open", () => {
  console.log("TweetStream connected");
});
 
ws.on("message", (raw) => {
  const event = JSON.parse(raw.toString());
  console.log(event.t, event.op, event.d);
});
 
ws.on("close", (code, reason) => {
  console.log("TweetStream closed", code, reason.toString());
});

Connect

Use `wss://ws-iad.tweetstream.io/ws` for US-based connections and `wss://ws-global.tweetstream.io/ws` outside the USA. The server selects `tweetstream.v1` as the application protocol and strips the auth token protocol before accepting the connection. This example uses the global endpoint and reconnects with exponential backoff because live sockets should be treated as long-running infrastructure.

Node.js consumertypescript
import WebSocket from "ws";
 
type StreamEvent = {
  t?: string;
  op?: string;
  d?: {
    author?: { handle?: string };
    detected?: unknown;
    text?: string;
  };
};
 
const apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) {
  throw new Error("Missing TWEETSTREAM_API_KEY");
}
 
let retry = 0;
 
function connect() {
  const ws = new WebSocket("wss://ws-global.tweetstream.io/ws", [
    "tweetstream.v1",
    `tweetstream.auth.token.${apiKey}`,
  ]);
 
  ws.on("open", () => {
    retry = 0;
    console.log("TweetStream connected");
  });
 
  ws.on("message", (raw) => {
    const event = JSON.parse(raw.toString()) as StreamEvent;
 
    if (event.t === "tweet" && event.op === "content") {
      const tweet = event.d;
      console.log(tweet?.author?.handle, tweet?.text);
    }
 
    if (event.t === "tweet" && event.op === "meta") {
      console.log("enrichment", event.d.detected);
    }
  });
 
  ws.on("close", (code, reason) => {
    console.warn("TweetStream closed", code, reason.toString());
    const delayMs = Math.min(30_000, 1_000 * 2 ** retry) + Math.floor(Math.random() * 500);
    retry += 1;
    setTimeout(connect, delayMs);
  });
}
 
connect();

Python consumer

Any WebSocket-capable runtime works as long as it can pass the two subprotocols. Python clients should reconnect after transport errors for the same reason as Node clients.

Python websocketspython
import asyncio
import json
import os
import websockets
 
API_KEY = os.environ["TWEETSTREAM_API_KEY"]
URI = "wss://ws-global.tweetstream.io/ws"
PROTOCOLS = ["tweetstream.v1", f"tweetstream.auth.token.{API_KEY}"]
 
async def main():
    retry = 0
    while True:
        try:
            async with websockets.connect(URI, subprotocols=PROTOCOLS) as ws:
                retry = 0
                async for raw in ws:
                    event = json.loads(raw)
                    if event["t"] == "tweet" and event["op"] == "content":
                        tweet = event["d"]
                        print(tweet.get("author", {}).get("handle"), tweet.get("text"))
        except Exception as error:
            wait = min(30, 2 ** retry)
            retry += 1
            print(f"reconnecting in {wait}s after {error}")
            await asyncio.sleep(wait)
 
asyncio.run(main())

Envelope contract

Every realtime message is an envelope. Route first by `t`, then by `op`, and keep handlers idempotent because history replay can return events you already processed live.

Envelope typetypescript
type VerifiedType = 'blue' | 'business' | 'government' | 'none';
type TweetVerifiedLabel = {
  badge: string | null;
  description: string;
  url: string | null;
};
 
type TweetAuthor = {
  banner?: string;
  bio?: string;
  followersCount?: number;
  followingCount?: number;
  id?: string;
  joinedAt?: number;
  location?: string;
  metrics?: {
    likes?: number;
    tweets?: number;
  };
  // Includes a leading @ when present, for example "@elonmusk".
  handle?: string;
  name?: string;
  platform?: 'twitter' | 'truth_social';
  profileImage?: string;
  url?: string;
  verifiedLabel?: TweetVerifiedLabel;
  verifiedType?: VerifiedType;
};
 
type Media = {
  url: string;
} & (
  | {
      type: 'video';
      // Every video is a progressive MP4. A public still poster is included when available.
      thumbnail?: string;
    }
  | {
      type?: 'image' | 'gif';
      thumbnail?: string;
    }
);
 
type TweetUrl = {
  url: string;
  name?: string;
  tco?: string;
};
 
type TweetMention = {
  handle?: string;
  id?: string;
  name?: string;
};
 
type TweetArticle = {
  description?: string;
  id?: string;
  publishedAt?: number;
  text?: string;
  thumbnail?: string;
  title: string;
  updatedAt?: number;
  url: string;
};
 
type TweetPollChoice = {
  id?: string;
  image?: string;
  label: string;
  votes?: number;
};
 
type TweetPoll = {
  choices: TweetPollChoice[];
  endsAt?: number;
  totalVotes?: number;
  updatedAt?: number;
};
 
type TweetContentKind = 'post' | 'reply' | 'quote' | 'retweet';
 
type TweetReference = {
  article?: TweetArticle;
  type: 'reply' | 'quote' | 'retweet';
  tweetId: string;
  text?: string;
  translatedText?: string;
  author?: TweetAuthor;
  media?: Media[];
  poll?: TweetPoll;
  quoted?: TweetReference;
};
 
type TweetContent = {
  tweetId: string;
  kind: TweetContentKind;
  // Original tweet text when the event includes both original and translated text.
  text: string;
  // Translation, present only when available.
  translatedText?: string;
  createdAt: number;
  author: TweetAuthor;
  article?: TweetArticle;
  link?: string;
  media?: Media[];
  mentions?: TweetMention[];
  poll?: TweetPoll;
  receivedAt?: number;
  urls?: TweetUrl[];
  ref?: TweetReference;
};
 
type TweetMeta = {
  tweetId: string;
  ocr?: {
    text: string;
  };
  detected?: {
    tokens?: Array<{
      symbol?: string;
      name?: string;
      contract?: string;
      chain?: string;
      networkId?: number;
      priceUsd?: number;
      sources: Array<'text' | 'ocr'>;
    }>;
    cex?: Array<{
      exchange: 'bybit' | 'binance' | 'hyperliquid';
      symbol?: string;
      priceUsd?: number;
      url?: string;
      baseAsset?: string;
      quoteAsset?: string;
      sources: Array<'text' | 'ocr'>;
    }>;
    prediction?: Array<{
      exchange: 'polymarket' | 'kalshi';
      marketId?: string;
      title?: string;
      priceUsd?: number;
      url?: string;
      sources: Array<'text' | 'ocr'>;
    }>;
  };
};
 
type TweetUpdate = {
  tweetId: string;
  article?: TweetArticle;
  kind?: TweetContentKind;
  translatedText?: string;
  author?: TweetAuthor;
  media?: Media[];
  mentions?: TweetMention[];
  poll?: TweetPoll;
  receivedAt?: number;
  urls?: TweetUrl[];
  ref?: TweetReference;
} & (
  | {
      text?: string;
      textUpdateType?: never;
    }
  | {
      text: string;
      // Completes an earlier truncated rendering. This is not an edit signal.
      textUpdateType: 'completion';
    }
);
 
type TweetDeleteEvent = {
  tweetId: string;
  eventId: string;
  deletedAt?: number;
  receivedAt?: number;
  author?: TweetAuthor;
  text?: string;
};
 
type TweetPinEvent = {
  tweetId: string;
  eventId: string;
  observedAt: number;
  receivedAt?: number;
  action: 'pin' | 'unpin';
  author: TweetAuthor;
  text?: string;
  tweet?: TweetContent;
};
 
type AccountEventActor = TweetAuthor & {
  websiteUrl?: string;
};
 
type ProfileUpdateEvent = {
  kind: 'PROFILE';
  eventId: string;
  observedAt: number;
  receivedAt?: number;
  actor: AccountEventActor;
  changes: {
    avatar?: string;
    banner?: string;
    bio?: string;
    handle?: string;
    location?: string;
    name?: string;
    verifiedLabel?: TweetVerifiedLabel | null;
    websiteUrl?: string | null;
  };
  previous?: {
    avatar?: string;
    banner?: string;
    bio?: string;
    handle?: string;
    location?: string;
    name?: string;
    verifiedLabel?: TweetVerifiedLabel | null;
    websiteUrl?: string | null;
  };
};
 
type FollowEvent = {
  kind: 'FOLLOW' | 'UNFOLLOW';
  eventId: string;
  observedAt: number;
  receivedAt?: number;
  actor: AccountEventActor;
  target: AccountEventActor & {
    handle: string;
  };
};
 
type TwitterHandlesResult = {
  action: 'follow' | 'unfollow';
  requestId: string | null;
  results: Array<{
    input: string;
    state:
      | 'added'
      | 'already_following'
      | 'invalid_input'
      | 'duplicate'
      | 'not_found'
      | 'failed'
      | 'removed'
      | 'not_following';
    message?: string;
  }>;
  error: string | null;
};
 
type EnvelopeBase<
  TFamily extends 'tweet' | 'account' | 'control',
  TOp extends string,
  TPayload extends object,
> = {
  v: 1;
  t: TFamily;
  op: TOp;
  id?: string;
  ts: number;
  d: TPayload;
};
 
type TweetContentEnvelope = EnvelopeBase<'tweet', 'content', TweetContent>;
type TweetMetaEnvelope = EnvelopeBase<'tweet', 'meta', TweetMeta>;
type TweetUpdateEnvelope = EnvelopeBase<'tweet', 'update', TweetUpdate>;
type TweetLifecycleEnvelope = EnvelopeBase<
  'tweet',
  'delete' | 'pin' | 'unpin',
  TweetDeleteEvent | TweetPinEvent
>;
type AccountEnvelope = EnvelopeBase<
  'account',
  'profile_update' | 'follow' | 'unfollow',
  ProfileUpdateEvent | FollowEvent
>;
type ControlEnvelope = EnvelopeBase<
  'control',
  'auth_ping' | 'auth_pong' | 'twitter_handles_result',
  TwitterHandlesResult
>;
 
type TweetStreamEnvelope =
  | TweetContentEnvelope
  | TweetMetaEnvelope
  | TweetUpdateEnvelope
  | TweetLifecycleEnvelope
  | AccountEnvelope
  | ControlEnvelope;
 
function route(event: TweetStreamEnvelope) {
  if (event.t === 'tweet' && event.op === 'content') {
    console.log(event.d.tweetId, event.d.text);
  }
  if (event.t === 'tweet' && event.op === 'meta') {
    console.log(event.d.tweetId, event.d.detected);
  }
}

Operations

FamilyOperationMeaning
tweetcontentOriginal post, reply, quote, retweet, or supported Truth Social post content
tweetmetaEnrichment for an existing tweetId: OCR, detected tokens, CEX, prediction markets
tweetupdateProgressive content update for a known tweetId
tweetdelete, pin, unpinObserved lifecycle event for a tweet
accountprofile_update, follow, unfollowObserved account state change for a monitored account
controltwitter_handles_resultResult for WebSocket handle-management commands

Heartbeat and disconnects

TweetStream sends native WebSocket ping frames every 30 seconds. Standard Node and Python WebSocket clients reply with pong frames automatically. If a client stops responding, the server terminates the socket; reconnect and use History API to backfill stored content, profile, and follow events.

Backfill after reconnect

Track the last content timestamp or tweetId you processed. After a reconnect, call History API with a bounded `startDate` and replay idempotently so downstream bots do not double-act. Lifecycle events such as delete, pin, and unpin are live-stream events; design downstream state so late lifecycle changes can still be handled when they arrive live.

History replay windowtypescript
const lastSeen = new Date(Date.now() - 60_000).toISOString();
const url = new URL("https://api.tweetstream.io/api/history");
 
url.searchParams.set("handles", "marketdesk");
url.searchParams.set("startDate", lastSeen);
url.searchParams.set("limit", "1000");
url.searchParams.set("type", "TWEET");
 
const response = await fetch(url, {
  headers: {
    Authorization: `Bearer ${process.env.TWEETSTREAM_API_KEY}`,
  },
});
 
const replay = await response.json();

Handshake errors

Failed WebSocket upgrades return a JSON body before the socket is accepted. These are client-observable errors and safe to log.

StatusLikely causeFix
400Missing or invalid WebSocket protocol headersSend tweetstream.v1 and a valid auth token protocol
401Missing, empty, or invalid API keyRegenerate or rotate the API key from the dashboard
403Subscription state does not allow live streamingStart a trial, renew, or upgrade the account
429Active WebSocket connection limit reachedClose an old socket or move to a higher plan
503Connection limiter temporarily unavailableRetry with backoff
Upgrade denial bodyjson
{
  "error": "WebSocket connection limit reached (3/3 active). Close an existing connection and retry.",
  "status": 429
}

Limits and retries

Plan limits control active WebSocket connections, monitored accounts, and History API access. If an endpoint returns `429`, pause the affected workflow and retry after the response window or your normal backoff interval.

SurfaceLimit signalRecommended handling
WebSocket429 during upgradeClose unused sockets or move to a plan with more connections
History APIretryAfterSeconds when rate limitedWait before replaying the next window
Tracked accountsPlan usage from /api/meCheck count and limit before batch add flows

Measure the full signal path

Capture local receipt time as the first operation in the message callback. For X/Twitter events, decode the tweet snowflake timestamp to measure publication-to-receipt. Keep the measurement host clock synced with NTP; otherwise wall-clock latency is not meaningful. Use a monotonic clock for the processing segments that begin after receipt.

  • Report the watchlist, consumer region, UTC test window, sample size, p50, and p95.
  • Record reconnects, missing events, clock-sync status, and every exclusion rule.
  • Keep cold starts and post-reconnect samples visible instead of mixing them silently into warm-path results.
  • Compare vendors only when publication point, receipt point, geography, sample, and percentile use the same boundary.
SegmentStartStop
Publication to receiptX snowflake timestampLocal time captured at the top of the socket callback
Receipt to decisionLocal socket receiptStrategy and risk decision ready
Decision to venue acknowledgementRisk-approved decisionSeparate venue response or rejection
Snowflake latencytypescript
type ContentEvent = {
  t?: string;
  op?: string;
  d?: {
    tweetId?: string;
  };
};
 
const TWITTER_EPOCH_MS = 1_288_834_974_657n;
 
function tweetIdToTimestampMs(tweetId: string) {
  const id = BigInt(tweetId);
  return Number((id >> 22n) + TWITTER_EPOCH_MS);
}
 
function measureSnowflakeLatency(tweetId: string, arrivedAtMs: number) {
  const tweetedAtMs = tweetIdToTimestampMs(tweetId);
  return arrivedAtMs - tweetedAtMs;
}
 
ws.on("message", (raw) => {
  const arrivedAtMs = Date.now();
  const event = JSON.parse(raw.toString()) as ContentEvent;
  const tweetId = event.d?.tweetId;
 
  if (event.t === "tweet" && event.op === "content" && tweetId) {
    console.log("publication-to-receipt ms", measureSnowflakeLatency(tweetId, arrivedAtMs));
  }
});

Production notes

  • Reconnect with backoff after close or network error.
  • Use only the documented TweetStream endpoints; TweetStream handles routing, and no infrastructure-provider headers are required.
  • Use a tweet envelope id as the entity key, not as a unique frame id. Apply every tweet/update idempotently; suppress an exact replay only with a fingerprint of the complete envelope.
  • Treat `meta` as late-arriving enrichment for a tweet you may already have routed.
  • Track active WebSocket connection count against your plan limit.