Watchlists.

Track competitor pages on the Meta Ad Library and read what they launch, kill and keep running. Everything here is the same data the dashboard shows, scoped to the workspace your API key belongs to (or the one named in X-Workspace-Id).

The Watchlist object

  • id: watchlist id. Matches data.watchlist.id on the webhook.
  • brand: Facebook page name.
  • page_url: canonical Facebook page URL.
  • workspace_id, workspace_name: owning workspace. null for legacy watchlists created before workspaces shipped.
  • created_at: when the watchlist was added.
  • last_alerted_at: when we last sent an alert for this watchlist. Useful for reconciling against your own event log.
  • alerts.email_enabled: whether email digests are on.
  • alerts.webhook_url: the webhook receiver configured for this watchlist, or null.

List watchlists

GET/api/v1/watchlists/

Every watchlist on your account, across all workspaces. No pagination: a typical account has a few hundred at most, so sort client-side.

curl https://api.brandmov.com/api/v1/watchlists/ \
  -H "Authorization: Bearer bm_live_your_key_here"
[
  {
    "id": "wl_abc123",
    "brand": "Example Brand",
    "page_url": "https://www.facebook.com/examplebrand",
    "workspace_id": "1c9d2f4a-7b6e-4a2f-9c3d-8e5f1a2b3c4d",
    "workspace_name": "Acme Growth",
    "created_at": "2026-08-15T10:30:00+00:00",
    "last_alerted_at": "2026-09-02T09:00:00+00:00",
    "alerts": {
      "email_enabled": true,
      "webhook_url": "https://ops.example.com/webhooks/brandmov"
    }
  }
]

Get a watchlist

GET/api/v1/watchlists/{watchlist_id}
curl https://api.brandmov.com/api/v1/watchlists/wl_abc123 \
  -H "Authorization: Bearer bm_live_your_key_here"
{
  "id": "wl_abc123",
  "brand": "Example Brand",
  "page_url": "https://www.facebook.com/examplebrand",
  "workspace_id": "1c9d2f4a-7b6e-4a2f-9c3d-8e5f1a2b3c4d",
  "workspace_name": "Acme Growth",
  "created_at": "2026-08-15T10:30:00+00:00",
  "last_alerted_at": "2026-09-02T09:00:00+00:00",
  "alerts": {
    "email_enabled": true,
    "webhook_url": "https://ops.example.com/webhooks/brandmov"
  }
}

404 when the id does not exist or belongs to another account. The two cases look the same on purpose.

{
  "detail": "Watchlist not found"
}

Add a competitor

POST/api/v1/watchlists/
page_urlstring, requiredBest: an Ad Library URL with view_all_page_id. Also accepts facebook.com/profile.php?id= and facebook.com/<slug>.
page_namestringDisplay name. Taken from the URL when left out.
auto_refreshboolDefault true: queue the first scrape now. Ads show up in the feed within a few minutes.
curl -X POST https://api.brandmov.com/api/v1/watchlists/ \
  -H "Authorization: Bearer bm_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"page_url": "https://www.facebook.com/ads/library/?view_all_page_id=104000000000000"}'

Response: 201 Created

{
  "watchlist": { "id": "wl_abc123", "brand": "Example Brand", "...": "..." },
  "background_scrape": {
    "status": "queued",
    "initial_job_id": "8f2c...",
    "target_ads": "all_available"
  }
}

400 when you are at your plan's watchlist limit, 409 when the page is already on your watchlist.

Remove a competitor

DELETE/api/v1/watchlists/{watchlist_id}
{ "removed": "wl_abc123" }

Ads feed

GET/api/v1/watchlists/{watchlist_id}/feed

The brand's ads as of the last scrape. Served from cache, so it is fast and free.

max_resultsint1 to 500. Default 50.
ad_active_statusstringactive or inactive.
media_typestringIMAGE or VIDEO.
sort_bystringdays_run or total_impressions. Default newest first.
curl "https://api.brandmov.com/api/v1/watchlists/wl_abc123/feed?max_results=20&ad_active_status=active&sort_by=days_run" \
  -H "Authorization: Bearer bm_live_your_key_here"

Returns {watchlist_id, count, results[]}. Each result carries the copy, media URLs, start and end dates, platforms and the Ad Library link.

Refresh

POST/api/v1/watchlists/{watchlist_id}/refresh

Queues a fresh scrape of the brand. Read the feed again a few minutes later. Once per watchlist per 24 hours. Each month includes one free refresh; after that each one costs one image credit.

