TwitterAPIs Docs
Guides

Build a Twitter/X brand monitor

Poll advanced_search on an interval, dedupe by tweet ID, and surface net-new mentions. Full cURL, Python, and Node samples with cost math.

This guide builds a lightweight brand monitor that polls GET /twitter/tweet/advanced_search on a fixed interval, deduplicates results against a local seen-set, and surfaces only net-new mentions. No queue, no infrastructure: just a loop, a file, and an API key.

Cost example: monitoring 50 keywords, polling every 30 minutes, 1 page per poll = 50 calls x 48 polls/day = 2,400 calls/day = $1.92/day at $0.0008/call. If most keywords are quiet you can skip pages after the first new tweet, cutting that further.

What you need

  • A TwitterAPIs API key. Sign up at twitterapis.com to get $0.50 in free credits.
  • The TWITTERAPIS_KEY environment variable set to your key.

How it works

  1. Build a search query for each keyword or handle you want to monitor (e.g. "your brand" OR @yourbrand).
  2. Call /twitter/tweet/advanced_search?query=...&product=Latest to get the most recent matching tweets.
  3. Filter the results to only tweet IDs you have not seen before.
  4. Store the new IDs so the next poll knows to skip them.
  5. Repeat on a timer.

The deduplication key is the tweet id field, which is a stable 64-bit integer returned as a string. A Set (Python) or a flat text file (any language) is enough for hundreds of keywords.

The search query

Twitter/X operator syntax lets you combine conditions in one query string:

"acme corp" OR @acmecorp OR #acme -is:retweet lang:en

Useful operators for brand monitoring:

OperatorWhat it does
"acme corp"Exact phrase match
@acmecorpMentions of a handle
#acmeHashtag
-is:retweetExclude retweets
lang:enEnglish tweets only
min_faves:5Engagement floor (cuts noise)
since:2026-01-01Date floor (use for backfill)

Keep the query under ~512 characters. The API returns up to roughly 20 tweets per call, ordered by recency when product=Latest.

Full samples

A single poll for one keyword. Run this in a cron job or shell loop.

#!/usr/bin/env bash
# brand-monitor.sh
# Usage: ./brand-monitor.sh "acme corp" OR @acmecorp

set -euo pipefail

QUERY="${1:-acme corp}"
SEEN_FILE="${HOME}/.brand-monitor-seen.txt"
touch "$SEEN_FILE"

# URL-encode the query
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$QUERY")

RESPONSE=$(curl -sf \
  "https://api.twitterapis.com/twitter/tweet/advanced_search?query=${ENCODED}&product=Latest" \
  -H "Authorization: Bearer ${TWITTERAPIS_KEY}")

# Extract tweet IDs and text
echo "$RESPONSE" | python3 - <<'PYEOF'
import json, sys

data = json.load(sys.stdin)
seen_file = os.path.expanduser("~/.brand-monitor-seen.txt")

import os
with open(seen_file) as f:
    seen = set(f.read().splitlines())

new_tweets = []
new_ids = []

for tweet in data.get("tweets", []):
    tid = tweet["id"]
    if tid not in seen:
        new_tweets.append(tweet)
        new_ids.append(tid)

if new_tweets:
    print(f"--- {len(new_tweets)} new mention(s) ---")
    for t in new_tweets:
        print(f"@{t['author']['username']}: {t['text'][:120]}")
        print(f"  https://x.com/{t['author']['username']}/status/{t['id']}")
        print()
    with open(seen_file, "a") as f:
        f.write("\n".join(new_ids) + "\n")
else:
    print("No new mentions.")
PYEOF

Schedule with cron to run every 30 minutes:

*/30 * * * * TWITTERAPIS_KEY=your_key /path/to/brand-monitor.sh '"acme corp" OR @acmecorp' >> /var/log/brand-monitor.log 2>&1

A self-contained polling loop covering multiple keywords with deduplication, exponential backoff on errors, and optional webhook delivery.

#!/usr/bin/env python3
"""
brand_monitor.py  --  TwitterAPIs brand monitor

Usage:
    export TWITTERAPIS_KEY="your_key_here"
    python3 brand_monitor.py

Polls every POLL_INTERVAL_SECONDS. New mentions are printed to stdout
and optionally POSTed to WEBHOOK_URL.
"""

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"

