Build a Social Listening Dashboard with One API (2026)
Watch lists, cron polling, dedupe, spike detection, and Slack alerts — snapshot brand mentions across TikTok, X, YouTube, and Reddit with one JSON schema.
Networks do not push a webhook when someone misspells your product on TikTok. Social listening is a scheduling problem: you define watch lists, poll search endpoints on a cron, store snapshots, dedupe by post ID, diff against yesterday, and only then chart volume or page someone.
This guide walks through that pipeline end to end — watch lists through Slack alerts — using a fictional brand Acme as the running example. Swap in your keywords, cadence, warehouse, and alert channels.
The short version
Define watch terms, GET each platform's /search route on a schedule, upsert rows keyed by platform + postId, compare today's net-new count 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:
CREATE TABLE watch_terms (
id SERIAL PRIMARY KEY,
label TEXT NOT NULL, -- 'brand', 'competitor', 'crisis'
platform TEXT NOT NULL, -- 'tiktok', 'twitter', 'youtube', 'reddit'
query TEXT NOT NULL,
poll_cron TEXT NOT NULL, -- '0 * * * *' or '*/15 * * * *'
enabled BOOLEAN DEFAULT true
);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).
const result = await client.twitter.search({
query: '"acme" OR acmeapp',
section: "latest",
language: "en",
minLikes: 5,
});YouTube and Reddit
YouTube keyword search mirrors TikTok — query, sortBy, uploadDate, cursor pagination:
curl -sS \
-H "x-api-key: $SOCIALFETCH_API_KEY" \
-G "https://api.socialfetch.dev/v1/youtube/search" \
--data-urlencode "query=acme review" \
--data-urlencode "sortBy=date" \
--data-urlencode "uploadDate=week"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.
For Instagram brand monitoring, combine keyword search with profile lookups when you know the official handle — search catches UGC and misspellings; profile routes give follower counts for context on reach. Threads search works for text-first brand mentions where TikTok video search would miss the conversation entirely.
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:
import { SocialFetchClient } from "@socialfetch/sdk";
const client = new SocialFetchClient({
apiKey: process.env.SOCIALFETCH_API_KEY!,
});
async function pollTwitter(query: string) {
const capturedAt = new Date().toISOString();
const result = await client.twitter.search({
query,
section: "latest",
language: "en",
});
if (!result.ok) {
console.error(result.error.code, result.error.requestId);
return null;
}
return {
capturedAt,
platform: "twitter" as const,
query,
requestId: result.value.meta.requestId,
creditsCharged: result.value.meta.creditsCharged,
items: result.value.data.tweets ?? [],
};
}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:
import { Client } from "@upstash/qstash";
const qstash = new Client({ token: process.env.QSTASH_TOKEN! });
for (const term of await db.watchTerms.where({ enabled: true })) {
await qstash.publishJSON({
url: "https://your-app.com/jobs/listening-poll",
body: { watchTermId: term.id },
// Stagger crisis terms every 5 min; brand terms hourly via cron on the publisher
delay: term.label === "crisis" ? 0 : undefined,
});
}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).
CREATE TABLE poll_runs (
id BIGSERIAL PRIMARY KEY,
watch_term_id INT REFERENCES watch_terms(id),
captured_at TIMESTAMPTZ NOT NULL,
request_id TEXT NOT NULL,
credits_charged INT NOT NULL,
raw_count INT NOT NULL
);
CREATE TABLE mentions (
id BIGSERIAL PRIMARY KEY,
platform TEXT NOT NULL,
post_id TEXT NOT NULL,
watch_term_id INT REFERENCES watch_terms(id),
query TEXT NOT NULL,
url TEXT,
author_handle TEXT,
text_snippet TEXT,
like_count INT,
published_at TIMESTAMPTZ,
first_seen_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
last_request_id TEXT NOT NULL,
UNIQUE (platform, post_id)
);
CREATE INDEX mentions_first_seen_idx ON mentions (first_seen_at);
CREATE INDEX mentions_platform_day_idx ON mentions (platform, first_seen_at);Append-only snapshot blob (optional) for replay debugging:
{
"capturedAt": "2026-06-30T09:00:00.000Z",
"platform": "tiktok",
"query": "acme",
"requestId": "req_01example",
"creditsCharged": 1,
"items": []
}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:
-- New mentions per platform, last 30 days
SELECT platform, first_seen_at::date AS day, COUNT(*) AS new_mentions
FROM mentions
WHERE first_seen_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY 1, 2
ORDER BY 2, 1;
-- Top posts by engagement this week (for a recap email)
SELECT platform, url, author_handle, text_snippet, like_count
FROM mentions
WHERE first_seen_at >= date_trunc('week', CURRENT_DATE)
ORDER BY like_count DESC NULLS LAST
LIMIT 20;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.
type MentionRow = {
platform: string;
postId: string;
url?: string;
authorHandle?: string;
text?: string;
likes?: number;
publishedAt?: string;
};
async function upsertMentions(
db: Db,
watchTermId: number,
query: string,
platform: string,
items: MentionRow[],
requestId: string,
capturedAt: string,
) {
for (const item of items) {
await db.query(
`INSERT INTO mentions (
platform, post_id, watch_term_id, query, url, author_handle,
text_snippet, like_count, published_at, first_seen_at, last_seen_at, last_request_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$10,$11)
ON CONFLICT (platform, post_id) DO UPDATE SET
last_seen_at = EXCLUDED.last_seen_at,
last_request_id = EXCLUDED.last_request_id,
like_count = COALESCE(EXCLUDED.like_count, mentions.like_count)`,
[
platform,
item.postId,
watchTermId,
query,
item.url,
item.authorHandle,
item.text?.slice(0, 500),
item.likes,
item.publishedAt,
capturedAt,
requestId,
],
);
}
}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.
-- Net-new mentions in the last hour for watch term 1
SELECT COUNT(*) AS new_last_hour
FROM mentions
WHERE watch_term_id = 1
AND first_seen_at >= NOW() - INTERVAL '1 hour';Baseline: seven-day median of hourly net-new counts, excluding the current hour:
WITH hourly AS (
SELECT date_trunc('hour', first_seen_at) AS bucket, COUNT(*) AS cnt
FROM mentions
WHERE watch_term_id = 1
AND first_seen_at >= NOW() - INTERVAL '7 days'
AND first_seen_at < date_trunc('hour', NOW())
GROUP BY 1
)
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY cnt) AS median_hourly
FROM hourly;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.
async function maybeAlertSlack(watchTerm: WatchTerm, newCount: number, median: number) {
const threshold = median * watchTerm.spikeMultiplier;
if (newCount < threshold || newCount < watchTerm.minAlertCount) return;
const fresh = await db.query<MentionRow>(
`SELECT url, text_snippet, platform, author_handle
FROM mentions
WHERE watch_term_id = $1
AND first_seen_at >= NOW() - INTERVAL '1 hour'
ORDER BY like_count DESC NULLS LAST
LIMIT 5`,
[watchTerm.id],
);
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `Spike on "${watchTerm.query}": ${newCount} new mentions (median ${median.toFixed(0)})`,
blocks: fresh.rows.map((m) => ({
type: "section",
text: {
type: "mrkdwn",
text: `*${m.platform}* @${m.author_handle ?? "unknown"}\n${m.text_snippet}\n${m.url}`,
},
})),
}),
});
}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:
const terms = await db.watchTerms.where({ enabled: true, dueNow: true });
for (const term of terms) {
const poll =
term.platform === "tiktok"
? await client.tiktok.search({ query: term.query, datePosted: "this-week" })
: term.platform === "twitter"
? await client.twitter.search({ query: term.query, section: "latest" })
: term.platform === "youtube"
? await client.youtube.search({ query: term.query, uploadDate: "week" })
: await client.reddit.search({ query: term.query, sortBy: "new", timeframe: "week" });
if (!poll.ok) continue;
const items = normalizeItems(term.platform, poll.value.data);
await upsertMentions(db, term.id, term.query, term.platform, items, poll.value.meta.requestId, new Date().toISOString());
await recordPollRun(db, term.id, poll.value.meta);
}
await runSpikeChecks(db);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 — no surprise overage invoices; balance is the guardrail.
- No expiry — run listening windows 1–3 times per month without wasting a subscription cycle.
- Honest 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