Blog / For developers and API buyers
For developers and API buyers

How to add podcast search to your app: a complete API guide

A practical guide to building podcast search with a REST API: typeahead, episode and show search, filters, field queries, boolean syntax, pagination, caching and ranking, with working code.

Published by the PodSearch team

Search is the front door of most podcast products. Listeners arrive with a half-remembered show name, a guest they heard about, or a topic they want to learn, and they expect the right episode in the first few results. Building that experience from raw RSS feeds is a large project: you need to crawl millions of feeds, parse inconsistent XML, de-duplicate episodes, index text and keep everything fresh.

A podcast search API lets you skip the infrastructure and focus on the experience. This guide shows how to build a complete search feature on the podsearch.org API, which is compatible with the ListenNotes Listen API v2 format and lives at https://podsearch.org/api/v2. Every example uses real endpoints and parameters, and the patterns apply to any similar API.

The anatomy of a good podcast search experience

Before writing code, it helps to name the parts. A polished search feature usually has five pieces:

  1. Typeahead that suggests queries, shows and genres as the user types.
  2. Show search for people looking for a specific podcast by name or publisher.
  3. Episode search for people looking for a topic, a guest or a moment.
  4. Filters for language, genre, length and publish date.
  5. Result pages that paginate, highlight matches and load quickly.

Each maps onto one or two API calls. The interesting work is in deciding when to call which endpoint and how to combine results.

Getting set up

All requests send an API key in the X-ListenAPI-Key header. You can get a key on the developers page. The Free plan includes 150 requests a month at up to 2 per second and 20 per minute, with up to 30 results per query, which is enough to prototype. Pro starts at $100 a month for 5,000 requests and raises the limits to 300 results per query and 300 requests per minute.

A tiny client in Python:

import os
import requests

class PodcastAPI:
    def __init__(self, key=None, base="https://podsearch.org/api/v2"):
        self.base = base
        self.s = requests.Session()
        self.s.headers["X-ListenAPI-Key"] = key or os.environ["PODSEARCH_API_KEY"]

    def get(self, path, **params):
        r = self.s.get(f"{self.base}{path}", params=params, timeout=15)
        r.raise_for_status()
        return r.json()

api = PodcastAPI()

Never ship the key inside a browser or mobile bundle. Put a small proxy in front of the API (a serverless function is enough) so the key stays on your server, and so you can add caching and per-user rate limits there.

Step 1: typeahead

Typeahead is the highest-volume call in most apps because it can fire on every keystroke. The /typeahead endpoint returns three lists: suggested search terms, matching genres, and matching podcasts.

curl -s "https://podsearch.org/api/v2/typeahead?q=star%20tal&show_podcasts=1&show_genres=1" \
  -H "X-ListenAPI-Key: $PODSEARCH_API_KEY"

Three rules keep typeahead fast and cheap:

Here is a debounced browser-side caller that talks to your own proxy at /api/typeahead:

let timer;
const cache = new Map();

function onInput(e) {
  const q = e.target.value.trim();
  clearTimeout(timer);
  if (q.length < 3) return render([]);
  timer = setTimeout(async () => {
    if (cache.has(q)) return render(cache.get(q));
    const res = await fetch(`/api/typeahead?q=${encodeURIComponent(q)}`);
    const data = await res.json();
    cache.set(q, data);
    render(data);
  }, 200);
}

The min_count parameter on /typeahead hides query suggestions that have been searched only a handful of times. If you display term suggestions publicly, set it to a small number so one-off searches by other users never appear as suggestions.

The /search endpoint handles both, selected by type:

A common mistake is to run only episode search. When a user types "the daily", they almost certainly want the show, not 30 episodes from different shows that mention "daily". A good pattern is to run a podcast search first and, if the top result is a strong title match, show it as a card above episode results.

def search_everything(q):
    shows = api.get("/search", q=q, type="podcast", page_size=5)
    episodes = api.get("/search", q=q, type="episode", page_size=20)
    return shows["results"], episodes["results"]

shows, eps = search_everything("history of rome")
for s in shows:
    print("SHOW", s["id"], s.get("title"), "by", s.get("publisher"))
for e in eps:
    print("EP  ", e["id"], e["podcast"]["title_original"], "-", e["title_original"])