max_resultsint, query1 to 500. Default 100.
scrape_allbool, queryDefault true: active and inactive ads.

Response: 202 Accepted

{
  "watchlist_id": "wl_abc123",
  "queued": true,
  "job_id": "8f2c...",
  "queued_at": "2026-09-30T09:00:00+00:00",
  "charged_credits": 0,
  "free_refreshes_remaining": 0
}

Response: 429 Too Many Requests

{
  "detail": {
    "error": "Watchlists refresh at most once every 24 hours.",
    "last_refreshed_at": "2026-09-30T08:00:00+00:00",
    "next_refresh_available_at": "2026-10-01T08:00:00+00:00",
    "retry_after_seconds": 82800
  }
}

What changed

GET/api/v1/watchlists/diff

New and killed ads across every watchlist since a cutoff, grouped by brand, busiest brand first. The call to poll from a daily job.

sincestringlast_24h (default), last_48h, last_7d, last_14d, last_30d, or an ISO 8601 timestamp.
watchlist_idstringLimit to one brand.
max_ads_per_brandint1 to 50. Default 10. Counts are always the full count.
curl "https://api.brandmov.com/api/v1/watchlists/diff?since=last_7d" \
  -H "Authorization: Bearer bm_live_your_key_here"
{
  "since": "last_7d",
  "cutoff_utc": "2026-09-23T09:00:00+00:00",
  "total_new_ads": 14,
  "total_killed_ads": 6,
  "brands_with_changes": 3,
  "brands": [
    {
      "watchlist_id": "wl_abc123",
      "page_name": "Example Brand",
      "new_ad_count": 9,
      "killed_ad_count": 2,
      "new_ads": [
        {
          "ad_id": "1234567890",
          "headline": "The 3am wake-up fix",
          "media_type": "IMAGE",
          "media_url": "https://...",
          "video_url": null,
          "ad_library_url": "https://www.facebook.com/ads/library/?id=1234567890",
          "start_date": "2026-09-28"
        }
      ],
      "killed_ads": [{ "...": "same shape, plus end_date" }]
    }
  ]
}

An ad counts as killed when it is inactive and its end date falls inside the window.

Winners

GET/api/v1/watchlists/winners
GET/api/v1/watchlists/{watchlist_id}/winners

Ads ranked by how long they have been running, across every brand or for one. Running time is the strongest public signal that an ad is profitable.

top_nintPer brand, 1 to 50. Default 10.
min_running_daysintFloor. 30 means it survived learning, 90 means proven.
active_onlyboolDefault true. Set false for what worked, not just what is working now.
{
  "watchlist_id": "all",
  "filters": { "top_n": 10, "min_running_days": 30, "active_only": true },
  "total_winners": 12,
  "brands": [
    {
      "watchlist_id": "wl_abc123",
      "page_name": "Example Brand",
      "winner_count": 7,
      "max_running_days": 214,
      "median_running_days": 61,
      "winners": [
        {
          "ad_id": "1234567890",
          "headline": "The 3am wake-up fix",
          "media_type": "IMAGE",
          "start_date": "2026-02-28",
          "end_date": null,
          "is_active": true,
          "running_days": 214
        }
      ]
    }
  ]
}

Hooks

GET/api/v1/watchlists/{watchlist_id}/hooks

Reads the text on each image and the opening line of the copy, tags each hook by type (question, bold claim, social proof, curiosity, offer, problem agitation) and returns the ranked set with a short playbook.

max_adsintAds to sample, 1 to 20. Default 8. Longest runners first.
include_ocrboolDefault true. false reads copy only and is faster.
active_onlyboolDefault true.

Returns {per_ad[], top_hooks[], commentary}.

Angle shifts

GET/api/v1/watchlists/{watchlist_id}/angles

Compares the brand's last window_days (7 to 90, default 30) with the window before it and returns newly_ramped, retired and still_running angles. Needs at least three ads in each window; below that you get empty lists and a note instead of a guess.

Strategy teardown

GET/api/v1/watchlists/{watchlist_id}/strategy

A written breakdown of the brand's ads: hooks, offers, audience persona, strengths and weaknesses, and content gaps. Cached after the first call.

Errors

  • 404: watchlist not found on this workspace.
  • 400: bad input, or no ads cached yet. Call refresh first.
  • 429: refresh within 24 hours of the last one. Honour retry_after_seconds.