API Documentation

Everything you need to integrate with FernPod programmatically.

v0.1.0 Base URL: https://fernpod.com

LLM / Agent-friendly version

Plain text, optimized for AI consumption. Feed this to your agent.

https://fernpod.com/docs/llm.txt

Authentication

FernPod supports two authentication methods. All authenticated endpoints require one of these headers.

Bearer Token (JWT)

Obtained from /auth/login or /auth/register. Expires after 7 days.

Authorization: Bearer <jwt-token>

API Key

Created via /api-keys. Requires Pro or Studio plan. Keys start with fpod_.

Authorization: ApiKey fpod_<key>

Auth Endpoints

POST /auth/register Public

Create a new account. Returns a JWT token.

Notes

  • email (string, required) — valid email address
  • password (string, required) — minimum 8 characters
  • name (string, optional) — display name

Request Body

{
  "email": "you@example.com",
  "password": "securepassword",
  "name": "Jane Podcaster"
}

Response

{
  "user": {
    "id": "a1b2c3d4e5f6a1b2c3d4",
    "email": "you@example.com",
    "name": "Jane Podcaster",
    "plan": "free"
  },
  "token": "eyJhbGciOiJIUzI1NiJ9..."
}
POST /auth/login Public

Authenticate and receive a JWT token.

Notes

  • email (string, required)
  • password (string, required)

Request Body

{
  "email": "you@example.com",
  "password": "securepassword"
}

Response

{
  "user": {
    "id": "a1b2c3d4e5f6a1b2c3d4",
    "email": "you@example.com",
    "name": "Jane Podcaster",
    "plan": "pro"
  },
  "token": "eyJhbGciOiJIUzI1NiJ9..."
}
GET /auth/me Auth Required

Get the current authenticated user's info.

Response

{
  "user": {
    "id": "a1b2c3d4e5f6a1b2c3d4",
    "email": "you@example.com",
    "name": "Jane Podcaster",
    "plan": "pro",
    "stripe_customer_id": "cus_xxx",
    "created_at": "2026-01-15T10:30:00Z"
  }
}

Podcasts

GET /podcasts Auth Required

List all podcasts owned by the authenticated user.

Response

{
  "podcasts": [
    {
      "id": "a1b2c3d4e5f6a1b2c3d4",
      "title": "My Great Podcast",
      "slug": "my-great-podcast",
      "description": "A show about things",
      "author": "Jane Podcaster",
      "language": "en",
      "category": "Technology",
      "image_url": "/covers/uid/pid/cover.jpg",
      "explicit": 0,
      "is_private": 0,
      "created_at": "2026-01-15T10:30:00Z",
      "updated_at": "2026-01-15T10:30:00Z"
    }
  ]
}
POST /podcasts Auth Required

Create a new podcast. Enforces plan limits (Free: 1, Pro: 3, Studio: unlimited).

Notes

  • title (string, required) — podcast title
  • description (string, optional)
  • author (string, optional) — defaults to user name
  • email (string, optional) — defaults to user email
  • language (string, optional) — ISO code, defaults to "en"
  • category (string, optional) — iTunes category
  • subcategory (string, optional) — iTunes subcategory
  • explicit (boolean, optional) — defaults to false

Request Body

{
  "title": "My Great Podcast",
  "description": "A show about things",
  "author": "Jane Podcaster",
  "language": "en",
  "category": "Technology",
  "explicit": false
}

Response

{
  "id": "a1b2c3d4e5f6a1b2c3d4",
  "title": "My Great Podcast",
  "slug": "my-great-podcast",
  ...
}
GET /podcasts/:id Auth Required

Get a single podcast by ID.

Response

{
  "podcast": { ... }
}
PATCH /podcasts/:id Auth Required

Update a podcast. Send only the fields you want to change.

Notes

  • title, description, author, email, language, category, subcategory, image_url, website_url, explicit — all optional

Request Body

{
  "title": "Updated Title",
  "category": "Arts"
}

Response

{ ... updated podcast }
POST /podcasts/:id/cover Auth Required

Upload cover art. Send raw image bytes (not multipart form data). Max 10 MB. Supports PNG, JPG, WebP, GIF.

Notes

  • Request body: raw binary image data
  • Set Content-Type to the image MIME type

Response

{
  "image_url": "/covers/uid/pid/cover.jpg"
}
DELETE /podcasts/:id Auth Required

Delete a podcast and all its episodes, audio files, and analytics. This is permanent.

Response

{
  "deleted": true
}

Episodes

All episode routes are nested under /podcasts/:podcastId/episodes.

GET /podcasts/:podcastId/episodes Auth Required

List all episodes for a podcast. Optionally filter by status.

Notes

  • Query: ?status=draft|published|scheduled|unpublished|planned (optional)

Response