KEYWORDS = [
    '"acme corp" OR @acmecorp -is:retweet lang:en',
    '#acme min_faves:5 -is:retweet',
]

POLL_INTERVAL_SECONDS = 30 * 60   # 30 minutes
SEEN_FILE = Path.home() / ".brand-monitor-seen.json"
WEBHOOK_URL = os.environ.get("WEBHOOK_URL")  # optional

# Cost note: len(KEYWORDS) calls per poll.
# At $0.0008/call and 48 polls/day:
#   2 keywords x 48 = 96 calls/day = $0.077/day

# ---------- setup ----------
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("brand-monitor")

session = requests.Session()
session.headers.update({"Authorization": f"Bearer {API_KEY}"})


def load_seen() -> set:
    if SEEN_FILE.exists():
        return set(json.loads(SEEN_FILE.read_text()))
    return set()


def save_seen(seen: set) -> None:
    SEEN_FILE.write_text(json.dumps(list(seen)))


def search(query: str, retries: int = 3) -> list:
    """Fetch the first page of latest tweets matching query."""
    for attempt in range(retries):
        try:
            resp = session.get(
                f"{BASE_URL}/tweet/advanced_search",
                params={"query": query, "product": "Latest"},
                timeout=15,
            )
            if resp.status_code == 429:
                wait = 60 * (2 ** attempt)
                log.warning("Rate limited. Waiting %ss before retry.", wait)
                time.sleep(wait)
                continue
            resp.raise_for_status()
            return resp.json().get("tweets", [])
        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 []


def deliver(tweet: dict) -> None:
    """Print and optionally webhook new mentions."""
    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"New mention from @{handle}:\n{tweet['text']}\n{url}",
            "tweet_id": tweet["id"],
        }
        try:
            requests.post(WEBHOOK_URL, json=payload, timeout=5)
        except requests.RequestException as exc:
            log.warning("Webhook delivery failed: %s", exc)


def poll_once(seen: set) -> set:
    new_ids: list = []
    for query in KEYWORDS:
        tweets = search(query)
        for tweet in tweets:
            tid = tweet["id"]
            if tid not in seen:
                deliver(tweet)
                new_ids.append(tid)
    seen.update(new_ids)
    log.info("Poll complete. %d new mention(s). Seen set size: %d", len(new_ids), len(seen))
    return seen


def main() -> None:
    seen = load_seen()
    log.info("Starting brand monitor. %d keyword group(s). Poll interval: %ds.",
             len(KEYWORDS), POLL_INTERVAL_SECONDS)
    while True:
        seen = poll_once(seen)
        save_seen(seen)
        time.sleep(POLL_INTERVAL_SECONDS)


if __name__ == "__main__":
    main()

Install the only dependency:

pip install requests

A TypeScript/Node polling loop. Works in Bun or Node 18+.

// brand-monitor.ts
// Usage:
//   TWITTERAPIS_KEY=your_key bun brand-monitor.ts
//   TWITTERAPIS_KEY=your_key npx tsx brand-monitor.ts

import { writeFileSync, readFileSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

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 SEEN_FILE = join(homedir(), ".brand-monitor-seen.json");
const POLL_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
const WEBHOOK_URL = process.env.WEBHOOK_URL; // optional

// Cost: $0.0008 per call. Two keywords every 30 min = $0.077/day.

const KEYWORDS = [
  '"acme corp" OR @acmecorp -is:retweet lang:en',
  "#acme min_faves:5 -is:retweet",
];

// ---- seen-set helpers ----
function loadSeen(): Set<string> {
  if (existsSync(SEEN_FILE)) {
    return new Set(JSON.parse(readFileSync(SEEN_FILE, "utf8")));
  }
  return new Set();
}

function saveSeen(seen: Set<string>): void {
  writeFileSync(SEEN_FILE, JSON.stringify([...seen]));
}

// ---- API call with backoff ----
interface Tweet {
  id: string;
  text: string;
  author: { username: string; id: string; name: string };
  favorite_count?: number;
}

interface SearchResponse {
  tweets: Tweet[];
  next_cursor: string;
}

async function search(query: string, attempt = 0): Promise<Tweet[]> {
  const url = new URL(`${BASE_URL}/tweet/advanced_search`);
  url.searchParams.set("query", query);
  url.searchParams.set("product", "Latest");

  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. Waiting ${wait / 1000}s.`);
    await sleep(wait);
    return search(query, attempt + 1);
  }

  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Search failed ${res.status}: ${body}`);
  }

  const data: SearchResponse = await res.json();
  return data.tweets ?? [];
}

