# FernPod API Reference # Base URL: https://fernpod.com # Version: 0.1.0 ## Authentication Two methods supported on all authenticated endpoints: Header: Authorization: Bearer - Get token from POST /auth/register or POST /auth/login - Tokens expire after 7 days Header: Authorization: ApiKey fpod_ - Create via POST /api-keys (requires Pro or Studio plan) - Keys start with "fpod_" prefix --- ## POST /auth/register Auth: none Body: { email: string (required), password: string (required, min 8 chars), name: string (optional) } Response 201: { user: { id, email, name, plan }, token: string } ## POST /auth/login Auth: none Body: { email: string (required), password: string (required) } Response 200: { user: { id, email, name, plan }, token: string } ## GET /auth/me Auth: required Response 200: { user: { id, email, name, plan, stripe_customer_id, created_at } } --- ## GET /podcasts Auth: required Response 200: { podcasts: PodcastRow[] } ## POST /podcasts Auth: required Body: { title: string (required), description: string, author: string, email: string, language: string (default "en"), category: string, subcategory: string, explicit: boolean } Plan limits: Free=1, Pro=3, Studio=unlimited Response 201: PodcastRow ## GET /podcasts/:id Auth: required Response 200: { podcast: PodcastRow } ## PATCH /podcasts/:id Auth: required Body: { title?, description?, author?, email?, language?, category?, subcategory?, image_url?, website_url?, explicit? } Response 200: PodcastRow ## POST /podcasts/:id/cover Auth: required Body: raw binary image data (PNG, JPG, WebP, GIF). Max 10MB. Response 200: { image_url: string } ## DELETE /podcasts/:id Auth: required Response 200: { deleted: true } Note: cascades to all episodes, audio files, and analytics --- ## GET /podcasts/:podcastId/episodes Auth: required Query: ?status=draft|published|scheduled|unpublished|planned (optional) Response 200: { episodes: EpisodeRow[] } ## POST /podcasts/:podcastId/episodes Auth: required Body: { title: string (required), description: string, season: number, episode_number: number, episode_type: "full"|"trailer"|"bonus", explicit: boolean, status: "draft"|"published"|"planned", scheduled_at: ISO string, planned_for: string } Plan limits: Free=10, Pro=100, Studio=unlimited Response 201: EpisodeRow ## POST /podcasts/:podcastId/episodes/:episodeId/upload Auth: required Body: raw binary audio data (MP3, M4A, OGG, FLAC, WAV) Response 200: { episode: EpisodeRow, audio: { size: number, duration: number, type: string, tags: { title, artist, album, year, duration } } } Note: checks storage limits. Set Content-Type header to audio MIME type. ## GET /podcasts/:podcastId/episodes/:episodeId Auth: required Response 200: { episode: EpisodeRow } ## PATCH /podcasts/:podcastId/episodes/:episodeId Auth: required Body: { title?, description?, season?, episode_number?, episode_type?, explicit?, status?, scheduled_at?, planned_for? } Response 200: EpisodeRow Note: setting status="published" auto-sets published_at ## DELETE /podcasts/:podcastId/episodes/:episodeId Auth: required Response 200: { deleted: true } --- ## GET /podcasts/:podcastId/analytics Auth: required Query: ?days=30 (optional, default 30) Response 200 (Free plan): { analytics: { totalDownloads, previousDownloads, downloadGrowth, dailyDownloads[] }, plan_note: "..." } Response 200 (Pro/Studio): { analytics: { totalDownloads, previousDownloads, downloadGrowth, dailyDownloads[], topEpisodes[], byCountry[], byListeningApp[], referrers[] } } ## GET /podcasts/:podcastId/analytics/episodes/:episodeId Auth: required Query: ?days=30 (optional, default 30) Response 200: { analytics: { totalDownloads, previousDownloads, downloadGrowth, dailyDownloads[], byCountry[], byListeningApp[], referrers[] } } --- ## POST /billing/checkout Auth: required Body: { plan: "pro"|"studio" } Response 200: { url: string (Stripe checkout URL) } ## POST /billing/portal Auth: required Response 200: { url: string (Stripe portal URL) } ## GET /billing/status Auth: required Response 200: { plan, stripe_customer_id, stripe_subscription_id } --- ## GET /api-keys Auth: required (Pro/Studio only) Response 200: { api_keys: [{ id, key_prefix, name, scopes, last_used_at, created_at }] } ## POST /api-keys Auth: required (Pro/Studio only) Body: { name: string (required), scopes: string (optional, default "read,write") } Response 201: { api_key: { id, key, prefix, name, scopes }, warning: "Save this API key now. It will not be shown again." } ## DELETE /api-keys/:id Auth: required Response 200: { deleted: true } --- ## POST /feedback Auth: required Body: { type: "bug"|"feature"|"general" (optional, default "general"), subject: string (required, max 200), message: string (required, max 5000) } Response 201: { feedback: FeedbackRow } ## GET /feedback Auth: required Response 200: { feedback: FeedbackRow[] } ## GET /feedback/:id Auth: required Response 200: { feedback: FeedbackRow } --- ## Public Routes (no auth) GET /feed/:slug — RSS feed (application/rss+xml) GET /p/:slug — Public podcast website (HTML) GET /p/:slug/episodes/:episodeSlug — Public episode page (HTML) GET /embed/:episodeId — Embeddable episode player (HTML) GET /embed/podcast/:podcastId — Embeddable podcast player (HTML, 5 latest episodes) GET /audio/:userId/:podcastId/:filename — Audio streaming (supports range requests) GET /covers/:userId/:podcastId/:filename — Cover art serving --- ## Plan Limits Free: 1 podcast, 10 episodes, 500MB storage, 5K downloads/mo, basic analytics, no API Pro ($9/mo): 3 podcasts, 100 episodes, 25GB storage, unlimited downloads, full analytics, API access, custom domain Studio ($29/mo): unlimited podcasts/episodes/storage, unlimited downloads, full analytics, API + webhooks, custom domain, private podcasts, 5 team members, AI features Fair use: 200K downloads/mo soft cap on unlimited plans ## Error Format All errors return: { "error": "message" } Status codes: 400 (bad request), 401 (unauthorized), 403 (forbidden/plan limit), 404 (not found), 429 (rate limited), 500 (server error) ## Data Types PodcastRow: { id, user_id, title, description, author, email, language, category, subcategory, image_url, website_url, custom_domain, explicit (0|1), slug, feed_url, is_private (0|1), created_at, updated_at } EpisodeRow: { id, podcast_id, title, description, audio_url, audio_size, audio_duration, audio_type, season, episode_number, episode_type, explicit (0|1), slug, status, published_at, scheduled_at, planned_for, r2_key, created_at, updated_at } FeedbackRow: { id, user_id, type, subject, message, status, admin_response, responded_at, notified_at, created_at, updated_at } IDs: 20-character hex strings Timestamps: ISO 8601 format Booleans: 0 or 1 (SQLite integers)