{
  "episodes": [
    {
      "id": "b2c3d4e5f6a1b2c3d4e5",
      "podcast_id": "a1b2c3d4e5f6a1b2c3d4",
      "title": "Episode 1: Getting Started",
      "slug": "episode-1-getting-started",
      "description": "The first episode",
      "status": "published",
      "audio_url": "/audio/uid/pid/eid.mp3",
      "audio_size": 15728640,
      "audio_duration": 1845,
      "audio_type": "audio/mpeg",
      "season": 1,
      "episode_number": 1,
      "episode_type": "full",
      "published_at": "2026-01-20T08:00:00Z"
    }
  ]
}
POST /podcasts/:podcastId/episodes Auth Required

Create a new episode (metadata only — upload audio separately). Enforces plan limits (Free: 10, Pro: 100, Studio: unlimited).

Notes

  • title (string, required)
  • description (string, optional)
  • season (number, optional)
  • episode_number (number, optional)
  • episode_type (string, optional) — "full", "trailer", or "bonus"
  • explicit (boolean, optional)
  • status (string, optional) — "draft" (default), "published", or "planned"
  • scheduled_at (string, optional) — ISO timestamp for scheduled publish
  • planned_for (string, optional) — date for content calendar

Request Body

{
  "title": "Episode 1: Getting Started",
  "description": "The first episode",
  "season": 1,
  "episode_number": 1,
  "episode_type": "full",
  "status": "draft"
}

Response

{ ... episode object }
POST /podcasts/:podcastId/episodes/:episodeId/upload Auth Required

Upload audio to an existing episode. Send raw audio bytes (not multipart). Supports MP3, M4A, OGG, FLAC, WAV.

Notes

  • Request body: raw binary audio data
  • Set Content-Type to the audio MIME type
  • Checks storage plan limits before accepting

Response

{
  "episode": { ... updated episode with audio fields },
  "audio": {
    "size": 15728640,
    "duration": 1845,
    "type": "audio/mpeg",
    "tags": {
      "title": "Episode 1",
      "artist": "Jane Podcaster",
      "album": "My Podcast",
      "year": 2026,
      "duration": 1845
    }
  }
}
GET /podcasts/:podcastId/episodes/:episodeId Auth Required

Get a single episode by ID.

Response

{
  "episode": { ... }
}
PATCH /podcasts/:podcastId/episodes/:episodeId Auth Required

Update an episode. Send only the fields you want to change. Setting status to "published" auto-sets published_at.

Notes

  • title, description, season, episode_number, episode_type, explicit, status, scheduled_at, planned_for — all optional

Request Body

{
  "status": "published",
  "description": "Updated show notes"
}

Response

{ ... updated episode }
DELETE /podcasts/:podcastId/episodes/:episodeId Auth Required

Delete an episode and its audio file from storage. This is permanent.

Response

{
  "deleted": true
}

Analytics

Free plan gets basic analytics (totals + daily chart). Pro/Studio get full analytics with geographic, app, and referrer breakdowns.

GET /podcasts/:podcastId/analytics Auth Required

Get podcast-level analytics.

Notes

  • Query: ?days=30 (optional, default: 30)
  • Free plan: only totalDownloads, previousDownloads, downloadGrowth, dailyDownloads
  • Pro/Studio: all fields including topEpisodes, byCountry, byListeningApp, referrers

Response

{
  "analytics": {
    "totalDownloads": 12450,
    "previousDownloads": 10200,
    "downloadGrowth": 22.1,
    "dailyDownloads": [
      { "date": "2026-01-15", "downloads": 450 }
    ],
    "topEpisodes": [
      { "id": "...", "title": "...", "downloads": 3200 }
    ],
    "byCountry": [
      { "country": "US", "downloads": 8000 }
    ],
    "byListeningApp": [
      { "app": "Apple Podcasts", "downloads": 5000 }
    ],
    "referrers": [
      { "referer": "twitter.com", "downloads": 1200 }
    ]
  }
}
GET /podcasts/:podcastId/analytics/episodes/:episodeId Auth Required

Get analytics for a single episode.

Notes

  • Query: ?days=30 (optional, default: 30)

Response

{
  "analytics": {
    "totalDownloads": 3200,
    "previousDownloads": 2800,
    "downloadGrowth": 14.3,
    "dailyDownloads": [ ... ],
    "byCountry": [ ... ],
    "byListeningApp": [ ... ],
    "referrers": [ ... ]
  }
}

Billing

POST /billing/checkout Auth Required

Create a Stripe Checkout session to upgrade your plan.

Notes

  • plan (string, required) — "pro" or "studio"
  • Redirect the user to the returned URL

Request Body

{
  "plan": "pro"
}

Response

{
  "url": "https://checkout.stripe.com/c/pay/..."
}
POST /billing/portal Auth Required

Create a Stripe Customer Portal session to manage subscription.

Notes

  • Requires an existing Stripe subscription

Response

