Back to Blog
General

Building a World Cup Social Listening Dashboard (Without the Enterprise Price Tag)

June 19, 2026
11 min read
S
By SociaVault Team
world cupsocial media datasocial listeningdashboarddata engineering

Building a World Cup Social Listening Dashboard (Without the Enterprise Price Tag)

A marketing lead at a mid-sized agency gets the brief two weeks before the tournament: the client wants a live World Cup social listening dashboard. Buzz by team, sentiment swings, trending clips, the works. So she does what anyone does and requests quotes from the big enterprise listening platforms. The numbers come back with a comma in places she wasn't expecting, plus an annual contract and a six-week onboarding. The tournament is in fourteen days.

This is the moment a lot of teams quietly give up and settle for screenshots and gut feel. They shouldn't. You can build a genuinely useful World Cup social listening dashboard yourself, on a budget, in a few days, using a single data API and tools you already know. It won't have every bell and whistle of a six-figure platform, but for tracking a tournament it'll do the job and you'll actually understand how it works.

This guide builds that dashboard end to end: one API for multi-platform data, a simple store, a collection job, and a live view of buzz, sentiment, and trends. Code is in Node.js and Python so you can use whichever fits your stack.

What We're Building

The architecture is deliberately boring, because boring is what survives a match-day traffic spike. Three layers:

  1. Collector — a scheduled job that pulls fresh data from social platforms through the SociaVault API and writes it to a database.
  2. Store — a lightweight database (SQLite to start, Postgres when you scale) holding time-stamped snapshots.
  3. Dashboard — a view that reads the store and renders charts: buzz over time, sentiment split, top clips, team leaderboard.

The reason this stays cheap is the first layer. Instead of integrating five different platform APIs, each with its own auth, rate limits, and quirks, you hit one API with one key for X, TikTok, Reddit, YouTube, and Threads. That's the difference between a two-week build and a two-day build.

Base URL is https://api.sociavault.com, auth is the X-API-Key header, and most endpoints cost one credit per request. Grab a key and 50 free credits to prototype with: start free with SociaVault. Endpoint reference is at docs.sociavault.com.

Step 1: The Store

Start with SQLite. It's a single file, needs no server, and handles a tournament's worth of data without complaint. You can swap to Postgres later without changing your code's shape.

import sqlite3

def init_db(path="worldcup.db"):
    con = sqlite3.connect(path)
    con.execute("""
        CREATE TABLE IF NOT EXISTS buzz (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            captured_at TEXT NOT NULL,
            platform TEXT NOT NULL,
            team TEXT,
            post_id TEXT,
            text TEXT,
            engagement INTEGER DEFAULT 0,
            sentiment TEXT
        )
    """)
    con.execute("CREATE INDEX IF NOT EXISTS idx_buzz_time ON buzz(captured_at)")
    con.execute("CREATE INDEX IF NOT EXISTS idx_buzz_team ON buzz(team)")
    con.commit()
    return con
const Database = require("better-sqlite3");

function initDb(path = "worldcup.db") {
  const db = new Database(path);
  db.exec(`
    CREATE TABLE IF NOT EXISTS buzz (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      captured_at TEXT NOT NULL,
      platform TEXT NOT NULL,
      team TEXT,
      post_id TEXT,
      text TEXT,
      engagement INTEGER DEFAULT 0,
      sentiment TEXT
    );
    CREATE INDEX IF NOT EXISTS idx_buzz_time ON buzz(captured_at);
    CREATE INDEX IF NOT EXISTS idx_buzz_team ON buzz(team);
  `);
  return db;
}

One table, time-stamped rows, indexes on the two columns you'll filter by most. That's all the schema a tournament dashboard needs to start.

Step 2: The Collector

The collector is the heart of the system. It loops over your tracked teams, pulls recent posts across platforms, scores a quick sentiment, and inserts rows. We'll define a shared API client first.

import os, time, requests
from datetime import datetime, timezone

API_KEY = os.environ["SOCIAVAULT_API_KEY"]
BASE = "https://api.sociavault.com/v1/scrape"

def sv(path, params=None):
    res = requests.get(f"{BASE}{path}", headers={"X-API-Key": API_KEY},
                       params=params or {}, timeout=30)
    res.raise_for_status()
    return res.json()

POSITIVE = {"goal", "incredible", "class", "magic", "deserved", "hero", "stunning"}
NEGATIVE = {"disgrace", "robbed", "var", "offside", "shambles", "rigged", "awful"}

def quick_sentiment(text):
    words = set((text or "").lower().split())
    pos, neg = len(words & POSITIVE), len(words & NEGATIVE)
    if pos == neg:
        return "neutral"
    return "positive" if pos > neg else "negative"

Now the collection routine. It writes one row per post, tagged with the team and platform.

