Skip to content

Podcast Lifecycle (Pause & Deletion)

This guide covers the complete lifecycle management system for podcasts: pausing, unpausing, and deleting podcasts, including their effects on RSS feeds, billing, and data retention.

Prerequisite

For billing sync details (Stripe integration), see Subscription Lifecycle Management. This guide focuses on the podcast-level state machine and its downstream effects.

Architecture Overview

Podcast Status State Machine

Status Definitions

Statusis_activeRSS BehaviorApp FeaturesBilling
activetrueServe feed normallyFull accessActive
pausedtrueServe feed normallyFrozen (read-only)Paused (if last podcast)
pending_deletionfalseRedirect or grace periodNo accessCanceled (if last podcast)

The is_active column is kept in sync via the sync_podcast_is_active() database trigger for backwards compatibility.

Database Schema

Migration: supabase/migrations/20260413111213_add_podcast_lifecycle_states.sql

New Enum Type

sql
CREATE TYPE podcast_status AS ENUM ('active', 'paused', 'pending_deletion');

Columns Added to podcasts

ColumnTypeDefaultPurpose
statuspodcast_status'active'Lifecycle state (source of truth)
paused_atTIMESTAMPTZNULLWhen the podcast was paused
pause_expires_atTIMESTAMPTZNULLWhen pause auto-expires (max 90 days)
last_pause_ended_atTIMESTAMPTZNULLWhen last pause ended (12-month cooldown)
deletion_requested_atTIMESTAMPTZNULLWhen owner requested deletion
deletion_redirect_urlTEXTNULLRSS redirect URL for subscriber migration
deletion_scheduled_atTIMESTAMPTZNULLWhen hard delete + R2 cleanup occurs

Constraints

ConstraintRule
chk_pause_requires_timestampspaused status requires paused_at and pause_expires_at
chk_deletion_requires_timestampspending_deletion requires deletion_requested_at and deletion_scheduled_at
chk_pause_max_90_dayspause_expires_at <= paused_at + 91 days
chk_deletion_redirect_max_90_daysdeletion_scheduled_at <= deletion_requested_at + 91 days

Indexes

sql
-- Lifecycle worker: find paused podcasts to auto-reactivate
CREATE INDEX idx_podcasts_pause_expires ON podcasts (pause_expires_at) WHERE status = 'paused';

-- Lifecycle worker: find pending_deletion podcasts to hard-delete
CREATE INDEX idx_podcasts_deletion_scheduled ON podcasts (deletion_scheduled_at) WHERE status = 'pending_deletion';

-- General status filtering
CREATE INDEX idx_podcasts_status ON podcasts (status);

Backwards Compatibility Trigger

sql
-- Keeps is_active in sync: TRUE for active/paused, FALSE for pending_deletion
CREATE OR REPLACE FUNCTION sync_podcast_is_active()
RETURNS TRIGGER AS $$
BEGIN
  IF NEW.status IN ('active', 'paused') THEN
    NEW.is_active := TRUE;
  ELSE
    NEW.is_active := FALSE;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Pause Flow

Eligibility Rules

A podcast can be paused when all of these are true:

  1. User is on a paid plan (starter or professional)
  2. Podcast status is active
  3. 12-month cooldown has elapsed since last_pause_ended_at

