Competitor Ads
Track a client's competitors in the Meta Ad Library. Mythic stores their ads, transcribes videos and tags each ad's hook, angle and offer.
Overview
Add up to 10 competitor Facebook pages per client. Mythic scrapes each page's Meta Ad Library ads once a day, or now when you ask, and keeps every ad it has seen.
For every ad Mythic:
- Stores the copy, headline, call to action, landing link, format, platforms and Ad Library start date, plus the full scraped row.
- Copies the images and video to Mythic storage. Ad Library media links expire after about five days; Mythic's copies do not.
- Transcribes the video with sentence timestamps. A video shared by several ads or clients is transcribed once.
- Reads the ad and its transcript with two Cloudflare models. One quotes the hook, intro, problem, mechanism, offer and call to action, and gives the video seconds where each one is spoken. The other tags the hook type, persuasion angle and offer elements.
The summary then answers what each competitor runs: formats, new ads in the last 7 and 30 days, which hooks, angles and offers they use most, and which active ads have run longest. Days running is the only public signal of a winning ad: advertisers keep paying for ads that work.
Base URL
https://mythic-analytics.gulp.workers.dev/client/v1/competitor-ads
The same key system as the rest of /client/v1: an agency key (ak_, which
must bind a location on every request with ?location_id= or the
X-Location-Id header) or a location secret key (sk_, auto-scoped to its
own location). An agency key that binds no location returns
400 location_required; one that names a location it is not linked to returns
403 location_not_linked.
A location secret key (sk_) can read pages, ads and the summary. Adding, changing, refreshing and deleting pages needs an agency key or an mcp_ key with competitor_ads:write. Reads with an mcp_ key need competitor_ads:read.
This surface is sold by tier. If your plan does not include it, every endpoint
answers 402 feature_not_in_plan — the key is valid, the tier is what is
missing. GET /client/v1/agency/quota lists what your plan includes.
Competitor ads is part of the Pro plan. Pausing a page (PATCH /pages/{id} with only active: false) and deleting one work on every plan.
Track a page
Find the page id in the Ad Library URL (view_all_page_id=64841063928).
curl -X POST "https://mythic-analytics.gulp.workers.dev/client/v1/competitor-ads/pages?location_id=LOC" \
-H "Authorization: Bearer ak_..." -H "Content-Type: application/json" \
-d '{"page_id": "64841063928", "label": "Juvenon", "country": "US", "max_ads": 50}'
| Field | Default | Notes |
|---|---|---|
page_id | required | Numeric Facebook page id, as a string. |
country | US | Ad Library country filter: ISO code or ALL. |
max_ads | 50 | Ads scraped per refresh, 1 to 600. A refresh of 600 ads takes up to about a minute. |
label | none | Your name for the competitor. |
active | true | false pauses the daily refresh and keeps the stored ads. |
The first scrape runs within 5 minutes. POST /pages/{id}/refresh runs it now and waits for it (up to about a minute for 50 ads). It answers:
429 refreshed_recentlywithin 10 minutes of the last scrape of that page for this client.429 refresh_budget_exhaustedafter 20 manual refreshes by this client in 24 hours. Pages still refresh daily.409 refresh_in_progresswhile a refresh runs.
Deleting and re-adding a page, or pausing and resuming it, does not reset these limits. A re-added or resumed page is first refreshed 24 hours after its last scrape.
Ads missing from a scrape are marked gone only when the scrape reached the end of the page. A scrape capped by max_ads never marks an ad as stopped. last_refresh_status is ok for a complete scrape and partial for a capped one.
Processing
A refresh stores the ads at once. Media copies, transcripts and analysis follow in the background, usually within a few minutes. Each ad shows its progress:
media_status:pending,done,partial(some files failed),failed,none(no media).analysis_status:pending,done,failed. Video ads are analysed after their transcript is ready.
A changed ad copy or headline is analysed again. New media is copied again.
Read ads
GET /ads lists one row per ad, or per card for carousel and dynamic (DCO) ads, newest start date first.
| Filter | Example | Meaning |
|---|---|---|
page_id | 64841063928 | One competitor. |
active | true | Active and seen in the latest complete scrape. false: inactive or gone. |
format | VIDEO | Ad Library format: VIDEO, IMAGE, DCO, CAROUSEL, … |
has_video | true | Ads with or without a video. |
has_transcript | true | Ads whose transcript is ready. |
tag | offer.guarantee | Ads carrying an analysis tag. |
epic | in_order | Ads whose current analysis (analysis_status: done) has all four E.P.I.C. stages in order in the copy or transcript. |
landing_url | https://juvenon.com/pages/bf7 | Ads whose landing page is this URL. Tracking parameters are ignored. |
domain | juvenon.com | Ads whose landing page is on this domain (www. ignored). |
since | 2026-10-01 | Ad Library start date on or after. |
Page with limit (up to 200) and next_cursor.
Landing pages
Every ad carries landing_page: { url, domain, path }, the link_url Meta shows, normalized so ads that send people to the same page group together:
- Tracking parameters are removed:
utm_*,hsa_*,fb_*,fbclid,gclid,gbraid,wbraid,dclid,msclkid,ttclid,twclid,yclid,igshid,li_fat_id,mc_cid,mc_eid,_ga,_gl,_hsenc,_hsmi,mkt_tok,irclickid,epik,sscid. Other parameters, such asvariantorid, are kept and sorted by name. - The scheme and host are lowercase. The fragment, credentials, default port and trailing slash are dropped.
domainis the host withoutwww..- Redirect links, such as
link.juvenon.com/xxxl-men-fb, are shown as Meta shows them. They are not followed. - Cards of a carousel or dynamic ad often share one landing page.
landing_page is null when the ad has no http(s) link.
GET /ads/{id} adds the transcript, the full analysis and media links. ?include=raw adds the scraped Ad Library row.
Media links
thumbnail_url and each media[].url are signed links valid for about an hour. They work in <img> and <video> tags without a key and support range requests. Read the ad again for fresh links. Videos larger than 100 MB, or larger than 32 MB when the CDN sends no file size, are not copied and show skipped: "too_large".
Analysis
{
"taxonomy_version": 2,
"has_transcript": true,
"fields": {
"hook": { "text": "This is what happens to a man's body when he takes nitric oxide boosters every single day for 30 days straight.", "confidence": 0.76, "source": "transcript", "start_s": 0, "end_s": 5.98 },
"offer": { "text": "60-day money-back guarantee", "confidence": 0.81, "source": "transcript", "start_s": 80.1, "end_s": 84.2 },
"cta": { "text": "Grab them before they're gone.", "confidence": 0.5, "source": "transcript" }
},
"hook_start_s": 0,
"hook_end_s": 5.98,
"tags": { "hook_type": ["story"], "angle": ["authority_science"], "offer": ["guarantee"] },
"probabilities": { "offer": { "guarantee": 0.947, "discount": 0.08 } }
}
fields:hook,intro,problem,mechanism,offer,cta. Each is quoted from the ad copy or transcript, ornullwhen the ad has none.sourcesays where it was found. Text from the video carriesstart_sandend_s.tags: every tag with probability 0.5 or more.probabilitieskeeps all of them.
| Family | Tags |
|---|---|
hook_type | question, bold_claim, story, problem_callout, curiosity, social_proof, offer_first, demonstration, statistic |
angle | pain_point, aspiration, urgency_scarcity, authority_science, social_proof, price_value, comparison, testimonial |
offer | discount, guarantee, free_shipping, bundle, free_trial, limited_time |
taxonomy_version changes when the tag set or the E.P.I.C. test changes. Stored analyses with an older version are analysed again in the background, after new ads. Until the new analysis fully succeeds, the ad keeps its current analysis, and an upgrade that fails three times leaves it on the older version.
E.P.I.C. test
analysis.epic applies Chris Rocheleau's publicly described E.P.I.C. long-copy formula: Engage, Present, Illustrate, Convert. The rule wording and scoring are Mythic's own interpretation of his public descriptions. They are not yet checked against spend results, so there is no overall score. rules_followed counts the rules an ad meets and lists them.
{
"copy": { "words": 126, "you_top_half": 3, "you_bottom_half": 0 },
"you_only_bottom_half": false,
"checks": { "engage.direct_question": 0.0365, "engage.mechanism_in_opener": 0.7146, "convert.cta": 0.8764 },
"stages": {
"copy": {
"labels": [{ "i": 1, "stage": "E", "p": 0.5884 }, { "i": 2, "stage": "E", "p": 0.3328 }, { "i": 3, "stage": "I", "p": 0.3887 }],
"sequence": "EIPIC", "stages_present": "EPIC", "in_order": false, "all_stages_in_order": false,
"paragraphs_total": 7, "paragraphs_assessed": 7, "truncated": false
}
},
"all_stages_in_order": false,
"rules_followed": { "met": 7, "total": 16, "rules": [{ "rule": "not:engage.direct_question", "met": true }] },
"input_tokens": 5072,
"cost_usd": 0.00019274
}
copy,transcript: word count and the number of "you", "your", "you're", "yours" and "yourself" in the top and bottom half. His rule is no "you" until the bottom half.checks: probability that each of 14 rules applies. Engage:direct_question,indirect_open,negative_callout,pain_and_benefit_in_opener,mechanism_in_opener. Present:struggle,empathy. Illustrate:journey,teaches_value,differentiation,substantiated,lead_is_other_persons_story. Convert:cta,risk_reversal.direct_question,negative_calloutandlead_is_other_persons_storybreak a rule when they apply.stages:copy, andtranscriptfor video ads. Each paragraph takes its most likely stage when that probability is 0.3 or more, else-. The ad copy and the video transcript are separate sequences.sequencecollapses repeats,in_ordermeans it never goes back to an earlier stage. Copy or transcript under 40 words is not labeled (skipped).- Only the first 30 paragraphs of a sequence are labeled.
paragraphs_total,paragraphs_assessedandtruncatedsay so. A truncated sequence hasin_orderandall_stages_in_ordernull(unknown), unless a reversal was already seen (false). all_stages_in_order(top level):truewhen the copy or the transcript is known to have all four stages in order. Otherwisenullwhen either is unknown (labelling failed, skipped, or truncated), andfalseonly when every present sequence is known not to.nullis never counted as a negative.rules_followed: the "you" rule, each check (met at 0.5 or more, or under 0.5 for the three that break a rule) andall_stages_in_orderwhen it is notnull.- A part that fails stores
error(checks_errorfor the checks, withchecks_input_tokenswhen the call was billed). Its tokens still count ininput_tokensandcost_usd. The rest of the analysis is kept.
GET /ads rows carry epic: { all_stages_in_order, rules_met, rules_total }.
Summary
GET /summary (optional page_id) returns overall and one entry per tracked page. Counts are per ad: a three-card carousel is one ad, carrying a tag when any of its cards does. Tag shares are over analysed ads only.
| Field | Meaning |
|---|---|
ads, active_ads, analyzed_ads | Ad counts. |
new_ads_7d, new_ads_30d | Ads whose Ad Library start date is that recent. |
formats | Ads and share per format. |
hook_types, angles, offer_elements | Ads and share per tag. |
longest_running | Top 10 active ads by days since their start date. |
top_hooks, top_offers | Most used hook and offer texts, with the longest-running example. |
landing_pages | Top 50 landing pages by ads: url, domain, ads, active_ads, formats, first_seen and last_seen (earliest and latest Ad Library start date), longest_running_days. total counts all of them; truncated is true when more than 50 exist. An ad whose cards link to two pages counts under both. active_ads and longest_running_days count an ad only while a live card (active, not gone) links to the page; a dropped card's page keeps its other counts. |
epic | E.P.I.C. test over ads with a result: share following each rule, share with all four stages in order (over stage_order_evaluated_ads, the ads with a non-null verdict), median copy word count, share with "you" only in the bottom half. |
Ad prompts
POST /ad-prompts writes 1-5 ad briefs for the client from its brand and its competitors' working ads: ads that have run 30 days or more, weighted by days running, with active ads counted over stopped ones. Ads under 30 days are shown to the model as new tests and are never evidence. When the client's plan includes creative analysis, its top 5 active own ads by 90-day spend join the grounding with their hooks, tags and E.P.I.C. result, and prompts may cite them by ad_id in own_creative_ids. Own ads are ranked by spend only: no cost per result is stored for them. An mcp_ key also needs creative_analysis:read; without it own ads are left out (own_creatives.reason: insufficient_scope). Send "include_own_creatives": false to leave them out yourself.
The grounding is at most 45,000 characters. Every text field is cut, then new tests, own ads, the summary and lower-ranked working ads are dropped in that order (dropped_for_size). When the ad to rewrite and the top working ad alone do not fit, the call fails with 422 grounding_too_large before any model call.
It runs on the agency's own model key, the same key as scouts: Mythic pays for no model call, and a client with no key gets 409 no_model_key. Write scope (competitor_ads:write or an agency key), 20 per client per rolling 24 hours, failed calls included. A call can take a few minutes.
Save the brand once with PUT /brand (product is required; audience, benefits, angles, proof, offers, must_avoid, compliance are optional), or send brand in the request to use it for that call only.
curl -X POST "https://mythic-analytics.gulp.workers.dev/client/v1/competitor-ads/ad-prompts?location_id=$LOC" \
-H "Authorization: Bearer $AK" -H "Content-Type: application/json" \
-d '{"count": 2, "formats": ["copy", "image", "video", "ugc", "rewrite"]}'
Each prompt has:
| Part | Contents |
|---|---|
concept | Angle, hook, primary text, headline, CTA, landing page, and sources: the competitor ad ids and own creative ids it learned from. |
image_prompt | Layout, subject, text overlay, aspect ratio and a full prompt ready for an image model. |
video | A 15-60 second script: beats with an E.P.I.C. stage, visual, voiceover and on-screen text, plus a shot list. |
ugc_brief | Who should film it, the setting, talking points, dos and don'ts. |
rewrite | First prompt only, with rewrite in formats: one competitor ad (rewrite_ad_id, default the top working ad) rewritten into the client's version. It keeps the ad's format, length and stage order, and lists each section's original text next to the rewrite with a note on what changed. |
Before it is returned, every answer is checked:
- It matches the prompt schema for the requested
formats. - Every cited id is in the grounding (
grounding.competitor_ad_ids,own_creative_ids,rewrite_ad_id). Only working ads may be cited as sources. - Each rewrite section's
originalis the selected ad's text, quoted verbatim (pieces may be joined with...; every piece is checked, and an empty original is refused). - The headline, hook, primary text, video script and rewrite follow the E.P.I.C. rules: no direct self-identification question ("Are you...?"), no "you" in the top half, no negative call-out of the reader, and the stages E, P, I, C all present and in order.
An answer that fails gets one repair turn with the errors; a second failure returns 502 generation_invalid with the errors in details. The response reports the provider, the model and token usage. usage.cost_usd is the total when every call reported a cost (OpenRouter does; Gemini and Cloudflare do not). When any call did not, including a call that timed out or hit a network error after it was sent, cost_usd is null, usage_complete is false, and cost_usd_known is the cost of the calls that did report one.
record.saved says whether the generation's status and usage were stored, and record.prompts_saved whether the prompts were. The prompts are returned either way, since the model was already paid for. Model errors name the category and the provider's HTTP status only; the provider's text is never returned.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_page, invalid_tag, invalid_since, invalid_landing_url, invalid_domain, … | Bad input. The message names the field. |
| 402 | feature_not_in_plan | The client's plan does not include competitor ads. |
| 403 | agency_required, location_disabled | Write with a read-only key, or the client is disabled. |
| 409 | already_tracked, page_limit, refresh_in_progress | Duplicate page, 10 pages already, or a refresh is running. |
| 429 | refreshed_recently, refresh_budget_exhausted, rate_limited | See Retry-After. |
| 502, 504 | scraper_failed, scraper_untrusted_redirect, scraper_timeout | The Ad Library scrape failed. Retry later. |
| 503 | pipeline_disabled, scraper_not_configured | Refreshes are switched off. |
| 400 | brand_required, invalid_brand, invalid_request | Ad prompts: no saved brand and none sent, or a bad body. |
| 404 | rewrite_ad_not_found | rewrite_ad_id is not one of this client's competitor ads. |
| 409 | no_model_key, model_key_rejected, model_key_unreadable | Add or fix the agency's model key. |
| 422 | no_working_ads, model_unsupported, grounding_too_large | No competitor ad has run 30 days yet, the key's model cannot run here, or the ad to rewrite is too long to fit. |
| 429 | generation_budget_exhausted, model_rate_limited | 20 generations per client per 24 hours, or the provider rate-limited the key. See Retry-After. |
| 502 | generation_invalid, model_failed | The model's answer failed the checks after one repair, or the model call failed. |
MCP tools
list_competitor_pages, add_competitor_page, update_competitor_page, delete_competitor_page, refresh_competitor_page, list_competitor_ads, get_competitor_ad, get_competitor_ads_summary, get_competitor_ad_brand, set_competitor_ad_brand, generate_ad_prompts. See MCP tools.