Consumer examples
Copy a complete consumer, then replace console output with your idempotent application handler.
Build the reference consumer
Use these examples together. Start with the exhaustive router, then add one reconnecting client, durable recovery, and account management.
Route every server operation
This TypeScript router covers all 15 server operations after validation. Use the executable reference consumer for the runtime decoder. Ignore unknown operations without closing the socket.
Keep the handlers small. Persist the receipt and local projection before calling a strategy, notification, or execution boundary.
Exhaustive TypeScript router
type VerifiedType = 'blue' | 'business' | 'government' | 'verified' | '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';
author: TweetAuthor;
text?: string;
tweet?: TweetContent;
};
type TweetUnpinEvent = {
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: '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';
eventId: string;
observedAt: number;
receivedAt?: number;
actor: AccountEventActor;
target: AccountEventActor & {
handle: string;
};
};
type UnfollowEvent = {
kind: '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 TweetDeleteEnvelope = EnvelopeBase<'tweet', 'delete', TweetDeleteEvent>;
type TweetPinEnvelope = EnvelopeBase<'tweet', 'pin', TweetPinEvent>;
type TweetUnpinEnvelope = EnvelopeBase<'tweet', 'unpin', TweetUnpinEvent>;
type AccountProfileEnvelope = EnvelopeBase<'account', 'profile_update', ProfileUpdateEvent>;
type AccountFollowEnvelope = EnvelopeBase<'account', 'follow', FollowEvent>;
type AccountUnfollowEnvelope = EnvelopeBase<'account', 'unfollow', UnfollowEvent>;
type AccountAffiliateEnvelope = EnvelopeBase<
'account',
'affiliate_update',
AffiliateUpdateEvent
>;
type AccountCalloutEnvelope = EnvelopeBase<'account', 'callout', CalloutEvent> & {
id: string;
};
type AccountEnvelope =
| AccountProfileEnvelope
| AccountFollowEnvelope
| AccountUnfollowEnvelope
| AccountAffiliateEnvelope
| AccountCalloutEnvelope;
type CharityAddedEnvelope = EnvelopeBase<'charity', 'added', CharityAddedEvent> & {
id: string;
};
type EmptyPayload = Record<string, never>;
type AuthPingCommand = { op: 'auth_ping' };
type AuthPongEnvelope = EnvelopeBase<'control', 'auth_pong', EmptyPayload>;
type TwitterHandlesResultEnvelope = EnvelopeBase<
'control',
'twitter_handles_result',
TwitterHandlesResult
>;
type ControlEnvelope = AuthPongEnvelope | TwitterHandlesResultEnvelope;
type NewsEnvelope = EnvelopeBase<'news', 'article', NewsArticle> & {
id: string;
};
type TweetStreamEnvelope =
| TweetContentEnvelope
| TweetMetaEnvelope
| TweetUpdateEnvelope
| TweetDeleteEnvelope
| TweetPinEnvelope
| TweetUnpinEnvelope
| AccountEnvelope
| CharityAddedEnvelope
| NewsEnvelope
| ControlEnvelope;
// Feed this router only values returned by a complete runtime decoder.
// See the reference consumer linked below for the executable decoder.
type DecodedEnvelope =
| { kind: 'known'; event: TweetStreamEnvelope }
| { kind: 'unknown' };
type ProtocolHandlers = {
tweetContent: (event: TweetContentEnvelope) => void;
tweetMeta: (event: TweetMetaEnvelope) => void;
tweetUpdate: (event: TweetUpdateEnvelope) => void;
tweetDelete: (event: TweetDeleteEnvelope) => void;
tweetPin: (event: TweetPinEnvelope) => void;
tweetUnpin: (event: TweetUnpinEnvelope) => void;
accountProfileUpdate: (event: AccountProfileEnvelope) => void;
accountFollow: (event: AccountFollowEnvelope) => void;
accountUnfollow: (event: AccountUnfollowEnvelope) => void;
accountAffiliateUpdate: (event: AccountAffiliateEnvelope) => void;
accountCallout: (event: AccountCalloutEnvelope) => void;
charityAdded: (event: CharityAddedEnvelope) => void;
newsArticle: (event: NewsEnvelope) => void;
authPong: (event: AuthPongEnvelope) => void;
twitterHandlesResult: (event: TwitterHandlesResultEnvelope) => void;
// Count and discard unknown input without logging its raw payload.
unknown: () => void;
};
function assertNever(value: never): never {
throw new Error('Unhandled validated envelope');
}
function route(decoded: DecodedEnvelope, handlers: ProtocolHandlers): void {
if (decoded.kind === 'unknown') {
handlers.unknown();
return;
}
const event = decoded.event;
switch (event.t) {
case 'tweet':
switch (event.op) {
case 'content': handlers.tweetContent(event); return;
case 'meta': handlers.tweetMeta(event); return;
case 'update': handlers.tweetUpdate(event); return;
case 'delete': handlers.tweetDelete(event); return;
case 'pin': handlers.tweetPin(event); return;
case 'unpin': handlers.tweetUnpin(event); return;
default: return assertNever(event);
}
case 'account':
switch (event.op) {
case 'profile_update': handlers.accountProfileUpdate(event); return;
case 'follow': handlers.accountFollow(event); return;
case 'unfollow': handlers.accountUnfollow(event); return;
case 'affiliate_update': handlers.accountAffiliateUpdate(event); return;
case 'callout': handlers.accountCallout(event); return;
default: return assertNever(event);
}
case 'charity':
handlers.charityAdded(event);
return;
case 'news':
handlers.newsArticle(event);
return;
case 'control':
switch (event.op) {
case 'auth_pong': handlers.authPong(event); return;
case 'twitter_handles_result': handlers.twitterHandlesResult(event); return;
default: return assertNever(event);
}
default:
return assertNever(event);
}
}Run a 5-minute smoke test
Add one monitored account, copy the example, and run it. TweetStream connected confirms authentication. Events appear when that account posts.
Run the TypeScript smoke test
// bun add ws
// TWEETSTREAM_API_KEY=ts_... bun run smoke.ts
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());
});
ws.on("unexpected-response", (_request, response) => {
console.error("Connection rejected", response.statusCode, response.statusMessage);
response.resume();
process.exitCode = 1;
});
ws.on("error", (error) => {
console.error("WebSocket error", error.message);
});Connect
Use wss://ws-iad.tweetstream.io/ws in the USA and wss://ws-global.tweetstream.io/ws elsewhere. The server responds with tweetstream.v1; it does not echo the authentication-token protocol. The example reconnects with exponential backoff. Short sessions keep increasing the delay; 30 seconds of uptime resets it.
Connect with Node.js
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;
let healthyTimer: ReturnType<typeof setTimeout> | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleReconnect(reason: string) {
if (reconnectTimer) return;
const delayMs = Math.min(30_000, 1_000 * 2 ** retry) + Math.floor(Math.random() * 500);
retry += 1;
console.warn(`Reconnecting in ${delayMs}ms: ${reason}`);
reconnectTimer = setTimeout(() => {
reconnectTimer = undefined;
connect();
}, delayMs);
}
function connect() {
const ws = new WebSocket("wss://ws-global.tweetstream.io/ws", [
"tweetstream.v1",
`tweetstream.auth.token.${apiKey}`,
]);
ws.on("open", () => {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = undefined;
healthyTimer = setTimeout(() => {
retry = 0;
healthyTimer = undefined;
}, 30_000);
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) => {
if (healthyTimer) clearTimeout(healthyTimer);
healthyTimer = undefined;
scheduleReconnect(`close ${code}: ${reason.toString()}`);
});
ws.on("unexpected-response", (_request, response) => {
console.error("Connection rejected", response.statusCode, response.statusMessage);
response.resume();
if (response.statusCode === 429 || response.statusCode === 503) {
scheduleReconnect(`HTTP ${response.statusCode}`);
return;
}
process.exitCode = 1;
});
ws.on("error", (error) => {
console.error("WebSocket error", error.message);
});
}
connect();Connect with Python
Use any WebSocket runtime that can send both subprotocols. Clean closes and transport errors reconnect with exponential backoff. Short sessions keep increasing the delay; 30 seconds of uptime resets it.
Python reconnecting consumer
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():
loop = asyncio.get_running_loop()
retry = 0
while True:
connected_at = None
reason = "connection closed"
try:
async with websockets.connect(URI, subprotocols=PROTOCOLS) as ws:
connected_at = loop.time()
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:
reason = str(error)
if connected_at is not None and loop.time() - connected_at >= 30:
retry = 0
wait = min(30, 2 ** retry)
retry = min(retry + 1, 5)
print(f"reconnecting in {wait}s after {reason}")
await asyncio.sleep(wait)
asyncio.run(main())Backfill after reconnect
Choose a bounded startDate and endDate, follow nextCursor to null without changing the other filters, and process every returned row idempotently. Persist the window and cursor together if an interrupted replay must resume rather than restart.
History returns stored events; it does not promise completeness or a retention duration. AFFILIATE returns stored affiliate changes, not a current-list snapshot. Delete, pin, and unpin remain live-only events.
Replay a recovery window
type HistoryRow = {
time: string;
tweetId: string;
};
type HistoryPage = {
data: Array<HistoryRow>;
metadata: { nextCursor: string | null };
};
async function replayAfterReconnect(
checkpoint: string,
processEvent: (event: HistoryRow) => Promise<void>,
saveCheckpoint: (eventTime: string) => Promise<void>,
) {
const apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) throw new Error("Missing TWEETSTREAM_API_KEY");
const url = new URL("https://api.tweetstream.io/api/history");
const replayEnd = new Date().toISOString();
url.searchParams.set("handles", "marketdesk");
url.searchParams.set("startDate", checkpoint);
url.searchParams.set("endDate", replayEnd);
url.searchParams.set("limit", "1000");
url.searchParams.set("type", "TWEET");
const pages: Array<Array<HistoryRow>> = [];
let cursor: string | null = null;
do {
if (cursor) url.searchParams.set("cursor", cursor);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
throw new Error(`Replay failed (${response.status}): ${await response.text()}`);
}
const page = (await response.json()) as HistoryPage;
pages.push(page.data);
cursor = page.metadata.nextCursor;
} while (cursor);
for (const event of pages.reverse().flatMap((page) => [...page].reverse())) {
await processEvent(event);
}
await saveCheckpoint(replayEnd);
}Request history
Use GET /api/history to replay stored events after reconnecting or inspect a specific time window.
Check access
Pro and Scale can request every History event type. Active or trialing Ultra can request AFFILIATE with the standard or Ultra key that owns the account. Supported types are TWEET, PROFILE, FOLLOW, and AFFILIATE. Delete, pin, and unpin remain live-only events.
Set filters
Without a handle filter, History searches all active accounts tracked by the key. A filter can include only handles that key currently tracks.
| Parameter | Required | Notes |
|---|---|---|
| handle, handles, handle[], handles[] | No | One handle, repeated handles, or comma-separated handles |
| startDate | No | ISO datetime lower bound |
| endDate | No | ISO datetime upper bound |
| limit | No | Defaults to 100; maximum 1000 |
| type | No | TWEET, PROFILE, FOLLOW, or AFFILIATE. Defaults to TWEET |
| cursor | No | Opaque nextCursor from the previous page; keep every other filter unchanged |
History request
const apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) throw new Error("Missing TWEETSTREAM_API_KEY");
const response = await fetch(
"https://api.tweetstream.io/api/history?limit=25&type=TWEET",
{
headers: {
Authorization: `Bearer ${apiKey}`,
},
},
);
if (!response.ok) {
throw new Error(`History failed (${response.status}): ${await response.text()}`);
}
const page = await response.json();
console.log(page.data);Add and remove accounts
Use the REST endpoints from your backend to change the watchlist.
Send handles
Send one handle or an array in accounts. A leading @ is optional, and matching is case-insensitive.
Add accounts
const apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) throw new Error("Missing TWEETSTREAM_API_KEY");
const response = await fetch("https://api.tweetstream.io/api/add-account", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
accounts: ["marketdesk", "realDonaldTrump"],
}),
});
if (!response.ok && response.status !== 207) {
throw new Error(`Add failed (${response.status}): ${await response.text()}`);
}
console.log(await response.json());Remove an account
const apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) throw new Error("Missing TWEETSTREAM_API_KEY");
const response = await fetch("https://api.tweetstream.io/api/remove-account", {
method: "DELETE",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
accounts: "marketdesk",
}),
});
if (!response.ok && response.status !== 207) {
throw new Error(`Remove failed (${response.status}): ${await response.text()}`);
}
console.log(await response.json());Choose the credential scope
A standard API key updates the standard tracked-account list. An active Ultra key updates the Ultra selection and enforces its paid account limit.
Read every result
The response contains one result per handle. Use the row state, not only the HTTP status, before retrying.
Command result
{
"action": "follow",
"requestId": "8b4f9c9c-9e7b-4a0c-9c7d-2d4d6f0a9a25",
"error": null,
"results": [
{
"input": "marketdesk",
"state": "added"
},
{
"input": "realDonaldTrump",
"state": "added"
}
],
"summary": {
"failed": 0,
"succeeded": 2,
"total": 2
}
}