diff --git a/.gitignore b/.gitignore
index b30b7fb4070809c02cc1e9b051f5c2d8e4769c36..5048fc8e8d677d96b8f035b4273bf44892a5881d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -128,6 +128,7 @@ nb-configuration.xml
 
 # Visual Studio Code
 .vscode/
+.history/
 tmp/
 
 /results/
diff --git a/app/Config/Filters.php b/app/Config/Filters.php
index 85ab79abcebf94bbe7281b8fcec99fefa7fc0a6a..1cf07e27141bf01b54da6b98d09b7bfa3eb4725e 100644
--- a/app/Config/Filters.php
+++ b/app/Config/Filters.php
@@ -73,7 +73,11 @@ class Filters extends BaseConfig
         'before' => [
             // 'honeypot',
             'csrf' => [
-                'except' => ['@[a-zA-Z0-9\_]{1,32}/inbox'],
+                'except' => [
+                    '@[a-zA-Z0-9\_]{1,32}/inbox',
+                    'api/rest/v1/episodes',
+                    'api/rest/v1/episodes/[0-9]+/publish',
+                ],
             ],
             // 'invalidchars',
         ],
diff --git a/modules/Api/Rest/V1/Config/Routes.php b/modules/Api/Rest/V1/Config/Routes.php
index 6cbba9d86322e2fa30aa47423082c0f5ef3a0cc4..99429fa71829b5fa9d30fd0506a3cb1d0f7ca367 100644
--- a/modules/Api/Rest/V1/Config/Routes.php
+++ b/modules/Api/Rest/V1/Config/Routes.php
@@ -31,6 +31,8 @@ $routes->group(
     ],
     static function ($routes): void {
         $routes->get('/', 'EpisodeController::list');
+        $routes->post('/', 'EpisodeController::attemptCreate');
+        $routes->post('(:num)/publish', 'EpisodeController::attemptPublish/$1');
         $routes->get('(:num)', 'EpisodeController::view/$1');
         $routes->get('(:any)', 'ExceptionController::notFound');
     }
diff --git a/modules/Api/Rest/V1/Controllers/EpisodeController.php b/modules/Api/Rest/V1/Controllers/EpisodeController.php
index d992b530e25e5e9c6e2422f6d1db1b487fb5b8e2..53f8d4163ca0bcbde651971773f5ca36088ae290 100644
--- a/modules/Api/Rest/V1/Controllers/EpisodeController.php
+++ b/modules/Api/Rest/V1/Controllers/EpisodeController.php
@@ -5,11 +5,18 @@ declare(strict_types=1);
 namespace Modules\Api\Rest\V1\Controllers;
 
 use App\Entities\Episode;
+use App\Entities\Location;
+use App\Entities\Podcast;
+use App\Entities\Post;
 use App\Models\EpisodeModel;
+use App\Models\PodcastModel;
+use App\Models\PostModel;
 use CodeIgniter\API\ResponseTrait;
 use CodeIgniter\Controller;
 use CodeIgniter\HTTP\ResponseInterface;
+use CodeIgniter\I18n\Time;
 use Modules\Api\Rest\V1\Config\Services;
+use Modules\Auth\Models\UserModel;
 
 class EpisodeController extends Controller
 {
@@ -68,6 +75,224 @@ class EpisodeController extends Controller
         return $this->respond(static::mapEpisode($episode));
     }
 