// ---- delivery ----
async function deliver(tweet: Tweet): Promise<void> {
  const url = `https://x.com/${tweet.author.username}/status/${tweet.id}`;
  console.log(`NEW @${tweet.author.username}: ${tweet.text.slice(0, 100)}`);
  console.log(`    ${url}`);

  if (WEBHOOK_URL) {
    await fetch(WEBHOOK_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text: `New mention from @${tweet.author.username}:\n${tweet.text}\n${url}`,
        tweet_id: tweet.id,
      }),
    }).catch((err) => console.warn("Webhook failed:", err));
  }
}

// ---- poll ----
async function pollOnce(seen: Set<string>): Promise<void> {
  let newCount = 0;
  for (const query of KEYWORDS) {
    let tweets: Tweet[];
    try {
      tweets = await search(query);
    } catch (err) {
      console.error(`Search error for query "${query}":`, err);
      continue;
    }
    for (const tweet of tweets) {
      if (!seen.has(tweet.id)) {
        await deliver(tweet);
        seen.add(tweet.id);
        newCount++;
      }
    }
  }
  console.log(
    `[${new Date().toISOString()}] Poll done. ${newCount} new. Seen: ${seen.size}.`
  );
}

function sleep(ms: number): Promise<void> {
  return new Promise((r) => setTimeout(r, ms));
}

// ---- main ----
async function main(): Promise<void> {
  const seen = loadSeen();
  console.log(`Brand monitor started. ${KEYWORDS.length} keyword group(s). Interval: ${POLL_INTERVAL_MS / 60_000}min.`);

  while (true) {
    await pollOnce(seen);
    saveSeen(seen);
    await sleep(POLL_INTERVAL_MS);
  }
}

main().catch((err) => {
  console.error("Fatal:", err);
  process.exit(1);
});

No dependencies beyond the runtime. For Node 18+ you may need to install a fetch polyfill if it is not available globally:

npm install node-fetch

Cost math

SetupCalls/dayCost/dayCost/month
1 keyword, poll every 30 min48$0.038$1.15
10 keywords, poll every 30 min480$0.38$11.57
50 keywords, poll every 30 min2,400$1.92$57.60
50 keywords, poll every 15 min4,800$3.84$115.20

Each call returns up to 20 tweets. If a keyword is active and you need to catch up you can paginate through additional pages by following next_cursor, but each extra page costs another $0.0008. For most brand-monitoring use cases the first page is enough.

Cut cost with smarter polling

If the tweets array came back empty, or you got fewer than 5 new tweets, the keyword is quiet and you can skip it on the next cycle or extend its interval dynamically.

Pagination (optional catch-up)

If a keyword gets a burst of activity between polls, paginate until you reach a tweet ID you have already seen:

def search_all_new(query: str, seen: set) -> list:
    """Fetch pages until we hit a known tweet ID or run out of pages."""
    results = []
    cursor = ""
    while True:
        params = {"query": query, "product": "Latest"}
        if cursor:
            params["cursor"] = cursor
        resp = session.get(f"{BASE_URL}/tweet/advanced_search", params=params, timeout=15)
        resp.raise_for_status()
        data = resp.json()
        page_done = False
        for tweet in data.get("tweets", []):
            if tweet["id"] in seen:
                page_done = True
                break
            results.append(tweet)
        if page_done or not data.get("next_cursor") or not data.get("tweets"):
            break
        cursor = data["next_cursor"]
    return results

Error handling reference

StatusMeaningAction
200OKProcess tweets
400Bad query syntaxFix the operator string
401Invalid or missing API keyCheck TWITTERAPIS_KEY
402Credit balance exhaustedTop up at twitterapis.com
429Rate limitedBack off with exponential retry
5xxServer errorRetry with backoff; contact emma@twitterapis.com if persistent

Next steps

On this page