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.
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.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
The standard realtime endpoint is `wss://ws.tweetstream.io/ws`. Paid Ultra Speed clients should use `wss://ws-iad.tweetstream.io/ws` with the Ultra API key from the dashboard. The server selects `tweetstream.v1` as the application protocol and strips the auth token protocol before accepting the connection. This example reconnects with exponential backoff because live sockets should be treated as long-running infrastructure.
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.tweetstream.io/ws", [
"tweetstream.v1",
`tweetstream.auth.token.${apiKey}`,
], {
headers: {
"fly-prefer-region": "iad",
},
});
ws.on("upgrade", (response) => {
console.log("connected region", response.headers["x-fly-region"]);
});
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();
Regional routing
TweetStream accepts an optional `fly-prefer-region` header and returns the selected region in `x-fly-region`. Public regions are `iad`, `nrt`, and `ams`; use `iad` first unless your own infrastructure measures a better path.
| Region | Location | Default guidance |
|---|---|---|
| iad | Ashburn, Virginia | Use first; generally fastest |
| nrt | Tokyo, Japan | Use when your trading stack is closer to Asia |
| ams | Amsterdam, Netherlands | Use when your trading stack is closer to Europe |
import WebSocket from "ws";
const ws = new WebSocket("wss://ws.tweetstream.io/ws", [
"tweetstream.v1",
`tweetstream.auth.token.${process.env.TWEETSTREAM_API_KEY}`,
], {
headers: {
"fly-prefer-region": "iad",
},
});
ws.on("upgrade", (response) => {
console.log("connected region", response.headers["x-fly-region"]);
});
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.
import asyncio
import json
import os
import websockets
API_KEY = os.environ["TWEETSTREAM_API_KEY"]
URI = "wss://ws.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.
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?: 'image' | 'video' | 'gif';
thumbnail?: string;
};
type TweetUrl = {
url: string;
name?: string;
tco?: string;
};
type TweetMention = {
handle?: string;
id?: string;
name?: string;
};
type TweetContentKind = 'post' | 'reply' | 'quote' | 'retweet';
type TweetReference = {
type: 'reply' | 'quote' | 'retweet';
tweetId?: string;
text?: string;
translatedText?: string;
author?: TweetAuthor;
media?: Media[];
// True when the upstream only supplied truncated text.
partial?: boolean;
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;
link?: string;
media?: Media[];
mentions?: TweetMention[];
// True when text is known to be truncated.
partial?: boolean;
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;
kind?: TweetContentKind;
text?: string;
translatedText?: string;
author?: TweetAuthor;
media?: Media[];
mentions?: TweetMention[];
partial?: boolean;
receivedAt?: number;
urls?: TweetUrl[];
ref?: TweetContent['ref'];
};
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;
};
type TwitterHandlesResult = {
action: 'follow' | 'unfollow';
requestId: string | null;
results: Array<{
input: string;
handle?: string;
name?: string;
normalizedHandle?: string;
profileImage?: string;
twitterId?: 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
| Family | Operation | Meaning |
|---|---|---|
| tweet | content | Original post, reply, quote, retweet, or supported Truth Social post content |
| tweet | meta | Enrichment for an existing tweetId: OCR, detected tokens, CEX, prediction markets |
| tweet | update | Progressive content update for a known tweetId |
| tweet | delete, pin, unpin | Observed lifecycle event for a tweet |
| account | profile_update, follow, unfollow | Observed account state change for a monitored account |
| control | twitter_handles_result | Result 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.
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.
| Status | Likely cause | Fix |
|---|---|---|
| 400 | Missing or invalid WebSocket protocol headers | Send tweetstream.v1 and a valid auth token protocol |
| 401 | Missing, empty, or invalid API key | Regenerate or rotate the API key from the dashboard |
| 403 | Subscription state does not allow live streaming | Start a trial, renew, or upgrade the account |
| 429 | Active WebSocket connection limit reached | Close an old socket or move to a higher plan |
| 503 | Connection limiter temporarily unavailable | Retry with backoff |
{
"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.
| Surface | Limit signal | Recommended handling |
|---|---|---|
| WebSocket | 429 during upgrade | Close unused sockets or move to a plan with more connections |
| History API | retryAfterSeconds when rate limited | Wait before replaying the next window |
| Tracked accounts | Plan usage from /api/me | Check count and limit before batch add flows |
Measure snowflake latency
For X/Twitter events, decode the tweet snowflake timestamp and compare it with the local receipt time captured immediately when the message arrives. Keep the measurement host clock synced with NTP; otherwise wall-clock latency is not meaningful.
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) {
const arrivedAtMs = Date.now();
const tweetedAtMs = tweetIdToTimestampMs(tweetId);
return arrivedAtMs - tweetedAtMs;
}
ws.on("message", (raw) => {
const event = JSON.parse(raw.toString()) as ContentEvent;
const tweetId = event.d?.tweetId;
if (event.t === "tweet" && event.op === "content" && tweetId) {
console.log("snowflake latency ms", measureSnowflakeLatency(tweetId));
}
});
Production notes
- Reconnect with backoff after close or network error.
- Deduplicate by envelope id first; fall back to `d.tweetId` plus operation only for older stored rows or defensive replay handling.
- Treat `meta` as late-arriving enrichment for a tweet you may already have routed.
- Track active WebSocket connection count against your plan limit.