Scenario
Wistia lets you embed CTAs (calls-to-action) at specific timestamps in your videos — but click-through rates are often disappointing and A/B testing natively is limited. This job pulls CTA click-through data from Wistia’s stats, then sends the CTA text, placement, and performance data to a Focus Group testing variations of messaging and timing. The result is a CTA optimization plan with specific wording changes and placement shifts validated by synthetic personas before you commit to changes.Architecture
Code
import os, requests, time
WS = os.environ["WISTIA_API_TOKEN"]
MV = os.environ["MAVERA_API_KEY"]
WS_BASE = "https://api.wistia.com"
MV_BASE = "https://app.mavera.io/api/v1"
WS_H = {"Authorization": f"Bearer {WS}", "Accept": "application/json"}
MV_H = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
# 1. Fetch medias with their customizations (CTAs)
medias = requests.get(f"{WS_BASE}/v1/medias.json", headers=WS_H, params={
"per_page": 50, "sort_by": "play_count", "sort_direction": 0,
}).json()
cta_videos = []
for media in medias:
hashed_id = media.get("hashed_id", "")
customizations = media.get("embed_options", {})
cta = customizations.get("call_to_action", {}) or customizations.get("postRollCTA", {})
if not cta:
continue
# 2. Get stats for this media
stats = requests.get(
f"{WS_BASE}/v1/stats/medias/{hashed_id}.json", headers=WS_H
).json()
play_count = stats.get("play_count", 0)
cta_clicks = cta.get("clicks", 0)
cta_ctr = round(cta_clicks / max(play_count, 1) * 100, 2)
cta_videos.append({
"name": media.get("name", "Untitled"),
"hashed_id": hashed_id,
"duration": media.get("duration", 0),
"plays": play_count,
"cta_text": cta.get("text", ""),
"cta_url": cta.get("url", ""),
"cta_type": cta.get("type", "text"),
"cta_time": cta.get("time", "end"),
"cta_clicks": cta_clicks,
"cta_ctr": cta_ctr,
})
time.sleep(0.2)
print(f"Found {len(cta_videos)} videos with CTAs")
# 3. Sort by CTR for analysis
cta_videos.sort(key=lambda x: x["cta_ctr"])
# 4. Create Focus Group personas
persona_ids = []
for name, desc in [
("Impulse Clicker", "Clicks CTAs that create urgency or curiosity. Responds to 'limited time,' 'see inside,' and action verbs. Ignores generic 'learn more' buttons. Mobile-first viewer who needs the CTA to be thumb-friendly."),
("Deliberate Researcher", "Only clicks CTAs after watching the full video. Needs the CTA to match the video's promise exactly. Distrusts 'bait-and-switch' where the CTA leads somewhere unrelated. Values specificity over urgency."),
("Skeptical Executive", "Senior decision-maker watching to evaluate vendors. Will only click if the CTA offers something they can't get elsewhere — a demo, a calculator, an exclusive report. Ignores 'contact us' and 'subscribe.'"),
("Content Binger", "Watches 5+ videos in a session. CTAs interrupt their flow. Will only click if the CTA offers a natural next step in their learning journey, not a sales redirect."),
] :
p = requests.post(f"{MV_BASE}/personas", headers=MV_H, json={
"name": name, "description": desc,
}).json()
persona_ids.append(p["id"])
time.sleep(0.3)
# 5. Build CTA performance summary
cta_block = "\n\n".join(
f"VIDEO: \"{v['name']}\"\n"
f" CTA Text: \"{v['cta_text']}\"\n"
f" CTA URL: {v['cta_url']}\n"
f" Placement: {v['cta_type']} at {v['cta_time']} | Plays: {v['plays']:,} | "
f"Clicks: {v['cta_clicks']} | CTR: {v['cta_ctr']}%"
for v in cta_videos[:8]
)
# 6. Run Focus Group
fg = requests.post(f"{MV_BASE}/focus-groups", headers=MV_H, json={
"name": "CTA Performance Optimization",
"persona_ids": persona_ids,
"questions": [
f"Review these video CTAs and their click-through rates. For each, explain why you would or wouldn't click:\n\n{cta_block}",
"Rewrite the 3 lowest-performing CTA texts to maximize YOUR click probability. Explain what you changed and why.",
"Where in the video should the CTA appear? At the end (post-roll)? Mid-video? As an overlay? Why does placement matter to you?",
"What CTA format would make you most likely to click: text button, image banner, or interactive annotation? Why?",
"If you've already decided you're interested in the product, what CTA text would make you convert immediately?",
],
"responses_per_persona": 2,
}).json()
for _ in range(20):
time.sleep(5)
fg_data = requests.get(f"{MV_BASE}/focus-groups/{fg['id']}", headers=MV_H).json()
if fg_data.get("status") == "completed":
break
# 7. Output
print("\nCTA PERFORMANCE × FOCUS GROUP")
print("=" * 60)
print(f"{'Video':<35} {'CTA Text':<25} {'Plays':>7} {'CTR':>7}")
print("-" * 60)
for v in cta_videos[:8]:
print(f" {v['name'][:33]:<35} {v['cta_text'][:23]:<25} {v['plays']:>7,} {v['cta_ctr']:>6.2f}%")
print("\nFOCUS GROUP RESPONSES:")
for resp in fg_data.get("responses", []):
print(f"\n[{resp.get('persona_name', '?')}] {resp.get('question', '')[:55]}...")
print(f" → {resp.get('answer', '')[:400]}")
const WS = process.env.WISTIA_API_TOKEN;
const MV = process.env.MAVERA_API_KEY;
const WS_BASE = "https://api.wistia.com";
const MV_BASE = "https://app.mavera.io/api/v1";
const WS_H = { Authorization: `Bearer ${WS}`, Accept: "application/json" };
const MV_H = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
// 1. Fetch medias with customizations
const medias = await fetch(
`${WS_BASE}/v1/medias.json?per_page=50&sort_by=play_count&sort_direction=0`, { headers: WS_H }
).then(r => r.json());
const ctaVideos = [];
for (const media of medias) {
const hashedId = media.hashed_id || "";
const cta = media.embed_options?.call_to_action || media.embed_options?.postRollCTA;
if (!cta) continue;
// 2. Stats
const stats = await fetch(
`${WS_BASE}/v1/stats/medias/${hashedId}.json`, { headers: WS_H }
).then(r => r.json());
const playCount = stats.play_count || 0;
const ctaClicks = cta.clicks || 0;
ctaVideos.push({
name: media.name || "Untitled", hashedId,
duration: media.duration || 0, plays: playCount,
ctaText: cta.text || "", ctaUrl: cta.url || "",
ctaType: cta.type || "text", ctaTime: cta.time || "end",
ctaClicks, ctaCtr: playCount > 0 ? Math.round(ctaClicks / playCount * 10000) / 100 : 0,
});
await new Promise(r => setTimeout(r, 200));
}
console.log(`Found ${ctaVideos.length} videos with CTAs`);
ctaVideos.sort((a, b) => a.ctaCtr - b.ctaCtr);
// 3. Personas
const personaIds = [];
for (const [name, desc] of [
["Impulse Clicker", "Clicks urgency/curiosity CTAs. Ignores 'learn more.' Mobile-first."],
["Deliberate Researcher", "Clicks only after full watch. CTA must match video promise. Values specificity."],
["Skeptical Executive", "Senior buyer. Only clicks for demos, calculators, exclusives. Ignores 'contact us.'"],
["Content Binger", "Watches 5+ videos. CTAs interrupt flow. Only clicks natural next-step CTAs."],
]) {
const p = await fetch(`${MV_BASE}/personas`, {
method: "POST", headers: MV_H,
body: JSON.stringify({ name, description: desc }),
}).then(r => r.json());
personaIds.push(p.id);
await new Promise(r => setTimeout(r, 300));
}
// 4. CTA summary
const ctaBlock = ctaVideos.slice(0, 8).map(v =>
`VIDEO: "${v.name}"\n CTA: "${v.ctaText}" | ${v.ctaType} at ${v.ctaTime} | ` +
`Plays: ${v.plays.toLocaleString()} | Clicks: ${v.ctaClicks} | CTR: ${v.ctaCtr}%`
).join("\n\n");
// 5. Focus Group
const fg = await fetch(`${MV_BASE}/focus-groups`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
name: "CTA Performance Optimization", persona_ids: personaIds,
questions: [
`Review CTAs and CTRs. Would you click each? Why/why not?\n\n${ctaBlock}`,
"Rewrite the 3 worst CTAs to maximize YOUR clicks. Explain changes.",
"Best CTA placement: end, mid-video, overlay? Why does it matter to you?",
"Best format: text button, image banner, or interactive annotation?",
"If already interested, what CTA text converts you immediately?",
],
responses_per_persona: 2,
}),
}).then(r => r.json());
let fgData;
for (let i = 0; i < 20; i++) {
await new Promise(r => setTimeout(r, 5000));
fgData = await fetch(`${MV_BASE}/focus-groups/${fg.id}`, { headers: MV_H }).then(r => r.json());
if (fgData.status === "completed") break;
}
// 6. Output
console.log("\nCTA PERFORMANCE × FOCUS GROUP");
console.log("=".repeat(60));
ctaVideos.slice(0, 8).forEach(v =>
console.log(` ${v.name.slice(0, 33).padEnd(35)} ${v.ctaText.slice(0, 23).padEnd(25)} ` +
`${String(v.plays.toLocaleString()).padStart(7)} ${(v.ctaCtr + "%").padStart(7)}`)
);
console.log("\nFOCUS GROUP:");
for (const resp of fgData.responses || []) {
console.log(`\n[${resp.persona_name || "?"}] ${(resp.question || "").slice(0, 55)}...`);
console.log(` → ${(resp.answer || "").slice(0, 400)}`);
}
Example Output
Found 12 videos with CTAs
CTA PERFORMANCE × FOCUS GROUP
============================================================
Video CTA Text Plays CTR
------------------------------------------------------------
Product Demo — Dashboard Learn More 4,280 1.20%
Customer Story — Acme Corp Contact Sales 2,100 1.85%
How-To: Getting Started Start Free Trial 3,700 4.20%
Pricing Explainer See Plans & Pricing 1,800 6.10%
ROI Calculator Walkthrough Calculate Your Savings 1,200 8.75%
FOCUS GROUP:
[Impulse Clicker] Review CTAs and CTRs. Would you click each?...
→ "Learn More" — NEVER clicking that. It's the "I surrender" of CTAs.
It tells me nothing about what I'll learn. "Contact Sales" — only if
I'm already sold, which I'm not after a demo. "Start Free Trial" —
yes, because it's action + zero risk. "Calculate Your Savings" — YES,
this is the best one. It promises a personalized result. I NEED to
know my number.
[Deliberate Researcher] Rewrite the 3 worst CTAs...
→ 1. "Learn More" → "See the Dashboard in Action (2-min interactive demo)"
Why: Specificity. I know exactly what happens when I click.
2. "Contact Sales" → "Get a Custom Demo for [Your Industry]"
Why: Personalization signals. I don't want generic sales, I want
someone who understands my context.
3. "Product Demo" CTA should be → "Try It Free — No Credit Card"
Why: Removes friction. The word "free" plus removing the objection
about payment makes this a no-brainer click.
[Content Binger] Best CTA placement...
→ Mid-video annotation at the moment you demonstrate the feature I care
about. Post-roll CTAs are wasted on me — by the time the video ends,
I'm already clicking the next video. If you catch me at the moment of
peak interest (usually the "aha" demo moment), I'll click.
Error Handling
CTA data in embed options
CTA data in embed options
Wistia stores CTA configuration in the
embed_options field of each media. The key name varies: call_to_action, postRollCTA, or midrollLink. Check all three fields for completeness.Click tracking accuracy
Click tracking accuracy
Wistia tracks CTA clicks at the player level. If viewers use ad blockers or privacy browsers, clicks may be underreported by 10-15%. Use CTA CTR as a relative metric (for comparing CTAs) rather than an absolute conversion measure.
No CTA configured
No CTA configured
Videos without CTAs return empty embed options. Filter these out before analysis. Consider recommending CTA addition for high-play, no-CTA videos as a quick win.
What’s Next
Wistia Integration
Back to Wistia integration overview
Heatmap-Informed Creative Optimization
Diagnose drop-off points with specific edit recommendations
Wistia Embeds → Brand Voice Source
Build a brand voice profile from spoken content
Focus Groups API
Full reference for POST /api/v1/focus-groups