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_KEYenvironment variable set to your key.
How it works
- Build a search query for each keyword or handle you want to monitor (e.g.
"your brand" OR @yourbrand). - Call
/twitter/tweet/advanced_search?query=...&product=Latestto get the most recent matching tweets. - Filter the results to only tweet IDs you have not seen before.
- Store the new IDs so the next poll knows to skip them.
- 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:enUseful operators for brand monitoring:
| Operator | What it does |
|---|---|
"acme corp" | Exact phrase match |
@acmecorp | Mentions of a handle |
#acme | Hashtag |
-is:retweet | Exclude retweets |
lang:en | English tweets only |
min_faves:5 | Engagement floor (cuts noise) |
since:2026-01-01 | Date 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.")
PYEOFSchedule 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>&1A 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 requestsA 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-fetchCost math
| Setup | Calls/day | Cost/day | Cost/month |
|---|---|---|---|
| 1 keyword, poll every 30 min | 48 | $0.038 | $1.15 |
| 10 keywords, poll every 30 min | 480 | $0.38 | $11.57 |
| 50 keywords, poll every 30 min | 2,400 | $1.92 | $57.60 |
| 50 keywords, poll every 15 min | 4,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 resultsError handling reference
| Status | Meaning | Action |
|---|---|---|
200 | OK | Process tweets |
400 | Bad query syntax | Fix the operator string |
401 | Invalid or missing API key | Check TWITTERAPIS_KEY |
402 | Credit balance exhausted | Top up at twitterapis.com |
429 | Rate limited | Back off with exponential retry |
5xx | Server error | Retry with backoff; contact emma@twitterapis.com if persistent |
Next steps
Guides
End-to-end recipes for common Twitter/X data tasks. Each guide ships working cURL, Python, and Node samples with cost math included.
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.