Scenario
TikTok’s audience skews young, but your ads target different generational segments. This job pulls audience demographic data from your campaigns, maps age brackets to Gen Z, Millennial, and Gen Alpha personas with platform-native behaviors, then runs a Focus Group testing your ad concepts before you commit production budget. You get generational feedback calibrated to TikTok’s unique content culture.Architecture
Code
import os, requests, time
TT = os.environ["TIKTOK_ACCESS_TOKEN"]
ADV = os.environ["TIKTOK_ADVERTISER_ID"]
MV = os.environ["MAVERA_API_KEY"]
TT_BASE = "https://business-api.tiktok.com/open_api/v1.3"
MV_BASE = "https://app.mavera.io/api/v1"
TT_H = {"Access-Token": TT}
MV_H = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
# 1. Pull audience demographics
demo = requests.post(f"{TT_BASE}/reports/integrated/get/",
headers=TT_H,
json={
"advertiser_id": ADV,
"report_type": "AUDIENCE",
"data_level": "AUCTION_ADVERTISER",
"dimensions": ["age", "gender"],
"metrics": ["impressions", "clicks", "spend", "conversion"],
"start_date": "2025-01-01", "end_date": "2025-12-31",
"page_size": 50,
}).json()
if demo.get("code") != 0:
raise SystemExit(f"TikTok: {demo.get('message')}")
age_data = {}
for row in demo.get("data", {}).get("list", []):
age = row.get("dimensions", {}).get("age", "Unknown")
m = row.get("metrics", {})
existing = age_data.get(age, {"impressions": 0, "clicks": 0, "spend": 0})
age_data[age] = {
"impressions": existing["impressions"] + int(m.get("impressions", 0)),
"clicks": existing["clicks"] + int(m.get("clicks", 0)),
"spend": existing["spend"] + float(m.get("spend", 0)),
}
# 2. Map age brackets to generational personas
GEN_MAP = {
"AGE_13_17": {"gen": "Gen Alpha/Z", "desc": "13-17. Digital native. Discovers brands through TikTok, values authenticity and humor. Skeptical of polished ads. Influenced by creators, not brands."},
"AGE_18_24": {"gen": "Gen Z", "desc": "18-24. Core TikTok demographic. Values transparency, social impact, and UGC. Short attention span. Responds to trends, not traditional marketing."},
"AGE_25_34": {"gen": "Millennial", "desc": "25-34. Established spending power. Values convenience and brand story. Uses TikTok for discovery. Responds to aspirational + practical content."},
"AGE_35_44": {"gen": "Elder Millennial", "desc": "35-44. Decision-makers and parents. Uses TikTok as entertainment. Responds to relatable, informational content. Less trend-driven."},
"AGE_45_54": {"gen": "Gen X", "desc": "45-54. Fastest-growing TikTok segment. Values expertise and straightforward messaging. Skeptical but engaged when content is relevant."},
}
persona_ids = []
for age_bracket, gen_info in GEN_MAP.items():
stats = age_data.get(age_bracket, {})
if stats.get("impressions", 0) < 100:
continue
ctr = (stats["clicks"] / stats["impressions"] * 100) if stats["impressions"] else 0
p = requests.post(f"{MV_BASE}/personas", headers=MV_H, json={
"name": f"TikTok: {gen_info['gen']}",
"description": f"{gen_info['desc']} Campaign data: {stats['impressions']:,} impressions, {ctr:.2f}% CTR, ${stats['spend']:,.0f} spend.",
"demographic": {"age_range": age_bracket, "platform": "TikTok"},
"psychographic": {"content_preferences": "short-form video", "generation": gen_info["gen"]},
}).json()
persona_ids.append({"id": p["id"], "gen": gen_info["gen"], "ctr": ctr})
time.sleep(0.3)
# 3. Run Focus Group with ad concepts
AD_CONCEPTS = """
CONCEPT A — "Day in My Life" UGC:
Creator shows morning routine using your product. Lo-fi aesthetic, trending audio, text overlays.
CONCEPT B — "3 Things You Didn't Know":
Founder on camera, fast cuts, educational hook. "Stop scrolling if you..." opener.
CONCEPT C — "Transition Reveal":
Before/after transformation. Product reveal at the cut. Trending transition effect.
CONCEPT D — "Stitch Response":
Stitches a popular creator's question. Authentic, conversational, no script.
"""
fg = requests.post(f"{MV_BASE}/focus-groups", headers=MV_H, json={
"name": "TikTok Ad Concept — Generational Testing",
"persona_ids": [p["id"] for p in persona_ids],
"questions": [
f"Review these TikTok ad concepts:\n\n{AD_CONCEPTS}\n\nWhich concept would stop your scroll? Why?",
"Which concept feels most authentic to TikTok? Which feels like an ad?",
"Would you share any of these with friends? Which one and why?",
"What trending format or audio would make you more likely to engage?",
],
"responses_per_persona": 2,
}).json()
for _ in range(20):
time.sleep(5)
data = requests.get(f"{MV_BASE}/focus-groups/{fg['id']}", headers=MV_H).json()
if data.get("status") == "completed": break
for resp in data.get("responses", []):
gen = next((p["gen"] for p in persona_ids if p["id"] == resp.get("persona_id")), "?")
print(f"[{gen}] {resp.get('question','')[:60]}...")
print(f" → {resp.get('answer','')[:250]}\n")
const TT = process.env.TIKTOK_ACCESS_TOKEN;
const ADV = process.env.TIKTOK_ADVERTISER_ID;
const MV = process.env.MAVERA_API_KEY;
const TT_BASE = "https://business-api.tiktok.com/open_api/v1.3";
const MV_BASE = "https://app.mavera.io/api/v1";
const TT_H = { "Access-Token": TT, "Content-Type": "application/json" };
const MV_H = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
// 1. Audience demographics
const demo = await fetch(`${TT_BASE}/reports/integrated/get/`, {
method: "POST", headers: TT_H,
body: JSON.stringify({
advertiser_id: ADV, report_type: "AUDIENCE", data_level: "AUCTION_ADVERTISER",
dimensions: ["age", "gender"],
metrics: ["impressions", "clicks", "spend", "conversion"],
start_date: "2025-01-01", end_date: "2025-12-31", page_size: 50,
}),
}).then(r => r.json());
if (demo.code !== 0) throw new Error(`TikTok: ${demo.message}`);
const ageData = {};
for (const row of demo.data?.list || []) {
const age = row.dimensions?.age || "Unknown";
const m = row.metrics || {};
const e = ageData[age] || { impressions: 0, clicks: 0, spend: 0 };
ageData[age] = {
impressions: e.impressions + parseInt(m.impressions || "0", 10),
clicks: e.clicks + parseInt(m.clicks || "0", 10),
spend: e.spend + parseFloat(m.spend || "0"),
};
}
// 2. Generational personas
const GEN_MAP = {
AGE_13_17: { gen: "Gen Alpha/Z", desc: "13-17. Digital native. Values authenticity, humor. Influenced by creators." },
AGE_18_24: { gen: "Gen Z", desc: "18-24. Core TikTok demo. Values transparency, UGC, trends." },
AGE_25_34: { gen: "Millennial", desc: "25-34. Spending power. Values convenience and story." },
AGE_35_44: { gen: "Elder Millennial", desc: "35-44. Uses TikTok for entertainment. Practical content." },
AGE_45_54: { gen: "Gen X", desc: "45-54. Fastest-growing segment. Values expertise." },
};
const personaIds = [];
for (const [bracket, info] of Object.entries(GEN_MAP)) {
const stats = ageData[bracket] || {};
if ((stats.impressions || 0) < 100) continue;
const ctr = stats.impressions ? ((stats.clicks / stats.impressions) * 100).toFixed(2) : "0";
const p = await fetch(`${MV_BASE}/personas`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
name: `TikTok: ${info.gen}`,
description: `${info.desc} Data: ${stats.impressions.toLocaleString()} imp, ${ctr}% CTR, $${stats.spend.toLocaleString()} spend.`,
demographic: { age_range: bracket, platform: "TikTok" },
psychographic: { content_preferences: "short-form video", generation: info.gen },
}),
}).then(r => r.json());
personaIds.push({ id: p.id, gen: info.gen, ctr: parseFloat(ctr) });
await new Promise(r => setTimeout(r, 300));
}
// 3. Focus Group
const AD_CONCEPTS = `CONCEPT A — "Day in My Life" UGC: Creator + product, lo-fi, trending audio.
CONCEPT B — "3 Things You Didn't Know": Founder on camera, fast cuts, educational hook.
CONCEPT C — "Transition Reveal": Before/after with product reveal.
CONCEPT D — "Stitch Response": Stitches popular creator question, conversational.`;
const fg = await fetch(`${MV_BASE}/focus-groups`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
name: "TikTok Ad Concept — Generational Testing",
persona_ids: personaIds.map(p => p.id),
questions: [
`Review:\n\n${AD_CONCEPTS}\n\nWhich concept would stop your scroll? Why?`,
"Which feels most authentic to TikTok? Which feels like an ad?",
"Would you share any with friends? Which and why?",
"What trending format or audio would make you engage?",
],
responses_per_persona: 2,
}),
}).then(r => r.json());
let data;
for (let i = 0; i < 20; i++) {
await new Promise(r => setTimeout(r, 5000));
data = await fetch(`${MV_BASE}/focus-groups/${fg.id}`, { headers: MV_H }).then(r => r.json());
if (data.status === "completed") break;
}
for (const resp of data.responses || []) {
const gen = personaIds.find(p => p.id === resp.persona_id)?.gen || "?";
console.log(`[${gen}] ${(resp.question || "").slice(0, 60)}...`);
console.log(` → ${(resp.answer || "").slice(0, 250)}\n`);
}
Example Output
[Gen Z] Which concept would stop your scroll? Why?...
→ Concept D — Stitch Response. It feels like actual TikTok content, not an ad.
The conversational format matches how I discover products. If the stitched
creator is someone I follow, I'd definitely watch to the end.
[Millennial] Which feels most authentic to TikTok?...
→ Concept A feels most native. The "Day in My Life" format is what I watch
already. Concept B feels like a YouTube ad someone repurposed for TikTok —
I'd scroll past. Concept C depends entirely on the transition quality.
[Gen X] Would you share any with friends?...
→ I'd share Concept B if the information was genuinely surprising. I don't
share UGC-style content but I do share "did you know" posts. Make the
facts verifiable and I'll forward it.
[Gen Alpha/Z] What trending format would make you engage?...
→ Green screen format with a controversial take. Or the "POV" format where
you role-play a situation. Also, use a trending sound — not the original,
the remix version that's actually trending.
Error Handling
Age bracket availability
Age bracket availability
TikTok may not report all age brackets if your campaigns exclude certain ages. Check your targeting settings if brackets return zero data.
Audience report permissions
Audience report permissions
Audience-level reports require the
Audience Management permission scope. Without it, the endpoint returns code: 40100.Small sample sizes
Small sample sizes
Age brackets with fewer than 100 impressions are filtered out to avoid unreliable CTR calculations. Extend the date range for newer campaigns.