Broad, multi-term brand listening is a scheduling problem: define watch lists, poll search endpoints on a cron, store snapshots, dedupe by post ID, diff against yesterday, then chart volume or page someone.
Only watching a handful of fixed accounts or terms? Monitors runs the poll-diff-alert loop for you and delivers a signed webhook — no cron job, no snapshot table. This guide covers the harder case: many watch terms and custom spike-detection thresholds in your own warehouse.
Using fictional brand Acme: define watch terms, GET each platform's /search route on a schedule, upsert rows keyed by platform + postId, compare net-new counts to a baseline, and alert on spikes. One API key, one { data, meta } envelope.
You'll need an API key, a job runner (cron, QStash, Temporal, GitHub Actions), and a database (Postgres is used in the examples below). Prototype searches in the Playground.
Why listening is a pull problem
Brand monitoring tools often sell "real-time" dashboards. In practice, most teams:
- Run keyword searches on a timer — there is no firehose subscription for public TikTok or X posts.
- Store point-in-time JSON — metrics on a post change; your warehouse records what you saw at poll time.
- Alert on diffs — a spike is "40% more net-new mentions than the seven-day median," not "search returned 47 items."
Social Fetch handles step 1 with normalized search routes. Steps 2–4 live in your stack. The same envelope (data + meta.requestId + meta.creditsCharged) means one ingestion function with a platform column instead of four scraper codepaths.
Architecture at a glance
| Layer | You own | Social Fetch provides |
|---|---|---|
| Watch lists | Brand, competitor, crisis query strings grouped by cadence | — |
| Collection | Cron schedule, concurrency, retry on 503 | /v1/{platform}/search routes |
| Storage | mentions, poll_runs tables with capturedAt | meta.requestId for support traces |
| Dedupe | Upsert on platform + postId | Stable post IDs in JSON |
| Spike detection | Baseline median, multiplier threshold | — |
| Alerting | Slack webhook, PagerDuty, email | — |
| Dashboard | Metabase, Grafana, Looker, Sheets | — |
Filter noise before you store: language=en on X, datePosted=this-week on TikTok, minLikes to drop drive-by mentions. Post-filter in SQL on author handle blocklists if needed.
Step 1: Define watch lists
Write watch terms before writing cron code. A typical B2C brand might run three groups:
| Group | Example queries | Cadence | Platforms |
|---|---|---|---|
| Brand | acme, "acme app", #acme | Hourly | TikTok, X, YouTube, Reddit |
| Competitor | rivalco, "switched from rivalco" | Daily | Reddit, X |
| Crisis | acme scam, acme lawsuit, acme down | Every 5–15 min | X (section=latest) |
Query tactics that reduce false positives:
- Quote exact phrases on X —
"acme widget"avoids matching unrelated "acme" (the generic word). - OR crisis variants —
"acme" OR acmeapp OR @acmeofficialcatches handle mentions and text. - Hashtag vs keyword — campaign tracking often uses
/search/hashtagson TikTok and YouTube; brand misspellings need keyword search. - Date windows — TikTok
datePosted, XstartDate/endDate, Reddittimeframekeep polls bounded so you are not re-billing the same historical page every hour.
Store watch lists in your database, not hard-coded in the worker:
Set spike multipliers per group in config — crisis terms might alert at 2× baseline; brand terms at 4× to avoid noise.
Maintain a blocklist table for owned accounts (@acmeofficial, your CEO's handle) and noisy domains so brand polls do not inflate mention counts with your own marketing posts. Apply the blocklist in SQL after upsert, not in the API query — you still want audit rows in poll_runs even when every hit is filtered out client-side.
Step 2: Poll each platform
Each platform exposes GET /v1/{platform}/search (plus hashtag variants). Responses share data.lookupStatus, cursor pagination via data.page.nextCursor, and meta.requestId. Map the items array to a common mention shape in your worker.
TikTok keyword search
curl -sS \
-H "x-api-key: $SOCIALFETCH_API_KEY" \
-G "https://api.socialfetch.dev/v1/tiktok/search" \
--data-urlencode "query=ai agents"Parameters worth tuning for listening:
| Parameter | Values | Listening use |
|---|---|---|
sortBy | relevance, date | date for "what posted today" widgets |
datePosted | today, this-week, this-month | Narrow polls so hourly cron does not replay old viral hits |
cursor | from data.page.nextCursor | Paginate until hasMore is false or credit budget stops you |
Reference: TikTok search. Hashtag campaigns: TikTok hashtag search.
X (Twitter) search
const params = new URLSearchParams({"query":"social listening"});
const response = await fetch(
`https://api.socialfetch.dev/v1/twitter/search?${params.toString()}`,
{
headers: {
"x-api-key": process.env.SOCIALFETCH_API_KEY,
},
}
);
const body = await response.json();
console.log(response.status, body);| Parameter | Values | Listening use |
|---|---|---|
section | top, latest, people, photos, videos | latest for crisis watches; top for weekly recap dashboards |
language | ISO code, e.g. en | Drop non-target-locale noise before warehouse insert |
minLikes, minRetweets | integers | Suppress low-engagement hits in brand dashboards |
startDate, endDate | YYYY-MM-DD | Bound incident windows |
Reference: Twitter search — 2 credits per successful request (most other platform search routes are 1).
YouTube and Reddit
YouTube keyword search mirrors TikTok — query, sortBy, uploadDate, cursor pagination:
Reference: YouTube search. Campaign hashtags: YouTube hashtag search.
Reddit catches early honest signal — complaints in niche subreddits often appear before a story trends on X. Use global GET /v1/reddit/search for brand strings; see the Reddit research guide for subreddit-scoped passes and comment pulls.
Instagram, Threads, LinkedIn, and Web search follow the same pattern under /v1/{platform}/search in the API reference.
Step 3: Schedule with cron or QStash
There is no platform push stream — your worker GETs on a schedule. Common patterns:
| Runner | When to use |
|---|---|
| Vercel cron / system cron | Single-region worker, simple hourly brand poll |
| QStash | Retries, delayed jobs, fan-out one message per watch term |
| Temporal / Inngest | Long-running workflows with per-platform retry policies |
Budget credits before enabling 5-minute crisis polls: platforms × keywords × polls_per_day × credits_per_call. Example: 4 platforms × 3 brand terms × 24 hourly polls × ~1.5 avg credits ≈ 432 credits/day (X at 2 credits pulls the average up).
Wrap each poll with capturedAt and persist meta.requestId:
Retry lookup_failed and HTTP 503 with exponential backoff — those responses are not charged. Do not retry validation errors.
QStash fits bursty listening well — publish one message per watch term so a slow Reddit page does not block your X crisis poll:
Your HTTP handler loads the watch term, runs the platform-specific search, upserts mentions, and returns 200 so QStash does not retry a successful poll.
Step 4: Storage schema
Split poll runs (audit log) from mentions (deduped facts).
Append-only snapshot blob (optional) for replay debugging:
Chart COUNT(*) FILTER (WHERE first_seen_at::date = ...) grouped by platform for "new mentions per day." Use last_seen_at to show posts still appearing in search results.
Dashboard queries your BI tool can run directly on mentions:
Pipe text_snippet from high-engagement rows into an LLM summary for weekly exec digests — always attach url and requestId as citations.
Step 5: Dedupe mentions
The same post surfaces under multiple queries ("acme" and "acme app"). Dedupe on platform + postId before counting.
Field mapping per platform:
| Platform | Post ID source | Text source |
|---|---|---|
| TikTok | video.id | video.description |
| X | tweet.id | tweet.text |
| YouTube | video.id | video.title |
post.id | post.title |
Cross-posts (same text, different IDs) are rare in listening workloads. If you need one row per discussion, normalize on canonical url instead.
Re-running the same cursor chain twice bills twice — treat cursors as single-use pagination tokens within one poll run.
Step 6: Spike detection
Do not alert on raw items.length from a single API response. Platform ranking changes move that number without a real story.
Better signal: net-new mentions per watch term per rolling window.
Baseline: seven-day median of hourly net-new counts, excluding the current hour:
Fire when new_last_hour > median_hourly * spike_multiplier (e.g. 3× for brand, 2× for crisis). Optionally add a floor — AND new_last_hour >= 10 — so a jump from 1 to 4 does not page anyone.
Velocity alerts: for X crisis watches, flag individual posts where like_count or retweet_count jumped more than 50% since last_seen_at on the previous poll. That catches a single viral complaint before hourly aggregates move.
Step 7: Route alerts
Send net-new high-signal items, not the full poll payload.
Include requestId in internal alert metadata so support can trace the exact lookup if a URL looks wrong.
After spikes, pull comment threads on the top post for sentiment — see the sentiment guide. Captions alone miss most brand damage in replies.
Full multi-platform job
import { SocialFetchClient } from "@socialfetch/sdk";
const client = new SocialFetchClient({
apiKey: process.env.SOCIALFETCH_API_KEY!,
});
const brand = "acme";
const capturedAt = new Date().toISOString();
const [tiktok, twitter, youtube, reddit] = await Promise.all([
client.tiktok.search({ query: brand, sortBy: "date", datePosted: "this-week" }),
client.twitter.search({ query: `"${brand}" OR ${brand}app`, section: "latest", language: "en" }),
client.youtube.search({ query: `${brand} review`, sortBy: "date", uploadDate: "week" }),
client.reddit.search({ query: brand, sortBy: "new", timeframe: "week" }),
]);
type Mention = { platform: string; postId: string; url?: string; text?: string };
function normalize(platform: string, data: Record<string, unknown>): Mention[] {
if (platform === "tiktok") {
return ((data.videos as { id: string; url?: string; description?: string }[]) ?? []).map((v) => ({
platform, postId: v.id, url: v.url, text: v.description,
}));
}
if (platform === "twitter") {
return ((data.tweets as { id: string; url?: string; text?: string }[]) ?? []).map((t) => ({
platform, postId: t.id, url: t.url, text: t.text,
}));
}
if (platform === "youtube") {
return ((data.videos as { id: string; url?: string; title?: string }[]) ?? []).map((v) => ({
platform, postId: v.id, url: v.url, text: v.title,
}));
}
return ((data.posts as { id: string; url?: string; title?: string }[]) ?? []).map((p) => ({
platform: "reddit", postId: p.id, url: p.url, text: p.title,
}));
}
const mentions: Mention[] = [];
const pollMeta: { platform: string; requestId: string; credits: number; count: number }[] = [];
for (const [platform, result] of [
["tiktok", tiktok],
["twitter", twitter],
["youtube", youtube],
["reddit", reddit],
] as const) {
if (!result.ok) {
console.error(platform, result.error.code, result.error.requestId);
continue;
}
const items = normalize(platform, result.value.data);
mentions.push(...items);
pollMeta.push({
platform,
requestId: result.value.meta.requestId,
credits: result.value.meta.creditsCharged,
count: items.length,
});
}
// Upsert mentions by platform + postId; compare first_seen_at counts for spike alerts.
console.log({ capturedAt, mentions: mentions.length, pollMeta });Extend the preset: loop watch terms from your DB, map each platform to the right SDK method, upsert mentions, record poll_runs, then run spike SQL. Pseudocode for the outer loop:
Run platforms in parallel with Promise.all per term group if credit balance allows — stay under ~500 concurrent requests.
Troubleshooting
Chart shows a spike but nothing looks new
- You counted total poll results instead of net-new
first_seen_atrows. Fix the SQL before tuning alert thresholds. - TikTok
datePostedrolled forward at midnight UTC and resurfaced a different result set. Storequery+ filter params onpoll_runsfor comparison.
Duplicate rows in the dashboard
- Same post matched two queries. Expected — dedupe in charts with
COUNT(DISTINCT post_id)or attribute mentions to a primary watch term.
Alert fatigue on launch day
- Raise
spike_multipliertemporarily or switch brand terms to daily cadence while crisis terms stay at 5 minutes. - Add
minLikeson X andminRetweetsfor non-crisis groups.
Empty results but the post exists in a browser
- Search indexes lag behind profile URLs. Try a direct post lookup route if you have the URL.
- Query too broad or too narrow — test variants in the Playground before adding to cron.
lookup_failed or HTTP 503
- Not charged. Retry with backoff; log
meta.requestIdfrom the failed attempt.
Credits burned faster than expected
- Pagination loops billing per page. For hourly brand polls, often one page per term is enough — stop when
hasMoreis false or you have 50 items. - X costs 2 credits per search call; factor that into multi-platform budgets.
- Storing full raw JSON blobs in S3 is cheap; re-fetching the same paginated cursor chain because you lost
poll_runsmetadata is not.
Credits and scheduling
- Prepaid credits — prepaid refill packs, no metered overage invoices; balance is the guardrail.
- No expiry — run listening windows 1–3 times per month without wasting a subscription cycle.
- Billing — completed searches that return empty arrays are still charged;
lookup_failedand503are not. See Credits.
Rough daily math for planning:
| Setup | Calls/day | Credits (approx.) |
|---|---|---|
| 3 terms × 4 platforms × hourly (X=2, others=1) | 288 | ~360 |
| 5 crisis terms × X every 15 min | 480 | 960 |
| Weekly recap: 10 terms × 2 pages Reddit | 20 (once) | 20 |
Public data only — you remain responsible for lawful use under Terms.
What you can build
- Brand pulse dashboard — multi-platform net-new mention volume with week-over-week deltas in Metabase or Grafana.
- Crisis monitor — Slack alert when X
latestsearch net-new count exceeds 3× the seven-day median. - Campaign wrap-up — hashtag search across TikTok and YouTube in one export, deduped by post ID.
- Competitor share-of-voice — same watch-list machinery with competitor query group; chart relative
first_seen_atcounts. - Sentiment handoff — comment pulls on posts that breached velocity thresholds, fed to your classifier.
Next steps: Playground · API reference · Social media monitoring use case · Pricing