TwitterAPIs Docs
Guides

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 pollingX API v2 filtered stream
SetupOne API key, one HTTP callDeveloper account approval + OAuth 2.0 app setup
Cost (1 keyword, 30 s interval)~$2.88/dayFree tier capped at 500K tweets/month; $5,000+/month for production volumes
ReliabilityStateless, resumable anywherePersistent TCP connection, reconnect logic required
Historical backfillWalk next_cursor back in timeStream only; no backfill
Write/private actionsSame API keySeparate 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:

  1. Call /twitter/tweet/advanced_search?query=...&product=Latest.
  2. Collect tweets with ID greater than your stored high-water mark.
  3. Update the high-water mark to the largest ID you received.
  4. 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:retweet

Useful operators:

OperatorWhat it does
"exact phrase"Literal phrase match
#hashtagHashtag
@handleMentions of a handle
lang:enEnglish only
-is:retweetDrop retweets
min_faves:10Engagement floor (reduces noise)
since:2026-01-01Date 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.")
PYEOF

Run in a shell loop every 30 seconds:

while true; do
  ./keyword-stream.sh '"#openai" lang:en -is:retweet'
  sleep 30
done

Or 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>&1

A 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 requests

A 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.ts

Cost math

Poll intervalCalls/dayCost/dayCost/month
10 seconds8,640$6.91$207
30 seconds2,880$2.30$69
1 minute1,440$1.15$35
5 minutes288$0.23$6.91
15 minutes96$0.077$2.30
30 minutes48$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

StatusMeaningAction
200OKProcess tweets
400Bad query syntaxFix the operator string and verify URL encoding
401Invalid or missing API keyCheck the Authorization header and TWITTERAPIS_KEY
402Credit balance exhaustedTop up at twitterapis.com
429Rate limitedBack off with exponential retry; the samples handle this automatically
5xxServer errorRetry 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

On this page