# Podcast Sponsor Tracker - Which Brands Advertise on Which Shows (`mibedk/podcast-sponsor-placement-feed`) Actor

Reads public podcast RSS show notes and returns one row per detected sponsor placement: show, episode, advertiser, tracked URL, and the offer or promo code when the notes contain one. Keeps advertiser history between runs and emits first-seen, returned, lapsed and promo-code-changed events.

- **URL**: https://apify.com/mibedk/podcast-sponsor-placement-feed.md
- **Developed by:** [Mikkel Bech-Hansen](https://apify.com/mibedk) (community)
- **Categories:** Marketing, Business
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $50.00 / 1,000 podcast-processeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Podcast Sponsor Tracker - Which Brands Advertise on Which Shows

This actor reads the public RSS feeds of the podcasts you name and returns one row for every sponsor placement it finds in the show notes: which brand, on which show, in which episode, the tracked link the sponsor was given (`https://eightsleep.com/huberman`), and the offer or promo code when the notes contain one. It remembers what it saw last time, so from the second run onwards it also tells you when an advertiser shows up on a show for the first time, comes back after a break, goes quiet, or changes its promo code. There is no AI model in the loop - the tracked URL and the promo code are the evidence, and the actor reports what is literally written in the feed.

### What you actually get

Three kinds of row, tagged by `recordType`, all in one dataset:

- **`placement`** - one advertiser found in one episode. This is the unit you pay for.
- **`change_event`** - a typed change for a (brand, show) pair: `advertiser-first-seen-on-show`, `advertiser-returned`, `advertiser-lapsed`, `promo-code-changed`.
- **`brand_show_rollup`** - one summary row per (advertiser, show): tenure, placement count, cadence, last known offer. These are free.

### Input

Every field has a working default. Running it with no input at all scans two real podcasts (Huberman Lab and My First Million) for the last 90 days.

| Field | Type | Default | Notes |
|---|---|---|---|
| `feedUrls` | array of strings | Huberman Lab + My First Million | Podcast RSS feed URLs. The fastest and most reliable way to name a show. |
| `applePodcastIds` | array of strings | `[]` | Apple Podcasts numeric IDs (`1469759170`) or full Apple Podcasts URLs containing `/id...`. Resolved to feed URLs via Apple's public iTunes Lookup API and cached. |
| `searchTerms` | array of strings | `[]` | Show names, resolved through Apple's public iTunes Search API. First result with a public feed wins, so use exact titles. |
| `daysBack` | integer | `90` | Episode window, 1-1825. Feeds stream newest-first and the download aborts once episodes fall outside it. |
| `maxEpisodesPerShow` | integer | `50` | 1-500. Hard cap per show. |
| `maxShows` | integer | `10` | 1-200. Counts feed URLs, Apple IDs and search terms together. |
| `minConfidence` | `high` | `medium` | `high` | See "How detection works" below. |
| `showAliases` | array of strings | `[]` | Extra URL path slugs identifying the show in sponsor links (add `tfs` if sponsors link to `brand.com/tfs`). Applied to every show in the run. |
| `extraSponsorDomains` | array of strings | `[]` | Domains always treated as an advertiser. Useful to follow one brand across many shows. |
| `ignoreDomains` | array of strings | `[]` | Added to the built-in ignore list. |
| `brandNameOverrides` | array of objects | `[]` | `[{"domain":"joinhampton.com","brandName":"Hampton"}]`. |
| `resolveRedirects` | boolean | `false` | HEAD-follow each tracked link (up to 3 redirects) to record the final landing URL. Adds one request per unique link and **noticeably slows the run**. |
| `lapsedAfterDays` | integer | `45` | 1-365. Days without a placement before an advertiser counts as lapsed. Set it above the show's publishing gap. |
| `emitChangeEvents` | boolean | `true` | Change-event rows. |
| `emitBrandRollups` | boolean | `true` | Rollup rows. Never charged. |
| `forceFullRescan` | boolean | `false` | Re-process episodes already seen. Re-charges for placements already delivered. |
| `stateKeyValueStoreName` | string | `podcast-sponsor-placement-state` | Named key-value store holding advertiser history between runs. Keep it constant across scheduled runs of the same watchlist. |
| `requestTimeoutSecs` | integer | `45` | 5-300. |
| `maxRequestRetries` | integer | `2` | 0-10. Exponential backoff. |
| `maxFeedMegabytes` | integer | `12` | 1-100. Safety cap on how much of one feed is downloaded. |
| `proxyConfiguration` | object | no proxy | Podcast RSS is plain public XML and normally needs no proxy. |

### Output

A real `placement` record, exactly as returned by the default run:

```json
{
  "recordType": "placement",
  "showTitle": "Huberman Lab",
  "showFeedUrl": "https://feeds.megaphone.fm/hubermanlab",
  "showAppleCollectionId": null,
  "showDomain": "hubermanlab.com",
  "episodeTitle": "Essentials: Improve Flexibility with Research-Supported Stretching Protocols",
  "episodeGuid": "3b42bd84-6a51-11f1-b442-0f78f81b3455",
  "episodePublishedAt": "2026-06-18T08:00:00.000Z",
  "episodeUrl": "https://www.hubermanlab.com/episode/essentials-improve-flexibility-with-research-supported-stretching-protocols",
  "brandName": "AG1",
  "brandDomain": "drinkag1.com",
  "trackedUrl": "https://drinkag1.com/huberman",
  "landingUrl": null,
  "promoCode": null,
  "offerText": null,
  "confidence": "high",
  "signals": ["slugMatch:huberman", "strongCue:thank you to our sponsor"],
  "cueText": "Thank you to our sponsors AG1: https://drinkag1.com/huberman LMNT: https://drinklmnt.com/huberman Eight Sleep: https://eightsleep.com/huberman",
  "detectedAt": "2026-09-11T23:27:17.514Z"
}
```

A `change_event` record:

```json
{
  "recordType": "change_event",
  "eventType": "advertiser-lapsed",
  "showTitle": "Huberman Lab",
  "brandName": "Waking Up",
  "brandDomain": "wakingup.com",
  "daysSinceLastSeen": 50.6,
  "firstSeenAt": "2026-07-23T08:00:00.000Z",
  "lastSeenAt": "2026-07-23T08:00:00.000Z",
  "placementCount": 1,
  "promoCode": null,
  "previousPromoCode": null,
  "detectedAt": "2026-09-11T23:31:02.118Z"
}
```

A `brand_show_rollup` record carries `firstSeenAt`, `lastSeenAt`, `tenureDays`, `placementCount`, `episodesWithPlacement`, `placementsPerMonth`, `lastPromoCode`, `lastOfferText`, `isActive` and `daysSinceLastSeen`.

### How detection works

A link in the show notes becomes a placement when there is deterministic evidence for it:

- **`high` (default)** - the link's last path segment matches a slug derived from the show's title (`drinkag1.com/huberman`, `somewhere.com/mfm`) - this is the strongest possible signal, because a sponsor-specific vanity path exists only for tracking - **or** the link directly follows an explicit advertising cue such as "brought to you by", "thanks to our sponsors", "presented by".
- **`medium`** - also returns links that merely follow a call to action ("visit", "check out", "go to") or sit next to a promo code. Wider coverage, and it will pick up links that are not ads.

The show's own domain, domains containing the show's name, social profiles, podcast platforms, ad-tech hosts and link shorteners are excluded automatically. Promo codes and offers are only read from the same line as the link they belong to, so one sponsor's "use code SAVE20" is never attached to the next sponsor down the page.

### Cost

Pay-per-event. Prices are set in the Store listing; the plan this actor is built around is `sponsor-placement-detected` $0.006, `advertiser-change-event` $0.01, `show-scanned` $0.01. Rollup rows are free.

- **First run, 10 shows x 50 episodes**, a typical backfill: 10 show scans ($0.10) plus roughly 300-600 placements ($1.80-$3.60). **About $2-$4.**
- **A daily scheduled run over the same 10 shows**, which is the way this is meant to be used: 10 show scans ($0.10) plus only the placements in genuinely new episodes - a handful of shows publish per day, so typically 5-30 placements ($0.03-$0.18) and 0-5 change events ($0-$0.05). **Usually under $0.35 a day.**

The second run onwards is cheap by design: episodes already processed are never re-charged. Set `ACTOR_MAX_TOTAL_CHARGE_USD` on the run to cap spend - the actor checks the limit after each charge and ends the run cleanly instead of working for free.

### Known limitations

Read these before you buy. Most are inherent to the data source, not bugs.

- **It only sees sponsors that are written into the show notes.** Dynamically inserted / programmatic ads - the ads stitched into the audio at download time by the ad server - never appear in the RSS feed at all. What you get is host-read and notes-listed sponsorship, which is a **subset** of a show's true ad load. No RSS-based tool can see the rest.
- **Some shows list no sponsors in their notes at all**, even though they clearly run ads. Conan O'Brien Needs A Friend, for example, carries only its own network's link in the notes. Zero rows for such a show is a correct answer, not a failure.
- **Promo codes are much rarer than you would expect.** In the feeds checked while building this, only about 1 episode in 15-25 contained an explicit `code XXXX`. The near-universal evidence is the tracked URL; offer text ("$500 off", "20% off") is fairly common; an actual code is the exception. Do not buy this expecting a promo-code database.
- **Offers and codes are read from the same line as the link.** If a show puts the offer on the following line, it is missed. This is deliberate - the alternative mis-attributes offers between sponsors.
- **Brand identity is the registrable domain.** An advertiser using several domains appears as several brands; use `brandNameOverrides` and `extraSponsorDomains` to reconcile. Brand names are read from the notes where they corroborate the domain, and fall back to the domain otherwise.
- **Change events need at least two runs** against the same `stateKeyValueStoreName`. The first run is a baseline and deliberately emits no "first seen" events, or every advertiser would be reported as new.
- **`advertiser-lapsed` is relative to your `lapsedAfterDays` setting**, not to any inside knowledge of the contract. A show that skips a month will produce lapse events unless you raise the threshold.
- **Detection is pattern matching, not comprehension.** It will miss unusual note formats and, at `medium` confidence, will return some links that are not ads. The `signals` field on every row shows exactly why the row was emitted, so you can audit it.
- **Apple lookups are rate limited** to roughly 20 calls per minute per IP, and answer HTTP 429 above that. Resolved feeds are cached in the state store; prefer `feedUrls` for large watchlists.
- **Very large archives are capped** by `maxFeedMegabytes`. Feeds are streamed newest-first and normally stop far below the cap, but a feed sorted oldest-first will be truncated.
- **No personal data.** The actor never emits `itunes:owner` contact fields, host or guest emails, or any listener data. It reports show-level and advertiser-level facts only.

### Legal

This actor is **not affiliated with, endorsed by, or connected to** Apple, Apple Podcasts, any podcast host or network, any podcast platform, or any of the advertisers whose placements it reports.

It uses two public sources:

**Podcast RSS feeds.** RSS feeds are published openly and are intended to be retrieved automatically by podcast clients, which is what this actor does - it sends a descriptive User-Agent, reads the feed once per run, and stops downloading as soon as it has the episodes in your window. Even so, "publicly reachable" and "permitted by the publisher's terms" are separate questions: an individual podcast, host or network may have terms of service or feed policies that restrict automated access, redistribution, or commercial use of its feed content, regardless of the feed being public. Using this actor may not comply with those terms. Show notes are also the publisher's copyrighted material; this actor extracts factual data points from them (links, brand domains, codes) rather than republishing the notes, but how you store, redistribute or publish the output is your responsibility.

**Apple's iTunes Search / Lookup API** (used only when you supply `applePodcastIds` or `searchTerms`). This is a public endpoint Apple provides without an API key, but it is governed by Apple's own terms, which restrict how the returned data may be used and rate-limit requests to roughly 20 per minute per IP. Review Apple's current terms before relying on this path, and prefer supplying `feedUrls` directly if you do not want Apple in the loop at all - the actor never calls Apple when only `feedUrls` is set.

**You are responsible for your own compliance** with all of the above, and for how you use the returned data - including any competitive-intelligence, outreach or advertising use, and any applicable local law. If you are unsure whether your use case is permitted, take your own legal advice.

# Actor input Schema

## `feedUrls` (type: `array`):

The podcast RSS feeds to scan. This is the fastest and most reliable way to name a show - no Apple lookup needed. Find a feed URL with a podcast database, or let 'Podcast search terms' resolve it for you.

## `applePodcastIds` (type: `array`):

Shows named by their Apple Podcasts ID (for example 1469759170) or by a full Apple Podcasts URL containing '/id1469759170'. Each one is resolved to an RSS feed URL through Apple's public iTunes Lookup API and cached for later runs.

## `searchTerms` (type: `array`):

Show names to look up through Apple's public iTunes Search API. The first result with a public RSS feed is used, so prefer an exact show title. Apple rate-limits this to roughly 20 lookups per minute per IP; resolved feeds are cached between runs.

## `daysBack` (type: `integer`):

Only look at episodes published within this many days. Feeds are streamed newest-first and the download is aborted once episodes fall outside the window, which is what keeps 30 MB podcast archives cheap to scan.

## `maxEpisodesPerShow` (type: `integer`):

Hard cap on how many in-window episodes are parsed per show, whichever comes first with the episode window.

## `maxShows` (type: `integer`):

Stop after this many shows, counting feed URLs, Apple IDs and search terms together.

## `minConfidence` (type: `string`):

'high' only returns links backed by strong evidence: the tracked URL ends in the show's own slug (brand.com/huberman), or the link directly follows an explicit cue like 'brought to you by'. 'medium' also returns links that merely follow a call to action ('visit', 'check out') or sit next to a promo code - more coverage, more false positives.

## `showAliases` (type: `array`):

Additional URL path slugs that identify this show in sponsor links, beyond the ones derived from the show title. Use this when a show's tracked links use a slug you cannot guess - for example add 'tfs' if sponsors link to brand.com/tfs. Applied to every show in the run.

## `extraSponsorDomains` (type: `array`):

Domains to always treat as an advertiser when they appear in show notes, regardless of cue phrases. Useful for tracking one specific brand across many shows.

## `ignoreDomains` (type: `array`):

Domains to never treat as an advertiser, added to the built-in list (social networks, podcast platforms, ad-tech hosts, link shorteners). The show's own domain is always ignored automatically.

## `brandNameOverrides` (type: `array`):

Force a readable advertiser name for a domain, as objects like {"domain": "joinhampton.com", "brandName": "Hampton"}. Brand names are otherwise read from the show notes and fall back to the domain, which is not always the name a human would use.

## `resolveRedirects` (type: `boolean`):

Follow each tracked URL with a HEAD request (up to 3 redirects) to record the final landing URL. Turn this on when shows use link shorteners or ad-network redirects. It adds one request per unique link, so runs take longer.

## `lapsedAfterDays` (type: `integer`):

How many days without a placement before an advertiser counts as lapsed. Also decides when a reappearance is reported as 'advertiser-returned' instead of business as usual. Set this above the show's publishing gap - a weekly show needs a larger value than a daily one.

## `emitChangeEvents` (type: `boolean`):

Include 'change_event' rows (advertiser-first-seen-on-show, advertiser-returned, advertiser-lapsed, promo-code-changed). These only appear from the second run onwards against the same state store, because the first run is a baseline.

## `emitBrandRollups` (type: `boolean`):

Include one 'brand_show_rollup' row per (advertiser, show) with tenure, placement count, cadence and last known offer. Rollups are never charged for.

## `forceFullRescan` (type: `boolean`):

Re-process every in-window episode even if a previous run already saw it. Use for a first backfill or after changing detection settings. Note that this re-charges for placements already delivered in earlier runs.

## `stateKeyValueStoreName` (type: `string`):

Named key-value store holding advertiser history and cached feed lookups between runs. Keep it identical across scheduled runs of the same watchlist to get real change events. Use a different name to track a second watchlist independently, or to start from a clean baseline.

## `requestTimeoutSecs` (type: `integer`):

How long to wait for a feed or lookup to respond before treating it as failed.

## `maxRequestRetries` (type: `integer`):

How many times to retry a failed feed fetch or Apple lookup, with exponential backoff, before skipping that show.

## `maxFeedMegabytes` (type: `integer`):

Safety cap on how much of any single feed is downloaded. Feeds are parsed as they stream and normally stop well before this; the cap only matters for very large archives sorted oldest-first.

## `proxyConfiguration` (type: `object`):

Optional. Podcast RSS feeds are plain public XML and normally need no proxy, so this defaults to off. Turn it on only if a feed host blocks Apify's data-center IPs.

## Actor input object example

```json
{
  "feedUrls": [
    "https://feeds.megaphone.fm/hubermanlab",
    "https://feeds.megaphone.fm/HS2300184645"
  ],
  "applePodcastIds": [],
  "searchTerms": [],
  "daysBack": 90,
  "maxEpisodesPerShow": 50,
  "maxShows": 10,
  "minConfidence": "high",
  "showAliases": [],
  "extraSponsorDomains": [],
  "ignoreDomains": [],
  "brandNameOverrides": [],
  "resolveRedirects": false,
  "lapsedAfterDays": 45,
  "emitChangeEvents": true,
  "emitBrandRollups": true,
  "forceFullRescan": false,
  "stateKeyValueStoreName": "podcast-sponsor-placement-state",
  "requestTimeoutSecs": 45,
  "maxRequestRetries": 2,
  "maxFeedMegabytes": 12,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `sponsorPlacements` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "feedUrls": [
        "https://feeds.megaphone.fm/hubermanlab",
        "https://feeds.megaphone.fm/HS2300184645"
    ],
    "applePodcastIds": [],
    "searchTerms": [],
    "showAliases": [],
    "extraSponsorDomains": [],
    "ignoreDomains": [],
    "brandNameOverrides": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("mibedk/podcast-sponsor-placement-feed").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "feedUrls": [
        "https://feeds.megaphone.fm/hubermanlab",
        "https://feeds.megaphone.fm/HS2300184645",
    ],
    "applePodcastIds": [],
    "searchTerms": [],
    "showAliases": [],
    "extraSponsorDomains": [],
    "ignoreDomains": [],
    "brandNameOverrides": [],
}

# Run the Actor and wait for it to finish
run = client.actor("mibedk/podcast-sponsor-placement-feed").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "feedUrls": [
    "https://feeds.megaphone.fm/hubermanlab",
    "https://feeds.megaphone.fm/HS2300184645"
  ],
  "applePodcastIds": [],
  "searchTerms": [],
  "showAliases": [],
  "extraSponsorDomains": [],
  "ignoreDomains": [],
  "brandNameOverrides": []
}' |
apify call mibedk/podcast-sponsor-placement-feed --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,mibedk/podcast-sponsor-placement-feed"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/MjAcdgeJ1cllhmAo3/builds/ugOkjJlYJjUL7S9N6/openapi.json