+    public function attemptCreate(): ResponseInterface
+    {
+        $rules = [
+            'created_by'        => 'required|is_natural_no_zero',
+            'updated_by'        => 'required|is_natural_no_zero',
+            'title'             => 'required',
+            'slug'              => 'required|max_length[128]',
+            'podcast_id'        => 'required|is_natural_no_zero',
+            'audio_file'        => 'uploaded[audio_file]|ext_in[audio_file,mp3,m4a]',
+            'cover'             => 'permit_empty|is_image[cover]|ext_in[cover,jpg,jpeg,png]|min_dims[cover,1400,1400]|is_image_ratio[cover,1,1]',
+            'transcript_file'   => 'permit_empty|ext_in[transcript_file,srt,vtt]',
+            'chapters_file'     => 'permit_empty|ext_in[chapters_file,json]|is_json[chapters_file]',
+            'transcript-choice' => 'permit_empty|in_list[upload-file,remote-url]',
+            'chapters-choice'   => 'permit_empty|in_list[upload-file,remote-url]',
+        ];
+
+        if (! $this->validate($rules)) {
+            return $this->failValidationErrors(array_values($this->validator->getErrors()));
+        }
+
+        $podcastId = $this->request->getPost('podcast_id');
+
+        $podcast = (new PodcastModel())->getPodcastById($podcastId);
+
+        if (! $podcast instanceof Podcast) {
+            return $this->failNotFound('Podcast not found');
+        }
+
+        $createdByUserId = $this->request->getPost('created_by');
+
+        $userModel = new UserModel();
+        $createdByUser = $userModel->find($createdByUserId);
+
+        if (! $createdByUser) {
+            return $this->failNotFound('User not found');
+        }
+
+        $updatedByUserId = $this->request->getPost('updated_by');
+
+        $updatedByUser = $userModel->find($updatedByUserId);
+
+        if (! $updatedByUser) {
+            return $this->failNotFound('Updated by user not found');
+        }
+
+        if ($podcast->type === 'serial' && $this->request->getPost('type') === 'full') {
+            $rules['episode_number'] = 'required';
+        }
+
+        if (! $this->validate($rules)) {
+            return $this->failValidationErrors(array_values($this->validator->getErrors()));
+        }
+
+        $validData = $this->validator->getValidated();
+
+        if ((new EpisodeModel())
+            ->where([
+                'slug'       => $validData['slug'],
+                'podcast_id' => $podcast->id,
+            ])
+            ->first() instanceof Episode) {
+            return $this->fail('An episode with the same slug already exists in this podcast.', 409);
+        }
+
+        $newEpisode = new Episode([
+            'created_by'           => $createdByUserId,
+            'updated_by'           => $updatedByUserId,
+            'podcast_id'           => $podcast->id,
+            'title'                => $validData['title'],
+            'slug'                 => $validData['slug'],
+            'guid'                 => null,
+            'audio'                => $this->request->getFile('audio_file'),
+            'cover'                => $this->request->getFile('cover'),
+            'description_markdown' => $this->request->getPost('description'),
+            'location'             => in_array($this->request->getPost('location_name'), ['', null], true)
+                ? null
+                : new Location($this->request->getPost('location_name')),
+            'parental_advisory' => $this->request->getPost('parental_advisory') !== 'undefined'
+                ? $this->request->getPost('parental_advisory')
+                : null,
+            'number' => $this->request->getPost('episode_number') ? (int) $this->request->getPost(
+                'episode_number'
+            ) : null,
+            'season_number' => $this->request->getPost('season_number') ? (int) $this->request->getPost(
+                'season_number'
+            ) : null,
+            'type'              => $this->request->getPost('type'),
+            'is_blocked'        => $this->request->getPost('block') === 'yes',
+            'custom_rss_string' => $this->request->getPost('custom_rss'),
+            'is_premium'        => $this->request->getPost('premium') === 'yes',
+            'published_at'      => null,
+        ]);
+
+        $transcriptChoice = $this->request->getPost('transcript-choice');
+        if ($transcriptChoice === 'upload-file') {
+            $newEpisode->setTranscript($this->request->getFile('transcript_file'));
+        } elseif ($transcriptChoice === 'remote-url') {
+            $newEpisode->transcript_remote_url = $this->request->getPost(
+                'transcript_remote_url'
+            ) === '' ? null : $this->request->getPost('transcript_remote_url');
+        }
+
+        $chaptersChoice = $this->request->getPost('chapters-choice');
+        if ($chaptersChoice === 'upload-file') {
+            $newEpisode->setChapters($this->request->getFile('chapters_file'));
+        } elseif ($chaptersChoice === 'remote-url') {
+            $newEpisode->chapters_remote_url = $this->request->getPost(
+                'chapters_remote_url'
+            ) === '' ? null : $this->request->getPost('chapters_remote_url');
+        }
+
+        $episodeModel = new EpisodeModel();
+        if (! ($newEpisodeId = $episodeModel->insert($newEpisode, true))) {
+            return $this->fail($episodeModel->errors(), 400);
+        }
+
+        $episode = $episodeModel->find($newEpisodeId)
+            ->toRawArray();
+
+        return $this->respond($episode);
+    }
+
+    public function attemptPublish(int $id): ResponseInterface
+    {
+        $episodeModel = new EpisodeModel();
+        $episode = $episodeModel->getEpisodeById($id);
+
+        if (! $episode instanceof Episode) {
+            return $this->failNotFound('Episode not found');
+        }
+
+        if ($episode->publication_status !== 'not_published') {
+            return $this->fail('Episode is already published or scheduled for publication', 409);
+        }
+
+        $rules = [
+            'publication_method' => 'required',
+            'created_by'         => 'required|is_natural_no_zero',
+        ];
+
+        if (! $this->validate($rules)) {
+            return $this->failValidationErrors(array_values($this->validator->getErrors()));
+        }
+
+        if ($this->request->getPost('publication_method') === 'schedule') {
+            $rules['scheduled_publication_date'] = 'required|valid_date[Y-m-d H:i]';
+        }
+
+        if (! $this->validate($rules)) {
+            return $this->failValidationErrors(array_values($this->validator->getErrors()));
+        }
+
+        $createdByUserId = $this->request->getPost('created_by');
+
+        $userModel = new UserModel();
+        $createdByUser = $userModel->find($createdByUserId);
+
+        if (! $createdByUser) {
+            return $this->failNotFound('User not found');
+        }
+
+        $validData = $this->validator->getValidated();
+
+        $db = db_connect();
+        $db->transStart();
+
+        $newPost = new Post([
+            'actor_id'   => $episode->podcast->actor_id,
+            'episode_id' => $episode->id,
+            'message'    => $this->request->getPost('message') ?? '',
+            'created_by' => $createdByUserId,
+        ]);
+
+        $clientTimezone = $this->request->getPost('client_timezone') ?? app_timezone();
+
+        if ($episode->podcast->publication_status === 'published') {
+            $publishMethod = $validData['publication_method'];
+            if ($publishMethod === 'schedule') {
+                $scheduledPublicationDate = $validData['scheduled_publication_date'] ?? null;
+                if ($scheduledPublicationDate) {
+                    $episode->published_at = Time::createFromFormat(
+                        'Y-m-d H:i',
+                        $scheduledPublicationDate,
+                        $clientTimezone
+                    )->setTimezone(app_timezone());
+                } else {
+                    $db->transRollback();
+                    return $this->fail('Scheduled publication date is required', 400);
+                }
+            } else {
+                $episode->published_at = Time::now();
+            }
+        } elseif ($episode->podcast->publication_status === 'scheduled') {
+            // podcast publication date has already been set
+            $episode->published_at = $episode->podcast->published_at->addSeconds(1);
+        } else {
+            $episode->published_at = Time::now();
+        }
+
+        $newPost->published_at = $episode->published_at;
+
+        $postModel = new PostModel();
+        if (! $postModel->addPost($newPost)) {
+            $db->transRollback();
+            return $this->fail($postModel->errors(), 400);
+        }
+
+        if (! $episodeModel->update($episode->id, $episode)) {
+            $db->transRollback();
+            return $this->fail($episodeModel->errors(), 400);
+        }
+
+        $db->transComplete();
+
+        // @phpstan-ignore-next-line
+        return $this->respond(self::mapEpisode($episode));
+    }
+
     protected static function mapEpisode(Episode $episode): Episode
     {
         $episode->cover_url = $episode->getCover()
diff --git a/modules/Api/Rest/V1/Filters/ApiFilter.php b/modules/Api/Rest/V1/Filters/ApiFilter.php
index 8ce40143717cf6eb35a3903e42c22f49d0a19b3c..4b1932d30ff43ebdf70f2eff29dffd611c62e1a3 100644
--- a/modules/Api/Rest/V1/Filters/ApiFilter.php
+++ b/modules/Api/Rest/V1/Filters/ApiFilter.php
@@ -29,6 +29,13 @@ class ApiFilter implements FilterInterface
             throw PageNotFoundException::forPageNotFound();
         }
 
