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.
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.
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.
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.
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
| Family | Operation | Meaning |
|---|---|---|
| tweet | content | Original post, reply, quote, retweet, or supported Truth Social post content |
| tweet | meta | Adds OCR, detected tokens, CEX, and prediction-market data to a known post |
| tweet | update | Updates fields on a known post |
| tweet | delete, pin, unpin | Marks a known post as deleted, pinned, or unpinned |
| account | profile_update, follow, unfollow, affiliate_update | Observed account state change for a monitored account |
| charity | added | A charity was added. Turn alerts on in the dashboard |
| news | article | Article from a publication you enabled |
| control | twitter_handles_result | Result 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.
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.
| 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
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.
| 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 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.
| Segment | Start | Stop |
|---|---|---|
| Publication to receipt | X snowflake timestamp | Local time captured at the top of the socket callback |
| Receipt to decision | Local socket receipt | Strategy and risk decision ready |
| Decision to venue acknowledgement | Risk-approved decision | Separate venue response or rejection |
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. Readplatformfromauthor.platformon content andd.platformon later operations. On older operations withoutd.platform, use an included author platform or default totwitter. Apply everytweet/updateidempotently. When an update includesref, replace the stored reference with that snapshot and followref.subtweetfor the next referenced post. Fingerprint the full envelope before dropping an exact replay. - Treat
metaas late-arriving enrichment for a tweet you may already have routed. - Track active WebSocket connection count against your plan limit.