Stream the latest tweets for a keyword
Use advanced_search with product=Latest and a high-water-mark tweet ID to poll new tweets in near real time. Full cURL, Python, and Node samples with cost math.
This guide builds a polling loop that pulls net-new tweets for any keyword every few seconds or minutes. No webhooks to configure, no persistent TCP connection to maintain: just GET /twitter/tweet/advanced_search, a high-water-mark tweet ID, and a sleep interval.
Cost: each poll is one API call at $0.0008. Polling every 30 seconds costs $2.88/day. Polling every 5 minutes costs $0.23/day. See the cost table below for the full range.
Why polling beats the official filtered stream for most use cases
| TwitterAPIs polling | X API v2 filtered stream | |
|---|---|---|
| Setup | One API key, one HTTP call | Developer account approval + OAuth 2.0 app setup |
| Cost (1 keyword, 30 s interval) | ~$2.88/day | Free tier capped at 500K tweets/month; $5,000+/month for production volumes |
| Reliability | Stateless, resumable anywhere | Persistent TCP connection, reconnect logic required |
| Historical backfill | Walk next_cursor back in time | Stream only; no backfill |
| Write/private actions | Same API key | Separate app credentials |
For low-to-medium volume keyword tracking, the polling model is simpler to operate and far cheaper.
How the high-water mark works
Twitter tweet IDs are monotonically increasing 64-bit integers. The newest tweet on a page always has the largest ID. You store that ID after each poll and stop paginating on the next poll as soon as you see it (or any ID smaller than it). This gives you exactly-once delivery with no deduplication set required for a single keyword.
The loop:
- Call
/twitter/tweet/advanced_search?query=...&product=Latest. - Collect tweets with ID greater than your stored high-water mark.
- Update the high-water mark to the largest ID you received.
- Sleep for your poll interval, then repeat.
If the keyword is very busy and one page of ~20 tweets is not enough, paginate via next_cursor until you reach the high-water mark ID, then stop.
The search query
Use product=Latest to get chronological order newest-first. Without it the API returns Top tweets, which is sorted by engagement and useless for streaming.
#openai lang:en -is:retweetUseful operators:
| Operator | What it does |
|---|---|
"exact phrase" | Literal phrase match |
#hashtag | Hashtag |
@handle | Mentions of a handle |
lang:en | English only |
-is:retweet | Drop retweets |
min_faves:10 | Engagement floor (reduces noise) |
since:2026-01-01 | Date floor for backfill |
Keep the query under 512 characters.
Full samples
A one-shot poll that prints only tweets newer than the stored high-water mark. Chain it into a watch or while loop.
#!/usr/bin/env bash
# keyword-stream.sh
# Usage: ./keyword-stream.sh "#openai lang:en -is:retweet"
set -euo pipefail
QUERY="${1:-#openai lang:en -is:retweet}"
HWM_FILE="${HOME}/.kwstream-hwm.txt"
# Load high-water mark (empty on first run)
HWM=""
if [[ -f "$HWM_FILE" ]]; then
HWM=$(cat "$HWM_FILE")
fi
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$QUERY")
URL="https://api.twitterapis.com/twitter/tweet/advanced_search?query=${ENCODED}&product=Latest"
RESPONSE=$(curl -sf "$URL" -H "Authorization: Bearer ${TWITTERAPIS_KEY}")
python3 - <<PYEOF
import json, sys
data = json.loads("""${RESPONSE}""")
hwm = "${HWM}"
new_hwm = hwm
new_tweets = []
for tweet in data.get("tweets", []):
tid = tweet["id"]
# IDs are numeric strings; compare as integers
if not hwm or int(tid) > int(hwm):
new_tweets.append(tweet)
if not new_hwm or int(tid) > int(new_hwm):
new_hwm = tid
if new_tweets:
print(f"--- {len(new_tweets)} new tweet(s) ---")
for t in sorted(new_tweets, key=lambda x: int(x["id"])):
print(f"@{t['author']['username']}: {t['text'][:120]}")
print(f" https://x.com/{t['author']['username']}/status/{t['id']}")
print()
with open("${HWM_FILE}", "w") as f:
f.write(new_hwm)
else:
print("No new tweets.")
PYEOFRun in a shell loop every 30 seconds:
while true; do
./keyword-stream.sh '"#openai" lang:en -is:retweet'
sleep 30
doneOr schedule with cron for a longer interval:
*/5 * * * * TWITTERAPIS_KEY=your_key /path/to/keyword-stream.sh '"#openai" lang:en -is:retweet' >> /var/log/kwstream.log 2>&1A self-contained polling loop with a high-water mark, multi-page catch-up when the keyword is busy, exponential backoff on errors, and optional webhook delivery.
#!/usr/bin/env python3
"""
keyword_stream.py -- TwitterAPIs keyword stream via polling
Usage:
export TWITTERAPIS_KEY="your_key_here"
python3 keyword_stream.py
Cost: $0.0008 per call (one call per poll interval, more if paginating catch-up pages).
"""
import os
import json
import time
import logging
import requests
from pathlib import Path
# ---------- config ----------
API_KEY = os.environ["TWITTERAPIS_KEY"]
BASE_URL = "https://api.twitterapis.com/twitter"
# The keyword you want to stream. Supports full Twitter operator syntax.
QUERY = "#openai lang:en -is:retweet"
POLL_INTERVAL_SECONDS = 30 # how often to check for new tweets
MAX_CATCHUP_PAGES = 10 # stop paginating after this many pages
HWM_FILE = Path.home() / ".kwstream-hwm.json" # stores high-water mark per query
# ---------- setup ----------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("kwstream")
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {API_KEY}"})
# ---------- high-water mark helpers ----------
def load_hwm() -> dict:
if HWM_FILE.exists():
return json.loads(HWM_FILE.read_text())
return {}
def save_hwm(hwm: dict) -> None:
HWM_FILE.write_text(json.dumps(hwm))
# ---------- API call with backoff ----------
def search_page(query: str, cursor: str = "", retries: int = 4) -> dict:
"""Fetch one page of latest tweets. Returns the full response dict."""
params: dict = {"query": query, "product": "Latest"}
if cursor:
params["cursor"] = cursor
for attempt in range(retries):
try:
resp = session.get(
f"{BASE_URL}/tweet/advanced_search",
params=params,
timeout=15,
)
if resp.status_code == 429:
wait = 60 * (2 ** attempt)
log.warning("Rate limited. Retrying in %ss.", wait)
time.sleep(wait)
continue
resp.raise_for_status()
return resp.json()
except requests.RequestException as exc:
log.error("Request error (attempt %d/%d): %s", attempt + 1, retries, exc)
if attempt < retries - 1:
time.sleep(5 * (2 ** attempt))
return {"tweets": [], "next_cursor": ""}
# ---------- collect all new tweets ----------
def fetch_new_tweets(query: str, hwm_id: str) -> tuple[list, str]:
"""
Walk pages newest-first, collecting tweets with ID > hwm_id.
Returns (new_tweets_oldest_first, new_hwm_id).
"""
new_tweets: list = []
new_hwm = hwm_id
cursor = ""
for page_num in range(MAX_CATCHUP_PAGES):
data = search_page(query, cursor=cursor)
tweets = data.get("tweets", [])
caught_up = False
for tweet in tweets:
tid = tweet["id"]
if hwm_id and int(tid) <= int(hwm_id):
# Reached a tweet we already processed
caught_up = True
break
new_tweets.append(tweet)
if not new_hwm or int(tid) > int(new_hwm):
new_hwm = tid
if caught_up or not data.get("next_cursor") or not data.get("tweets"):
break
cursor = data["next_cursor"]
log.debug("Page %d done. Fetching next page.", page_num + 1)
# Reverse so we process oldest-first
new_tweets.reverse()
return new_tweets, new_hwm
# ---------- delivery ----------
WEBHOOK_URL = os.environ.get("WEBHOOK_URL") # optional
def deliver(tweet: dict) -> None:
handle = tweet["author"]["username"]
url = f"https://x.com/{handle}/status/{tweet['id']}"
log.info("NEW @%s: %s", handle, tweet["text"][:100])
log.info(" %s", url)
if WEBHOOK_URL:
payload = {
"text": f"@{handle}: {tweet['text']}\n{url}",
"tweet_id": tweet["id"],
"author": handle,
}
try:
requests.post(WEBHOOK_URL, json=payload, timeout=5)
except requests.RequestException as exc:
log.warning("Webhook failed: %s", exc)
# ---------- poll loop ----------
def poll_once(hwm: dict) -> dict:
hwm_id = hwm.get(QUERY, "")
new_tweets, new_hwm_id = fetch_new_tweets(QUERY, hwm_id)
for tweet in new_tweets:
deliver(tweet)
log.info(
"Poll done. %d new tweet(s). HWM: %s",
len(new_tweets),
new_hwm_id or "(none yet)",
)
if new_hwm_id:
hwm[QUERY] = new_hwm_id
return hwm
def main() -> None:
hwm = load_hwm()
log.info(
"Keyword stream started. Query: %r. Interval: %ds.",
QUERY,
POLL_INTERVAL_SECONDS,
)
while True:
hwm = poll_once(hwm)
save_hwm(hwm)
time.sleep(POLL_INTERVAL_SECONDS)
if __name__ == "__main__":
main()Install the only dependency:
pip install requestsA TypeScript/Node polling loop. Runs in Bun or Node 18+ with no additional dependencies.
// keyword-stream.ts
// Usage:
// TWITTERAPIS_KEY=your_key bun keyword-stream.ts
// TWITTERAPIS_KEY=your_key npx tsx keyword-stream.ts
//
// Cost: $0.0008 per call.
import { writeFileSync, readFileSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
// ---------- config ----------
const API_KEY = process.env.TWITTERAPIS_KEY;
if (!API_KEY) throw new Error("Set TWITTERAPIS_KEY environment variable.");
const BASE_URL = "https://api.twitterapis.com/twitter";
const QUERY = "#openai lang:en -is:retweet";
const POLL_INTERVAL_MS = 30_000; // 30 seconds
const MAX_CATCHUP_PAGES = 10;
const HWM_FILE = join(homedir(), ".kwstream-hwm.json");
const WEBHOOK_URL = process.env.WEBHOOK_URL; // optional
// ---------- types ----------
interface Tweet {
id: string;
text: string;
author: { id: string; username: string; name: string };
favorite_count?: number;
retweet_count?: number;
}
interface SearchResponse {
tweets: Tweet[];
next_cursor: string;
}
// ---------- high-water mark helpers ----------
function loadHwm(): Record<string, string> {
if (existsSync(HWM_FILE)) {
return JSON.parse(readFileSync(HWM_FILE, "utf8"));
}
return {};
}
function saveHwm(hwm: Record<string, string>): void {
writeFileSync(HWM_FILE, JSON.stringify(hwm));
}
// ---------- API call with backoff ----------
async function searchPage(
query: string,
cursor = "",
attempt = 0
): Promise<SearchResponse> {
const url = new URL(`${BASE_URL}/tweet/advanced_search`);
url.searchParams.set("query", query);
url.searchParams.set("product", "Latest");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
if (res.status === 429) {
const wait = 60_000 * Math.pow(2, attempt);
console.warn(`Rate limited. Retrying in ${wait / 1000}s.`);
await sleep(wait);
return searchPage(query, cursor, attempt + 1);
}
if (!res.ok) {
const body = await res.text();
throw new Error(`Search failed ${res.status}: ${body}`);
}
return res.json() as Promise<SearchResponse>;
}
// ---------- collect all new tweets ----------
async function fetchNewTweets(
query: string,
hwmId: string
): Promise<{ tweets: Tweet[]; newHwmId: string }> {
const collected: Tweet[] = [];
let newHwmId = hwmId;
let cursor = "";
for (let page = 0; page < MAX_CATCHUP_PAGES; page++) {
const data = await searchPage(query, cursor);
let caughtUp = false;
for (const tweet of data.tweets) {
if (hwmId && BigInt(tweet.id) <= BigInt(hwmId)) {
caughtUp = true;
break;
}
collected.push(tweet);
if (!newHwmId || BigInt(tweet.id) > BigInt(newHwmId)) {
newHwmId = tweet.id;
}
}
if (caughtUp || !data.next_cursor || data.tweets.length === 0) break;
cursor = data.next_cursor;
console.log(`Page ${page + 1} done, fetching next.`);
}
// Reverse so oldest-first
collected.reverse();
return { tweets: collected, newHwmId };
}
// ---------- delivery ----------
async function deliver(tweet: Tweet): Promise<void> {
const tweetUrl = `https://x.com/${tweet.author.username}/status/${tweet.id}`;
console.log(`NEW @${tweet.author.username}: ${tweet.text.slice(0, 100)}`);
console.log(` ${tweetUrl}`);
if (WEBHOOK_URL) {
await fetch(WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `@${tweet.author.username}: ${tweet.text}\n${tweetUrl}`,
tweet_id: tweet.id,
author: tweet.author.username,
}),
}).catch((err) => console.warn("Webhook failed:", err));
}
}
// ---------- poll loop ----------
async function pollOnce(hwm: Record<string, string>): Promise<void> {
const hwmId = hwm[QUERY] ?? "";
let tweets: Tweet[];
let newHwmId: string;
try {
({ tweets, newHwmId } = await fetchNewTweets(QUERY, hwmId));
} catch (err) {
console.error("Fetch error:", err);
return;
}
for (const tweet of tweets) {
await deliver(tweet);
}
console.log(
`[${new Date().toISOString()}] Poll done. ${tweets.length} new. HWM: ${newHwmId || "(none yet)"}.`
);
if (newHwmId) hwm[QUERY] = newHwmId;
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// ---------- main ----------
async function main(): Promise<void> {
const hwm = loadHwm();
console.log(`Keyword stream started. Query: ${JSON.stringify(QUERY)}. Interval: ${POLL_INTERVAL_MS / 1000}s.`);
while (true) {
await pollOnce(hwm);
saveHwm(hwm);
await sleep(POLL_INTERVAL_MS);
}
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});No dependencies needed for Bun. For Node 18+, fetch is available natively. If you need TypeScript support without Bun:
npm install --save-dev tsx typescript
npx tsx keyword-stream.tsCost math
| Poll interval | Calls/day | Cost/day | Cost/month |
|---|---|---|---|
| 10 seconds | 8,640 | $6.91 | $207 |
| 30 seconds | 2,880 | $2.30 | $69 |
| 1 minute | 1,440 | $1.15 | $35 |
| 5 minutes | 288 | $0.23 | $6.91 |
| 15 minutes | 96 | $0.077 | $2.30 |
| 30 minutes | 48 | $0.038 | $1.15 |
Each extra catch-up page (when the keyword spikes between polls) costs another $0.0008. The MAX_CATCHUP_PAGES cap bounds runaway spend on a viral keyword.
Right-size your interval
A 30-second poll gives you near-real-time coverage for $2.30/day. For most analytics or alerting use cases, 5 minutes is enough and costs 10x less. Only go below 30 seconds if you are building a genuinely time-sensitive product.
Handling ID comparison correctly
Tweet IDs exceed JavaScript's safe integer range (Number.MAX_SAFE_INTEGER is 2^53). Always compare them as BigInt in Node, or as Python int (which has arbitrary precision). The samples above do this. Comparing as plain JS numbers will produce silent ordering bugs on large IDs.
// Correct
BigInt(tweet.id) > BigInt(hwmId)
// Wrong: may silently misorder large IDs
Number(tweet.id) > Number(hwmId)Error handling reference
| Status | Meaning | Action |
|---|---|---|
200 | OK | Process tweets |
400 | Bad query syntax | Fix the operator string and verify URL encoding |
401 | Invalid or missing API key | Check the Authorization header and TWITTERAPIS_KEY |
402 | Credit balance exhausted | Top up at twitterapis.com |
429 | Rate limited | Back off with exponential retry; the samples handle this automatically |
5xx | Server error | Retry with backoff; contact emma@twitterapis.com if it persists |
Tuning tips
Reduce noise with operators. Add -is:retweet and a min_faves: floor to your query. A noisy keyword like #ai returns hundreds of retweets per poll; filtering to originals with at least 5 likes cuts volume by 80-90% for most topics.
Multiple keywords. Run one poller per query with its own high-water mark key in the hwm file. Do not merge them into a single OR query unless the keywords are closely related, because a merged query makes the HWM logic ambiguous.
Webhook targets. Set WEBHOOK_URL to a Slack incoming webhook, a Discord webhook, or any HTTP endpoint that accepts a POST with {"text": "..."}. No code change needed.
Backfill. On the very first run, hwm_id is empty, so the loop returns only the current page (~20 tweets). To backfill a longer window, add since:YYYY-MM-DD to your query on the first run, then remove it after.
Next steps
Export an account's full follower list
Page through /twitter/user/followers_v2 with the cursor until exhausted, write results to CSV or JSON. Full cURL, Python, and Node samples with pagination, error handling, and cost math for a 100K-follower account.
Post and delete tweets with a session
Register a customer session once with your X credentials, then create and delete tweets programmatically. Full cURL, Python, and Node samples with cost math.