Pause Effects

  • statuspaused
  • paused_at → now
  • pause_expires_at → now + 90 days
  • RSS feed continues serving (subscribers don't notice)
  • App features are frozen (read-only)
  • If this is the user's last active podcast: Stripe billing paused via pause_collection: { behavior: 'void' }

Unpause Effects

  • statusactive
  • paused_at → NULL, pause_expires_at → NULL
  • last_pause_ended_at → now (starts the 12-month cooldown)
  • Stripe billing resumed (clears pause_collection)

Auto-Reactivation

The Lifecycle Manager Worker runs daily at 04:00 UTC and auto-reactivates any podcast where status = 'paused' and pause_expires_at <= NOW(). This also triggers billing resumption.

Deletion Flow

Deletion works differently based on hosting_type:

External Podcasts (hosting_type = 'external')

External podcasts have no RSS feed or R2 audio managed by show.fm, so they are hard-deleted immediately:

  1. DELETE FROM podcasts WHERE id = $id (CASCADE handles all related tables)
  2. Response: { status: 'deleted', immediate: true }

Self-Hosted Podcasts (hosting_type = 'podcasterplus')

Self-hosted podcasts have an RSS feed and R2 audio, so they go through a grace period:

Delete Confirmation

The UI requires the user to type the exact podcast name (case-insensitive) to confirm deletion. The API validates this:

typescript
const deleteSchema = z.object({
	confirm_name: z.string().min(1),
	redirect_url: z.string().url().optional().nullable(),
	redirect_days: z.number().int().min(0).max(90).optional().default(90)
});

What Gets Deleted (CASCADE)

When the podcast row is deleted, CASCADE removes:

TableWhat's deleted
episodesAll episodes
episode_creditsAll episode credits
episode_guestsAll guest portal access
episode_peopleAll episode rosters
bookingsAll bookings
booking_linksAll booking links
automation_rulesAll automation rules
automation_executionsAll execution history
podcast_membersAll team memberships
team_invitationsAll pending invitations

R2 cleanup (best-effort): All objects under podcasts/{podcastId}/ are deleted.

What is NOT deleted: Guest user profiles (user_profiles) survive — guests can still be invited to other podcasts.

RSS Feed Behavior by Status

The RSS Feed Worker query now includes podcast lifecycle status:

sql
WHERE slug = $slug
  AND hosting_type = 'podcasterplus'
  AND (
    (status IN ('active', 'paused') AND is_active = true)
    OR status = 'pending_deletion'
  )
StatusRedirect URLDays Since RequestRSS Response
active200 with feed
paused200 with feed (unchanged)
pending_deletionSet0-7200 with feed + <itunes:new-feed-url>
pending_deletionSet8+301 redirect to URL
pending_deletionNot setWithin window200 with feed (grace period)
pending_deletionPast deletion_scheduled_at404
Hard deleted404

Billing Sync

All billing sync functions are safe side effects: they try-catch internally, log failures, and never block the main operation.

Podcast EventConditionStripe Action
PauseLast active podcast for ownerpause_collection: { behavior: 'void' }
UnpauseAny podcast unpausedClear pause_collection
Delete (external)Last active/paused podcastcancel_at_period_end: true
Delete (self-hosted)Last active/paused podcastcancel_at_period_end: true
Auto-reactivation (cron)Pause expiredClear pause_collection

Exempt billing models: enterprise_manual, ltd, and enterprise tier users skip all Stripe sync.

Location: src/api/utils/stripe-sync.ts (app), workers/lifecycle-manager/src/index.ts (worker)

UI Flow (Settings Page)

Location: src/routes/(app)/p/[slug]/settings/+page.svelte

The settings page implements a multi-step state machine for the delete/pause journey:

idle
  ├→ pause-offer (if paid + active + eligible)
  │    └→ pause-confirm → [Execute Pause] → redirect home

  └→ warning (delete path)
       └→ Step 1: What gets deleted
            └→ billing-warning (if last podcast on paid plan)
                 └→ redirect (self-hosted only: URL + duration slider 0-90 days)
                      └→ confirm (type podcast name)
                           └→ [Execute Delete] → redirect home

BillingWarning Component

Location: src/lib/components/subscription/BillingWarning.svelte

Shown when deleting/pausing the user's last active podcast on a paid plan. Warns about subscription cancellation or billing pause.

Key Files

FilePurpose
src/api/routes/podcast-lifecycle.tsAPI: GET state, POST pause/unpause/delete
src/api/utils/stripe-sync.tsBilling sync functions
src/routes/(app)/p/[slug]/settings/+page.svelteUI: multi-step pause/delete journey
src/lib/components/subscription/BillingWarning.svelteBilling impact warning
workers/lifecycle-manager/src/index.tsCron: auto-reactivate pauses, hard-delete
workers/rss-feed/src/index.tsRSS: pending_deletion redirect logic
workers/rss-feed/src/db/client.tsRSS: status-aware podcast query
supabase/migrations/20260413111213_add_podcast_lifecycle_states.sqlSchema: status enum, columns, constraints

Internal documentation - Not for public distribution