# TweetStream: real-time X/Twitter WebSocket feed for trading systems TweetStream sends monitored X/Twitter posts over WebSocket to crypto and prediction-market systems. The same connection can carry news from 22 publications, posts, replies, and quotes from selected Binance Square accounts, pump.fun callouts, and charity notifications. Pro, Scale, and Ultra Speed include supported Donald Trump and White House Truth Social posts over WebSocket and Discord. Minimum does not include Truth Social. If Truth Social was already enabled on an active plan, you keep access. TweetStream's median X post detection time was 170 ms over the seven days to September 30, 2026, measured from X's post timestamp to first receipt on a TweetStream server. The rolling production report at /speedcheck publishes current p25, p50, p95, sample sizes, and per-account results for monitored X posts. These figures measure X post publication to first TweetStream server receipt, not customer network transit or trade execution. Latency figures are comparable only when they measure the same start and end points. This timing claim does not apply to Truth Social. ## Start here - [Full LLM context](/llms-full.txt): All public developer docs in Markdown. - [Docs full context](/docs/llms-full.txt): The same docs under the docs path. - [简体中文 LLM context](/zh-cn/docs/llms-full.txt): 完整的简体中文开发者文档。 - [Interactive documentation](/docs): Developer docs for browsing and search. - [OpenAPI](/docs/openapi.json): OpenAPI 3.1 contract for the REST API. - [AsyncAPI](/docs/asyncapi.json): AsyncAPI 3.0 contract for WebSocket v1. - [Machine-readable pricing](/pricing.md): Current Minimum, Pro, Scale, Ultra Speed, and trial facts from the live pricing catalog. - [Live X/Twitter server detection data](/speedcheck): Rolling production p25, p50, p95, sample sizes, UTC windows, per-account results, and the exact measurement boundary. - [Live X/Twitter speed data as JSON](/speedcheck.json): The same rolling report in structured JSON. - [Live X/Twitter speed data as CSV](/speedcheck.csv): Aggregate and per-account rows from the same rolling report. ## Trading use cases - [Crypto Twitter monitor and tracker](/crypto-twitter-alerts): Follow selected accounts for memecoin launches, founder posts, and KOL calls. Get supported alerts in Discord or connect a bot over WebSocket, with token detection, OCR, and price context on relevant posts. - [Selected-account Twitter WebSocket API](/twitter-websocket-api): WebSocket delivery for trading bots with account events, OCR, token detection, and live price context when available. - [Polymarket Twitter alerts](/polymarket-twitter-alerts): X source events for prediction-market strategies, with supported Truth Social posts on Pro, Scale, and Ultra Speed. - [Polymarket bot guide](/blog/polymarket-twitter-alert-bot): Architecture for joining social events with Polymarket market data and execution risk rules. - [Kalshi trading bot guide](/blog/kalshi-trading-bot-social-signals): Join selected social events with official Kalshi market data while keeping risk and execution in your own system. - [Social alternative data guide](/blog/social-events-alternative-trading-data): Compare selected-account events with news APIs, sentiment products, and scrapers for trading systems. - [Twitter-to-trade latency guide](/blog/twitter-to-trade-latency-measurement): Measure publication, receipt, decision, risk, order, and fill timestamps with consistent boundaries. - [Truth Social API access guide](/truth-social-api): Compare institutional Truth API access with TweetStream's narrower support for Donald Trump and White House posts. - [Trump Truth Social alerts](/trump-truth-social-alerts): Supported Donald Trump and White House posts over WebSocket and Discord on Pro, Scale, and Ultra Speed. - [Competitor comparisons](/alternatives): Current comparisons with uTrack, Bark.gg, TwitterAPI.io, and other API options. ## Product facts - `tweet/content` carries the initial usable post. It includes text, author identity, and timestamps; media and reference context are included when available. - `tweet/update` can add reference context or supply the full text of a previously shortened post. Apply it to the existing post by `(platform, tweetId)`. - `tweet/meta` can follow with OCR, token and contract detections, prices, exchange context, and prediction-market context. - Feed plans can deliver X/Twitter events, news from 22 publications, posts, replies, and quotes from selected Binance Square accounts, pump.fun callouts, and charity notifications on the same WebSocket. - Minimum has no History API access. Pro, Scale, and Ultra Speed include full history replay. - Minimum and Pro have three-day trials. Ultra Speed has a 24-hour trial. - TweetStream does not provide Polymarket prices or order execution; use Polymarket's APIs and combine both feeds in the strategy. - TweetStream is independent and is not affiliated with X Corp, Truth Social, TMTG, Donald J. Trump, or Polymarket. ## Pages ### Start - [Overview](/docs): Connect to TweetStream and find the realtime, event, REST, and example references. - [Discord bot](/docs/discord-bot): Set up the TweetStream Discord bot on a paid plan or trial, choose a channel, and manage a shared watchlist of X accounts. - [Billing in Discord](/docs/discord-billing): Buy and pay for a TweetStream subscription from the Discord bot, and turn limit alerts on or off. - [Quickstart](/docs/quickstart): Open an authenticated TweetStream WebSocket and inspect the first event. - [Production consumer](/docs/production-consumer): Build a long-running TweetStream consumer with routing, reconnects, and checkpoints. ### WebSocket protocol - [Connection, auth, heartbeat](/docs/connection-auth-heartbeat): Choose a WebSocket endpoint, authenticate, and keep the connection responsive. - [Envelope and delivery](/docs/envelope-delivery-recovery-errors): Route envelopes, recover after disconnects, and handle public WebSocket errors. ### Event reference - [All events](/docs/payloads-detections-ocr): Browse every TweetStream event family and open its payload and integration guide. - [Social content](/docs/social-content): Consume Twitter, Truth Social, and Binance Square content through the tweet envelope. - [Tweet meta and lifecycle](/docs/tweet-updates-lifecycle): Replace live metadata snapshots, merge tweet updates, then apply delete, pin, and unpin events. ### Account and alert events - [Profile, follow, affiliate](/docs/profile-follow-affiliate): Consume profile, follow, unfollow, and affiliate relationship changes. - [Callouts](/docs/callouts): Configure callout alerts and consume account callout events. - [FOMO](/docs/fomo): Track FOMO users and coins, read thesis events, and handle observed token verification. - [Charity](/docs/charity): Enable charity alerts and consume charity added events. - [News](/docs/news): Select publications, consume live news, and paginate news history. - [Control messages](/docs/control): Use authentication and handle-management control messages. ### REST reference - [History API](/docs/history-api): Query stored account events through the bearer-authenticated History API. - [Accounts and settings](/docs/account-management): Add, remove, inspect, and configure accounts through authenticated REST endpoints. ### Examples - [Examples](/docs/examples): Copy and run one TypeScript or Python program that connects, reconnects, merges tweet updates, follows reference chains, and measures latency. ### Instagram - [Instagram WebSocket](/docs/instagram-websocket): Connect to the Instagram WebSocket and read post, reel, carousel, and story events. ## Public product and buying pages - [TweetStream](/) - [About TweetStream's Real-Time Trading Feed](/about) - [TweetStream X Post Latency and Detection Speed Data](/speedcheck) - [How TweetStream Measures X Post Detection Speed](/speedcheck/method) - [X API, Truth API and Alert Speed Facts](/speedcheck/stats) - [Contact TweetStream for API Access and Pricing](/contact) - [Twitter Discord Bot for X Account Alerts](/twitter-discord-bot) - [Instagram Post Alerts and Tracker for Reels and Stories](/instagram-alerts) - [Twitter/X Feed and Trading Bot FAQ](/faq) - [TweetStream Features: Fast X Alerts Built to Trade](/features) - [TweetStream Pricing: X and News Feeds from $199/mo](/pricing) - [WebSocket API Security for Trading Systems](/security) - [Binance Square Alerts for Trading Bots](/binance-square-alerts) - [New Charity Alerts over WebSocket](/features/charity-alerts) - [Crypto News Alerts for Trading Bots](/news-monitoring-for-trading-bots) - [pump.fun Callout Alerts for Trading Bots](/features/pumpfun-callout-alerts) - [Crypto Twitter Monitor & Tracker for Traders](/crypto-twitter-alerts) - [Polymarket Twitter Alerts for Trading Systems](/polymarket-twitter-alerts) - [Social Media Alerts for Trading Bots](/social-media-alerts-for-trading-bots) - [Trump Truth Social Alerts: WebSocket & Discord](/trump-truth-social-alerts) - [Truth Social Alerts for Trading Workflows](/truth-social-alerts-for-traders) - [Truth Social API Integration for Trading Bots](/truth-social-api) - [TweetStream Alternatives](/alternatives) - [Twitter API Alternative](/alternatives/twitter-api) - [TweetCatcher Alternative](/alternatives/tweetcatcher) - [uTrack Alternative](/alternatives/utrack) - [1322 Alternative for Crypto Trading Bots](/alternatives/1322) - [Bark.gg Alternative for Fast Trading Alerts](/alternatives/bark-gg) - [TwitterAPI.io Alternative for Trading Bots](/alternatives/twitterapi-io) - [X-Relay Alternative for Trading Bots and Teams](/alternatives/x-relay) - [Xanguard Alternative for Crypto Trading](/alternatives/xanguard) - [TweetStream Solutions](/solutions) - [X Account Alert Workflow for Crypto Desks](/solutions/twitter-alerts-crypto-traders) - [Twitter to Discord Alerts](/solutions/twitter-to-discord-alerts) - [Build an Event-Driven X Feed for Trading Bots](/solutions/websocket-twitter-api-trading-bots) - [Twitter Monitor API for Real-Time X Accounts](/solutions/realtime-twitter-account-monitoring) - [Discord Crypto Alerts](/solutions/discord-crypto-alerts) - [TweetStream developer docs](/docs) - [Set up the Discord bot](/docs/discord-bot) - [Billing in Discord](/docs/discord-billing) - [WebSocket quickstart](/docs/quickstart) - [Build a production consumer](/docs/production-consumer) - [Connection, authentication, and heartbeat](/docs/connection-auth-heartbeat) - [Envelope, delivery, recovery, and errors](/docs/envelope-delivery-recovery-errors) - [Event reference](/docs/payloads-detections-ocr) - [Social content events](/docs/social-content) - [Tweet metadata, updates, and lifecycle](/docs/tweet-updates-lifecycle) - [Profile, follow, and affiliate events](/docs/profile-follow-affiliate) - [Callout events](/docs/callouts) - [FOMO theses and token verification](/docs/fomo) - [Charity events](/docs/charity) - [News events and history](/docs/news) - [Control messages](/docs/control) - [History API](/docs/history-api) - [Accounts and settings](/docs/account-management) - [Complete WebSocket example](/docs/examples) - [Instagram WebSocket](/docs/instagram-websocket) - [History Replay: Recover Missed Trading Signals](/features/history-replay) - [Manage your X/Twitter watchlist](/features/account-management) - [Connect TweetStream to Bots, Discord, and Python](/integrations) - [Python WebSocket Integration](/integrations/python-websockets) - [Discord Webhook Integration](/integrations/discord-webhooks) - [TweetStream Blog](/blog) - [X (Twitter) API Pricing 2026: Cost Calculator](/blog/twitter-api-pricing) - [Twitter Streaming API](/blog/twitter-streaming-api) - [Twitter to Discord Webhook](/blog/twitter-to-discord-webhook) - [DexScreener API Alerts from X/Twitter Posts](/blog/dexscreener-alerts) - [Does Twitter Use WebSockets?](/blog/does-twitter-use-websockets) - [Best Crypto Twitter Monitoring Tools](/blog/best-crypto-twitter-monitoring-tools) - [Build a Real-Time Twitter Dashboard](/blog/build-real-time-twitter-dashboard) - [Crypto Rug Pull Twitter Signals](/blog/crypto-rug-pull-twitter-signals) - [Kalshi Trading Bot: Real-Time Social Signals](/blog/kalshi-trading-bot-social-signals) - [Monitor Competitor Twitter Accounts for Crypto](/blog/monitor-competitor-twitter-crypto) - [Node.js Twitter WebSocket Client](/blog/node-twitter-websocket-client) - [Polymarket Trading Bot: Real-Time X Alerts](/blog/polymarket-twitter-alert-bot) - [Pump.fun Twitter Alerts](/blog/pumpfun-twitter-alerts) - [Python Twitter WebSocket Client](/blog/python-twitter-websocket-client) - [Social Alternative Data for Trading Bots](/blog/social-events-alternative-trading-data) - [Solana Memecoin Twitter Alerts](/blog/solana-memecoin-twitter-alerts) - [How to Build a Crypto Twitter Watchlist](/blog/track-crypto-twitter-accounts) - [Twitter Account Activity API Alternative](/blog/twitter-account-activity-api-alternative) - [Twitter API Rate Limits](/blog/twitter-api-rate-limits) - [Twitter Cashtag Monitoring for Crypto](/blog/twitter-cashtag-monitoring) - [Twitter Data Storage for Trading Bots](/blog/twitter-data-storage-for-trading-bots) - [Twitter Keyword Alerts for Crypto](/blog/twitter-keyword-alerts-for-crypto) - [Twitter Notification Bot](/blog/twitter-notification-bot) - [Twitter Push Notifications](/blog/twitter-push-notifications) - [Twitter to Discord Bot](/blog/twitter-to-discord-bot) - [Twitter to Telegram Alerts](/blog/twitter-to-telegram-alerts) - [Twitter-to-Trade Latency: How to Measure It](/blog/twitter-to-trade-latency-measurement) - [Twitter Webhook API](/blog/twitter-webhook-api) - [Twitter WebSocket Reconnect and Backoff](/blog/twitter-websocket-reconnect-backoff) - [WebSocket vs Webhook Twitter Alerts](/blog/websocket-vs-webhook-twitter-alerts) - [Twitter WebSocket API for Trading Bots](/twitter-websocket-api) ## Full public docs ## TweetStream developer docs Connect first, then use focused references for the WebSocket protocol, events, REST APIs, and complete consumers. Canonical URL: https://tweetstream.io/docs ### Start streaming - [Complete WebSocket example](https://tweetstream.io/docs/examples#run): Download one file, run it with your API key, and watch events arrive. - [Production consumer](https://tweetstream.io/docs/production-consumer): Add routing, reconnects, and checkpoints. - [Event reference](https://tweetstream.io/docs/payloads-detections-ocr): Find the payload for each event operation. - [REST reference](https://tweetstream.io/docs/history-api): Replay stored events and manage account settings. ### Choose a base URL Connect to `wss://ws-iad.tweetstream.io/ws` in the USA or `wss://ws-global.tweetstream.io/ws` elsewhere. REST requests use `https://api.tweetstream.io`. - [Connection details](https://tweetstream.io/docs/connection-auth-heartbeat#base-urls): Every endpoint, authentication method, and heartbeat rule. ### Use machine-readable contracts - [OpenAPI 3.1](https://tweetstream.io/docs/openapi.json): Generate REST clients from the public endpoint contract. - [AsyncAPI 3.0](https://tweetstream.io/docs/asyncapi.json): Inspect every WebSocket channel, operation, and payload schema. - [LLM context](https://tweetstream.io/docs/llms-full.txt): Load the complete public docs as Markdown. ## Set up the Discord bot Get posts from the X accounts you track in your own Discord channel at standard speed. The bot is included with paid plans and trials and needs no code. Canonical URL: https://tweetstream.io/docs/discord-bot ### Sign up and verify Discord Sign up with email or Discord and start a trial or plan. Your dashboard shows the next step. In the dashboard, select Verify Discord. Link your Discord account if you signed up with email, authorize it to join the TweetStream community, and accept the rules in Discord. The dashboard checks your membership automatically. Verification opens the member channels and Speed Check. Your feed goes to your own server, not the community server. - [Start a trial](https://tweetstream.io/signup): Create an account and choose the plan to try. ### Install and set up your feed - Select Add bot to your server in the dashboard. An admin can install it for you. To connect the server, your linked Discord account needs Manage Server or Administrator permission. - Run `/setup` in your server and choose a feed channel. Give the bot View Channel, Send Messages, Embed Links, and Attach Files permission. Only your linked Discord account can manage the feed. ### Manage your feed and watchlist - Use `/add` to save an X account, `/remove` to stop tracking it, and `/list` to see active and paused accounts. The bot and dashboard share this watchlist with your other eligible TweetStream delivery paths. - With Ultra Speed, `/add-ultra` turns on Ultra for an X account and `/remove-ultra` turns it off. `/add-ultra` saves the account to your watchlist first if it isn't there yet. Without Ultra Speed, `/add-ultra` shows the plan, and the account turns on once the plan starts. `/list` marks the accounts that have Ultra. - Use `/channel` to change channels. One TweetStream account connects to one server. To move the feed, run `/setup` in the new server and confirm. Your linked Discord account needs Manage Server or Administrator permission there; delivery to the old server then stops. - Run `/me` for a private view of your plan and tracking limit. Paid renewal dates and trial end dates appear when applicable. Base and Ultra subscriptions appear separately. - The bot keeps X accounts active up to your plan's account limit: accounts you picked during a trial first, then your oldest saved accounts. The rest pause but stay saved. Paid Ultra Speed includes Minimum, so Ultra Speed on its own has a limit of 50. An Ultra Speed trial on its own is limited to the accounts the trial covers, and those accounts get Discord delivery only; WebSocket access to them needs a base plan or trial. Without a paid plan, trial, or Ultra Speed subscription, the limit is 0, so every account pauses until you start one. Check `/list` before removing an account you still need elsewhere. - A successful `/add` confirms tracking. It does not mean a post has already arrived; wait for the account to publish again. ### Limits, speed, and plans The bot delivers X posts. Accounts covered by an Ultra subscription use Ultra speed; other accounts use standard speed. With an Instagram Tracking plan, the bot can also post the Instagram accounts on that plan to your channel. The bot and your saved watchlist stay on the same account when you change plans. WebSocket access, other sources, history, and customer webhooks require an applicable paid plan or eligible trial. Each delivery path follows its own limits. - [Compare plans](https://tweetstream.io/pricing): Check source access, delivery options, and account limits. ### Billing and trial reminders Paid plans get renewal reminders by Discord DM; trials get trial-ending notices. Accounts without a plan or trial get no billing reminders. Reminders default on when you first link Discord. Manage reminders and send a test DM in Dashboard → Plan. You do not need to install the bot or run `/setup` to receive reminders. Reminders go to your linked Discord account. Discord privacy settings may block DMs; billing details stay out of public channels. Email reminders continue independently. Minimum and Pro trial notices arrive within the final 24 hours; Ultra trial notices within the final 12 hours. Card renewal notices start three days before renewal. Stablecoin notices start three days before monthly renewal or fourteen days before annual renewal. Each notice shows the payment action or access-end date. - [Manage Discord reminders](https://tweetstream.io/dashboard?tab=plan): Open the reminder setting in Dashboard → Plan. - [Billing in Discord](https://tweetstream.io/docs/discord-billing): Buy, pay for, and change your plan with /billing. ### Troubleshooting - Run `/help` in your server or in a DM with the bot to see every command. - Already have an account? Sign in before linking Discord. If your Discord identity belongs to another account, use that account or contact support; another signup will not bypass the link. - Membership pending? Accept the community rules in Discord and check again. If verification is unavailable, retry later. - No posts? Check `/list`, the selected channel, and the bot's permissions. The account may not have posted since you added it. - Existing customers can dismiss the dashboard link or join prompt. It does not reset their server or block existing access. ## Billing in Discord Run `/billing` in your server or in a DM with the bot to buy a plan, pay a bill, or change your subscription. Only you can see the replies. Canonical URL: https://tweetstream.io/docs/discord-billing ### Get a plan from Discord - [Compare plans](https://tweetstream.io/pricing): See prices, limits, and what each plan includes. - Run `/billing`, or press Get a plan or Billing on `/me`, a server panel, or your trial welcome message. Choose Base, Instagram Tracking, or Ultra Speed, then a plan and a term. For Scale, the button opens our contact page. - Choose how to pay: Start free trial with card, Pay with USDC, or Pay with USDT. Choosing a payment method accepts the Terms. If your Discord account isn't linked to TweetStream yet, this creates a TweetStream account for it. If you already have an account, press Link account first so the plan goes on that account. - Card or free trial: the reply has a button that opens a checkout page in your browser, where you enter your card. Your bank may ask you to confirm. Free trials need a card, and each card gets one trial of each product. If yours already had one, the reply says so and you can still pay. - USDC or USDT: the reply shows the exact amount, a Solana address, a QR code, and when the request expires. Send exactly that amount of that token on Solana before then. Open in wallet hands the payment to a Solana wallet on your device, and Pay on web shows the same payment in your browser. - Ultra Speed starts with no X accounts selected. Once it's active, run `/add-ultra` in your server to choose them. If you started from `/add-ultra`, that account turns on by itself and the reply says so. - The reply updates itself. It shows Payment received while the payment is applied, then something like "Paid. Pro is active through 16 Nov." or "Trial active until 19 Oct." once your subscription is active. ### Pay a renewal or a bill - Renewal reminders in your DMs have a Pay button. It always opens what you owe now, so an older reminder still pays the current amount. `/billing` also shows Pay when a payment is due. - Stablecoin renewals work like a new plan: exact amount, Solana address, QR code, and expiry time. If the request expired, press Pay or run `/billing` to get a new one. - If a card payment fails or needs bank approval, Pay opens the invoice in your browser, where you can pay or approve it. ### Limit alerts - [Discord limit alerts](https://tweetstream.io/dashboard?tab=plan): Turn alerts on or off in Dashboard → Plan. - The bot sends you a DM when an account limit is nearly full and again when it is full, for example "18 of 20 accounts used. Need more room?" Nearly full means 10% of the limit or one account is left, whichever is more. Upgrade opens your plan options, and nothing is charged until you confirm. - Alerts cover your X account limit, your Instagram Tracking accounts, and your Ultra Speed accounts. You get at most one alert a week and two in 30 days. There are no alerts during a trial, while a subscription is paused, canceling, or unpaid, or when a bigger plan wouldn't raise that limit. - Alerts are on by default and go to your linked Discord account, even if billing reminders are off. To turn them off, press Turn off alerts on any alert, or use the Discord limit alerts switch in Dashboard → Plan. Email and billing reminders don't change. - When `/add` hits your account limit, its reply also has an Upgrade button. ### Payment safety - The bot never asks for card details in Discord. You enter a card only on the checkout or invoice page in your browser, and payment buttons open tweetstream.io first. - Billing menus expire after 15 minutes, and a stablecoin request expires at the time it shows. - Billing replies are private, and their buttons only work for the Discord account that opened them. ### FAQ - [Contact support](https://tweetstream.io/contact): Tell us what you paid and when, and we'll check it. #### The pay link expired Run `/billing` again, or press Pay on your renewal reminder. You get a new request for the current amount. Don't send funds to a request that has expired. #### I paid but got no confirmation A stablecoin payment can take a few minutes to apply. Discord only lets the bot update a reply for about 15 minutes, so a later confirmation comes as a DM if billing reminders are on. Run `/me` or open Dashboard → Plan to see your plan. If it still isn't active, contact support with the time you paid and the transaction ID. #### I use a different Discord account Billing goes to the TweetStream account linked to the Discord account you run `/billing` from. If that Discord account isn't linked, paying creates a new TweetStream account. To pay for your existing account, link this Discord account first with Link account in the plan reply, or select Change on the Discord tab of your dashboard. One Discord account links to one TweetStream account. #### The bot says "This menu expired" Billing menus last 15 minutes. Run `/billing` for a new one. The Billing button on `/me` and the buttons in your DMs keep working. ## WebSocket quickstart Add one monitored account, open a server-side WebSocket, and print each v1 envelope. Canonical URL: https://tweetstream.io/docs/quickstart ### 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 ```typescript // 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); }); ``` ### Route operations Each event names its family in `t` and its operation in `op`. The delivery reference lists all 18 server operations. - [All operations](https://tweetstream.io/docs/envelope-delivery-recovery-errors#operations): Family, operation, and meaning for every server event. ### Make it durable - [Connection details](https://tweetstream.io/docs/connection-auth-heartbeat): Choose an endpoint and understand authentication and heartbeat behavior. - [Production consumer](https://tweetstream.io/docs/production-consumer): Add reconnects, event routing, and recovery checkpoints. ## Build a production consumer Route known operations, reconnect with backoff, and make every side effect safe to repeat. Canonical URL: https://tweetstream.io/docs/production-consumer ### Reconnect with backoff Start from the reconnecting Node.js or Python consumer, then route every operation through one exhaustive handler. - [Reconnecting consumers](https://tweetstream.io/docs/connection-auth-heartbeat#connect): Node.js and Python clients with exponential backoff. - [Complete router](https://tweetstream.io/docs/production-consumer#complete-router): Typed handlers for all 18 server operations. ### Route every server operation This TypeScript router covers all 18 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 ```typescript 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; // The account's own website, when it has one. 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. When omitted, use twitter. 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. When absent, use 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; // Supplies the full text of a previously shortened post. Not an edit. textUpdateType: 'completion'; } ); type TweetDeleteEvent = { tweetId: string; // When absent, use author.platform, then twitter. platform?: 'twitter' | 'truth_social' | 'binance_square'; eventId?: string; deletedAt?: number; receivedAt?: number; author?: TweetAuthor; text?: string; }; type TweetPinEvent = { tweetId: string; // When absent, use 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; // When absent, use 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 CalloutCaller = { username: string; image?: string; }; type CalloutReply = { caller: CalloutCaller; replyId: string; parentReplyId?: string; rootReplyId?: string; text?: string; mediaUrl?: string; }; type CalloutEvent = { platform: 'pump_fun'; caller: CalloutCaller; kind?: 'callout' | 'author_update' | 'reply' | 'repost'; text?: string; mediaUrl?: string; // Required for replies and author updates. replyId?: string; parentReplyId?: string; rootReplyId?: string; reference?: { caller: CalloutCaller; text?: string; mediaUrl?: string; reply?: CalloutReply; }; position?: { amountHeld?: string; valueUsd?: string; costBasisUsd?: string; pnlUsd?: string; pnlPercentage?: string; realizedPnlUsd?: string; }; token: { address: string; chainId?: string; symbol?: string; name?: string; image?: string; }; url?: string; marketCapUsd?: number; // SOL, exact decimal. calloutPrice?: string; // USD, exact decimal. calloutPriceUsd?: string; multiple?: number; // SOL peak, exact decimal. maxPriceSol?: string; // USD peak, exact decimal. maxPriceUsd?: string; calloutId: string; createdAt: number; receivedAt?: number; }; type CalloutUpdateEvent = CalloutEvent & { // Original envelope id, not calloutId. updateOf: string; }; type FomoCoin = { address: string; networkId: number; name?: string; symbol?: string; image?: string; metadataObservedAt?: number; priceUsd?: string; marketCapUsd?: string; liquidityUsd?: string; volume24hUsd?: string; change24hPercent?: string; }; type FomoThesisEvent = { platform: 'fomo'; coin: FomoCoin; author: { id: string; username: string; verified: boolean; displayName?: string; avatar?: string; twitter?: string; }; thesis: { id: string; text: string; createdAt: number; likes: number; replies: number; tradeId?: string; parentId?: string; segments?: Array<{ text: string; link?: string }>; }; position?: { tokenAmount?: string; usdValue?: string; unrealizedPnlUsd?: string; realizedPnlUsd?: string; unrealizedPnlPercent?: string; realizedPnlPercent?: string; closedAt?: number; }; observedAt?: number; receivedAt?: number; }; type FomoTokenVerifiedEvent = { platform: 'fomo'; coin: FomoCoin; observedAt: number; 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 AccountCalloutUpdateEnvelope = EnvelopeBase< 'account', 'callout_update', CalloutUpdateEvent > & { id: string; }; type AccountThesisEnvelope = EnvelopeBase<'account', 'thesis', FomoThesisEvent> & { id: string; }; type AccountTokenVerifiedEnvelope = EnvelopeBase< 'account', 'token_verified', FomoTokenVerifiedEvent > & { id: string; }; type AccountEnvelope = | AccountProfileEnvelope | AccountFollowEnvelope | AccountUnfollowEnvelope | AccountAffiliateEnvelope | AccountCalloutEnvelope | AccountCalloutUpdateEnvelope | AccountThesisEnvelope | AccountTokenVerifiedEnvelope; type CharityAddedEnvelope = EnvelopeBase<'charity', 'added', CharityAddedEvent> & { id: string; }; type EmptyPayload = Record; 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; accountCalloutUpdate: (event: AccountCalloutUpdateEnvelope) => void; accountThesis: (event: AccountThesisEnvelope) => void; accountTokenVerified: (event: AccountTokenVerifiedEnvelope) => 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; case 'callout_update': handlers.accountCalloutUpdate(event); return; case 'thesis': handlers.accountThesis(event); return; case 'token_verified': handlers.accountTokenVerified(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); } } ``` ### Prepare for production - 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. If it 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. - Store receipts and projections in indexed durable storage. Define a retention or archival policy for your replay window and storage budget; never remove pending work. - Log failed actions without payload data and retry them with backoff. Keep the same receipt ID as the idempotency key. - Track active WebSocket connection count against your plan limit. ### Measure signal latency Record local receipt time at the start of the message callback. For X/Twitter events, decode the tweet snowflake timestamp to measure publication to receipt. Keep the host clock synced with NTP for meaningful wall-clock results. Use a monotonic clock for processing segments 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. - Compare results only when 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 | #### Measure snowflake latency ```typescript 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) { const latencyMs = measureSnowflakeLatency(tweetId, arrivedAtMs); console.log("publication-to-receipt ms", latencyMs); } }); ``` ### Build the reference consumer When you build your own consumer, these pages cover each part. Start with the router above, then add one reconnecting client, durable recovery, and account management. - [Route every operation](https://tweetstream.io/docs/production-consumer#complete-router): Typed handlers for all 18 server operations. - [Reconnect with Node.js](https://tweetstream.io/docs/connection-auth-heartbeat#connect): Classify upgrade failures and reconnect with backoff. - [Reconnect with Python](https://tweetstream.io/docs/connection-auth-heartbeat#python): Run the same connection loop with Python. - [Recover with History](https://tweetstream.io/docs/envelope-delivery-recovery-errors#backfill): Replay stored pages with an opaque cursor. - [Query History](https://tweetstream.io/docs/history-api#request): Set a bounded window and fixed filters. - [Manage accounts](https://tweetstream.io/docs/account-management#add-remove): Add and remove monitored Twitter handles. ## Connection, authentication, and heartbeat Use the documented regional endpoint, authenticate during the upgrade, and leave native ping and pong handling enabled. Canonical URL: https://tweetstream.io/docs/connection-auth-heartbeat ### Choose a base URL | Surface | URL | Use | | --- | --- | --- | | WebSocket (USA) | `wss://ws-iad.tweetstream.io/ws` | Use for connections in the USA | | WebSocket (global) | `wss://ws-global.tweetstream.io/ws` | Use for connections outside the USA | | REST | `https://api.tweetstream.io` | Base origin for `/api/history`, `/api/me`, and account management | | Dashboard | `https://tweetstream.io/dashboard` | API key, watchlists, billing, and Discord routing | ### Authenticate requests Authenticate realtime clients with WebSocket subprotocols and REST requests with a bearer token. Keep API keys on the server, never in public browser code. | Context | Header or protocol | Notes | | --- | --- | --- | | WebSocket | `tweetstream.v1` + `tweetstream.auth.token.` | Preferred realtime authentication | | WebSocket fallback | `Authorization: Bearer ` or `?apiKey=` | Prefer Authorization; use query authentication only when the runtime cannot set headers | | REST | `Authorization: Bearer ` | History accepts a standard key for Pro or Scale. An active base plan with current Ultra can use its standard key for full History, while a current Ultra key can request full History for its selected accounts. Account management and `/api/me` accept either key | #### Authenticate a server connection ```typescript const socket = new WebSocket("wss://ws-global.tweetstream.io/ws", [ "tweetstream.v1", `tweetstream.auth.token.${process.env.TWEETSTREAM_API_KEY}`, ]); socket.addEventListener("message", (event) => { const message = JSON.parse(event.data); console.log(message.t, message.op, message.d); }); ``` ### Connect with Node.js 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. #### Node.js reconnecting consumer ```typescript 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 | undefined; let reconnectTimer: ReturnType | undefined; function scheduleReconnect(reason: string) { if (reconnectTimer) return; const backoffMs = Math.min(30_000, 1_000 * 2 ** retry); const jitterMs = Math.floor(Math.random() * 500); const delayMs = backoffMs + jitterMs; 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 ```python 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"] handle = tweet.get("author", {}).get("handle") print(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()) ``` ### Handle heartbeats and disconnects TweetStream sends a native WebSocket ping every 30 seconds. Standard Node and Python clients reply with pong automatically. The server closes an unresponsive socket. Reconnect, then use History API to backfill stored content, profile, follow, and affiliate events. ### Handle limits and retries Your plan sets active WebSocket, monitored-account, and History API limits. Handle add and remove requests share one limit across all your API keys and connections. You can send 100 requests in a burst, then 1 per second. A request with more than 100 handles counts once for every 100 handles, rounded up. On `429`, pause that workflow and wait for `retryAfterSeconds` or the `Retry-After` header when present. 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 | | Handle requests | `429`, or the limit `error` in `twitter_handles_result` | Batch changed handles, up to 100 per request, then back off before retrying | ## Envelope, delivery, recovery, and errors Route the v1 envelope by family and operation, tolerate duplicates, and use stored history when a replay window is available. Canonical URL: https://tweetstream.io/docs/envelope-delivery-recovery-errors ### Route the envelope Route every realtime envelope first by `t`, then by `op`. Keep handlers idempotent because history replay can return events already processed live. - [Open the complete router](https://tweetstream.io/docs/production-consumer#complete-router): Every payload type and an exhaustive `switch` over all server operations. #### Envelope base type ```typescript 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; }; ``` ### Route operations TweetStream sends 18 server operations. Route each envelope by `t`, then `op`, and ignore unknown operations without closing the socket. Clients send two commands: `auth_ping` for an authentication check, and `twitter_handles` to add or remove accounts on a handle-management socket. | Family | Operation | Meaning | | --- | --- | --- | | `tweet` | `content` | Post content | | `tweet` | `meta` | Post enrichment | | `tweet` | `update` | Post update | | `tweet` | `delete` | Post deletion | | `tweet` | `pin` | Post pinned | | `tweet` | `unpin` | Post unpinned | | `account` | `profile_update` | Profile update | | `account` | `follow` | Account followed | | `account` | `unfollow` | Account unfollowed | | `account` | `affiliate_update` | Affiliate membership update | | `account` | `callout` | Token callout | | `account` | `callout_update` | Callout enrichment | | `account` | `thesis` | FOMO thesis | | `account` | `token_verified` | FOMO token verification observed | | `charity` | `added` | Charity added | | `news` | `article` | News article | | `control` | `auth_pong` | Authentication pong | | `control` | `twitter_handles_result` | Handle command result | ### Treat delivery as an event stream The envelope has no acknowledgement, resume token, or sequence number. Do not infer ordering or exactly-once delivery from `ts` or `id`. Make handlers idempotent and keep enough local state to recognize events your application has already applied. ### 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 ```typescript type HistoryRow = { time: string; tweetId: string; }; type HistoryPage = { data: Array; metadata: { nextCursor: string | null }; }; async function replayAfterReconnect( checkpoint: string, processEvent: (event: HistoryRow) => Promise, saveCheckpoint: (eventTime: string) => Promise, ) { 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> = []; 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) { const detail = await response.text(); throw new Error(`Replay failed (${response.status}): ${detail}`); } 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); } ``` ### Fix handshake errors A failed WebSocket upgrade returns JSON before the socket is accepted. The client can safely log the response. | 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 limit service temporarily unavailable | Retry with backoff | #### Read an upgrade denial ```json { "error": "WebSocket connection limit reached (3/3 active). Close an existing connection and retry.", "status": 429 } ``` ## Event reference Find an event family, then open its full payload and integration guide. Canonical URL: https://tweetstream.io/docs/payloads-detections-ocr ### Tweet content Use `tweet/content` as the first usable post, then branch on `author.platform`. - [Open social content](https://tweetstream.io/docs/social-content): Compare Twitter, Truth Social, and Binance Square payloads. #### Route by platform Read the post key from `(author.platform, tweetId)`. If `platform` is absent, use `twitter`. #### Read references Keep referenced posts and their attachments under `ref`; each nested reference has its own `tweetId`. ### Enrichment metadata Replace the metadata at `(platform, tweetId)` with each `tweet/meta` snapshot. Do not keep fields missing from the new snapshot. - [Open tweet/meta](https://tweetstream.io/docs/tweet-updates-lifecycle#meta): Read detection, OCR, token, exchange, and market fields. ### Tweet updates Patch only fields present in `tweet/update`; do not replace the whole post. - [Open tweet/update](https://tweetstream.io/docs/tweet-updates-lifecycle#update): Apply text completion, references, media, polls, and articles. #### Match the post Match every update with `(platform, tweetId)`. #### Replace reference snapshots If an update contains `ref`, replace the stored reference with that snapshot. #### Complete optional fields Treat present poll, article, and media values as complete replacements for those fields. ### Tweet lifecycle Apply delete, pin, and unpin to the stored post without overwriting its content. - [Open lifecycle events](https://tweetstream.io/docs/tweet-updates-lifecycle#lifecycle-events): Copy the exact delete, pin, and unpin payloads. ### Account events Route profile, follow, unfollow, and affiliate changes under the `account` family. - [Open account events](https://tweetstream.io/docs/profile-follow-affiliate): Copy every account event and its storage key. #### Route account operations Branch on `op`; ignore unknown operations without closing the socket. #### Store affiliate relationships Apply the action to `(organization.id, member.id)` and use `eventId` only for an exact replay. #### Replay stored changes Use `type=PROFILE`, `FOLLOW`, or `AFFILIATE` with the History API. ### Callouts Selected accounts emit structured `account/callout` events on the same WebSocket. - [Open callout events](https://tweetstream.io/docs/callouts): Configure accounts and consume caller, token, price, and timestamp fields. #### Select accounts Add a pump.fun username, Solana wallet, or profile URL in the Callouts tab. #### Route the event Handle initial `account/callout` content and later `account/callout_update` enrichment. Read the caller and token address before optional fields. #### Use identifiers Deduplicate by envelope `id`. Merge enrichment into `d.updateOf`, including nested fields, without another alert. ### Charity events Enabled charity alerts emit live-only `charity/added` events. - [Open charity events](https://tweetstream.io/docs/charity): Copy the full event and its optional location fields. ### News articles Enabled publications emit `news/article`; stored articles use the separate News History API. - [Open news events and history](https://tweetstream.io/docs/news): Select publications, consume articles, and continue with `nextCursor`. #### Select publications Read and update the enabled publication set before filtering live events. #### Read live articles Use the envelope `id` for idempotency and keep `source.id` with the article. #### Page through history Pass `nextCursor` back unchanged until it is null. ### Event sequence Use an idempotent local projection; the WebSocket envelope does not contain a sequence number. #### Receive content Store the first usable content event. #### Replace enrichment Replace each live metadata snapshot by post key. #### Apply lifecycle Apply lifecycle operations to the same post. ### Control messages Use `auth_pong` for connection state and `twitter_handles_result` for command outcomes. - [Open control messages](https://tweetstream.io/docs/control): Copy auth and handle-management messages. ### Handle management channel Follow or unfollow monitored Twitter handles over the handle-management WebSocket or REST API. - [Read command results](https://tweetstream.io/docs/control#twitter-handles-result): Handle partial, idempotent, and failed rows. - [Manage accounts with REST](https://tweetstream.io/docs/account-management): Add, remove, inspect, and configure monitored accounts. ## Social content events Social content uses `tweet/content`; select the platform from `author.platform` before applying platform-specific fields. Canonical URL: https://tweetstream.io/docs/social-content ### tweet/content Route a content event by `t="tweet"` and `op="content"`, then key the post by `(author.platform, tweetId)`. #### Tweet content type ```typescript 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; // The account's own website, when it has one. 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; }; ``` #### Tweet content event ```json { "v": 1, "t": "tweet", "op": "content", "id": "1234567890", "ts": 1702500000130, "d": { "tweetId": "1234567890", "kind": "reply", "text": "Could this launch today?", "createdAt": 1702500000000, "author": { "handle": "@marketdesk", "platform": "twitter" } } } ``` ### Twitter Twitter content can be a post, reply, quote, or retweet. References and media are included when available. ### Truth Social Truth Social is opt-in live delivery for Donald Trump (`@realDonaldTrump`) and the White House (`@WhiteHouse`). Configure it from the Accounts tab in the dashboard when your account is eligible. Content uses the tweet envelope with `author.platform="truth_social"` and can be a post, reply, quote, or retweet. Metadata, updates, delete, and pin events can appear when observed. Do not use general account management to add arbitrary Truth Social handles, and do not expect History replay. #### Truth Social content event ```json { "v": 1, "t": "tweet", "op": "content", "id": "tweet-content", "ts": 1772000001060, "d": { "tweetId": "tweet-content", "text": "hello", "createdAt": 1772000001000, "receivedAt": 1772000001050, "kind": "post", "author": { "id": "truth-author", "handle": "@realDonaldTrump", "name": "Donald J. Trump", "platform": "truth_social" } } } ``` ### Binance Square Enabled catalog accounts use `author.platform="binance_square"`. Content is live-only and does not consume a monitored Twitter account slot. #### Binance Square content event ```json { "v": 1, "t": "tweet", "op": "content", "id": "square-content", "ts": 1772000001060, "d": { "tweetId": "square-content", "text": "Weekly market outlook is live.", "createdAt": 1772000001000, "receivedAt": 1772000001050, "kind": "post", "author": { "id": "square-author", "handle": "@marketresearch", "name": "Market Research", "platform": "binance_square" } } } ``` ### References and media Read the referenced post from `d.ref`. Its author or media may arrive before `text`. References can arrive in later `tweet/update` frames or remain incomplete. Treat each nested reference as its own post. ## Tweet metadata, updates, and lifecycle Replace live metadata snapshots and merge updates by `(platform, tweetId)`, then apply lifecycle operations without replacing stored content. Canonical URL: https://tweetstream.io/docs/tweet-updates-lifecycle ### tweet/meta A live metadata payload is the latest snapshot for its post. Replace the previous metadata so omitted fields are cleared. History returns the stored enrichment attached to its row. During recovery, use it only when local metadata is empty; do not overwrite a live snapshot. #### Tweet metadata type ```typescript type TweetMeta = { tweetId: string; // Pair with tweetId when merging enrichment. When omitted, use twitter. 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'>; }>; }; }; ``` #### Tweet metadata event ```json { "v": 1, "t": "tweet", "op": "meta", "id": "1234567890", "ts": 1702500001000, "d": { "tweetId": "1234567890", "platform": "twitter", "ocr": { "text": "Chart showing SOL breakout at $100" }, "detected": { "tokens": [ { "symbol": "SOL", "name": "Solana", "priceUsd": 98.50, "sources": ["text", "ocr"] } ] } } } ``` ### tweet/update Apply every update by `(platform, tweetId)`. Keep absent fields; replace an included `ref` snapshot. Reprocess the updated post. #### Tweet update type ```typescript 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; // The account's own website, when it has one. 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 TweetUpdate = { tweetId: string; // Pair with tweetId. When absent, use 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; // Supplies the full text of a previously shortened post. Not an edit. textUpdateType: 'completion'; } ); ``` #### Tweet update event ```json { "v": 1, "t": "tweet", "op": "update", "id": "1234567890", "ts": 1702500000180, "d": { "tweetId": "1234567890", "platform": "twitter", "ref": { "type": "reply", "tweetId": "1234567880", "text": "Mainnet is ready for launch." } } } ``` #### Recursive reference update ```json { "v": 1, "t": "tweet", "op": "update", "id": "1234567890", "ts": 1702500000240, "d": { "tweetId": "1234567890", "platform": "twitter", "ref": { "type": "reply", "tweetId": "1234567880", "text": "Mainnet is ready for launch.", "author": { "handle": "@projectteam", "name": "Project Team" }, "subtweet": { "type": "quote", "tweetId": "1234567800", "text": "Launch proposal and contract details.", "author": { "handle": "@projectteam", "name": "Project Team" } } } } } ``` ### Lifecycle events Lifecycle operations update a known post and do not replace its stored content. #### Tweet lifecycle types ```typescript 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; // The account's own website, when it has one. 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 TweetDeleteEvent = { tweetId: string; // When absent, use author.platform, then twitter. platform?: 'twitter' | 'truth_social' | 'binance_square'; eventId?: string; deletedAt?: number; receivedAt?: number; author?: TweetAuthor; text?: string; }; type TweetPinEvent = { tweetId: string; // When absent, use 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; // When absent, use 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; }; ``` #### tweet/delete Mark the post deleted when this event is observed. ##### Tweet delete event ```json { "v": 1, "t": "tweet", "op": "delete", "id": "tweet-delete", "ts": 1772000001060, "d": { "eventId": "delete.29043b5a1a4415618c95", "tweetId": "tweet-delete", "platform": "twitter", "author": { "id": "10228272", "handle": "@TeamYouTube" }, "deletedAt": 1772000001050, "receivedAt": 1772000001050, "text": "deleted" } } ``` #### tweet/pin Mark the post pinned and retain any included post snapshot. ##### Tweet pin event ```json { "v": 1, "t": "tweet", "op": "pin", "id": "tweet-pin", "ts": 1772000001060, "d": { "action": "pin", "author": { "id": "10228272", "handle": "@TeamYouTube" }, "eventId": "pin.ab70542b9aa6bec229ef", "observedAt": 1772000001050, "tweetId": "tweet-pin", "platform": "twitter", "receivedAt": 1772000001050, "text": "pinned" } } ``` #### tweet/unpin Remove the pinned state for the identified post. ##### Tweet unpin event ```json { "v": 1, "t": "tweet", "op": "unpin", "id": "tweet-unpin", "ts": 1772000001060, "d": { "action": "unpin", "author": { "id": "10228272", "handle": "@TeamYouTube" }, "eventId": "unpin.0bef09a9f46866cd26d7", "observedAt": 1772000001050, "tweetId": "tweet-unpin", "platform": "twitter", "receivedAt": 1772000001050, "text": "unpinned" } } ``` ## Profile, follow, and affiliate events Account events carry observed profile, follow, and affiliate relationship changes. Canonical URL: https://tweetstream.io/docs/profile-follow-affiliate ### Account event types Route by `op`. Ignore unknown account operations without closing the socket. #### Account event contracts ```typescript 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; // The account's own website, when it has one. url?: string; verifiedLabel?: TweetVerifiedLabel; verifiedType?: VerifiedType; }; 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; }; ``` ### account/profile_update Apply only fields present in `changes`; absence means unchanged. #### Profile update event ```json { "v": 1, "t": "account", "op": "profile_update", "id": "profile-1", "ts": 1772000001060, "d": { "actor": { "id": "10228272", "handle": "@TeamYouTube", "name": "TeamYouTube", "profileImage": "https://pbs.twimg.com/media/avatar.jpg", "websiteUrl": "https://youtube.com" }, "changes": { "handle": "@TeamYouTube", "name": "Team YouTube", "websiteUrl": "https://youtube.com/new" }, "eventId": "profile-1", "kind": "PROFILE", "observedAt": 1772000001050, "previous": { "handle": "@OldTeam" }, "receivedAt": 1772000001050 } } ``` ### account/follow Store the observed relationship using stable actor and target identifiers. #### Follow event ```json { "v": 1, "t": "account", "op": "follow", "id": "follow-1", "ts": 1772000001060, "d": { "actor": { "id": "10228272", "handle": "@TeamYouTube" }, "eventId": "follow-1", "kind": "FOLLOW", "observedAt": 1772000001050, "target": { "id": "4398626122", "handle": "@OpenAI" }, "receivedAt": 1772000001050 } } ``` ### account/unfollow Remove the observed relationship using the same identifiers. #### Unfollow event ```json { "v": 1, "t": "account", "op": "unfollow", "id": "unfollow-1", "ts": 1772000001060, "d": { "actor": { "id": "10228272", "handle": "@TeamYouTube" }, "eventId": "unfollow-1", "kind": "UNFOLLOW", "observedAt": 1772000001050, "target": { "id": "4398626122", "handle": "@OpenAI" }, "receivedAt": 1772000001050 } } ``` ### account/affiliate_update Apply the `added` or `removed` action idempotently to `(organization.id, member.id)`. Use `eventId` only to suppress an exact replay. Keyword filters do not apply. Replay stored changes with `GET /api/history?type=AFFILIATE`. History is not a current-list snapshot and does not backfill changes from before the feature was enabled. #### Affiliate update event ```json { "v": 1, "t": "account", "op": "affiliate_update", "id": "affiliate-update-1", "ts": 1772000001060, "d": { "action": "added", "organization": { "id": "10228272", "handle": "@TeamYouTube", "name": "TeamYouTube", "profileImage": "https://pbs.twimg.com/media/avatar.jpg", "websiteUrl": "https://youtube.com" }, "member": { "id": "4398626122", "handle": "@OpenAI", "name": "OpenAI", "profileImage": "https://pbs.twimg.com/media/member-avatar.jpg" }, "eventId": "affiliate-update-1", "observedAt": 1772000001050, "receivedAt": 1772000001050 } } ``` ## Callout events Follow pump.fun callouts, author updates, replies, and reposts on your WebSocket. Render initial content, then merge optional enrichment. Canonical URL: https://tweetstream.io/docs/callouts ### Select callout accounts In the Callouts tab, add a pump.fun username, Solana wallet, or profile URL. Labels are optional. - Use a Solana wallet for the account, regardless of the token's chain. - Minimum: 10 accounts. Pro, Scale, and Ultra Speed: 100. Pending additions count toward the limit. ### account/callout Show content immediately. `d.platform` is `pump_fun`; caller username and token address are required. Optional `d.token.chainId` uses CAIP-2: `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` for Solana mainnet; `eip155:56`, `eip155:999`, `eip155:4663`, or `eip155:8453` for EVM chains. When absent, the chain is unspecified. Text, media, positions, and token/price details are optional. Optional images: `d.caller.image` (avatar), `d.token.image` (token artwork). - Optional `d.kind`: `callout`, `author_update`, `reply`, or `repost`. Author updates are new posts, not enrichment. - `d.caller` is the tracked actor. `d.reference` holds the original author, text, and media; `d.reference.reply` holds a reposted reply and its author, not the reposter. - `replyId` identifies a reply or author update. Optional `parentReplyId` and `rootReplyId` link the thread; `calloutId` identifies the original callout. - Exact decimal prices: `calloutPrice` / `maxPriceSol` (SOL), `calloutPriceUsd` / `maxPriceUsd` (USD). Keep strings or use a decimal library. USD prices do not imply SOL prices. - `marketCapUsd` and `multiple` are optional numbers. Position values are exact decimal strings: `amountHeld` is a token amount, `pnlPercentage` a percentage, and `*Usd` fields USD amounts. #### Callout event type ```typescript type CalloutCaller = { username: string; image?: string; }; type CalloutReply = { caller: CalloutCaller; replyId: string; parentReplyId?: string; rootReplyId?: string; text?: string; mediaUrl?: string; }; type CalloutEvent = { platform: 'pump_fun'; caller: CalloutCaller; kind?: 'callout' | 'author_update' | 'reply' | 'repost'; text?: string; mediaUrl?: string; // Required for replies and author updates. replyId?: string; parentReplyId?: string; rootReplyId?: string; reference?: { caller: CalloutCaller; text?: string; mediaUrl?: string; reply?: CalloutReply; }; position?: { amountHeld?: string; valueUsd?: string; costBasisUsd?: string; pnlUsd?: string; pnlPercentage?: string; realizedPnlUsd?: string; }; token: { address: string; chainId?: string; symbol?: string; name?: string; image?: string; }; url?: string; marketCapUsd?: number; // SOL, exact decimal. calloutPrice?: string; // USD, exact decimal. calloutPriceUsd?: string; multiple?: number; // SOL peak, exact decimal. maxPriceSol?: string; // USD peak, exact decimal. maxPriceUsd?: string; calloutId: string; createdAt: number; receivedAt?: number; }; type CalloutUpdateEvent = CalloutEvent & { // Original envelope id, not calloutId. updateOf: string; }; ``` #### Callout event ```json { "v": 1, "t": "account", "op": "callout", "id": "callout.0123456789abcdef0123", "ts": 1772000001060, "d": { "platform": "pump_fun", "caller": { "username": "CryptoCalls", "image": "https://cdn.example/avatar.png" }, "kind": "callout", "text": "Watching this token.", "mediaUrl": "https://cdn.example/chart.png", "token": { "address": "So11111111111111111111111111111111111111112", "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" }, "url": "https://pump.fun/callouts/So11111111111111111111111111111111111111112/9ab3177c-7b35-4d99-9cca-8b426f74a270", "marketCapUsd": 156230.75, "calloutPriceUsd": "0.00018750", "multiple": 2.5, "calloutId": "9ab3177c-7b35-4d99-9cca-8b426f74a270", "createdAt": 1772000001000, "receivedAt": 1772000001050 } } ``` #### Repost of another author's reply ```json { "v": 1, "t": "account", "op": "callout", "id": "callout.repost-example", "ts": 1772000003060, "d": { "platform": "pump_fun", "kind": "repost", "caller": { "username": "MarketReader" }, "token": { "address": "So11111111111111111111111111111111111111112" }, "calloutId": "9ab3177c-7b35-4d99-9cca-8b426f74a270", "createdAt": 1772000003000, "reference": { "caller": { "username": "CryptoCalls" }, "text": "Watching this token.", "reply": { "caller": { "username": "ReplyAuthor" }, "replyId": "reply-example", "text": "Watching the volume too." } } } } ``` ### account/callout_update Merge optional details into the original event. Do not send another alert. - Deduplicate by this envelope's `id`. `d.updateOf` points to the original envelope `id`, not `calloutId`. - Merge supplied fields, including nested `caller`, `token`, `position`, and `reference` fields. Omitted fields do not erase earlier values. - If the original is missing, store or ignore the update; do not treat it as a new callout. - Updates are optional. Show initial content even if no update arrives. #### Callout enrichment ```json { "v": 1, "t": "account", "op": "callout_update", "id": "callout_update.556abf0021a7ff937a1a804ec0a0c245d497bcc6090af66179e74ce03c0970e0", "ts": 1772000002060, "d": { "platform": "pump_fun", "updateOf": "callout.0123456789abcdef0123", "caller": { "username": "CryptoCalls" }, "kind": "callout", "token": { "address": "So11111111111111111111111111111111111111112", "symbol": "ABC", "name": "Alpha Beta", "image": "https://cdn.example/token.png" }, "calloutPriceUsd": "0.00018750", "calloutPrice": "0.00000125", "maxPriceSol": "0.00000450", "maxPriceUsd": "0.00067500", "calloutId": "9ab3177c-7b35-4d99-9cca-8b426f74a270", "createdAt": 1772000001000 } } ``` ### Use identifiers and timestamps Deduplicate by envelope `id`. Replies and reposts share the original `calloutId`, not the envelope ID. - `d.createdAt`: occurrence creation time. - `d.receivedAt`: optional TweetStream receipt time. - Envelope `ts`: preparation time. ## FOMO theses and token verification Track FOMO users and coins in the FOMO tab. Receive theses and observed token verification on your existing WebSocket. Canonical URL: https://tweetstream.io/docs/fomo ### Choose users and coins - Minimum includes 10 FOMO users and 10 FOMO coins. Pro, Scale, and Ultra Speed include 100 users and 100 coins. These limits are independent of each other and your X watchlist. - Track a user to receive theses they author. Track a coin to receive theses from any author and its first observed verification. - Manage both lists in the dashboard's FOMO tab. FOMO events are live-only; History does not replay them. ### Manage lists through REST Use Bearer API-key authentication with `/api/callouts`. Set `platform` to `fomo_user` or `fomo_coin` for management; both emit events with `d.platform: fomo`. - `GET /api/callouts` - `PUT /api/callouts` - `DELETE /api/callouts` - `GET` returns `callers`, `pending`, and per-platform `limits`. Read `limits` for current capacity; all values are `0` without an active eligible plan. The legacy top-level `limit` stays `10`. - `PUT` accepts `{ platform, input, label? }`. Use a FOMO username or profile URL for users. For coins, use a FOMO coin URL or `network:address`; a plain contract address must resolve to one network. The response contains `caller`; fetch the list again after a change. - `DELETE` accepts `{ platform, callerId }` to remove a tracked item, or `{ platform, intentId }` to cancel a pending item. Use identifiers from `GET`; success returns `removed: true`. ### account/thesis Read `d.author`, `d.thesis`, and `d.coin`. `d.platform` is `fomo`. Position data and coin metadata appear when available. Use `thesis.id` for the thesis and `(coin.networkId, coin.address)` for the coin. #### FOMO event types ```typescript type FomoCoin = { address: string; networkId: number; name?: string; symbol?: string; image?: string; metadataObservedAt?: number; priceUsd?: string; marketCapUsd?: string; liquidityUsd?: string; volume24hUsd?: string; change24hPercent?: string; }; type FomoThesisEvent = { platform: 'fomo'; coin: FomoCoin; author: { id: string; username: string; verified: boolean; displayName?: string; avatar?: string; twitter?: string; }; thesis: { id: string; text: string; createdAt: number; likes: number; replies: number; tradeId?: string; parentId?: string; segments?: Array<{ text: string; link?: string }>; }; position?: { tokenAmount?: string; usdValue?: string; unrealizedPnlUsd?: string; realizedPnlUsd?: string; unrealizedPnlPercent?: string; realizedPnlPercent?: string; closedAt?: number; }; observedAt?: number; receivedAt?: number; }; type FomoTokenVerifiedEvent = { platform: 'fomo'; coin: FomoCoin; observedAt: number; receivedAt?: number; }; ``` #### Thesis event ```json { "v": 1, "t": "account", "op": "thesis", "id": "fomo:thesis:thesis-1", "ts": 1772000001060, "d": { "platform": "fomo", "coin": { "address": "So11111111111111111111111111111111111111112", "networkId": 1399811149, "name": "Example Token", "symbol": "EXM", "image": "https://cdn.example/token.png", "metadataObservedAt": 1772000000900, "priceUsd": "0.00000123456789012345", "marketCapUsd": "123456.7890123456789" }, "author": { "id": "user-1", "username": "SignalDesk", "displayName": "Signal Desk", "avatar": "https://cdn.example/avatar.png", "twitter": "signaldesk", "verified": true }, "thesis": { "id": "thesis-1", "text": "Watching liquidity", "createdAt": 1772000001000, "likes": 2, "replies": 1, "tradeId": "trade-1", "segments": [ { "text": "Watching liquidity", "link": "https://example.com/thesis" } ] }, "position": { "tokenAmount": "549351.552212985712345", "usdValue": "12.34567890123456789", "realizedPnlUsd": "0" }, "receivedAt": 1772000001050 } } ``` ### account/token_verified A coin-only event reports the first time TweetStream observes verification for a tracked coin. It contains no author, thesis, or position. `observedAt` is an observation time, not the time verification happened. Verification is not a safety or investment recommendation. #### Token verification observed ```json { "v": 1, "t": "account", "op": "token_verified", "id": "fomo:verified:1399811149:coin:1772000001000", "ts": 1772000001060, "d": { "platform": "fomo", "coin": { "address": "So11111111111111111111111111111111111111112", "networkId": 1399811149, "symbol": "EXM" }, "observedAt": 1772000001000, "receivedAt": 1772000001050 } } ``` ### Read timestamps and market values - Timestamps are Unix milliseconds. `thesis.createdAt` is the thesis creation time; optional `receivedAt` is TweetStream's receipt time; envelope `ts` is the delivery preparation time. - Coin market values and position amounts are decimal strings. Coin market values include `metadataObservedAt`; they describe that observation, not a guaranteed current quote. - Make exact envelope `id` replays idempotent. Missing optional fields mean unavailable, not zero. Published X latency figures do not apply to FOMO. ## Charity events Enable charity alerts in settings and consume live `charity/added` events. Canonical URL: https://tweetstream.io/docs/charity ### charity/added Charity events are live-only. Use the envelope `id` to ignore an exact replay. #### Charity added type ```typescript type CharityAddedEvent = { id: string; name: string; slug: string; enabled: boolean; taxId?: string; location?: { city?: string; state?: string; country?: string; }; observedAt: number; }; ``` #### Charity added event ```json { "v": 1, "t": "charity", "op": "added", "id": "4ffccf6e-66d4-555a-af5d-ceb36c10e3f0", "ts": 1772000001060, "d": { "id": "4ffccf6e-66d4-555a-af5d-ceb36c10e3f0", "name": "Council on Foreign Relations", "slug": "council-on-foreign-relations", "enabled": true, "taxId": "13-1628168", "location": { "city": "New York City", "state": "NY", "country": "US" }, "observedAt": 1772000001060 } } ``` ## News events and history Select publications, consume `news/article` live, and use the separate cursor-based News History API for stored articles. Canonical URL: https://tweetstream.io/docs/news ### Select publications Use the dashboard or `GET` and `PATCH /api/news/sources` to read and change your enabled publications. - `GET /api/news/sources` - `PATCH /api/news/sources` #### News sources response ```json { "sources": [ { "id": "bbc-news", "name": "BBC News", "icon": "/news-sources/bbc.png", "available": true, "enabled": true }, { "id": "dexerto", "name": "Dexerto", "icon": "/news-sources/dexerto.png", "available": true, "enabled": false } ] } ``` ### news/article Enabled publications send articles on the same WebSocket as other TweetStream events. #### News article type ```typescript 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; }; ``` #### News article event ```json { "v": 1, "t": "news", "op": "article", "id": "news_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "ts": 1787786858123, "d": { "source": { "id": "bbc-news", "name": "BBC News" }, "url": "https://www.bbc.com/news/articles/example", "title": "Markets open after the holiday", "publishedAt": "2026-08-26T23:27:38.000Z", "receivedAt": 1787786858123, "primaryCategory": "Business", "categories": ["Business"], "author": "BBC News", "keywords": ["markets"], "description": "A brief description supplied by the publication.", "summary": "Markets reopened after the holiday.", "media": [ { "url": "https://ichef.bbci.co.uk/news/example.jpg", "type": "image", "caption": "A market floor" } ], "content": "The stored article text, when available.", "language": "en", "copyright": "BBC" } } ``` ### News History API `GET /api/news/history` returns stored articles from enabled publications in `(receivedAt DESC, id DESC)` order. `limit` defaults to 50 and accepts 1 through 100. Continue with `nextCursor`. A page can contain fewer results when it reaches the response-size limit; this does not promise a retention duration or historical completeness. - `GET /api/news/history` #### News history type ```typescript 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 HistoricalNewsArticle = { id: string; source: NewsSource; url: string; title: string; publishedAt: string; receivedAt: string; modifiedAt: string | null; primaryCategory: string | null; categories: string[]; author: string | null; keywords: string[]; description: string | null; summary: string | null; media: NewsMedia[]; content: string | null; language: string | null; copyright: string | null; }; type NewsHistoryResult = { data: HistoricalNewsArticle[]; metadata: { count: number; nextCursor: string | null; sourceId?: string; }; }; ``` #### Fetch news history ```typescript 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/news/history", ); url.searchParams.set("sourceId", "bbc-news"); url.searchParams.set("limit", "1"); const response = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}`, }, }); if (!response.ok) { const detail = await response.text(); throw new Error(`News history failed (${response.status}): ${detail}`); } const page = await response.json(); console.log(page.data); if (page.metadata.nextCursor) { url.searchParams.set("before", page.metadata.nextCursor); } ``` #### News history response ```json { "data": [ { "id": "news_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "source": { "id": "bbc-news", "name": "BBC News" }, "url": "https://www.bbc.com/news/articles/example", "title": "Markets open after the holiday", "publishedAt": "2026-08-26T23:27:38.000Z", "receivedAt": "2026-08-26T23:27:38.123Z", "modifiedAt": null, "primaryCategory": "Business", "categories": ["Business"], "author": "BBC News", "keywords": ["markets"], "description": "A brief description supplied by the publication.", "summary": "Markets reopened after the holiday.", "media": [ { "url": "https://ichef.bbci.co.uk/news/example.jpg", "type": "image", "caption": "A market floor" } ], "content": "The stored article text, when available.", "language": "en", "copyright": "BBC" } ], "metadata": { "count": 1, "nextCursor": "v1.MTc4Nzc4Njg1ODEyMwpuZXdzXzAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY", "sourceId": "bbc-news" } } ``` ## Control messages Control messages keep a connection authenticated and report handle-management command results. Canonical URL: https://tweetstream.io/docs/control ### Send auth_ping Send `{ "op": "auth_ping" }` when your application needs an authentication check. This is a client message, not a server event. #### Authentication ping ```json { "op": "auth_ping" } ``` ### control/auth_pong A successful authentication check returns an empty data object. #### Authentication pong ```json { "v": 1, "t": "control", "op": "auth_pong", "ts": 1772000001060, "d": {} } ``` ### control/twitter_handles_result Read every result row. Idempotent success and row failures can appear in one response. #### Handle result type ```typescript 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; }; ``` #### Handle result event ```json { "v": 1, "t": "control", "op": "twitter_handles_result", "ts": 1772000001060, "d": { "action": "follow", "error": null, "requestId": "req-1", "results": [ { "input": "@one", "state": "added" } ] } } ``` ### Manage handles over WebSocket Use the handle-management subprotocol to add or remove monitored accounts through a long-running backend socket, without a separate REST request. #### Follow handles over WebSocket ```typescript import WebSocket from "ws"; type HandleManagementEvent = { t?: string; op?: string; d?: { error?: string | null; results?: Array<{ input: string; state: string }>; }; }; const ws = new WebSocket("wss://ws-global.tweetstream.io/ws", [ "tweetstream.handle-management", `tweetstream.auth.token.${process.env.TWEETSTREAM_API_KEY}`, ]); ws.on("open", () => { ws.send(JSON.stringify({ type: "twitter_handles", action: "follow", handles: ["marketdesk", "realDonaldTrump"], requestId: crypto.randomUUID(), })); }); ws.on("message", (raw) => { const event = JSON.parse(raw.toString()) as HandleManagementEvent; if (event.t === "control" && event.op === "twitter_handles_result") { console.log(event.d?.results, event.d?.error); } }); ``` ## History API Replay stored content, profile, follow, and affiliate events through the History API, or look up one post or account. Canonical URL: https://tweetstream.io/docs/history-api ### Request history Use `GET /api/history` to replay stored events after reconnecting or inspect a specific time window. - `GET /api/history` #### Check access Pro, Scale, and Ultra Speed can request every History event type. Standard keys query base-plan tracked accounts; Ultra keys query selected Ultra accounts. 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`; case-insensitive. Defaults to `TWEET` | | `cursor` | No | Opaque `nextCursor` from the previous page; keep every other filter unchanged | ##### History request ```typescript 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) { const detail = await response.text(); throw new Error(`History failed (${response.status}): ${detail}`); } const page = await response.json(); console.log(page.data); ``` ### Page through a bounded window History returns up to 1000 rows per request in stable newest-first order. #### Set both bounds Capture one `endDate`, choose a `startDate`, and keep both values unchanged for every page in the replay. #### Continue with nextCursor Send each returned `nextCursor` with the same type, handles, and date bounds. A null cursor ends the window. Process rows idempotently and keep live lifecycle handlers active because History replays stored event families separately from live-only state changes. ### Read the response Read the rows and request metadata before advancing the replay window. #### Read rows and metadata Results use stable newest-first traversal. `metadata.count` reports the returned rows, `metadata.nextCursor` continues the same filtered query, and a null cursor ends the result set. Older stored `FOLLOW` rows can have an empty `target`; treat the target identity as unavailable and continue processing the event. ##### History response types ```typescript 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; // History stores X and Truth Social posts; Binance Square content is live-only. platform?: 'twitter' | 'truth_social'; profileImage?: string; // The account's own website, when it has one. url?: string; verifiedLabel?: TweetVerifiedLabel; verifiedType?: VerifiedType; }; 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; }; type UnfollowEvent = { kind: 'UNFOLLOW'; eventId: string; observedAt: number; receivedAt?: number; actor: AccountEventActor; target: AccountEventActor; }; type AffiliateAccountIdentity = AccountEventActor & { id: string; }; type AffiliateUpdateEvent = { action: 'added' | 'removed'; eventId: string; observedAt: number; receivedAt?: number; organization: AffiliateAccountIdentity; member: AffiliateAccountIdentity; }; type TweetMeta = { tweetId: string; // Pair with tweetId when merging enrichment. When omitted, use twitter. platform?: 'twitter' | 'truth_social'; 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 HistoryMedia = { 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 HistoryTweetArticle = { description?: string; id?: string; publishedAt?: number; text?: string; thumbnail?: string; title?: string; updatedAt?: number; url?: string; }; type HistoryTweetPollChoice = { id?: string; image?: string; label?: string; votes?: number; }; type HistoryTweetPoll = { choices: HistoryTweetPollChoice[]; endsAt?: number; totalVotes?: number; updatedAt?: number; }; type TweetContentKind = 'post' | 'reply' | 'quote' | 'retweet'; type HistoryTweetReference = { article?: HistoryTweetArticle; type: 'reply' | 'quote' | 'retweet'; tweetId?: string; text?: string; translatedText?: string; author?: TweetAuthor; media?: HistoryMedia[]; poll?: HistoryTweetPoll; quoted?: HistoryTweetReference; subtweet?: HistoryTweetReference; }; type TweetContent = { tweetId: string; kind: TweetContentKind; // Original tweet text when the stored content includes both original and // translated text. text: string; translatedText?: string; createdAt: number; author: TweetAuthor; article?: HistoryTweetArticle; link?: string; media?: HistoryMedia[]; mentions?: TweetMention[]; poll?: HistoryTweetPoll; // Epoch ms from the realtime payload when the stored content includes it. receivedAt?: number; urls?: TweetUrl[]; ref?: HistoryTweetReference; }; type HistoricalContent = | TweetContent | ProfileUpdateEvent | FollowEvent | UnfollowEvent | AffiliateUpdateEvent; type HistoricalTweetResponse = { tweetId: string; twitterId: string; twitterHandle: string | null; body: string; time: string; // ISO TweetStream receipt time for the historical event. receivedTime: string; link: string; messageType: 'TWEET' | 'PROFILE' | 'FOLLOW' | 'AFFILIATE'; content: HistoricalContent; meta?: TweetMeta; }; type HistoryResult = { data: HistoricalTweetResponse[]; metadata: { count: number; nextCursor: string | null; handle?: string; handles?: string[]; startDate?: string; endDate?: string; type?: 'TWEET' | 'PROFILE' | 'FOLLOW' | 'AFFILIATE'; }; }; ``` ##### History response ```json { "data": [ { "tweetId": "account:affiliate:aff_01JQ8YQ5K8B8QKH6M0P8A1V2WX", "twitterId": "123", "twitterHandle": "organization", "body": "Added New Member (@newmember) to affiliate list", "time": "2025-04-09T00:00:00.123Z", "receivedTime": "2025-04-09T00:00:00.123Z", "link": "https://x.com/newmember", "messageType": "AFFILIATE", "content": { "action": "added", "eventId": "aff_01JQ8YQ5K8B8QKH6M0P8A1V2WX", "observedAt": 1744156800123, "receivedAt": 1744156800123, "organization": { "id": "123", "handle": "@organization", "name": "Organization", "verifiedType": "business" }, "member": { "id": "456", "handle": "@newmember", "name": "New Member", "profileImage": "https://pbs.twimg.com/profile_images/newmember_normal.jpg" } } } ], "metadata": { "count": 1, "nextCursor": null, "type": "AFFILIATE" } } ``` #### Map affiliate rows For `AFFILIATE`, the top-level account is the organization and `content` matches the live `affiliate_update` payload. ### Handle errors | Status | Body | Meaning | | --- | --- | --- | | `400` | `{ "error": "Invalid query parameters" }` | Malformed dates, limit, type, or handle | | `400` | `{ "error": "Invalid handle provided", "handle": "..." }` | Handle validation failed | | `400` | `{ "error": "startDate must be before endDate" }` | Date range is reversed | | `400` | `{ "error": "Invalid cursor" }` | Cursor is malformed or does not match the other filters | | `401` | `{ "error": "Missing or invalid API key" }` | Missing or malformed bearer token | | `401` | `{ "error": "Invalid API key" }` | Bearer token was well-formed but does not match an active API key | | `403` | `{ "error": "History is available on Pro, Scale, and Ultra Speed" }` | Plan does not include the requested History type | | `403` | `{ "error": "Your subscription is not active", "message": "Please ensure your subscription is active", "status": "PAST_DUE" }` | Subscription state does not allow history | | `403` | `{ "error": "Handle ... is not among your tracked accounts" }` | Requested replay handle is outside your watchlist | | `429` | `{ "error": "Too many history requests", "retryAfterSeconds": 60 }` | Rate limit exceeded | ### Look up a post `GET /api/tweet/{id}` returns one post and its engagement counts. `{id}` is the post ID, 1 to 20 digits. - `GET /api/tweet/{id}` - `tweet` has the same fields as `content` in a `TWEET` History row. - `metrics` holds `likes`, `replies`, `reposts`, `quotes`, `bookmarks`, and `views`. `views` can be null. #### Post lookup request ```typescript 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/tweet/1234567890", { headers: { Authorization: `Bearer ${apiKey}`, }, }, ); if (!response.ok) { const detail = await response.text(); throw new Error(`Lookup failed (${response.status}): ${detail}`); } const { tweet, metrics } = await response.json(); console.log(tweet.text, metrics.likes); ``` #### Post lookup response ```json { "tweet": { "tweetId": "1234567890", "text": "Rate decision at 2pm ET.", "createdAt": 1744156800000, "kind": "post", "link": "https://x.com/marketdesk/status/1234567890", "author": { "id": "1189745302", "handle": "@marketdesk", "name": "Market Desk", "platform": "twitter", "verifiedType": "blue", "followersCount": 48210, "followingCount": 312, "joinedAt": 1358208000000, "profileImage": "https://pbs.twimg.com/profile_images/marketdesk_400x400.jpg" } }, "metrics": { "likes": 1204, "replies": 87, "reposts": 230, "quotes": 19, "bookmarks": 41, "views": 98213 } } ``` ### Look up an account `GET /api/user/{id}` returns one account's profile. `{id}` is a numeric account ID or a handle, with or without `@`. Digits alone are read as an account ID, so a handle made only of digits needs the `@`. - `GET /api/user/{id}` - `user` has the same fields as `author` in WebSocket post events. - `pinnedTweet` is the account's pinned post, with the same fields as `tweet` in a post lookup. TweetStream fills it in only for accounts it has seen pin a post. It is null otherwise, after the account unpins, or when the post can't be shown right now. #### Account lookup response ```json { "user": { "id": "1189745302", "handle": "@marketdesk", "name": "Market Desk", "platform": "twitter", "verifiedType": "blue", "followersCount": 48210, "followingCount": 312, "joinedAt": 1358208000000, "profileImage": "https://pbs.twimg.com/profile_images/marketdesk_400x400.jpg" }, "pinnedTweet": { "tweetId": "1234567890", "text": "Rate decision at 2pm ET.", "createdAt": 1744156800000, "kind": "post", "link": "https://x.com/marketdesk/status/1234567890", "author": { "id": "1189745302", "handle": "@marketdesk", "name": "Market Desk", "platform": "twitter", "verifiedType": "blue", "followersCount": 48210, "followingCount": 312, "joinedAt": 1358208000000, "profileImage": "https://pbs.twimg.com/profile_images/marketdesk_400x400.jpg" } } } ``` ### Lookup access and errors Any paid or trialing plan can use lookups. Each account can make 60 lookups per minute across both endpoints, in bursts of up to 10. Up to 4 can be in progress at once. On `429`, wait the number of seconds in `retryAfterSeconds` (also sent as `Retry-After`) before retrying. | Status | Body | Meaning | | --- | --- | --- | | `400` | `{ "error": "Invalid post ID" }` | The post ID is not 1 to 20 digits | | `400` | `{ "error": "Invalid account ID or handle" }` | The account ID or handle is malformed | | `401` | `{ "error": "Missing or invalid Authorization header" }` | No bearer token was sent | | `401` | `{ "error": "Invalid API key" }` | The token does not match an API key | | `403` | `{ "error": "An active subscription is required" }` | The account has no paid or trialing subscription | | `404` | `{ "error": "Post not found" }` | The post was not found or is not public | | `404` | `{ "error": "Account not found" }` | No account has this ID or handle | | `429` | `{ "error": "Rate limit exceeded", "retryAfterSeconds": 1 }` | The account reached its lookup limit | | `429` | `{ "error": "Too many lookups in progress", "retryAfterSeconds": 1 }` | The account already has 4 lookups in progress | | `503` | `{ "error": "Lookup is temporarily unavailable" }` | Retry later, after `Retry-After` when it is sent | | `503` | `{ "error": "Authentication service unavailable" }` | The API key could not be checked. Retry later | ## Accounts and settings Manage monitored accounts and optional event sources through authenticated REST endpoints and account settings. Canonical URL: https://tweetstream.io/docs/account-management ### Endpoints Send each request to `https://api.tweetstream.io` with `Authorization: Bearer `. | Method | Path | Use | | --- | --- | --- | | `POST` | `/api/add-account` | Add monitored accounts | | `DELETE` | `/api/remove-account` | Remove monitored accounts | | `GET` | `/api/me` | Read plan limits and usage | | `POST`, `DELETE` | `/api/affiliate-alerts` | Turn affiliate list alerts on or off | | `GET`, `POST`, `DELETE` | `/api/binance-square` | List Binance Square accounts or turn one on or off | ### 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. - `POST /api/add-account` - `DELETE /api/remove-account` ##### Add accounts ```typescript 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) { const detail = await response.text(); throw new Error(`Add failed (${response.status}): ${detail}`); } console.log(await response.json()); ``` ##### Remove an account ```typescript 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) { const detail = await response.text(); throw new Error(`Remove failed (${response.status}): ${detail}`); } 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 plus a `summary` with `total`, `succeeded`, and `failed` counts. Use the row state, not only the HTTP status, before retrying. ##### Add result ```json { "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 } } ``` ##### Remove result ```json { "action": "unfollow", "requestId": "3f1c7d2e-5a4b-4c8d-9e0f-1a2b3c4d5e6f", "error": null, "results": [ { "input": "marketdesk", "state": "removed" } ], "summary": { "failed": 0, "succeeded": 1, "total": 1 } } ``` ### Check current usage Call `/api/me` with either the standard or Ultra API key. The private response is not cached. - `GET /api/me` #### Read plan and usage fields The response contains base-plan usage and any additive Ultra Speed details. | Field | Type | Notes | | --- | --- | --- | | `credentialScope` | `standard` or `ultra_speed` | Scope of the bearer key used | | `plan` | `BASIC`, `ELITE`, or `ENTERPRISE` | Runtime plan value: `BASIC` is Minimum, `ELITE` is Pro, and `ENTERPRISE` is Scale | | `trackedAccounts` | object | Count, limit, and normalized handles | | `websocket` | object | Current active connection count and plan limit | | `stripe` | object | Subscription status and billing period fields; treat identifiers as private | | `ultraSpeed` | `object` or `null` | Ultra status, billing cycle, limits, tracked handles (can repeat top-level `trackedAccounts` handles), scoped WebSocket usage, and cancellation timing | #### Inspect the response ##### Account usage request ```typescript 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/me", { headers: { Authorization: `Bearer ${apiKey}`, }, }); if (!response.ok) { const detail = await response.text(); throw new Error(`Account status failed (${response.status}): ${detail}`); } console.log(await response.json()); ``` ##### Account usage response ```json { "credentialScope": "standard", "plan": "ELITE", "trackedAccounts": { "count": 2, "limit": 250, "handles": ["marketdesk", "realDonaldTrump"] }, "websocket": { "count": 1, "limit": 10 }, "stripe": { "subscriptionStatus": "ACTIVE", "customerId": "[redacted]", "hasCustomer": true, "subscriptionId": "[redacted]", "currentPeriodStart": "2026-06-30T00:00:00.000Z", "currentPeriodEnd": "2026-07-30T00:00:00.000Z", "canceledAt": null }, "ultraSpeed": { "active": true, "status": "ACTIVE", "billingCycle": "MONTHLY", "paymentRail": "STRIPE_CARD", "accountLimit": 25, "trackedAccounts": { "count": 1, "handles": ["realDonaldTrump"] }, "websocket": { "count": 1, "limit": 5 }, "currentPeriodEnd": "2026-07-30T00:00:00.000Z", "cancelAtPeriodEnd": false, "canceledAt": null } } ``` ### Track affiliate list changes Affiliate list changes are off by default for tracked business accounts on active or trialing Pro, Scale, and Ultra plans. #### Enable or disable alerts Use `POST /api/affiliate-alerts`, `DELETE /api/affiliate-alerts`, or the dashboard. Send the tracked handle as `account`; a leading @ is optional. Repeating a request is safe. Success returns `{ account, affiliateAlertsEnabled }`. - `POST /api/affiliate-alerts` - `DELETE /api/affiliate-alerts` ##### Change the alert setting ```typescript async function setAffiliateAlerts(account: string, enabled: boolean) { const response = await fetch("https://api.tweetstream.io/api/affiliate-alerts", { method: enabled ? "POST" : "DELETE", headers: { Authorization: `Bearer ${process.env.TWEETSTREAM_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ account }), }); if (!response.ok) { throw new Error(await response.text()); } return await response.json(); } console.log(await setAffiliateAlerts("marketdesk", true)); console.log(await setAffiliateAlerts("marketdesk", false)); ``` #### Handle setting errors When enabling alerts, TweetStream checks that the account still qualifies as a business account. A failed check returns `400` with `{ error: "This account is not a business account." }`. | Status | When it appears | Action | | --- | --- | --- | | `200` | The saved setting matches the request | Read `affiliateAlertsEnabled` | | `400` | The body is invalid or the account is not a business account | Fix the request | | `401` | The bearer key is missing or invalid | Send a valid API key | | `403` | The subscription or feature access is inactive | Check plan access | | `404` | The handle is not tracked by this API key | Check the tracked handle | | `500` | TweetStream could not confirm eligibility | Retry later | #### Store and replay changes Changes go to WebSocket, the dashboard feed, and your Discord route. Keyword filters do not apply. Store relationships by `(organization.id, member.id)` and use `eventId` only for exact replay deduplication. Replay recorded changes with `GET /api/history?type=AFFILIATE`. There is no current-list snapshot or pre-feature backfill. ### Stream Binance Square posts Binance Square accounts are off by default. Enable them in the dashboard or through this API. They remain separate from tracked X accounts and do not use an X account slot. #### List and change settings `GET /api/binance-square` returns saved settings and the catalog fields `displayName`, `squareHandle`, `profileUrl`, `avatarUrl`, and `enabled`. `POST /api/binance-square` enables an account; `DELETE /api/binance-square` disables it. Send the Square handle in `account`; a leading @ is optional. Repeating a request is safe. - `GET /api/binance-square` - `POST /api/binance-square` - `DELETE /api/binance-square` - `profileUrl` is canonical. `avatarUrl` is `null` when unavailable. - Settings belong to your TweetStream user, so active Standard and Ultra credentials share them. - A saved response returns `{ account, binanceSquareEnabled }` with the canonical handle. ##### List Binance Square accounts ```typescript const response = await fetch( "https://api.tweetstream.io/api/binance-square", { headers: { Authorization: `Bearer ${process.env.TWEETSTREAM_API_KEY}`, }, }, ); if (!response.ok) { throw new Error(await response.text()); } const catalog = await response.json(); console.log(catalog); // { // accounts: [{ // avatarUrl: "https://bin.bnbstatic.com/static/content/live-admin-api/images/chVikg58jFQ6ScXcVmWNmj.png", // displayName: "币安Binance华语", // enabled: true, // profileUrl: "https://www.binance.com/en/square/profile/Vpo7Qwqy63rk7_Km3zYYaQ", // squareHandle: "binancezh" // }], // canManage: true // } ``` ##### Enable or disable posts ```typescript async function setBinanceSquarePosts(account: string, enabled: boolean) { const response = await fetch("https://api.tweetstream.io/api/binance-square", { method: enabled ? "POST" : "DELETE", headers: { Authorization: `Bearer ${process.env.TWEETSTREAM_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ account }), }); if (!response.ok) { throw new Error(await response.text()); } return await response.json(); } console.log(await setBinanceSquarePosts("binancezh", true)); // { account: "binancezh", binanceSquareEnabled: true } ``` #### Handle live content Enabled accounts send live posts, replies, and quotes over WebSocket and applicable Discord routes. History does not store Square content, so reconnects do not backfill missed posts. Keyword filters still apply. Discord uses the global webhook unless an account-specific route applies. - Content uses `tweet/content`, with `author.platform` set to `binance_square` and `kind` set to `post`, `reply`, or `quote`. - Later tweet operations set `d.platform` to `binance_square`; use `(platform, tweetId)` as the post key. - Replies and quotes include `ref` when reference context is available. Post links use canonical Binance URLs. Square authors have no `author.url`, since Square does not report an account website. ##### Binance Square content event ```json { "v": 1, "t": "tweet", "op": "content", "id": "358051617962575", "ts": 1772000001100, "d": { "tweetId": "358051617962575", "kind": "quote", "text": "Example Binance Square quote", "createdAt": 1772000001000, "receivedAt": 1772000001100, "link": "https://www.binance.com/en/square/post/358051617962575", "author": { "id": "Vpo7Qwqy63rk7_Km3zYYaQ", "handle": "@binancezh", "platform": "binance_square" }, "ref": { "type": "quote", "tweetId": "358012920288073" } } } ``` #### Handle setting errors | Status | When it appears | Action | | --- | --- | --- | | `200` | The catalog was listed or the setting was saved | Read the response body | | `400` | The body is invalid or the account is not in the catalog | Fix the request | | `401` | The bearer key is missing or invalid | Send a valid API key | | `403` | The API credential is not active | Check subscription status | | `500` | TweetStream could not save the setting | Retry later | ### Read handle result states | State | When it appears | Recommended handling | | --- | --- | --- | | `added` | Handle was added to the watchlist | Treat as success | | `already_following` | Handle is already tracked | Treat as idempotent at the row level | | `removed` | Handle was removed | Treat as success | | `not_following` | Handle was not tracked | Treat as idempotent at the row level | | `invalid_input`, `duplicate`, `not_found`, `failed` | Invalid input, duplicate input, missing account, or operation failure | Show the row-level message and retry only when appropriate | ### Read REST status codes Add and remove endpoints return one result per handle. The HTTP status describes the batch; each result row describes its handle. | Status | When it appears | Notes | | --- | --- | --- | | `200` | No row failed, including idempotent success rows | `summary.failed` is 0; `already_following` and `not_following` are successful outcomes | | `207` | Some rows succeeded and some rows failed | Read each `results` row before retrying | | `400` | The request body is invalid or every row failed for a non-temporary reason | Fix the request or row-level errors before retrying | | `429` | Too many handle requests | Wait, then retry the batch | | `503` | Every row failed for a temporary reason | Retry the batch with backoff | ### Check plan limits - Minimum: 50 monitored accounts and 3 WebSocket connections after trial. - Trial: 5 monitored accounts and 1 WebSocket connection for 3 days. - Pro: 250 monitored accounts and 10 WebSocket connections. - Scale: self-serve higher monitored-account and WebSocket limits from the pricing page. - History replay is available on Pro, Scale, and Ultra Speed. ## Complete WebSocket example Copy one TypeScript or Python program, run it with your API key, and watch it connect, reconnect, merge tweet updates, follow reference chains, and time each post. Canonical URL: https://tweetstream.io/docs/examples > Using an AI coding agent? > > Point it at this page's Markdown and the protocol specs, and ask it to follow these rules: > > 1. Key every post by `(platform, tweetId)`. > 2. Merge `tweet/update` fields over the stored content. An update carries only the fields it changes. > 3. When an update carries `ref`, replace the stored `ref` and follow `ref.subtweet` for the rest of the chain. > 4. Treat each `tweet/meta` as a full snapshot and replace the stored `meta` with it. > 5. Skip any `t` and `op` pair you don't handle and keep the socket open. > > - [This page as Markdown](https://tweetstream.io/docs/examples.md) > - [AsyncAPI spec](https://tweetstream.io/docs/asyncapi.json) > - [OpenAPI spec](https://tweetstream.io/docs/openapi.json) ### Run it Replace `YOUR_API_KEY` with the key from your dashboard. A trial key works. Posts arrive when a monitored account posts, so add at least one account in the dashboard or set `TWEETSTREAM_ACCOUNTS`. #### TypeScript Needs Node.js 22.18 or later, which runs TypeScript files directly. The program has no dependencies. 1. Copy the program ```typescript // TweetStream complete example: connect, reconnect, handle every event, and measure latency. // Node.js 22.18 or later runs this TypeScript file directly. No dependencies. // // TWEETSTREAM_API_KEY=YOUR_API_KEY node tweetstream-example.ts // // Optional settings: // TWEETSTREAM_ACCOUNTS=handle1,handle2 add these accounts before connecting // TWEETSTREAM_WS_URL=wss://ws-iad.tweetstream.io/ws use the US endpoint // The messages this program reads, limited to the fields it uses. Every field of every event: // https://tweetstream.io/docs/asyncapi.json // Every server message is a v1 envelope. Route on t (channel) and op (operation); d is the payload. type Envelope = { v: 1; t: string; op: string; id?: string; ts: number; d: unknown }; // Posts name their author by handle. Some account events use username instead. type Account = { id?: string; handle?: string; username?: string; platform?: string }; type PostKind = "post" | "quote" | "reply" | "retweet"; // A post that this one quotes, replies to, or reposts. It can reference another through subtweet. type Reference = { type: Exclude; tweetId: string; author?: Account; text?: string; subtweet?: Reference; }; // tweet/content: a new post. author.platform is "twitter" when it is missing. type PostContent = { tweetId: string; text: string; createdAt: number; // Unix milliseconds kind: PostKind; author: Account; ref?: Reference; }; // tweet/update: only the fields that changed. textUpdateType "completion" delivers the full text // of a post that arrived shortened; it is not an edit. type PostUpdate = Partial> & { tweetId: string; platform?: string; textUpdateType?: "completion"; }; // tweet/meta: a complete snapshot of the tokens, markets, and image text found in a post. type PostMeta = { tweetId: string; platform?: string; detected?: { tokens?: Array<{ symbol?: string; contract?: string; priceUsd?: number }>; prediction?: Array<{ title?: string; priceUsd?: number }>; }; ocr?: { text: string }; }; // tweet/delete, tweet/pin, and tweet/unpin. type PostEvent = { tweetId: string; platform?: string; author?: Account }; type Coin = { symbol?: string; address?: string }; // The payload of each t/op pair this program handles. type Payloads = { "control/auth_pong": Record; "tweet/content": PostContent; "tweet/update": PostUpdate; "tweet/meta": PostMeta; "tweet/delete": PostEvent; "tweet/pin": PostEvent; "tweet/unpin": PostEvent; "account/follow": { actor: Account; target: Account }; "account/unfollow": { actor: Account; target: Account }; "account/profile_update": { actor: Account; changes: Record }; "account/affiliate_update": { organization: Account; member: Account; action: string }; "account/callout": { caller: Account; token: Coin }; "account/callout_update": { updateOf: string; token?: Coin }; "account/thesis": { author: Account; coin: Coin; thesis: { text: string } }; "account/token_verified": { coin: Coin }; "news/article": { source?: { name?: string }; title?: string }; "charity/added": { name: string }; "control/twitter_handles_result": { results: Array<{ input: string; state: string }>; error: string | null; }; }; type Pair = keyof Payloads; type Handler

