Recommendations are where podcast apps win or lose retention. A listener who finishes a series and is immediately offered three shows they will love keeps listening. A listener who sees the same ten chart toppers as everyone else drifts away. Yet most teams do not have the listening history of a large platform, so the collaborative-filtering approaches described in famous papers are out of reach on day one.
The good news is that podcasts are unusually rich in text. Every show and episode comes with a title, a description, often show notes, genres, a publisher, sometimes a transcript and a list of people. That makes content-based recommendation, built on search and metadata, a strong starting point. This guide shows how to build one with the podsearch.org API (ListenNotes-compatible, at https://podsearch.org/api/v2), then layer in behavioral signals as your user base grows.
Three kinds of recommendation you need
Most apps need three recommendation surfaces, and they call for different techniques.
- Item-to-item ("more like this"). On a show or episode page: "If you like this, try these." This is the easiest to build and the most used.
- User-to-item ("for you"). A personalized home feed based on what this listener follows and plays.
- Cold start. What to show a brand-new user with no history, and how to surface brand-new shows that have no listeners yet.
We will build each in turn.
Building blocks available from the API
Before designing algorithms, take stock of the raw material.
| Signal | Where it comes from | Endpoint |
|---|---|---|
| Ready-made similar shows | API-side similarity | GET /podcasts/{id}/recommendations |
| Ready-made similar episodes | API-side similarity | GET /episodes/{id}/recommendations |
| Genres | Feed categories mapped to genre IDs | GET /genres, genre_ids on podcasts |
| Popular shows by genre and region | Curated and chart-based lists | GET /best_podcasts |
| Text similarity | Full-text search over titles, descriptions, content | GET /search |
| People | <podcast:person> tags where feeds provide them | podcast and episode data |
| Recency and activity | latest_pub_date_ms, total_episodes | GET /podcasts/{id} |
| A random good episode | Serendipity | GET /just_listen |
The fastest route to a working feature is to use the ready-made endpoints first and add your own logic around them.
Item-to-item: "more like this"
Start with the built-in endpoint
curl -s "https://podsearch.org/api/v2/podcasts/12345/recommendations?safe_mode=1" \
-H "X-ListenAPI-Key: $PODSEARCH_API_KEY"
Replace 12345 with a podcast ID you got from search. The response is a recommendations list of up to eight podcast objects in the familiar format (id, title, publisher, image, rss, genre_ids and so on). Episode-level recommendations work the same way through /episodes/{id}/recommendations.
Use these as your baseline. They require no data of your own and give every show page a populated "more like this" row on launch day.
Improve it with your own re-ranking
Built-in recommendations are generic by design. You know things the API does not: which shows your users actually finish, which publishers your app features, which languages your audience speaks. A small re-ranking layer captures that.
import math, time
def rerank(candidates, *, user_langs=("en",), boost_ids=frozenset(), now_ms=None):
now_ms = now_ms or int(time.time() * 1000)
scored = []
for rank, p in enumerate(candidates):
score = 1.0 / (rank + 1) # keep the API's order as a prior
lang = (p.get("language") or "").lower()
if lang and not lang.startswith(user_langs):
score *= 0.2 # mostly drop other languages
latest = p.get("latest_pub_date_ms") or 0
days_idle = (now_ms - latest) / 86_400_000 if latest else 9999
score *= 1.0 if days_idle < 30 else 0.6 if days_idle < 365 else 0.3
if p["id"] in boost_ids:
score *= 1.5 # editorial or partner boost
scored.append((score, p))
return [p for _, p in sorted(scored, key=lambda t: -t[0])]
Two signals do a surprising amount of work here. Language prevents the jarring experience of a Portuguese show recommended to an English-only listener. Activity keeps you from recommending shows that stopped publishing years ago, which is a real issue in an open catalog where millions of feeds are dormant.
Build text similarity with search
When you want more control, generate candidates yourself with full-text search. The idea: extract distinctive terms from the seed show and search for other shows that match them.
import re
from collections import Counter
STOP = set("the a an and or of to in for on with is are this that your you our we it be by at from as".split())
def key_terms(text, n=6):
words = re.findall(r"[a-z][a-z'-]{2,}", (text or "").lower())
counts = Counter(w for w in words if w not in STOP)
return [w for w, _ in counts.most_common(n)]
def similar_shows(seed):
terms = key_terms(seed["title"] + " " + seed.get("description", ""))
q = " OR ".join(terms)
res = api.get("/search", q=q, type="podcast", syntax="dql", page_size=30,
language=seed.get("language") or None)
return [p for p in res["results"] if p["id"] != seed["id"]]
Term-frequency extraction is crude but effective, because show descriptions are short and dense with topical words. For better quality, compute TF-IDF weights over a sample of descriptions so that common words such as "podcast", "weekly" and "episode" are discounted automatically, or embed descriptions with a sentence-embedding model and compare vectors.
Use genres as a guardrail
Pure text similarity sometimes drifts: a true-crime show about a financial fraud can match business podcasts. Requiring at least one shared genre, or penalizing candidates with no genre overlap, keeps recommendations on-topic:
def genre_overlap(a, b):
ga, gb = set(a.get("genre_ids") or []), set(b.get("genre_ids") or [])
return len(ga & gb) / max(1, len(ga | gb))
People-based recommendations
Where feeds publish <podcast:person> tags, you can recommend along people: "More with this guest" or "Other shows from this host". Guest appearances are a strong signal because listeners often follow a person across shows. Search by name across episode text to find appearances even when a feed does not tag people:
def guest_appearances(name, limit=20):
res = api.get("/search", q=f'"{name}"', type="episode", syntax="dql",
unique_podcasts=1, sort_by_date=1, page_size=limit)
return res["results"]
Quoting the name makes it a phrase match, and unique_podcasts=1 returns at most one episode per show, so the result reads as a list of shows the person has been on.
User-to-item: a personalized home feed
Once a listener follows a few shows, you can blend item-to-item results into a personal feed.
- Take the user's recently followed and most-played shows as seeds, weighting recent activity more.
- For each seed, fetch recommendations (cached).
- Merge candidates, summing each candidate's score across seeds, so shows similar to several of the user's interests rise.
- Remove shows the user already follows or has dismissed.
- Diversify, so one genre or publisher does not fill the whole feed.
from collections import defaultdict
def home_feed(seed_ids, followed, dismissed, k=30):
scores, items = defaultdict(float), {}
for weight, sid in zip([1.0, 0.8, 0.6, 0.5, 0.4], seed_ids):
recs = cached_get(f"/podcasts/{sid}/recommendations", ttl=86_400)
for rank, p in enumerate(recs.get("recommendations", recs.get("results", []))):
if p["id"] in followed or p["id"] in dismissed:
continue
scores[p["id"]] += weight / (rank + 1)
items[p["id"]] = p
ranked = sorted(items.values(), key=lambda p: -scores[p["id"]])
return diversify(ranked, k)
def diversify(ranked, k, per_genre=4):
out, seen = [], defaultdict(int)
for p in ranked:
g = (p.get("genre_ids") or [None])[0]
if seen[g] < per_genre:
out.append(p); seen[g] += 1
if len(out) == k:
break
return out
The recommendations endpoint returns its list under a recommendations key; the snippet also accepts results so the same helper works with search responses. The helpers api, cached_get and last_n_days are the small client, cache and date utilities from our podcast search API guide.
Add fresh-episode rows
Personal feeds feel alive when they include new episodes, not just shows. For each followed show, GET /podcasts/{id} returns the latest episodes sorted newest first. Combine them into a "new from your shows" row. Freshness matters here: podsearch.org re-polls feeds that announce updates via Podping within seconds to minutes of publish, so this row can show today's episodes promptly.
Topic rows from search
Not every recommendation has to come from a seed show. If a user plays several episodes about, say, astronomy, a row of recent, well-matched episodes on that topic is a good recommendation:
def topic_row(topic, days=14):
return api.get("/search", q=topic, type="episode", recency=1, quality=1,
unique_podcasts=1, published_after=last_n_days(days), page_size=10)["results"]
recency=1 boosts newer episodes, quality=1 prefers established shows and caps episodes per show, and unique_podcasts=1 keeps the row varied.
Cold start: new users and new shows
New users
With no history, ask. A two-tap onboarding screen ("pick three topics you like") gives you seeds immediately. Map each topic to a genre and fill the first screen with /best_podcasts:
curl -s "https://podsearch.org/api/v2/best_podcasts?genre_id=127&language=en&page_size=20" \
-H "X-ListenAPI-Key: $PODSEARCH_API_KEY"
Get the genre IDs from GET /genres and use language (a language code such as en or es) to localize. podsearch.org also tracks Apple Podcasts charts per country for its browse pages, which is a good reference for what is popular in a market right now.
New shows
New shows have no listeners and no history, which means behavior-based systems never recommend them. Content-based methods do not have this problem: a new show with a clear title and description matches text similarity queries on day one. To deliberately surface new shows, reserve one or two slots per row for candidates whose first episode is recent and whose text similarity is high. Your users get discovery; independent creators get a fair chance.
Quality filters
A recommendation engine is only as good as its worst suggestion. Common filters:
- Dormant shows. Penalize or drop shows with no new episode in a year, unless they are complete limited series.
- Very small feeds. A feed with one episode might be a trailer or abandoned experiment.
- Explicit content. Respect user settings with
safe_mode=1. - AI-generated shows. If your users prefer human-made shows,
exclude_ai=trueon search drops shows that an LLM classifier labelled "likely AI-generated" at 0.9 confidence or higher. Coverage is partial and on-demand, so an unlabelled show has not been judged rather than cleared. - Duplicates. The same show can exist under several feed URLs after a host migration. De-duplicate by normalized title plus publisher, or by
<podcast:guid>when present.
Measuring whether it works
Track three numbers from the start:
- Click-through rate on each recommendation row.
- Follow or play rate from recommendations, the action you actually care about.
- Coverage, the share of your catalog that is ever recommended. Low coverage means you are only recommending the head of the catalog.
Run changes as A/B tests on a slice of users. Recommendation quality is notoriously hard to judge by looking at a few examples; small, measured changes beat big redesigns.
A reference architecture
Putting the pieces together, a production recommendation system for a small or mid-sized podcast app usually has four parts.
A candidate generator. This is the code that calls the API: built-in recommendations for each seed show, text-similarity searches, topic searches and chart lists. It runs in a background worker, not in the request path, and writes candidates to your own database. Because it runs on a schedule, a slow or failed API call delays a refresh rather than breaking a page.
A feature store. For every candidate show you keep a handful of features: language, genre IDs, latest publish date, episode count, whether it has transcripts or people tags, and, from your own data, follow and completion rates. Most of these come straight from the podcast object; the rest come from your analytics.
A ranker. At first this is the hand-written scoring function shown earlier. Later it can be a learned model (a gradient-boosted tree is a common choice) trained on whether users followed or played what they were shown. Keep the hand-written version as a fallback and as a baseline to beat.
A serving layer. When the app asks for a home feed, the server reads precomputed rankings for that user, applies last-second filters (anything followed in the past hour, anything dismissed), and returns a page. This read should take a few milliseconds and never call the external API.
Separating generation from serving is the single most important design choice. It keeps latency low, makes cost predictable, and lets you change ranking logic without touching the code that talks to the API.
Explaining recommendations
Listeners trust recommendations they understand. A short reason under each suggestion, such as "Because you follow a history show", "With a guest from an episode you played" or "New this week in Science", measurably helps engagement in many products and costs you nothing, because your pipeline already knows why each candidate was generated. Store the reason alongside the candidate and render it.
Reasons also help your own team. When a recommendation looks wrong, the reason tells you which part of the pipeline produced it, which turns a vague complaint into a specific bug.
Privacy and listener trust
Listening history is personal. A few principles keep you on the right side of your users and of privacy law:
- Keep listening data on your own servers. The API only needs show and episode IDs and search terms, never user identities.
- Let users see and clear what shaped their recommendations, and honor "not interested" immediately.
- Be careful with sensitive topics. Recommending more shows about a health condition, religion or politics because a user played one episode can feel intrusive. Many apps exclude sensitive genres from personalization unless the user follows them explicitly.
Recommendations that respect these boundaries are the ones users keep turning on.
Cost planning
Recommendations are read-heavy and cacheable. A show's "more like this" list changes slowly, so caching it for a day is reasonable. A personal feed can be computed in a background job once a day per active user, so request volume scales with daily active users rather than page views. On the Pro plan, batch endpoints (POST /podcasts with up to 10 IDs) cut the cost of hydrating candidate lists.
Frequently asked questions
Do I need machine learning to build podcast recommendations?
No. Content-based recommendations from text, genres and activity signals work well from day one. Add learned models later, once you have enough listening data to train them.
What is the quickest way to get "more like this" on every show page?
Call GET /podcasts/{id}/recommendations, cache the result for a day, and apply a light re-ranking for language and recency.
How do I recommend brand-new shows?
Use text similarity, which works without listening history, and reserve a slot or two per row for new shows with strong matches.
Can I recommend episodes by guest?
Yes. Search the guest's name as a quoted phrase with syntax=dql and unique_podcasts=1. Where feeds publish <podcast:person> tags, you can also use that structured data.
How do I avoid recommending dead podcasts?
Use latest_pub_date_ms to penalize shows that have not published recently, and treat complete limited series as an exception.
Summary
You do not need a giant listening graph to recommend podcasts well. Start with the built-in recommendation endpoints, re-rank for language and recency, add text and genre similarity from search, layer in people and topic rows, and handle cold start with onboarding and new-show slots. Measure everything, and let behavioral data take a larger share as it accumulates.