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:

  1. Run keyword searches on a timer — there is no firehose subscription for public TikTok or X posts.
  2. Store point-in-time JSON — metrics on a post change; your warehouse records what you saw at poll time.
  3. 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

LayerYou ownSocial Fetch provides
Watch listsBrand, competitor, crisis query strings grouped by cadence
CollectionCron schedule, concurrency, retry on 503/v1/{platform}/search routes
Storagementions, poll_runs tables with capturedAtmeta.requestId for support traces
DedupeUpsert on platform + postIdStable post IDs in JSON
Spike detectionBaseline median, multiplier threshold
AlertingSlack webhook, PagerDuty, email
DashboardMetabase, 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:

GroupExample queriesCadencePlatforms
Brandacme, "acme app", #acmeHourlyTikTok, X, YouTube, Reddit
Competitorrivalco, "switched from rivalco"DailyReddit, X
Crisisacme scam, acme lawsuit, acme downEvery 5–15 minX (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 @acmeofficial catches handle mentions and text.
  • Hashtag vs keyword — campaign tracking often uses /search/hashtags on TikTok and YouTube; brand misspellings need keyword search.
  • Date windows — TikTok datePosted, X startDate/endDate, Reddit timeframe keep 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.

Request
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:

ParameterValuesListening use
sortByrelevance, datedate for "what posted today" widgets
datePostedtoday, this-week, this-monthNarrow polls so hourly cron does not replay old viral hits
cursorfrom data.page.nextCursorPaginate until hasMore is false or credit budget stops you

Reference: TikTok search. Hashtag campaigns: TikTok hashtag search.

Request
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);
ParameterValuesListening use
sectiontop, latest, people, photos, videoslatest for crisis watches; top for weekly recap dashboards
languageISO code, e.g. enDrop non-target-locale noise before warehouse insert
minLikes, minRetweetsintegersSuppress low-engagement hits in brand dashboards
startDate, endDateYYYY-MM-DDBound incident windows

Reference: Twitter search2 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:

RunnerWhen to use
Vercel cron / system cronSingle-region worker, simple hourly brand poll
QStashRetries, delayed jobs, fan-out one message per watch term
Temporal / InngestLong-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:

PlatformPost ID sourceText source
TikTokvideo.idvideo.description
Xtweet.idtweet.text
YouTubevideo.idvideo.title
Redditpost.idpost.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

Example
typescript
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_at rows. Fix the SQL before tuning alert thresholds.
  • TikTok datePosted rolled forward at midnight UTC and resurfaced a different result set. Store query + filter params on poll_runs for 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_multiplier temporarily or switch brand terms to daily cadence while crisis terms stay at 5 minutes.
  • Add minLikes on X and minRetweets for 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.requestId from 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 hasMore is 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_runs metadata 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_failed and 503 are not. See Credits.

Rough daily math for planning:

SetupCalls/dayCredits (approx.)
3 terms × 4 platforms × hourly (X=2, others=1)288~360
5 crisis terms × X every 15 min480960
Weekly recap: 10 terms × 2 pages Reddit20 (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 latest search 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_at counts.
  • 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