Be aware that podcast titles are not identifiers. Several distinct feeds can share a popular title, so always key your own data on the returned id or the RSS feed URL, never on the title text.

Step 3: filters that users actually use

The /search endpoint supports the filters you would expect:

ParameterWhat it doesExample
languageRestrict to a language code (prefix match, so en also matches en-us)language=es
genre_idsComma-separated genre IDs from /genresgenre_ids=127,93
len_min, len_maxAudio length in minuteslen_min=10&len_max=30
published_after, published_beforeEpoch millisecondspublished_after=1760000000000
only_inLimit matching to fieldsonly_in=title,description
safe_modeExclude explicit contentsafe_mode=1
unique_podcastsAt most one episode per showunique_podcasts=1
sort_by_date0 for relevance, 1 for newest firstsort_by_date=1

Fetch /genres once at startup and cache it; genre IDs change rarely. For date filters, compute epoch milliseconds on the server side to avoid time zone confusion:

from datetime import datetime, timedelta, timezone

def last_n_days(n):
    return int((datetime.now(timezone.utc) - timedelta(days=n)).timestamp() * 1000)

recent = api.get("/search", q="interest rates", type="episode",
                 published_after=last_n_days(7), sort_by_date=1, language="en")

Step 4: field queries and boolean syntax

Power users, internal tools and PR or research features need more control than a single text box. podsearch.org offers two layers.

Field queries

Four optional parameters restrict a term to one field: fq_podcast_title, fq_episode_title, fq_description and fq_content. They combine with q:

# Episodes mentioning "fusion" in the episode title, on shows with "science" in the show title
curl -s "https://podsearch.org/api/v2/search?type=episode&fq_episode_title=fusion&fq_podcast_title=science" \
  -H "X-ListenAPI-Key: $PODSEARCH_API_KEY"

Boolean (DQL) syntax

Set syntax=dql to use boolean operators. AND, OR and NOT must be uppercase, parentheses group terms, quotes make phrases, and a trailing * is a wildcard. Friendly field prefixes are available: title: (or podcast:) for the show title, episode: for the episode title, desc: for descriptions, and content: (or transcript:) for episode content.

(ai OR "machine learning") AND title:podcast NOT episode:trailer
title:"lex fridman" AND content:robotics
invest* AND desc:"real estate"

URL-encode the whole query when you send it:

q = '(ai OR "machine learning") AND title:podcast NOT episode:trailer'
r = api.get("/search", q=q, syntax="dql", type="episode")

Searching transcripts and content

By default, episode search matches titles and descriptions. Add deep_search=1 to include episode content and transcripts where feeds publish them. Deep search returns more matches for spoken-word topics but can also return more noise, so many apps expose it as an "also search inside episodes" toggle rather than a default.

Step 5: pagination done right

Every /search response includes count (results on this page), total (a pagination hint, not an exact count), results and next_offset. To get the next page, pass next_offset back as offset. When next_offset is -1, you have reached the end.

def all_results(q, limit=200, **params):
    out, offset = [], 0
    while len(out) < limit:
        page = api.get("/search", q=q, offset=offset, **params)
        out.extend(page["results"])
        if page["next_offset"] in (-1, None) or not page["results"]:
            break
        offset = page["next_offset"]
    return out[:limit]

Two cautions. First, treat total as a pagination hint rather than a count, and do not display it as an exact number of matches; rely on next_offset to know whether more pages exist. Second, cap deep pagination in the UI. Very few users go past page three, and each page is a billable request.

For a show's episode list, GET /podcasts/{id} returns the podcast with a page of episodes and a next_episode_pub_date cursor. Pass it back as next_episode_pub_date to fetch older episodes.

Step 6: ranking and relevance tuning

The default ranking weights matches in the show title most heavily, then the episode title, then descriptions, then content. That suits most "find this show" queries. Two optional switches help with others:

There is also exclude_ai=true, which removes episodes from shows that an LLM classifier has labelled "likely AI-generated" at 0.9 confidence or higher. Classification is on-demand and coverage is partial, so a missing label means "not yet judged", not "human-made". If your product promises listeners human-made content, treat this filter as one signal among several.