+        if ($request->getMethod() === 'POST' && ! $restApiConfig->basicAuth) {
+            /** @var Response $response */
+            $response = service('response');
+            $response->setStatusCode(401);
+            return $response;
+        }
+
         if ($restApiConfig->basicAuth) {
             /** @var Response $response */
             $response = service('response');
diff --git a/modules/Api/Rest/V1/podcast.json b/modules/Api/Rest/V1/podcast.json
index 4fe37fdeaca9402c16df514dbc9cf221926b40a6..3d24fc83c76a287a739302e14a5253cef7575a43 100644
--- a/modules/Api/Rest/V1/podcast.json
+++ b/modules/Api/Rest/V1/podcast.json
@@ -72,6 +72,162 @@
           }
         }
       }
+    },
+    "/api/rest/v1/episodes": {
+      "get": {
+        "summary": "List all episodes",
+        "responses": {
+          "200": {
+            "description": "Object of episodes",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Episodes"
+                }
+              }
+            }
+          },
+          "default": {
+            "description": "Unexpected error",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            }
+          }
+        }
+      },
+      "post": {
+        "summary": "Create a new episode",
+        "requestBody": {
+          "description": "Episode object that needs to be added",
+          "required": true,
+          "content": {
+            "multipart/form-data": {
+              "schema": {
+                "$ref": "#/components/schemas/EpisodeCreateRequest"
+              }
+            }
+          }
+        },
+        "responses": {
+          "201": {
+            "description": "Episode created successfully",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Episode"
+                }
+              }
+            }
+          },
+          "default": {
+            "description": "Unexpected error",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/api/rest/v1/episodes/{id}": {
+      "parameters": [
+        {
+          "name": "id",
+          "in": "path",
+          "required": true,
+          "description": "The id of the episode to retrieve",
+          "schema": {
+            "type": "integer",
+            "format": "int64",
+            "minimum": 1,
+            "maxLength": 10
+          }
+        }
+      ],
+      "get": {
+        "summary": "Info for a specific episode",
+        "responses": {
+          "200": {
+            "description": "Expected response to a valid request",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Episode"
+                }
+              }
+            }
+          },
+          "default": {
+            "description": "Unexpected error",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/api/rest/v1/episodes/{id}/publish": {
+      "post": {
+        "summary": "Publish an episode",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "The id of the episode to publish",
+            "schema": {
+              "type": "integer",
+              "format": "int64",
+              "minimum": 1,
+              "maxLength": 10
+            }
+          }
+        ],
+        "requestBody": {
+          "description": "Publish parameters",
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/EpisodePublishRequest"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Episode published successfully",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Episode"
+                }
+              }
+            }
+          },
+          "default": {
+            "description": "unexpected error",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            }
+          }
+        }
+      }
     }
   },
   "components": {
@@ -302,6 +458,209 @@
           "$ref": "#/components/schemas/Podcast"
         }
       },