def collect(con, teams):
    now = datetime.now(timezone.utc).isoformat()
    rows = []

    for team in teams:
        # X reactions
        try:
            x = sv("/twitter/search", {"query": team["query"], "limit": 40})
            for p in (x.get("tweets") or x.get("data") or []):
                text = p.get("text") or p.get("full_text") or ""
                eng = (p.get("favorite_count") or 0) + (p.get("retweet_count") or 0)
                rows.append((now, "x", team["country"], p.get("id"), text, eng, quick_sentiment(text)))
        except Exception as e:
            print("x failed", team["country"], e)
        time.sleep(1.0)

        # TikTok clips
        try:
            tt = sv("/tiktok/search-keyword", {"query": team["query"], "limit": 20})
            for v in (tt.get("videos") or tt.get("data") or []):
                stats = v.get("stats") or {}
                text = v.get("desc") or ""
                eng = (stats.get("playCount") or 0)
                rows.append((now, "tiktok", team["country"], v.get("id"), text, eng, quick_sentiment(text)))
        except Exception as e:
            print("tiktok failed", team["country"], e)
        time.sleep(1.0)

    con.executemany(
        "INSERT INTO buzz (captured_at, platform, team, post_id, text, engagement, sentiment) "
        "VALUES (?,?,?,?,?,?,?)", rows)
    con.commit()
    print(f"[{now}] inserted {len(rows)} rows")
    return len(rows)
const API_KEY = process.env.SOCIAVAULT_API_KEY;
const BASE = "https://api.sociavault.com/v1/scrape";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function sv(path, params = {}) {
  const qs = new URLSearchParams(params).toString();
  const res = await fetch(`${BASE}${path}?${qs}`, {
    headers: { "X-API-Key": API_KEY },
  });
  if (!res.ok) throw new Error(`SociaVault ${res.status}`);
  return res.json();
}

const POSITIVE = new Set([
  "goal",
  "incredible",
  "class",
  "magic",
  "deserved",
  "hero",
  "stunning",
]);
const NEGATIVE = new Set([
  "disgrace",
  "robbed",
  "var",
  "offside",
  "shambles",
  "rigged",
  "awful",
]);

function quickSentiment(text) {
  const words = new Set((text || "").toLowerCase().split(/\s+/));
  let pos = 0,
    neg = 0;
  words.forEach((w) => {
    if (POSITIVE.has(w)) pos++;
    if (NEGATIVE.has(w)) neg++;
  });
  if (pos === neg) return "neutral";
  return pos > neg ? "positive" : "negative";
}

async function collect(db, teams) {
  const now = new Date().toISOString();
  const insert = db.prepare(
    `INSERT INTO buzz (captured_at, platform, team, post_id, text, engagement, sentiment)
     VALUES (?,?,?,?,?,?,?)`,
  );
  let count = 0;

  for (const team of teams) {
    try {
      const x = await sv("/twitter/search", { query: team.query, limit: 40 });
      for (const p of x.tweets || x.data || []) {
        const text = p.text || p.full_text || "";
        const eng = (p.favorite_count || 0) + (p.retweet_count || 0);
        insert.run(
          now,
          "x",
          team.country,
          p.id,
          text,
          eng,
          quickSentiment(text),
        );
        count++;
      }
    } catch (e) {
      console.error("x failed", team.country, e.message);
    }
    await sleep(1000);

    try {
      const tt = await sv("/tiktok/search-keyword", {
        query: team.query,
        limit: 20,
      });
      for (const v of tt.videos || tt.data || []) {
        const text = v.desc || "";
        const eng = v.stats?.playCount || 0;
        insert.run(
          now,
          "tiktok",
          team.country,
          v.id,
          text,
          eng,
          quickSentiment(text),
        );
        count++;
      }
    } catch (e) {
      console.error("tiktok failed", team.country, e.message);
    }
    await sleep(1000);
  }

  console.log(`[${now}] inserted ${count} rows`);
  return count;
}

Step 3: Schedule It

You don't need fancy orchestration. A loop with a sleep works for a single tournament. For something more robust, a cron job that runs the collector every few minutes keeps the dashboard fresh without you babysitting it.

def run_loop(con, teams, interval_seconds=300):
    while True:
        try:
            collect(con, teams)
        except Exception as e:
            print("collection cycle failed:", e)
        time.sleep(interval_seconds)

# Or as a cron entry running a one-shot script every 5 minutes:
#   */5 * * * * /usr/bin/python3 /path/collect_once.py

A five-minute cadence is a sweet spot for a dashboard. It keeps credit usage predictable while staying fresh enough that the charts feel live during a match. Tighten the interval during big fixtures and widen it overnight when little is happening.

Step 4: Query for the Dashboard

With data accumulating, the dashboard is mostly SQL. Three queries cover the core views.

def buzz_over_time(con, bucket_minutes=15):
    # Total engagement per time bucket, for the main trend line
    return con.execute("""
        SELECT substr(captured_at, 1, 16) AS minute,
               SUM(engagement) AS total
        FROM buzz
        GROUP BY minute
        ORDER BY minute
    """).fetchall()

