How to track competitors' social media posts programmatically
To track what competitors post, call one listing endpoint per platform for each competitor's profile, keep only the posts inside your reporting window by publishedAt, and rank each post against the median of that same account on that same platform. Raw likes are not comparable across platforms or accounts, so the useful number is "how much better than this account usually does".
Below is a working version for seven platforms, and the four ways a digest built this way quietly goes wrong.
One listing call per platform
| Platform | Endpoint | Rows key |
|---|---|---|
| TikTok | /v1/tiktok/channel-posts |
posts |
/v1/instagram/channel-posts |
posts |
|
| YouTube | /v1/youtube/channel-videos |
videos |
| X | /v1/twitter/user-tweets |
tweets |
| Threads | /v1/threads/user-posts |
posts |
/v1/linkedin/company-posts |
posts |
|
/v1/facebook/profile-posts |
posts |
Each takes the profile url and a limit. Every row has url and an ISO publishedAt. Facebook's limit tops out at 4; asking for more is a 422 validation error, not a bigger page.
Pull and normalise
import os
import statistics
from datetime import datetime, timedelta, timezone
import requests
API = "https://api.captapi.com"
HEADERS = {"Authorization": f"Bearer {os.environ['CAPTAPI_API_KEY']}"}
SOURCES = {
"tiktok": ("/v1/tiktok/channel-posts", "posts", 10),
"instagram": ("/v1/instagram/channel-posts", "posts", 10),
"youtube": ("/v1/youtube/channel-videos", "videos", 10),
"x": ("/v1/twitter/user-tweets", "tweets", 10),
"threads": ("/v1/threads/user-posts", "posts", 10),
"linkedin": ("/v1/linkedin/company-posts", "posts", 10),
"facebook": ("/v1/facebook/profile-posts", "posts", 4),
}
def listing(platform, profile_url):
path, key, limit = SOURCES[platform]
r = requests.get(API + path, headers=HEADERS, params={"url": profile_url, "limit": limit}, timeout=180)
body = r.json()
if r.status_code >= 400:
return None, body.get("error", {})
return body["data"][key], None
def interactions(row):
"""Likes + comments/replies + shares/reposts/retweets/quotes, whatever the platform names them."""
e = row.get("engagement") or {}
fields = ("likes", "comments", "replies", "shares", "reposts", "retweets", "quotes")
return sum(e.get(f) or 0 for f in fields)
def score(rows, since):
baseline = statistics.median([interactions(r) for r in rows] or [0]) or 1
recent = [r for r in rows if datetime.fromisoformat(r["publishedAt"].replace("Z", "+00:00")) >= since]
return [
{"url": r["url"], "publishedAt": r["publishedAt"], "lift": round(interactions(r) / baseline, 2)}
for r in sorted(recent, key=lambda r: r["publishedAt"], reverse=True)
]
since = datetime.now(timezone.utc) - timedelta(days=7)
rows, err = listing("linkedin", "https://www.linkedin.com/company/notionhq")
print(err or score(rows, since)[:3])
YouTube listing rows carry viewCount rather than an engagement object, so for YouTube rank on viewCount against the channel's median views instead.
The same normaliser in JavaScript:
const FIELDS = ["likes", "comments", "replies", "shares", "reposts", "retweets", "quotes"];
const interactions = (row) => FIELDS.reduce((sum, f) => sum + (row.engagement?.[f] ?? 0), 0);
function score(rows, since) {
const sorted = rows.map(interactions).sort((a, b) => a - b);
const baseline = sorted[Math.floor(sorted.length / 2)] || 1;
return rows
.filter((r) => new Date(r.publishedAt) >= since)
.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))
.map((r) => ({ url: r.url, publishedAt: r.publishedAt, lift: interactions(r) / baseline }));
}
Posts with a lift of 2 or more are the ones worth a line in the digest.
Gotcha 1: "latest" is not "this week"
A listing returns the account's most recent posts, however old they are. When we pulled Notion's accounts, the newest TikTok was from May 2026 while their LinkedIn had eight posts in the past week. Facebook's logged-out page view returned a single post from December 2024. Filter on publishedAt every time, and report "no posts in this period" separately from "the call failed" — the first is a finding about the competitor, the second is a gap in your data.
Gotcha 2: the window is the window
Some platforms only show a logged-out visitor a slice of the feed, and the API says so in the envelope instead of pretending the list is complete:
- LinkedIn
company-postsreturns about the 10 latest posts, withtruncatedReason: "public_page_limit"and anoteexplaining it. Nothing older is reachable. - Facebook
profile-postsusually returns one or two posts, withlistingHitsandtruncatedReason: "listing-window". Treat Facebook as a spot check. - X
user-tweetsreportslistingHitsalongside what it returned; do not assume its order, sort bypublishedAtyourself as the code does.
If your digest says "Competitor A posted 2 times on Facebook", it should say "at least 2".
Gotcha 3: the same post, twice
Brands cross-post. Notion's Threads post at 21:59:42 and their LinkedIn post at 21:59:45 on the same day are the same announcement. Counted separately, one launch looks like two and inflates "posting frequency". Before you count, group posts from one competitor whose timestamps are within a few minutes of each other and whose text overlaps, and report them as one item with two platforms.
Gotcha 4: not every row has the same counters
Instagram image posts return views: null; Reels have views. Facebook rows can carry shares: null when the page does not show them. YouTube listings have views but no likes. The interactions() helper treats missing counters as zero, which is right for ranking a post against its own account and wrong for comparing two platforms. Do not add up "total engagement across platforms" — it mixes metrics that do not mean the same thing. The differences between those metrics are laid out in social media engagement metrics.
What it costs
On Captapi each listing is a flat 2 credits, except LinkedIn company-posts at 1 and Instagram channel-posts at about 0.3 credits per returned post. Failed calls are not billed. One competitor on all seven platforms with ten posts each is about 14 credits per run.
If you want an agent to produce the weekly digest, with the credit estimate printed first, the flow is packaged as an agent skill. For a single competitor's LinkedIn presence, the LinkedIn company posts API page has the full field list.