Skip to docs content
Docs

WebSocket quickstart

Open a server-side WebSocket, pass the v1 and API-key protocols, then route each envelope by t and op.

5-minute smoke test

Add one monitored account in the dashboard, then open a socket from your terminal. If the socket stays quiet, the watchlist may be empty or the account may not have 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 responds with tweetstream.v1; it does not echo the authentication-token protocol. The example below uses the global endpoint and reconnects with exponential backoff after a disconnect.

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 runtime that can send both subprotocols will work. Reconnect after transport errors.

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 uses the same envelope. Route it first by t, then by op. Keep handlers idempotent because history replay may 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' | 'binance_square';
  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;
  subtweet?: 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;
  // Pair with tweetId when merging enrichment. Omitted means twitter on legacy frames.
  platform?: 'twitter' | 'truth_social' | 'binance_square';
  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;
  // Pair with tweetId. On legacy frames, fall back to author.platform, then twitter.
  platform?: 'twitter' | 'truth_social' | 'binance_square';
  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;
  // On legacy frames, fall back to author.platform, then twitter.
  platform?: 'twitter' | 'truth_social' | 'binance_square';
  eventId: string;
  deletedAt?: number;
  receivedAt?: number;
  author?: TweetAuthor;
  text?: string;
};
 
type TweetPinEvent = {
  tweetId: string;
  // On legacy frames, fall back to tweet.author.platform or author.platform, then twitter.
  platform?: 'twitter' | 'truth_social' | 'binance_square';
  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 AffiliateAccountIdentity = AccountEventActor & {
  id: string;
};
 
type AffiliateUpdateEvent = {
  action: 'added' | 'removed';
  eventId: string;
  observedAt: number;
  receivedAt?: number;
  organization: AffiliateAccountIdentity;
  member: AffiliateAccountIdentity;
};
 
type CalloutEvent = {
  platform: 'pump_fun';
  caller: {
    username: string;
  };
  token: {
    address: string;
    symbol?: string;
    name?: string;
    // Token artwork when available. This is not an account profile image.
    image?: string;
  };
  url?: string;
  marketCapUsd?: number;
  // Exact decimal strings preserve small onchain values.
  calloutPrice?: string;
  multiple?: number;
  maxPriceSol?: string;
  // The pump.fun Callout UUID.
  calloutId: string;
  // When pump.fun created the Callout.
  createdAt: number;
  // When TweetStream received it.
  receivedAt?: number;
};
 
type CharityAddedEvent = {
  id: string;
  name: string;
  slug: string;
  enabled: boolean;
  taxId?: string;
  location?: {
    city?: string;
    state?: string;
    country?: string;
  };
  observedAt: number;
};
 
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 NewsSource = {
  id: string;
  name: string;
};
 
type NewsMedia = {
  url: string;
  type?: 'image' | 'video';
  caption?: string;
};
 
type NewsArticle = {
  source: NewsSource;
  url: string;
  title: string;
  publishedAt: string;
  receivedAt: number;
  modifiedAt?: string;
  primaryCategory?: string;
  categories: string[];
  author?: string;
  keywords: string[];
  description?: string;
  summary?: string;
  media: NewsMedia[];
  content?: string;
  language?: string;
  copyright?: string;
};
 
type EnvelopeBase<
  TFamily extends 'tweet' | 'account' | 'charity' | 'control' | 'news',
  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 AccountProfileEnvelope = EnvelopeBase<'account', 'profile_update', ProfileUpdateEvent>;
type AccountFollowEnvelope = EnvelopeBase<'account', 'follow' | 'unfollow', FollowEvent>;
type AccountAffiliateEnvelope = EnvelopeBase<
  'account',
  'affiliate_update',
  AffiliateUpdateEvent
>;
type AccountCalloutEnvelope = EnvelopeBase<'account', 'callout', CalloutEvent> & {
  id: string;
};
type AccountEnvelope =
  | AccountProfileEnvelope
  | AccountFollowEnvelope
  | AccountAffiliateEnvelope
  | AccountCalloutEnvelope;
type CharityAddedEnvelope = EnvelopeBase<'charity', 'added', CharityAddedEvent> & {
  id: string;
};
type ControlEnvelope = EnvelopeBase<
  'control',
  'auth_ping' | 'auth_pong' | 'twitter_handles_result',
  TwitterHandlesResult
>;
type NewsEnvelope = EnvelopeBase<'news', 'article', NewsArticle> & {
  id: string;
};
 
type TweetStreamEnvelope =
  | TweetContentEnvelope
  | TweetMetaEnvelope
  | TweetUpdateEnvelope
  | TweetLifecycleEnvelope
  | AccountEnvelope
  | CharityAddedEnvelope
  | NewsEnvelope
  | 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);
  }
  if (event.t === 'charity' && event.op === 'added') {
    console.log(event.d.id, event.d.name);
  }
  if (event.t === 'news' && event.op === 'article') {
    console.log(event.d.source.name, event.d.title);
  }
}

Operations

FamilyOperationMeaning
tweetcontentOriginal post, reply, quote, retweet, or supported Truth Social post content
tweetmetaAdds OCR, detected tokens, CEX, and prediction-market data to a known post
tweetupdateUpdates fields on a known post
tweetdelete, pin, unpinMarks a known post as deleted, pinned, or unpinned
accountprofile_update, follow, unfollow, affiliate_updateObserved account state change for a monitored account
charityaddedA charity was added. Turn alerts on in the dashboard
newsarticleArticle from a publication you enabled
controltwitter_handles_resultResult for WebSocket handle-management commands

Heartbeat and disconnects

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

Backfill after reconnect

Store the last content timestamp or tweetId you processed. After reconnecting, call History API with a bounded startDate and replay events idempotently so downstream bots do not act twice. Use type=AFFILIATE to replay recorded affiliate list changes. Affiliate recording starts with this feature, so there is no current-list snapshot or earlier backfill. Delete, pin, and unpin are live-stream events.

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

A failed WebSocket upgrade returns a JSON body before the socket is accepted. The client can observe and safely log these errors.

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

Your plan determines the number of active WebSocket connections and monitored accounts, as well as History API access. If an endpoint returns 429, pause that workflow. Wait for retryAfterSeconds when provided; otherwise, use your normal backoff.

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

Record the local receipt time at the start of the message callback. For X/Twitter events, decode the tweet snowflake timestamp to measure publication-to-receipt time. Keep the measurement host clock synced with NTP, or the wall-clock result will not be meaningful. Use a monotonic clock for processing segments that start 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.
  • Report cold-start and post-reconnect samples separately instead of folding them into warm-path results.
  • Only compare results when the 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.
  • Connect only to the documented TweetStream endpoints and use the authentication methods above. TweetStream handles routing; no infrastructure-specific headers are required.
  • Use (platform, tweetId) as the post key, not the envelope id. Read platform from author.platform on content and d.platform on later operations. On older operations without d.platform, use an included author platform or default to twitter. Apply every tweet/update idempotently. When an update includes ref, replace the stored reference with that snapshot and follow ref.subtweet for the next referenced post. Fingerprint the full envelope before dropping an exact replay.
  • Treat meta as late-arriving enrichment for a tweet you may already have routed.
  • Track active WebSocket connection count against your plan limit.