def team_leaderboard(con):
    return con.execute("""
        SELECT team,
               COUNT(*) AS posts,
               SUM(engagement) AS total_engagement
        FROM buzz
        WHERE captured_at > datetime('now', '-1 hour')
        GROUP BY team
        ORDER BY total_engagement DESC
    """).fetchall()

def sentiment_split(con, team):
    return con.execute("""
        SELECT sentiment, COUNT(*) AS n
        FROM buzz
        WHERE team = ? AND captured_at > datetime('now', '-1 hour')
        GROUP BY sentiment
    """, (team,)).fetchall()

The leaderboard query, scoped to the last hour, is what makes the dashboard feel alive: it ranks teams by current buzz, not all-time totals, so it reshuffles as matches play out.

Step 5: Render It

For the front end, use whatever's lowest-friction for you. Three solid options depending on your comfort level:

  • A notebook or a static HTML page with a charting library. Fastest to stand up. Read from SQLite, render line and bar charts. Good for an internal team that just needs to see the numbers.
  • A lightweight web app (a small Flask or Express server) exposing the queries above as JSON endpoints, with a single-page front end polling them every minute.
  • A no-code BI tool pointed straight at your database. If your team already uses one, this is the path of least resistance and gives non-technical stakeholders self-serve access.

Here's a minimal JSON endpoint so the front end has something to poll.

const express = require("express");
const app = express();
const db = initDb();

app.get("/api/leaderboard", (req, res) => {
  const rows = db
    .prepare(
      `
    SELECT team, COUNT(*) AS posts, SUM(engagement) AS total_engagement
    FROM buzz
    WHERE captured_at > datetime('now', '-1 hour')
    GROUP BY team
    ORDER BY total_engagement DESC
  `,
    )
    .all();
  res.json(rows);
});

app.get("/api/trend", (req, res) => {
  const rows = db
    .prepare(
      `
    SELECT substr(captured_at, 1, 16) AS minute, SUM(engagement) AS total
    FROM buzz GROUP BY minute ORDER BY minute
  `,
    )
    .all();
  res.json(rows);
});

app.listen(3000, () => console.log("Dashboard API on :3000"));

Point a simple front end at /api/leaderboard and /api/trend, refresh on an interval, and you've got a live World Cup social listening dashboard reading from data you collected and control entirely.

Keeping It Cheap

The whole pitch here is "without the enterprise price tag," so let's keep it that way:

  • Pay per request, not per seat. Because most endpoints cost one credit, your cost scales with how much you actually collect, not with how many people view the dashboard. Add stakeholders freely.
  • Tune your cadence. Collecting every five minutes for ten teams across two platforms is a manageable, predictable credit spend. Don't poll every 30 seconds unless a marquee match justifies it.
  • Store once, query forever. Once a row is in your database, querying it is free. Heavy aggregation happens in SQL, not in repeated API calls.
  • Start with the teams that matter. You don't need all 32 from day one. Track the favorites and the dark horses, expand as the bracket narrows.

Honest Limits

A self-built dashboard trades polish for control and price. Be clear-eyed about the tradeoffs:

  • You're working with public data. Public engagement counts, post text, video plays. Not private platform analytics like reach or impressions that only account owners see.
  • Sentiment from a keyword pass is rough. It catches obvious swings but misses sarcasm and context. Treat it as a directional signal; upgrade to a proper model if you need nuance.
  • You own the uptime. No vendor SLA. For a tournament that's fine, but run the collector somewhere reliable and check it before kickoff.
  • Official platform APIs remain an option. If you need a formal data agreement or platform-sanctioned access, the native APIs are there, with their own approval and integration overhead. A unified API is simply the faster, cheaper route to multi-platform coverage for a project like this.

Next Steps

Once the dashboard is humming, layer in the real-time techniques from tracking World Cup buzz in real time, add the country rankings from tracking fan engagement by country, and wire in early-warning detection from spotting viral World Cup moments before they trend. For the broader playbook beyond this one tournament, see our guide on how to track any sporting event with social media APIs.

Wrapping Up

A World Cup social listening dashboard isn't an enterprise-only luxury. With one API for multi-platform data, a single-file database, a scheduled collector, and a few SQL queries, you can stand up something genuinely useful in days, not weeks, for a fraction of the price. You'll understand every part of it, you can extend it however you like, and it'll be ready before the first whistle.

Start small, get the collector solid, and let the data accumulate. By the knockout rounds you'll have a tool the expensive platforms would charge you a fortune for.

Start free with SociaVault and use your 50 free credits to wire up the collector today. The docs cover every endpoint in the pipeline.

Found this helpful?

Share it with others who might benefit

Ready to Try SociaVault?

Start extracting social media data with our powerful API. No credit card required.