= (d: P, receivedAt: number) => void; // What this program keeps for each post. type StoredPost = { content: Partial | null; meta: PostMeta | null; deleted: boolean; pinned: boolean; hasContent: boolean; }; const API_KEY = process.env.TWEETSTREAM_API_KEY?.trim(); const DASHBOARD = "https://tweetstream.io/dashboard?tab=api"; if (!API_KEY) { console.error(`Set TWEETSTREAM_API_KEY. Copy your key from ${DASHBOARD}`); process.exit(1); } const WS_URL = process.env.TWEETSTREAM_WS_URL || "wss://ws-global.tweetstream.io/ws"; const API_URL = "https://api.tweetstream.io"; const ACCOUNTS = (process.env.TWEETSTREAM_ACCOUNTS ?? "") .split(",") .map((handle) => handle.trim().replace(/^@/, "")) .filter(Boolean); const TWITTER_EPOCH_MS = 1288834974657n; const posts = new Map(); // "platform:tweetId" -> stored post const counts = new Map(); // "t/op" -> number of events seen const latencies: number[] = []; // publish-to-receipt ms for the most recent 10,000 new posts let attempt = 0; let hinted = false; const log = (label: string, text: string) => console.log(`${label.padEnd(9)} ${text}`); const at = (account: Account | undefined) => `@${String(account?.handle ?? account?.username ?? "unknown").replace(/^@/, "")}`; const clip = (text: unknown, max = 80) => { const flat = String(text ?? "").replace(/\s+/g, " ").trim(); return JSON.stringify(flat.length > max ? `${flat.slice(0, max - 1)}…` : flat); }; // Posts are keyed by (platform, tweetId). Content carries the platform on its author; // later operations carry it on d.platform. const platformOf = (d: PostEvent) => d.platform ?? d.author?.platform ?? "twitter"; function postFor(d: PostEvent, platform = platformOf(d)): StoredPost { const key = `${platform}:${d.tweetId}`; let post = posts.get(key); if (!post) { post = { content: null, deleted: false, hasContent: false, meta: null, pinned: false }; posts.set(key, post); if (posts.size > 10_000) { const oldest = posts.keys().next().value; if (oldest !== undefined) posts.delete(oldest); // bound memory } } return post; } // Updates carry only the fields that changed, so merge them over what you stored. const merge = (base: Partial | null, patch: Partial | null) => ({ ...base, ...patch, author: { ...base?.author, ...patch?.author }, }); function publishedAtMs(platform: string, d: PostContent): number | null { // X post IDs encode their publish time: (id >> 22) + the Twitter epoch. if (platform === "twitter" && /^\d+$/.test(d.tweetId)) { return Number((BigInt(d.tweetId) >> 22n) + TWITTER_EPOCH_MS); } return typeof d.createdAt === "number" ? d.createdAt : null; } function referenceChain(ref: Reference | undefined): string { const links: string[] = []; // A referenced post can reference another one through ref.subtweet. for (let r = ref, depth = 0; r && depth < 8; r = r.subtweet, depth++) { links.push(`${r.type ?? "ref"} ${at(r.author)} ${r.tweetId} ${clip(r.text, 40)}`); } return links.join(" -> "); } function describeMeta(meta: PostMeta): string { const price = (value: number | undefined, prefix = "") => (value == null ? "" : ` ${prefix}${value}`); const tokens = meta.detected?.tokens ?? []; const markets = meta.detected?.prediction ?? []; const parts: string[] = []; if (tokens.length) { parts.push(`tokens ${tokens.map((t) => `${t.symbol ?? t.contract}${price(t.priceUsd, "$")}`).join(", ")}`); } if (markets.length) { parts.push(`markets ${markets.map((m) => `${clip(m.title, 40)}${price(m.priceUsd)}`).join(", ")}`); } if (meta.ocr?.text) parts.push(`ocr ${clip(meta.ocr.text, 40)}`); return parts.join(" | ") || "no detections"; } // Delete, pin, and unpin change the stored post's state and keep its content. function lifecycle(d: PostEvent, label: string, change: Partial) { Object.assign(postFor(d), change); log(label, `${at(d.author)} ${d.tweetId}`); } const handlers: { [P in Pair]: Handler } = { "control/auth_pong": () => log("auth", "ok, waiting for events"), "tweet/content": (d, receivedAt) => { const platform = d.author?.platform ?? "twitter"; const post = postFor(d, platform); post.content = merge(d, post.content); // an update that arrived first still wins let latency = ""; const publishedAt = publishedAtMs(platform, d); if (!post.hasContent && publishedAt !== null) { latencies.push(receivedAt - publishedAt); if (latencies.length > 10_000) latencies.shift(); latency = `${receivedAt - publishedAt} ms `; } post.hasContent = true; const where = platform === "twitter" ? "" : ` (${platform})`; log("post", `${at(d.author)}${where} ${d.tweetId} ${latency}${clip(post.content.text)}`); if (d.ref) log("", `chain: ${referenceChain(d.ref)}`); }, "tweet/update": (d) => { const post = postFor(d); post.content = merge(post.content, d); // a ref in the update replaces the stored one const who = `${at(post.content.author)} ${d.tweetId}`; const changed = Object.keys(d).filter((k) => !["platform", "receivedAt", "tweetId"].includes(k)); if (d.ref) log("update", `${who} chain: ${referenceChain(d.ref)}`); else if (d.textUpdateType === "completion") log("update", `${who} full text ${clip(d.text)}`); else log("update", `${who} changed ${changed.join(", ")}`); }, "tweet/meta": (d) => { const post = postFor(d); post.meta = d; // each meta is a complete snapshot: replace it, never merge log("meta", `${at(post.content?.author)} ${d.tweetId} ${describeMeta(d)}`); }, "tweet/delete": (d) => lifecycle(d, "delete", { deleted: true }), "tweet/pin": (d) => lifecycle(d, "pin", { pinned: true }), "tweet/unpin": (d) => lifecycle(d, "unpin", { pinned: false }), "account/follow": (d) => log("follow", `${at(d.actor)} followed ${at(d.target)}`), "account/unfollow": (d) => log("unfollow", `${at(d.actor)} unfollowed ${at(d.target)}`), "account/profile_update": (d) => log("profile", `${at(d.actor)} changed ${Object.keys(d.changes ?? {}).join(", ")}`), "account/affiliate_update": (d) => log("affiliate", `${at(d.organization)} ${d.action} ${at(d.member)}`), "account/callout": (d) => log("callout", `${at(d.caller)} called ${d.token?.symbol} ${d.token?.address}`), "account/callout_update": (d) => log("callout", `update to ${d.updateOf}: ${d.token?.symbol}`), "account/thesis": (d) => log("thesis", `${at(d.author)} on ${d.coin?.symbol}: ${clip(d.thesis?.text, 60)}`), "account/token_verified": (d) => log("verified", `${d.coin?.symbol ?? d.coin?.address}`), "news/article": (d) => log("news", `${d.source?.name}: ${clip(d.title)}`), "charity/added": (d) => log("charity", d.name), "control/twitter_handles_result": (d) => log("handles", (d.results ?? []).map((r) => `${r.input} ${r.state}`).join(", ") || String(d.error)), }; const isPair = (pair: string): pair is Pair => Object.hasOwn(handlers, pair); function onMessage(data: unknown, receivedAt: number) { let event: Partial | null; try { event = JSON.parse(String(data)); } catch { log("skip", "frame is not JSON"); return; } if (event?.v !== 1 || typeof event.t !== "string" || typeof event.op !== "string") return; const pair = `${event.t}/${event.op}`; counts.set(pair, (counts.get(pair) ?? 0) + 1); if (!isPair(pair)) return; // unknown pairs are counted, then ignored // The payload is JSON from the server; the types above describe what each pair carries. const handle = handlers[pair] as Handler; try { handle(event.d ?? {}, receivedAt); } catch (error) { log("error", `${pair}: ${error}`); // one malformed event must not stop the program } } function printSummary() { const sorted = [...latencies].sort((a, b) => a - b); const pct = (p: number) => sorted[Math.max(0, Math.ceil(p * sorted.length) - 1)]; const timing = sorted.length ? `p50 ${pct(0.5)} ms p95 ${pct(0.95)} ms (n=${sorted.length})` : "no posts yet"; const seen = [...counts].map(([pair, n]) => `${pair} ${n}`).join(", ") || "none"; log("summary", `latency ${timing} | events ${seen}`); } // POST /api/add-account answers with one row per handle. type AddAccountResponse = { results?: Array<{ input: string; state: string; message?: string }>; error?: string; }; async function addAccounts() { const response = await fetch(`${API_URL}/api/add-account`, { body: JSON.stringify({ accounts: ACCOUNTS }), headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" }, method: "POST", signal: AbortSignal.timeout(15_000), }); const body = (await response.json().catch(() => ({}))) as AddAccountResponse; // One row per handle, even on 207 (some failed) or 400 (all failed). Each row says why. const rows = Array.isArray(body.results) ? body.results : []; for (const row of rows) { log("accounts", `${row.input} ${row.state}${row.message ? `: ${row.message}` : ""}`); } if (!rows.length && !response.ok) { log("accounts", `add failed (${response.status}): ${body.error ?? "unknown error"}`); } } // GET /api/me reports which pool the key connects to and how many connections each has open. type ConnectionPool = { count: number; limit: number }; type MeResponse = { credentialScope?: "standard" | "ultra_speed"; websocket?: ConnectionPool; // standard keys ultraSpeed?: { websocket?: ConnectionPool } | null; // Ultra Speed keys }; async function explainRefusedConnection() { // Node's built-in WebSocket hides the HTTP status of a refused upgrade, so ask the REST API. const headers = { Authorization: `Bearer ${API_KEY}` }; const response = await fetch(`${API_URL}/api/me`, { headers, signal: AbortSignal.timeout(10_000), }).catch(() => null); if (response?.status === 401) { console.error(`API key rejected (401). Copy a current key from ${DASHBOARD}`); process.exit(1); } if (response?.status === 403) { console.error("Streaming is not active for this key (403). Start a trial or renew your plan."); process.exit(1); } const me = (response?.ok ? await response.json().catch(() => null) : null) as MeResponse | null; const pool = me?.credentialScope === "ultra_speed" ? me.ultraSpeed?.websocket : me?.websocket; if (pool && pool.count >= pool.limit) { log("limit", `all ${pool.limit} connections are in use; close another client`); } } function connect() { const socket = new WebSocket(WS_URL, ["tweetstream.v1", `tweetstream.auth.token.${API_KEY}`]); let openedAt: number | null = null; // set while this socket is open socket.addEventListener("open", () => { openedAt = Date.now(); log("connected", WS_URL); socket.send(JSON.stringify({ op: "auth_ping" })); // answered by control/auth_pong setTimeout(() => { if (openedAt !== null && !latencies.length && !hinted) { hinted = true; log("hint", "posts appear when a monitored account posts. Add accounts in the"); log("hint", "dashboard, or run again with TWEETSTREAM_ACCOUNTS=handle1,handle2"); } }, 120_000); }); // Take the receipt time before any parsing so latency excludes your own processing. socket.addEventListener("message", (event) => onMessage(event.data, Date.now())); let settled = false; // some runtimes fire both error and close; reconnect only once const reconnect = async (reason: string) => { if (settled) return; settled = true; if (openedAt === null) await explainRefusedConnection(); else if (Date.now() - openedAt >= 30_000) attempt = 0; // healthy session: reset backoff openedAt = null; const delay = Math.min(30_000, 1_000 * 2 ** attempt) + Math.floor(Math.random() * 250); attempt = Math.min(attempt + 1, 5); log("closed", `${reason}; reconnecting in ${(delay / 1000).toFixed(1)} s`); setTimeout(connect, delay); }; // Node's built-in WebSocket reports a refused or failed handshake with error and no close. socket.addEventListener("error", () => openedAt === null && reconnect("connection failed")); socket.addEventListener("close", (event) => reconnect(`code ${event.code}`)); } process.on("SIGINT", () => { printSummary(); process.exit(0); }); setInterval(printSummary, 60_000); if (ACCOUNTS.length) { addAccounts() .catch((error: Error) => log("accounts", `add failed: ${error.message}`)) .finally(connect); } else { connect(); } ``` 2. Save it as `tweetstream-example.ts` 3. Run it with your key ```bash TWEETSTREAM_API_KEY=YOUR_API_KEY node tweetstream-example.ts ``` [Raw file: tweetstream-example.ts](https://tweetstream.io/docs/examples/tweetstream-example.ts) #### Python Needs Python 3.10 or later. The run command creates a virtual environment and installs `websockets` into it. 1. Copy the program ```python """TweetStream complete example: connect, reconnect, handle every event, and measure latency. Python 3.10 or later. One dependency, installed in a virtual environment: python3 -m venv tweetstream-venv tweetstream-venv/bin/pip install 'websockets>=14' TWEETSTREAM_API_KEY=YOUR_API_KEY tweetstream-venv/bin/python tweetstream_example.py Optional settings: TWEETSTREAM_ACCOUNTS=handle1,handle2 add these accounts before connecting TWEETSTREAM_WS_URL=wss://ws-iad.tweetstream.io/ws use the US endpoint """ import asyncio import collections import json import math import os import random import sys import time import urllib.error import urllib.request if sys.version_info < (3, 10): sys.exit(f"This example needs Python 3.10 or later; this is {sys.version.split()[0]}.") import websockets API_KEY = os.environ.get("TWEETSTREAM_API_KEY", "").strip() DASHBOARD = "https://tweetstream.io/dashboard?tab=api" if not API_KEY: sys.exit(f"Set TWEETSTREAM_API_KEY. Copy your key from {DASHBOARD}") WS_URL = os.environ.get("TWEETSTREAM_WS_URL") or "wss://ws-global.tweetstream.io/ws" API_URL = "https://api.tweetstream.io" ACCOUNTS = [ handle.strip().lstrip("@") for handle in os.environ.get("TWEETSTREAM_ACCOUNTS", "").split(",") if handle.strip() ] TWITTER_EPOCH_MS = 1288834974657 posts = {} # (platform, tweetId) -> {"content", "meta", "deleted", "pinned", "has_content"} counts = {} # "t/op" -> number of events seen latencies = collections.deque(maxlen=10_000) # publish-to-receipt ms, most recent new posts hinted = False def log(label, text): print(f"{label:<9} {text}", flush=True) def at(account): account = account if isinstance(account, dict) else {} return "@" + str(account.get("handle") or account.get("username") or "unknown").lstrip("@") def clip(text, limit=80): flat = " ".join(str(text or "").split()) return json.dumps(flat if len(flat) <= limit else flat[: limit - 1] + "…", ensure_ascii=False) def field(d, name, key): return (d.get(name) or {}).get(key) # Posts are keyed by (platform, tweetId). Content carries the platform on its author; # later operations carry it on d.platform. def post_for(d, platform=None): platform = platform or d.get("platform") or field(d, "author", "platform") or "twitter" key = (platform, d["tweetId"]) if key not in posts: posts[key] = {"content": None, "meta": None, "deleted": False, "pinned": False} posts[key]["has_content"] = False if len(posts) > 10_000: posts.pop(next(iter(posts))) # bound memory return posts[key] def merge(base, patch): # Updates carry only the fields that changed, so merge them over what you stored. base, patch = base or {}, patch or {} return {**base, **patch, "author": {**(base.get("author") or {}), **(patch.get("author") or {})}} def published_at_ms(platform, d): tweet_id = str(d.get("tweetId", "")) if platform == "twitter" and tweet_id.isdigit(): return (int(tweet_id) >> 22) + TWITTER_EPOCH_MS # X post IDs encode their publish time created_at = d.get("createdAt") return created_at if isinstance(created_at, (int, float)) else None def reference_chain(ref): links = [] # A referenced post can reference another one through subtweet. while ref and len(links) < 8: text = clip(ref.get("text"), 40) links.append(f"{ref.get('type', 'ref')} {at(ref.get('author'))} {ref.get('tweetId')} {text}") ref = ref.get("subtweet") return " -> ".join(links) def describe_meta(meta): detected = meta.get("detected") or {} tokens = [t.get("symbol") or t.get("contract") for t in detected.get("tokens") or []] prices = [t.get("priceUsd") for t in detected.get("tokens") or []] markets = [clip(m.get("title"), 40) for m in detected.get("prediction") or []] odds = [m.get("priceUsd") for m in detected.get("prediction") or []] parts = [] if tokens: parts.append("tokens " + ", ".join(t if p is None else f"{t} ${p}" for t, p in zip(tokens, prices))) if markets: parts.append("markets " + ", ".join(m if p is None else f"{m} {p}" for m, p in zip(markets, odds))) if field(meta, "ocr", "text"): parts.append(f"ocr {clip(meta['ocr']['text'], 40)}") return " | ".join(parts) or "no detections" def on_content(d, received_at): platform = field(d, "author", "platform") or "twitter" post = post_for(d, platform) post["content"] = merge(d, post["content"]) # an update that arrived first still wins latency = "" published_at = published_at_ms(platform, d) if not post["has_content"] and published_at is not None: latencies.append(round(received_at - published_at)) latency = f"{latencies[-1]} ms " post["has_content"] = True where = "" if platform == "twitter" else f" ({platform})" text = clip(post["content"].get("text")) log("post", f"{at(d.get('author'))}{where} {d['tweetId']} {latency}{text}") if d.get("ref"): log("", f"chain: {reference_chain(d['ref'])}") def on_update(d, _): post = post_for(d) post["content"] = merge(post["content"], d) # a ref in the update replaces the stored one who = f"{at(post['content'].get('author'))} {d['tweetId']}" changed = ", ".join(k for k in d if k not in ("platform", "receivedAt", "tweetId")) if d.get("ref"): log("update", f"{who} chain: {reference_chain(d['ref'])}") elif d.get("textUpdateType") == "completion": log("update", f"{who} full text {clip(d.get('text'))}") else: log("update", f"{who} changed {changed}") def on_meta(d, _): post = post_for(d) post["meta"] = d # each meta is a complete snapshot: replace it, never merge log("meta", f"{at((post['content'] or {}).get('author'))} {d['tweetId']} {describe_meta(d)}") def lifecycle(label, **change): # Delete, pin, and unpin change the stored post's state and keep its content. def handle(d, _): post_for(d).update(change) log(label, f"{at(d.get('author'))} {d['tweetId']}") return handle HANDLERS = { "tweet/content": on_content, "tweet/update": on_update, "tweet/meta": on_meta, "tweet/delete": lifecycle("delete", deleted=True), "tweet/pin": lifecycle("pin", pinned=True), "tweet/unpin": lifecycle("unpin", pinned=False), } def event_line(pair, d): # One readable line for account, news, charity, and control events. actor, target = at(d.get("actor")), at(d.get("target")) token, coin = d.get("token") or {}, d.get("coin") or {} results = ", ".join(f"{r.get('input')} {r.get('state')}" for r in d.get("results") or []) return { "control/auth_pong": ("auth", "ok, waiting for events"), "control/twitter_handles_result": ("handles", results or str(d.get("error"))), "account/follow": ("follow", f"{actor} followed {target}"), "account/unfollow": ("unfollow", f"{actor} unfollowed {target}"), "account/profile_update": ("profile", f"{actor} changed {', '.join(d.get('changes') or {})}"), "account/affiliate_update": ( "affiliate", f"{at(d.get('organization'))} {d.get('action')} {at(d.get('member'))}", ), "account/callout": ( "callout", f"{at(d.get('caller'))} called {token.get('symbol')} {token.get('address')}", ), "account/callout_update": ("callout", f"update to {d.get('updateOf')}: {token.get('symbol')}"), "account/thesis": ( "thesis", f"{at(d.get('author'))} on {coin.get('symbol')}: {clip(field(d, 'thesis', 'text'), 60)}", ), "account/token_verified": ("verified", coin.get("symbol") or coin.get("address")), "news/article": ("news", f"{field(d, 'source', 'name')}: {clip(d.get('title'))}"), "charity/added": ("charity", d.get("name")), }.get(pair) def on_message(raw, received_at): try: event = json.loads(raw) except ValueError: return log("skip", "frame is not JSON") # Envelope: {v, t, op, id?, ts, d}. Route by t, then op. if not isinstance(event, dict) or event.get("v") != 1: return pair = f"{event.get('t')}/{event.get('op')}" counts[pair] = counts.get(pair, 0) + 1 d = event.get("d") or {} try: if pair in HANDLERS: HANDLERS[pair](d, received_at) elif event_line(pair, d): # unknown pairs are counted, then ignored log(*event_line(pair, d)) except Exception as error: # one malformed event must not close the connection log("error", f"{pair}: {error!r}") def print_summary(): ordered = sorted(latencies) pct = lambda p: ordered[max(0, math.ceil(p * len(ordered)) - 1)] timing = f"p50 {pct(0.5)} ms p95 {pct(0.95)} ms (n={len(ordered)})" if ordered else "no posts yet" seen = ", ".join(f"{pair} {n}" for pair, n in counts.items()) or "none" log("summary", f"latency {timing} | events {seen}") def add_accounts(): request = urllib.request.Request( f"{API_URL}/api/add-account", data=json.dumps({"accounts": ACCOUNTS}).encode(), headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=15) as response: status, raw = response.status, response.read() except urllib.error.HTTPError as error: # 400, 429, and 503 can still carry results status, raw = error.code, error.read() except OSError as error: return log("accounts", f"add failed: {error}") try: body = json.loads(raw) except ValueError: body = {} body = body if isinstance(body, dict) else {} # One row per handle, even on 207 (some failed) or 400 (all failed). Each row says why. rows = body.get("results") if isinstance(body.get("results"), list) else [] for row in rows: message = f": {row['message']}" if row.get("message") else "" log("accounts", f"{row.get('input')} {row.get('state')}{message}") if not rows and status >= 300: log("accounts", f"add failed ({status}): {body.get('error') or 'unknown error'}") async def report_every_minute(): while True: await asyncio.sleep(60) print_summary() async def hint_when_quiet(): global hinted await asyncio.sleep(120) if not latencies and not hinted: hinted = True log("hint", "posts appear when a monitored account posts. Add accounts in the") log("hint", "dashboard, or run again with TWEETSTREAM_ACCOUNTS=handle1,handle2") async def main(): asyncio.create_task(report_every_minute()) protocols = ["tweetstream.v1", f"tweetstream.auth.token.{API_KEY}"] attempt = 0 while True: opened_at, reason = None, "connection closed" try: async with websockets.connect(WS_URL, subprotocols=protocols) as ws: opened_at = time.monotonic() log("connected", WS_URL) await ws.send(json.dumps({"op": "auth_ping"})) # answered by control/auth_pong hint = asyncio.create_task(hint_when_quiet()) try: async for raw in ws: # Take the receipt time before parsing so latency excludes your own work. on_message(raw, time.time() * 1000) finally: hint.cancel() except Exception as error: # A refused upgrade carries its HTTP status on error.response. 429 means the pool # this key connects to (standard or Ultra Speed) has no free connection. status = getattr(getattr(error, "response", None), "status_code", None) if status == 401: sys.exit(f"API key rejected (401). Copy a current key from {DASHBOARD}") if status == 403: sys.exit("Streaming is not active for this key (403). Start a trial or renew your plan.") reason = "all connections are in use" if status == 429 else f"{type(error).__name__}: {error}" if opened_at is not None and time.monotonic() - opened_at >= 30: attempt = 0 # a healthy session resets backoff delay = min(30, 2**attempt) + random.random() / 4 attempt = min(attempt + 1, 5) log("closed", f"{reason}; reconnecting in {delay:.1f} s") await asyncio.sleep(delay) if __name__ == "__main__": if ACCOUNTS: add_accounts() try: asyncio.run(main()) except KeyboardInterrupt: print_summary() ``` 2. Save it as `tweetstream_example.py` 3. Run it with your key ```bash python3 -m venv tweetstream-venv && \ tweetstream-venv/bin/pip install 'websockets>=14' && \ TWEETSTREAM_API_KEY=YOUR_API_KEY tweetstream-venv/bin/python tweetstream_example.py ``` [Raw file: tweetstream_example.py](https://tweetstream.io/docs/examples/tweetstream_example.py) #### Expected output ```text connected wss://ws-global.tweetstream.io/ws auth ok, waiting for events post @marketdesk 2104934217937850514 183 ms "SOL breaks out of the 4h range. Watching $98.5 as…" update @marketdesk 2104934217937850514 chain: quote @projectteam 2104932246614966349 "Mainnet is live." -> reply @devteam 2104929205744566281 "Ship date?" update @marketdesk 2104934217937850514 full text "SOL breaks out of the 4h range. Watching $98.5 as support. Chart below." meta @marketdesk 2104934217937850514 tokens SOL $98.5 | ocr "SOL/USD 4h" delete @marketdesk 2104934217937850514 follow @marketdesk followed @projectteam summary latency p50 183 ms p95 183 ms (n=1) | events control/auth_pong 1, tweet/content 1, tweet/update 2, tweet/meta 1, tweet/delete 1, account/follow 1 ``` ### The event sequence These frames produce the expected output above. Their timestamps are illustrative. Each post starts with `tweet/content`. A `tweet/update` for the same `(platform, tweetId)` adds fields to it, and its `ref` links the post to the posts it quotes or replies to. 1. You send `auth_ping`. Sent as soon as the socket opens, to check the key. ```json { "op": "auth_ping" } ``` 2. Server sends `control/auth_pong`. The key works. The program prints an `auth` line and waits for events. ```json { "v": 1, "t": "control", "op": "auth_pong", "ts": 1790690396000, "d": {} } ``` 3. Server sends `tweet/content`. A new post, here with shortened text. The program stores it under `(platform, tweetId)` and prints it with its latency. ```json { "v": 1, "t": "tweet", "op": "content", "id": "2104934217937850514", "ts": 1790690400160, "d": { "tweetId": "2104934217937850514", "text": "SOL breaks out of the 4h range. Watching $98.5 as…", "createdAt": 1790690400000, "receivedAt": 1790690400150, "kind": "quote", "author": { "id": "1450012345678901248", "handle": "@marketdesk", "name": "Market Desk", "platform": "twitter" } } } ``` 4. Server sends `tweet/update`. Adds a reference to the same post. The program merges the fields over the stored post, replaces its `ref`, and prints the chain by following `ref.subtweet`. ```json { "v": 1, "t": "tweet", "op": "update", "id": "2104934217937850514", "ts": 1790690402000, "d": { "tweetId": "2104934217937850514", "platform": "twitter", "receivedAt": 1790690401990, "kind": "quote", "ref": { "type": "quote", "tweetId": "2104932246614966349", "text": "Mainnet is live.", "author": { "id": "1380123456789012480", "handle": "@projectteam", "name": "Project Team" }, "subtweet": { "type": "reply", "tweetId": "2104929205744566281", "text": "Ship date?", "author": { "id": "1290123456789012481", "handle": "@devteam", "name": "Dev Team" } } } } } ``` 5. Server sends `tweet/update`. Delivers the full text, with `textUpdateType` set to `completion`. The program merges it over the stored post and keeps the `ref` it already has. ```json { "v": 1, "t": "tweet", "op": "update", "id": "2104934217937850514", "ts": 1790690403000, "d": { "tweetId": "2104934217937850514", "platform": "twitter", "receivedAt": 1790690402990, "text": "SOL breaks out of the 4h range. Watching $98.5 as support. Chart below.", "textUpdateType": "completion" } } ``` 6. Server sends `tweet/meta`. Enrichment for the post. The program replaces the stored `meta` with this snapshot and prints tokens, markets, and OCR text. ```json { "v": 1, "t": "tweet", "op": "meta", "id": "2104934217937850514", "ts": 1790690404000, "d": { "tweetId": "2104934217937850514", "platform": "twitter", "receivedAt": 1790690403990, "detected": { "tokens": [ { "chain": "solana", "contract": "So11111111111111111111111111111111111111112", "name": "Wrapped SOL", "networkId": 1399811149, "priceUsd": 98.5, "sources": [ "text", "ocr" ], "symbol": "SOL" } ] }, "ocr": { "text": "SOL/USD 4h" } } } ``` 7. Server sends `tweet/delete`. The author deleted the post. The program marks it deleted and keeps its content. ```json { "v": 1, "t": "tweet", "op": "delete", "id": "2104934217937850514", "ts": 1790690430000, "d": { "eventId": "delete.264da58762fac7204da8", "tweetId": "2104934217937850514", "platform": "twitter", "author": { "id": "1450012345678901248", "handle": "@marketdesk" }, "deletedAt": 1790690429990, "receivedAt": 1790690429990 } } ``` 8. Server sends `account/follow`. A monitored account followed someone. The program prints one line and leaves stored posts alone. ```json { "v": 1, "t": "account", "op": "follow", "id": "follow-1450012345678901248-1380123456789012480", "ts": 1790690438000, "d": { "actor": { "id": "1450012345678901248", "handle": "@marketdesk" }, "target": { "id": "1380123456789012480", "handle": "@projectteam" }, "kind": "FOLLOW", "eventId": "follow-1450012345678901248-1380123456789012480", "observedAt": 1790690437990, "receivedAt": 1790690437990 } } ``` ### Connect and reconnect `connect()` opens the socket with two subprotocols, `tweetstream.v1` and `tweetstream.auth.token.`, then sends `auth_ping`. The `control/auth_pong` reply confirms the key. After a disconnect the program waits 1, 2, 4, 8, 16, and then 30 seconds between attempts, plus a little jitter. A session that stays up for 30 seconds resets the delay. A `401` or `403` stops the program with a message, because retrying will not fix the key or the plan. Node.js does not expose the HTTP status of a refused connection, so the TypeScript program asks `GET /api/me` for the reason. The Python program reads the status from the `websockets` error. ### Add accounts When `TWEETSTREAM_ACCOUNTS` has handles, `addAccounts()` sends them to `POST https://api.tweetstream.io/api/add-account` before connecting. A `207` response means some handles were added and some were not, so the program prints one line per handle with its `state`. Added accounts stay on your watchlist after the program exits. ### Posts and update chains The program stores each post by `(platform, tweetId)`. `tweet/content` creates the post, and `tweet/update` merges in only the fields it carries, so the text and author you already have stay in place. When an update includes `ref`, the program replaces the stored reference and prints the chain by following `ref.subtweet` from one referenced post to the next. An update can arrive before its content; the merge keeps the update's fields. `tweet/delete`, `tweet/pin`, and `tweet/unpin` change the stored post's state and leave its content alone. #### Merge updates and print the chain (TypeScript) ```typescript // Updates carry only the fields that changed, so merge them over what you stored. const merge = (base: Partial | null, patch: Partial | null) => ({ ...base, ...patch, author: { ...base?.author, ...patch?.author }, }); function referenceChain(ref: Reference | undefined): string { const links: string[] = []; // A referenced post can reference another one through ref.subtweet. for (let r = ref, depth = 0; r && depth < 8; r = r.subtweet, depth++) { links.push(`${r.type ?? "ref"} ${at(r.author)} ${r.tweetId} ${clip(r.text, 40)}`); } return links.join(" -> "); } "tweet/update": (d) => { const post = postFor(d); post.content = merge(post.content, d); // a ref in the update replaces the stored one const who = `${at(post.content.author)} ${d.tweetId}`; const changed = Object.keys(d).filter((k) => !["platform", "receivedAt", "tweetId"].includes(k)); if (d.ref) log("update", `${who} chain: ${referenceChain(d.ref)}`); else if (d.textUpdateType === "completion") log("update", `${who} full text ${clip(d.text)}`); else log("update", `${who} changed ${changed.join(", ")}`); }, ``` #### Merge updates and print the chain (Python) ```python def merge(base, patch): # Updates carry only the fields that changed, so merge them over what you stored. base, patch = base or {}, patch or {} return {**base, **patch, "author": {**(base.get("author") or {}), **(patch.get("author") or {})}} def reference_chain(ref): links = [] # A referenced post can reference another one through subtweet. while ref and len(links) < 8: text = clip(ref.get("text"), 40) links.append(f"{ref.get('type', 'ref')} {at(ref.get('author'))} {ref.get('tweetId')} {text}") ref = ref.get("subtweet") return " -> ".join(links) def on_update(d, _): post = post_for(d) post["content"] = merge(post["content"], d) # a ref in the update replaces the stored one who = f"{at(post['content'].get('author'))} {d['tweetId']}" changed = ", ".join(k for k in d if k not in ("platform", "receivedAt", "tweetId")) if d.get("ref"): log("update", f"{who} chain: {reference_chain(d['ref'])}") elif d.get("textUpdateType") == "completion": log("update", f"{who} full text {clip(d.get('text'))}") else: log("update", f"{who} changed {changed}") ``` ### Enrichment `tweet/meta` can arrive after the post. Each one is a complete snapshot, so the program replaces the stored metadata instead of merging it. The `meta` line lists detected tokens with prices, prediction markets with prices, and OCR text read from images, when present. ### Other events Follows, unfollows, profile changes, affiliate changes, callouts, FOMO theses and token verifications, news articles, and charity notices each print one line. Callouts, news, charity, and FOMO events arrive only after you turn them on in the dashboard. An unknown `t` and `op` pair is counted and skipped, and the connection stays open. If a handler fails, the program prints an `error` line and keeps reading. ### Measure latency The message handler reads the clock before it parses anything. For X posts, the publish time comes from the post ID: shift the ID right by 22 bits and add the Twitter epoch, `1288834974657`. Other platforms use `createdAt`. The difference is the time from publication to receipt on your machine, including the network path to you. A replayed post does not add a second sample. Every 60 seconds and when you press Ctrl+C, the program prints p50 and p95 over the most recent 10,000 posts, plus a count for each event type. #### Read the publish time (TypeScript) ```typescript function publishedAtMs(platform: string, d: PostContent): number | null { // X post IDs encode their publish time: (id >> 22) + the Twitter epoch. if (platform === "twitter" && /^\d+$/.test(d.tweetId)) { return Number((BigInt(d.tweetId) >> 22n) + TWITTER_EPOCH_MS); } return typeof d.createdAt === "number" ? d.createdAt : null; } ``` #### Read the publish time (Python) ```python def published_at_ms(platform, d): tweet_id = str(d.get("tweetId", "")) if platform == "twitter" and tweet_id.isdigit(): return (int(tweet_id) >> 22) + TWITTER_EPOCH_MS # X post IDs encode their publish time created_at = d.get("createdAt") return created_at if isinstance(created_at, (int, float)) else None ``` ### Optional settings Set these environment variables next to `TWEETSTREAM_API_KEY` in the run command. | Variable | Default | Use | | --- | --- | --- | | `TWEETSTREAM_API_KEY` | Required | Your API key. The program never prints it. | | `TWEETSTREAM_ACCOUNTS` | None | Comma-separated handles to add before connecting, such as `marketdesk,projectteam` | | `TWEETSTREAM_WS_URL` | `wss://ws-global.tweetstream.io/ws` | Use `wss://ws-iad.tweetstream.io/ws` when you run in the USA | ### Common mistakes - Treating `tweet/update` as a new post. It belongs to the post you stored under the same `(platform, tweetId)`. - Replacing the stored post with an update. An update carries only the fields it changes, so merge it. - Dropping an update that arrives before its content. Keep it, and when the content arrives, merge it in without overwriting the update's fields. - Merging a `tweet/meta` into older metadata. Each one is a full snapshot, so replace the stored one. - Ordering or deduplicating posts by the envelope `ts` or `id`. Use `(platform, tweetId)` for identity. - Closing the socket on an unknown `t` and `op` pair. Skip the frame and keep reading. - Retrying a `401` or `403` in a loop. A new connection will not fix the key or the plan. ### Troubleshooting - You only see `auth` and `summary` lines. Posts arrive when a monitored account posts. Add an active account, or check your watchlist with `GET /api/me`. After two quiet minutes the program prints a hint. - The program stops with `API key rejected (401)`. Copy the key again from the dashboard. A rotated key stops working. - The program stops with `Streaming is not active for this key (403)`. Start a trial or renew your plan, then run it again. - The program says all connections are in use. Your plan limits open WebSocket connections. Stop another client, or wait for an old connection to close. - Latency is negative or much higher than expected. The number compares your clock with the post's publish time, so sync your clock with NTP. Measure from the machine that runs your trading system. - Node.js reports `Unknown file extension ".ts"`. Your Node.js is older than 22.18. Update Node.js and run the command again. - Python reports `No module named 'websockets'`. Start the program with `tweetstream-venv/bin/python` as the run command shows. That environment has `websockets` installed. - Creating the virtual environment fails with `ensurepip is not available`. On Debian or Ubuntu, install the venv module with `sudo apt install python3-venv`, then run the Python command again. - pip reports `No matching distribution found for websockets>=14`. Your `python3` is older than 3.10. Install Python 3.10 or later and create the virtual environment with it. ### Build on it When you turn this program into a long-running consumer, start with the typed router and the reference consumer. - [Route every operation](https://tweetstream.io/docs/production-consumer#complete-router): Typed handlers for all 18 server operations. - [Build the reference consumer](https://tweetstream.io/docs/production-consumer#reference-consumer): Reconnects, durable recovery, and account management, one page each. - [Open-source Solana sniper bot](https://github.com/tweetstream-io/solana-sniper-bot): A TypeScript and Rust bot built on this feed that buys on posts, bio changes, follows, and callouts. ## Instagram WebSocket Connect to `wss://insta-ws.tweetstream.io/ws` with your TweetStream API key to receive new posts, reels, carousels, and stories from the Instagram accounts on your watchlist. Canonical URL: https://tweetstream.io/docs/instagram-websocket ### Quick start You need an active Instagram Tracking subscription or trial, at least one Instagram account on your dashboard watchlist, and your TweetStream API key. Run the example below. `Instagram feed connected` confirms authentication, and events print when a watched account publishes. | Setting | Value | Notes | | --- | --- | --- | | Endpoint | `wss://insta-ws.tweetstream.io/ws` | Separate from the X feed endpoints | | Subprotocol | `tweetstream.instagram.v1` | Required when you send any subprotocol, the auth token included. Other subprotocols get HTTP 400 | | Auth protocol | `tweetstream.auth.token.` | Your existing TweetStream API key | #### Run the TypeScript smoke test ```typescript // bun add ws // TWEETSTREAM_API_KEY=ts_... bun run instagram.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://insta-ws.tweetstream.io/ws", [ "tweetstream.instagram.v1", `tweetstream.auth.token.${apiKey}`, ]); ws.on("open", () => { console.log("Instagram feed connected"); }); ws.on("message", (raw) => { const event = JSON.parse(raw.toString()); if (event.t === "instagram" && (event.op === "content" || event.op === "update")) { console.log(event.op, event.d.kind, event.d.author.username, event.d.url); } }); ws.on("unexpected-response", (_request, response) => { console.error("Connection rejected", response.statusCode, response.statusMessage); response.resume(); process.exitCode = 1; }); ws.on("close", (code, reason) => { console.log("Instagram feed closed", code, reason.toString()); }); ``` ### Authenticate The Instagram WebSocket accepts the same API key and the same credential forms as the X feed. If you send the key as `tweetstream.auth.token.`, also offer `tweetstream.instagram.v1`. Keep the key on your server and out of public browser code. | Context | Header or protocol | Notes | | --- | --- | --- | | WebSocket | `tweetstream.instagram.v1` + `tweetstream.auth.token.` | Preferred | | WebSocket fallback | `Authorization: Bearer ` or `?apiKey=` | Use the query form only when your runtime cannot set headers | ### Read the envelope Every frame is one JSON envelope shaped like the X feed. Route by `t`, then `op`, and ignore operations you do not recognize without closing the socket. | Field | Type | Meaning | | --- | --- | --- | | `v` | `1` | Envelope version | | `t` | `instagram` or `control` | Channel | | `op` | `content`, `update`, or `auth_pong` | Operation | | `id` | string | The media ID. An update event reuses the ID of the content event it replaces | | `ts` | number | When TweetStream sent the frame, in Unix milliseconds | | `d` | object | Payload for the operation | #### Instagram envelope types ```typescript type InstagramKind = 'post' | 'reel' | 'carousel' | 'story' | 'unknown'; type InstagramMedia = { // Position in the media array, starting at 0. index: number; type: 'image' | 'video' | 'unknown'; url: string; // Present only on an image that is a video's cover. cover?: true; }; type InstagramContent = { // Instagram media ID; also the envelope id. id: string; // Present when known; the post URL uses it. shortcode?: string; kind: InstagramKind; url: string; author: { id?: string; // Lowercase, without a leading @. username: string; }; // Empty string when the post has no caption. caption: string; // May be empty or partial; see the media notes. media: Array; // Publish time in Unix milliseconds, or null when it is not known. createdAt: number | null; // True when createdAt is an estimate. createdAtEstimated: boolean; // When TweetStream received the post, in Unix milliseconds. receivedAt: number; // Content events only. True when this post reached TweetStream well after its // publish time; the post is still new to you. A missing flag does not mean // the post is recent: when createdAt is set, compare it with receivedAt. late?: true; }; type InstagramContentEnvelope = { v: 1; t: 'instagram'; op: 'content'; id: string; ts: number; d: InstagramContent; }; type InstagramUpdateEnvelope = { v: 1; t: 'instagram'; // Insert, or replace the stored event with the same id. Sent at most once per id. op: 'update'; id: string; ts: number; // Never carries late: keep the value from the content event, or compare // createdAt with receivedAt if you never got one. d: Omit; }; type InstagramAuthPongEnvelope = { v: 1; t: 'control'; op: 'auth_pong'; ts: number; d: Record; }; type InstagramEnvelope = | InstagramContentEnvelope | InstagramUpdateEnvelope | InstagramAuthPongEnvelope; ``` ### Content kinds `d.kind` says what was published when it is known. `d.url` links to the content on Instagram, or to the author's profile when no shortcode is known. | Kind | Meaning | URL shape | | --- | --- | --- | | `post` | A feed post with one image or video | `instagram.com/p//` | | `reel` | A reel | `instagram.com/reel//` | | `carousel` | A feed post with several images or videos | `instagram.com/p//` | | `story` | A story | `instagram.com/stories///` | | `unknown` | The kind was not established. The event still carries the ID, author, URL, and any media found, so treat it as new content from that account | `instagram.com/p//`, or the author's profile when no shortcode is known | ### Media order and covers - `index` is the item's position in `media`, starting at 0. `media` follows the post's slide order when that order is known. In a carousel that mixes photos and videos, the photos may come first. - `cover: true` marks an image that is a video's cover. Other items omit the field. - `type` is `image`, `video`, or `unknown` when the type was not established. - `media` can be empty or list fewer items than the post has. A later `update` may bring the rest. Open `url` for the full post. - Media URLs can expire. Download the files you need soon after the event arrives. ### Timestamps | Field | Type | Meaning | | --- | --- | --- | | `createdAt` | number or `null` | Publish time in Unix milliseconds. `null` when it is not known | | `createdAtEstimated` | boolean | `true` when `createdAt` is an estimate | | `receivedAt` | number | When TweetStream received the content, in Unix milliseconds | | `late` | `true`, or absent | `true` when this post reached TweetStream well after its publish time. The post is still new to you. Only `content` events carry it, never `update`. A missing `late` does not mean the post is recent: when `createdAt` is set, compare it with `receivedAt` | ### Example events Each example is a complete frame as it arrives on the socket. #### Carousel ```json { "v": 1, "t": "instagram", "op": "content", "id": "3988012345678901234", "ts": 1772000001060, "d": { "id": "3988012345678901234", "shortcode": "DdsPyK1GrTK", "kind": "carousel", "url": "https://www.instagram.com/p/DdsPyK1GrTK/", "author": { "id": "25025320", "username": "examplebrand" }, "caption": "Three frames from launch day", "media": [ { "index": 0, "type": "image", "url": "https://scontent-iad3-1.cdninstagram.com/v/1.jpg" }, { "index": 1, "type": "video", "url": "https://scontent-iad3-1.cdninstagram.com/v/2.mp4" }, { "index": 2, "type": "image", "url": "https://scontent-iad3-1.cdninstagram.com/v/2.jpg", "cover": true } ], "createdAt": 1772000000000, "createdAtEstimated": false, "receivedAt": 1772000001050 } } ``` #### Reel ```json { "v": 1, "t": "instagram", "op": "content", "id": "3987848477383929083", "ts": 1772000001060, "d": { "id": "3987848477383929083", "shortcode": "DdXrw_IjSz7", "kind": "reel", "url": "https://www.instagram.com/reel/DdXrw_IjSz7/", "author": { "id": "25025320", "username": "examplebrand" }, "caption": "New drop", "media": [ { "index": 0, "type": "video", "url": "https://scontent-iad3-1.cdninstagram.com/v/reel.mp4" } ], "createdAt": 1772000000000, "createdAtEstimated": false, "receivedAt": 1772000001050 } } ``` #### Story with an estimated publish time ```json { "v": 1, "t": "instagram", "op": "content", "id": "3988000000000000001", "ts": 1772000001060, "d": { "id": "3988000000000000001", "kind": "story", "url": "https://www.instagram.com/stories/examplebrand/3988000000000000001/", "author": { "id": "25025320", "username": "examplebrand" }, "caption": "", "media": [ { "index": 0, "type": "image", "url": "https://scontent-iad3-1.cdninstagram.com/v/story.jpg" } ], "createdAt": 1772000000000, "createdAtEstimated": true, "receivedAt": 1772000001050 } } ``` #### Kind not established ```json { "v": 1, "t": "instagram", "op": "content", "id": "3987848477383929083", "ts": 1772000001060, "d": { "id": "3987848477383929083", "shortcode": "DdXrw_IjSz7", "kind": "unknown", "url": "https://www.instagram.com/p/DdXrw_IjSz7/", "author": { "id": "25025320", "username": "examplebrand" }, "caption": "Kind not established", "media": [ { "index": 0, "type": "unknown", "url": "https://scontent-iad3-1.cdninstagram.com/v/first.jpg" } ], "createdAt": null, "createdAtEstimated": false, "receivedAt": 1772000001050 } } ``` #### Late post ```json { "v": 1, "t": "instagram", "op": "content", "id": "3987848477383929083", "ts": 1772000001060, "d": { "id": "3987848477383929083", "shortcode": "DdXrw_IjSz7", "kind": "post", "url": "https://www.instagram.com/p/DdXrw_IjSz7/", "author": { "id": "25025320", "username": "examplebrand" }, "caption": "Behind the scenes", "createdAt": 1771999101050, "media": [ { "type": "image", "url": "https://scontent-iad3-1.cdninstagram.com/v/late.jpg", "index": 0 } ], "createdAtEstimated": false, "late": true, "receivedAt": 1772000001050 } } ``` ### Updates An `update` event carries a complete `d` payload for its `id`. If you already stored an event with that `id`, replace it with the new one instead of merging fields. If you have not seen that `id`, for example because you connected after the first event, treat the update as new content. TweetStream sends at most one update per event, and only when it has a more complete version, for example one that adds media or a known kind. Updates arrive on the WebSocket only. Discord posts are not edited. #### Carousel update ```json { "v": 1, "t": "instagram", "op": "update", "id": "3988012345678901234", "ts": 1772000001060, "d": { "id": "3988012345678901234", "shortcode": "DdsPyK1GrTK", "kind": "carousel", "url": "https://www.instagram.com/p/DdsPyK1GrTK/", "author": { "id": "25025320", "username": "examplebrand" }, "caption": "Three frames from launch day", "createdAt": 1772000000000, "media": [ { "type": "image", "url": "https://scontent-iad3-1.cdninstagram.com/v/1.jpg", "index": 0 }, { "type": "video", "url": "https://scontent-iad3-1.cdninstagram.com/v/2.mp4", "index": 1 }, { "type": "image", "url": "https://scontent-iad3-1.cdninstagram.com/v/2.jpg", "index": 2, "cover": true } ], "createdAtEstimated": false, "receivedAt": 1772000001050 } } ``` ### Check authentication Send `{ "op": "auth_ping" }` when your application needs an authentication check. A valid connection answers with `control/auth_pong` and an empty data object. #### Authentication ping [Use the first complete copy of this example.](https://tweetstream.io/docs/control#auth-ping-message) #### Authentication pong [Use the first complete copy of this example.](https://tweetstream.io/docs/control#auth-pong-event) ### Manage accounts over HTTP Use your API key to change the same watchlist the dashboard shows. Send `accounts` as one username or an array of up to 100, with or without `@`. A `207` means some accounts changed and others were rejected; `rejected` gives the reason for each one. Adding an account that is already on the list returns `duplicate`, and removing one that is not on it returns `not_tracked`. Neither counts as a failure, so retrying a request is safe. | Method | Path | Result | | --- | --- | --- | | `GET` | `/api/instagram/accounts` | Your accounts, each `live`, `pending`, or `paused`, and your `limit` | | `POST` | `/api/instagram/add-account` | Adds accounts. Over your limit they come back as `capacity` | | `DELETE` | `/api/instagram/remove-account` | Removes accounts and lists them in `removed` | #### Add Instagram accounts ```typescript 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/instagram/add-account", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ accounts: ["nasa", "@natgeo"], }), }); if (!response.ok && response.status !== 207) { const detail = await response.text(); throw new Error(`Add failed (${response.status}): ${detail}`); } const { accounts, limit, rejected } = await response.json(); console.log(`${accounts.length}/${limit} accounts`, rejected); ``` ### Connections and accounts - Each Instagram Tracking subscription includes 3 Instagram WebSocket connections, or 1 during the trial. They are separate from your X plan's connections. - Add or remove Instagram accounts at any time, in the dashboard or over HTTP. Account changes never change your bill. - Events can also go to a Discord channel. Set that up in the dashboard.