+      "Episode": {
+        "type": "object",
+        "required": [
+          "id",
+          "title",
+          "slug",
+          "podcast_id",
+          "created_by",
+          "updated_by",
+          "created_at",
+          "updated_at"
+        ],
+        "properties": {
+          "id": {
+            "type": "integer",
+            "format": "int64"
+          },
+          "title": {
+            "type": "string"
+          },
+          "slug": {
+            "type": "string"
+          },
+          "podcast_id": {
+            "type": "integer",
+            "format": "int64"
+          },
+          "description_markdown": {
+            "type": "string"
+          },
+          "description_html": {
+            "type": "string"
+          },
+          "audio_url": {
+            "type": "string",
+            "format": "uri"
+          },
+          "cover_url": {
+            "type": "string",
+            "format": "uri"
+          },
+          "duration": {
+            "type": "integer",
+            "format": "int32"
+          },
+          "published_at": {
+            "type": "string",
+            "format": "date-time"
+          },
+          "created_by": {
+            "type": "integer",
+            "format": "int64"
+          },
+          "updated_by": {
+            "type": "integer",
+            "format": "int64"
+          }
+        }
+      },
+      "Episodes": {
+        "type": "array",
+        "items": {
+          "$ref": "#/components/schemas/Episode"
+        }
+      },
+      "EpisodeCreateRequest": {
+        "type": "object",
+        "required": [
+          "user_id",
+          "updated_by",
+          "title",
+          "slug",
+          "podcast_id",
+          "audio_file"
+        ],
+        "properties": {
+          "user_id": {
+            "type": "integer",
+            "format": "int64",
+            "description": "ID of the user creating the episode"
+          },
+          "updated_by": {
+            "type": "integer",
+            "format": "int64",
+            "description": "ID of the user updating the episode"
+          },
+          "title": {
+            "type": "string",
+            "description": "Title of the episode"
+          },
+          "slug": {
+            "type": "string",
+            "description": "URL-friendly slug of the episode"
+          },
+          "podcast_id": {
+            "type": "integer",
+            "format": "int64",
+            "description": "ID of the podcast the episode belongs to"
+          },
+          "audio_file": {
+            "type": "string",
+            "format": "binary",
+            "description": "Audio file for the episode"
+          },
+          "cover": {
+            "type": "string",
+            "format": "binary",
+            "description": "Cover image for the episode"
+          },
+          "description": {
+            "type": "string",
+            "description": "Description of the episode"
+          },
+          "location_name": {
+            "type": "string",
+            "description": "Location associated with the episode"
+          },
+          "parental_advisory": {
+            "type": "string",
+            "enum": ["clean", "explicit"],
+            "description": "Parental advisory rating"
+          },
+          "episode_number": {
+            "type": "integer",
+            "format": "int32",
+            "description": "Episode number (for serial podcasts)"
+          },
+          "season_number": {
+            "type": "integer",
+            "format": "int32",
+            "description": "Season number (for serial podcasts)"
+          },
+          "type": {
+            "type": "string",
+            "enum": ["full", "trailer", "bonus"],
+            "description": "Type of episode"
+          },
+          "block": {
+            "type": "string",
+            "enum": ["yes", "no"],
+            "description": "Block episode from being published"
+          },
+          "custom_rss": {
+            "type": "string",
+            "description": "Custom RSS content"
+          },
+          "premium": {
+            "type": "string",
+            "enum": ["yes", "no"],
+            "description": "Mark episode as premium content"
+          },
+          "transcript-choice": {
+            "type": "string",
+            "enum": ["upload-file", "remote-url"],
+            "description": "Transcript source choice"
+          },
+          "transcript_file": {
+            "type": "string",
+            "format": "binary",
+            "description": "Transcript file"
+          },
+          "transcript_remote_url": {
+            "type": "string",
+            "format": "uri",
+            "description": "Remote URL for transcript"
+          },
+          "chapters-choice": {
+            "type": "string",
+            "enum": ["upload-file", "remote-url"],
+            "description": "Chapters source choice"
+          },
+          "chapters_file": {
+            "type": "string",
+            "format": "binary",
+            "description": "Chapters file"
+          },
+          "chapters_remote_url": {
+            "type": "string",
+            "format": "uri",
+            "description": "Remote URL for chapters"
+          }
+        }
+      },
+      "EpisodePublishRequest": {
+        "type": "object",
+        "required": ["publication_method"],
+        "properties": {
+          "publication_method": {
+            "type": "string",
+            "enum": ["now", "schedule", "with_podcast"],
+            "description": "Method of publication"
+          },
+          "scheduled_publication_date": {
+            "type": "string",
+            "format": "date-time",
+            "description": "Scheduled date and time for publication"
+          },
+          "client_timezone": {
+            "type": "string",
+            "description": "Timezone of the client"
+          }
+        }
+      },
       "Error": {
         "type": "object",
         "properties": {