{
  "url": "https://billing.stripe.com/p/session/..."
}
GET /billing/status Auth Required

Get current billing status.

Response

{
  "plan": "pro",
  "stripe_customer_id": "cus_xxx",
  "stripe_subscription_id": "sub_xxx"
}

API Keys

Requires Pro or Studio plan. API keys provide an alternative to JWT tokens for programmatic access.

GET /api-keys Auth Required

List all API keys for the authenticated user.

Response

{
  "api_keys": [
    {
      "id": "c3d4e5f6a1b2c3d4e5f6",
      "key_prefix": "fpod_a1b2",
      "name": "CI/CD Pipeline",
      "scopes": "read,write",
      "last_used_at": "2026-02-10T14:00:00Z",
      "created_at": "2026-01-15T10:30:00Z"
    }
  ]
}
POST /api-keys Auth Required

Create a new API key. The full key is only returned once — save it immediately.

Notes

  • name (string, required) — descriptive name
  • scopes (string, optional) — defaults to "read,write"

Request Body

{
  "name": "CI/CD Pipeline",
  "scopes": "read,write"
}

Response

{
  "api_key": {
    "id": "c3d4e5f6a1b2c3d4e5f6",
    "key": "fpod_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
    "prefix": "fpod_a1b2",
    "name": "CI/CD Pipeline",
    "scopes": "read,write"
  },
  "warning": "Save this API key now. It will not be shown again."
}
DELETE /api-keys/:id Auth Required

Revoke an API key. This is immediate and permanent.

Response

{
  "deleted": true
}

Feedback

POST /feedback Auth Required

Submit feedback (bug report, feature request, or general).

Notes

  • type (string, optional) — "bug", "feature", or "general" (default: "general")
  • subject (string, required) — max 200 characters
  • message (string, required) — max 5000 characters

Request Body

{
  "type": "feature",
  "subject": "Add video support",
  "message": "It would be great to support video podcasts..."
}

Response

{
  "feedback": {
    "id": "d4e5f6a1b2c3d4e5f6a1",
    "type": "feature",
    "subject": "Add video support",
    "message": "It would be great to support video podcasts...",
    "status": "open",
    "created_at": "2026-02-15T10:30:00Z"
  }
}
GET /feedback Auth Required

List all your feedback submissions.

Response

{
  "feedback": [ ... ]
}
GET /feedback/:id Auth Required

Get a single feedback item.

Response

{
  "feedback": { ... }
}

Public Content

These routes require no authentication and serve public-facing content.

GET /feed/:slug

RSS feed for a podcast. Apple Podcasts and Spotify compatible. Submit this URL to podcast directories.

GET /p/:slug

Public podcast website page with episode listings and embedded player.

GET /p/:slug/episodes/:episodeSlug

Public episode page with player, show notes, and metadata.

GET /embed/:episodeId

Embeddable episode player widget. Use in an iframe.

GET /embed/podcast/:podcastId

Embeddable podcast player showing the 5 most recent episodes.

GET /audio/:userId/:podcastId/:filename

Audio file streaming with range request support. Downloads are tracked and deduplicated (same IP + episode within 24h = 1 download).

GET /covers/:userId/:podcastId/:filename

Cover art image serving. Cached for 24 hours.

Plan Limits

Feature Free Pro ($9/mo) Studio ($29/mo)
Podcasts13Unlimited
Episodes10100Unlimited
Storage500 MB25 GBUnlimited
Downloads/month5,000UnlimitedUnlimited
AnalyticsBasicFullFull
API accessNoYesYes + Webhooks
Custom domainNoYesYes
Private podcastsNoNoYes
Team members115
AI featuresNoNoYes

Fair use policy: unlimited downloads on paid plans have a 200,000 downloads/month soft cap. Exceeding this consistently will trigger a conversation, not an invoice.

Error Format

All errors return a JSON object with an error field and an appropriate HTTP status code.

{
  "error": "Description of what went wrong"
}
Status Meaning
400Bad request — missing or invalid parameters
401Unauthorized — missing or invalid auth token/key
403Forbidden — plan limit exceeded or insufficient permissions
404Not found — resource doesn't exist or not owned by you
429Rate limited — download cap reached
500Server error — something went wrong on our end

Quick Start

Create an account, make a podcast, upload an episode — in four API calls:

# 1. Register
curl -X POST https://fernpod.com/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","password":"securepass","name":"You"}'

# 2. Create a podcast (use the token from step 1)
curl -X POST https://fernpod.com/podcasts \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"My Podcast","description":"A great show"}'

# 3. Create an episode
curl -X POST https://fernpod.com/podcasts/PODCAST_ID/episodes \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Episode 1","status":"draft"}'

# 4. Upload audio
curl -X POST https://fernpod.com/podcasts/PODCAST_ID/episodes/EPISODE_ID/upload \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: audio/mpeg" \
  --data-binary @episode.mp3