Whatever you choose, measure it. Log the query, the result IDs shown and which one the user played. A simple metric such as "share of searches where the user played one of the top three results" will tell you more than any amount of tuning by intuition.

Step 7: caching and cost control

API cost scales with request count, so caching is the most effective cost control you have.

A simple server-side cache in Python:

import hashlib, json, time

_cache = {}

def cached_get(path, ttl=600, **params):
    key = hashlib.sha1(json.dumps([path, sorted(params.items())]).encode()).hexdigest()
    hit = _cache.get(key)
    if hit and time.time() - hit[0] < ttl:
        return hit[1]
    data = api.get(path, **params)
    _cache[key] = (time.time(), data)
    return data

In production, use Redis or your CDN's cache. Check the plan terms for what you are allowed to store long-term.

Step 8: handling errors and limits

Design for three failure modes from day one:

import random, time

def get_with_retry(path, tries=4, **params):
    for attempt in range(tries):
        try:
            return api.get(path, **params)
        except requests.HTTPError as e:
            if e.response.status_code != 429 or attempt == tries - 1:
                raise
            time.sleep((2 ** attempt) + random.random())

Putting it together: a search results page

A complete results page typically makes these calls:

  1. /typeahead while the user types (debounced, cached).
  2. On submit, /search?type=podcast&page_size=3 for a show strip and /search?type=episode for the main list, in parallel.
  3. When the user taps a show, /podcasts/{id} for details and the first page of episodes.
  4. When the user taps an episode, /episodes/{id} for full details, and optionally /episodes/{id}/recommendations for "more like this".

With caching, a typical session costs a handful of billable requests, which keeps even a busy app within a predictable budget.

Designing the result cards

The data you get back is rich enough to build informative result cards without extra calls. For episodes, show the show artwork from image or thumbnail, the episode title, the show title from the nested podcast object, a relative publish date computed from pub_date_ms, and a duration from audio_length_sec. A short plain-text excerpt of description_original (strip the HTML that many publishers include) helps users decide whether the episode is the one they want. For shows, the publisher name and total_episodes help people tell apart similarly named podcasts, which is a real problem in a catalog with millions of feeds.

Make the play action obvious. The audio field is the publisher's own enclosure URL from the RSS feed, so you can hand it straight to your player. Respect explicit_content if your app has a family mode, and consider safe_mode=1 on requests from those users so explicit episodes never reach the client at all.

Finally, think about accessibility from the start. Result lists should be real lists with headings for each card, the play button needs a label that includes the episode title, and keyboard users should be able to move from the search box into the results without a mouse. Typeahead dropdowns are a frequent source of accessibility bugs; follow the WAI-ARIA combobox pattern rather than building one from scratch. These details rarely appear in API guides, but they decide whether search feels finished.

Frequently asked questions

podsearch.org is built on the Podcast Index catalog of roughly 4.7 million RSS feeds. About 3.2 million active feeds are parsed into episodes and indexed for full-text search.

How fresh are search results?

Feeds whose hosts send Podping notifications are re-polled within seconds to minutes of a new episode. Other feeds are polled on an adaptive schedule: frequently-publishing shows are checked more often than dormant ones.

Can I search inside episodes?

Yes, with deep_search=1 or the content: field in DQL syntax. This searches episode content and transcripts where publishers provide them through tags such as <podcast:transcript>. Coverage depends on the feed.

Is the response format the same as ListenNotes?

Yes. The API follows the Listen API v2 endpoint paths, parameters and field names, and uses the same X-ListenAPI-Key header. IDs differ between the two services, and a few proprietary ListenNotes metrics return null.

Should I call the API from the browser?

No. Keep your key on a server. A small proxy also lets you cache responses, enforce your own per-user limits, and change providers without shipping a new app build.

What does the Free plan include?

150 requests a month at 2 per second and 20 per minute, with up to 30 results per query. It is designed for prototypes and personal projects.

Summary

A good podcast search experience is a combination of a few well-chosen calls: debounced typeahead, show and episode search in parallel, filters that match how people think, field queries and boolean syntax for power users, and disciplined pagination and caching. The podsearch.org API gives you all of these on top of an open catalog of millions of feeds, in a format that is compatible with the most widely used podcast API.