diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php
index 4d0d63009b84d26dd00c90395167dba6d47039a7..f68f50debdf96269a9803ca35714382baf515795 100644
--- a/app/Config/Autoload.php
+++ b/app/Config/Autoload.php
@@ -49,6 +49,7 @@ class Autoload extends AutoloadConfig
         'Modules\Analytics' => ROOTPATH . 'modules/Analytics/',
         'Modules\Install' => ROOTPATH . 'modules/Install/',
         'Modules\Fediverse' => ROOTPATH . 'modules/Fediverse/',
+        'Modules\WebSub' => ROOTPATH . 'modules/WebSub/',
         'Config' => APPPATH . 'Config/',
         'ViewComponents' => APPPATH . 'Libraries/ViewComponents/',
         'ViewThemes' => APPPATH . 'Libraries/ViewThemes/',
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index 87a3d717618bff4e1ecd39ab64b4c7b9b7400477..c706585c0b625a45edc9775c317b48b552f82b26 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -69,6 +69,7 @@ use RuntimeException;
  * @property string|null $location_osm
  * @property array|null $custom_rss
  * @property string $custom_rss_string
+ * @property bool $is_published_on_hubs
  * @property int $posts_count
  * @property int $comments_count
  * @property int $created_by
@@ -164,6 +165,7 @@ class Episode extends Entity
         'location_geo' => '?string',
         'location_osm' => '?string',
         'custom_rss' => '?json-array',
+        'is_published_on_hubs' => 'boolean',
         'posts_count' => 'integer',
         'comments_count' => 'integer',
         'created_by' => 'integer',
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 1b859e2cc2ef8ab448d17f3e5d922ce9c740e122..a54b7f62927f0f78abca4d0d42eaaa078444a825 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -73,6 +73,7 @@ use RuntimeException;
  * @property string|null $payment_pointer
  * @property array|null $custom_rss
  * @property string $custom_rss_string
+ * @property bool $is_published_on_hubs
  * @property string|null $partner_id
  * @property string|null $partner_link_url
  * @property string|null $partner_image_url
@@ -180,6 +181,7 @@ class Podcast extends Entity
         'location_osm' => '?string',
         'payment_pointer' => '?string',
         'custom_rss' => '?json-array',
+        'is_published_on_hubs' => 'boolean',
         'partner_id' => '?string',
         'partner_link_url' => '?string',
         'partner_image_url' => '?string',
diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php
index 51297b7371d8f8c12ae8f04c2598ec649a9713ae..0a82031c88c0fb1e02539413fc63ce57ccae32b1 100644
--- a/app/Helpers/rss_helper.php
+++ b/app/Helpers/rss_helper.php
@@ -41,6 +41,16 @@ if (! function_exists('get_rss_feed')) {
         $atomLink->addAttribute('rel', 'self');
         $atomLink->addAttribute('type', 'application/rss+xml');
 
+        // websub: add links to hubs defined in config
+        $websubHubs = config('WebSub')
+            ->hubs;
+        foreach ($websubHubs as $websubHub) {
+            $atomLinkHub = $channel->addChild('atom:link', null, 'http://www.w3.org/2005/Atom');
+            $atomLinkHub->addAttribute('href', $websubHub);
+            $atomLinkHub->addAttribute('rel', 'hub');
+            $atomLinkHub->addAttribute('type', 'application/rss+xml');
+        }
+
         if ($podcast->new_feed_url !== null) {
             $channel->addChild('new-feed-url', $podcast->new_feed_url, $itunesNamespace);
         }
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index 64fdc7e56301cde9c60817c3b4761e05f82f7321..e9fb463ff4c81216d1d93ee0d11624afcac49ae2 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -81,6 +81,7 @@ class EpisodeModel extends Model
         'location_geo',
         'location_osm',
         'custom_rss',
+        'is_published_on_hubs',
         'posts_count',
         'comments_count',
         'published_at',
@@ -378,7 +379,7 @@ class EpisodeModel extends Model
     /**
      * @param mixed[] $data
      *
-     * @return array<string, array<string|int, mixed>>
+     * @return mixed[]
      */
     public function clearCache(array $data): array
     {
@@ -404,7 +405,7 @@ class EpisodeModel extends Model
     /**
      * @param mixed[] $data
      *
-     * @return array<string, array<string|int, mixed>>
+     * @return mixed[]
      */
     protected function writeEnclosureMetadata(array $data): array
     {
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index 1a5be2b68ab52dd13b7c92b00c300eb1d1917893..d913fd68a40120e1b6ec2dcc198c6db9bf6c0e57 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -60,6 +60,7 @@ class PodcastModel extends Model
         'location_osm',
         'payment_pointer',
         'custom_rss',
+        'is_published_on_hubs',
         'partner_id',
         'partner_link_url',
         'partner_image_url',
diff --git a/crontab b/crontab
index b1e03dbb20b368b378a8822a180f266b1dfa6a3a..3589b3e9d90021933c9914761555f0b1b63fa491 100644
--- a/crontab
+++ b/crontab
@@ -1,2 +1,3 @@
 * * * * * /usr/local/bin/php /castopod/public/index.php scheduled-activities
 * * * * * /usr/local/bin/php /castopod/public/index.php scheduled-video-clips
+* * * * * /usr/local/bin/php /castopod/public/index.php scheduled-websub-publish
diff --git a/docs/src/getting-started/install.md b/docs/src/getting-started/install.md
index 5a62109813b21a035d065c965117bdbd1f1612c2..2a72ada3e7067f2316af0ed89a230b3df262c86d 100644
--- a/docs/src/getting-started/install.md
+++ b/docs/src/getting-started/install.md
@@ -88,6 +88,13 @@ want to generate Video Clips. The following extensions must be installed:
       * * * * * /path/to/php /path/to/castopod/public/index.php scheduled-activities
    ```
 
+   - For having your episodes be broadcasted on open hubs upon publication using
+     [WebSub](https://en.wikipedia.org/wiki/WebSub):
+
+   ```bash
+      * * * * * /usr/local/bin/php /castopod/public/index.php scheduled-websub-publish
+   ```
+
    - For Video Clips to be created (see
      [FFmpeg requirements](#ffmpeg-v418-or-higher-for-video-clips)):
 
diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php
index 58beff9793c6d803cace05500f0f9ce25f01ad93..84869bfbb87573904c3041e92568fd91546c9d48 100644
--- a/modules/Admin/Controllers/EpisodeController.php
+++ b/modules/Admin/Controllers/EpisodeController.php
@@ -280,6 +280,9 @@ class EpisodeController extends BaseController
         $this->episode->setAudio($this->request->getFile('audio_file'));
         $this->episode->setCover($this->request->getFile('cover'));
 
+        // republish on websub hubs upon edit
+        $this->episode->is_published_on_hubs = false;
+
         $transcriptChoice = $this->request->getPost('transcript-choice');
         if ($transcriptChoice === 'upload-file') {
             $transcriptFile = $this->request->getFile('transcript_file');
@@ -725,6 +728,11 @@ class EpisodeController extends BaseController
             (new PostModel())->removePost($post);
         }
 
+        // set podcast is_published_on_hubs to false to trigger websub push
+        (new PodcastModel())->update($this->episode->podcast->id, [
+            'is_published_on_hubs' => false,
+        ]);
+
         $episodeModel = new EpisodeModel();
         if ($this->episode->published_at !== null) {
             // if episode is published, set episode published_at to null to unpublish before deletion
diff --git a/modules/Admin/Controllers/PodcastController.php b/modules/Admin/Controllers/PodcastController.php
index ce649a9b46eb3b7b3d3fb8204990f39a43fd4323..8fbbee4d9422081235b3828709877ec60430075b 100644
--- a/modules/Admin/Controllers/PodcastController.php
+++ b/modules/Admin/Controllers/PodcastController.php
@@ -340,6 +340,9 @@ class PodcastController extends BaseController
         $this->podcast->is_locked = $this->request->getPost('lock') === 'yes';
         $this->podcast->updated_by = (int) user_id();
 
+        // republish on websub hubs upon edit
+        $this->podcast->is_published_on_hubs = false;
+
         $db = db_connect();
 
         $db->transStart();
diff --git a/modules/Auth/Database/Migrations/2020-07-03-191500_add_podcasts_users.php b/modules/Auth/Database/Migrations/2020-07-03-191500_add_podcasts_users.php
index 222eead7338b71657ad8cb5c0d85cc66c18816b7..b6535c4adac24bed0e3f3eb3f21a6f4cf60dab4b 100644
--- a/modules/Auth/Database/Migrations/2020-07-03-191500_add_podcasts_users.php
+++ b/modules/Auth/Database/Migrations/2020-07-03-191500_add_podcasts_users.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Database\Migrations;
+namespace Modules\Auth\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
diff --git a/modules/Install/Controllers/InstallController.php b/modules/Install/Controllers/InstallController.php
index 49ca7183593f122ae07e2cb91cf7c046669e8ba2..34209a343df4c963aee34d39f42a10ec071c7870 100644
--- a/modules/Install/Controllers/InstallController.php
+++ b/modules/Install/Controllers/InstallController.php
@@ -251,6 +251,8 @@ class InstallController extends Controller
             ->latest();
         $migrations->setNamespace(APP_NAMESPACE)
             ->latest();
+        $migrations->setNamespace('Modules\WebSub')
+            ->latest();
         $migrations->setNamespace('Modules\Auth')
             ->latest();
         $migrations->setNamespace('Modules\Analytics')
diff --git a/modules/WebSub/Config/Routes.php b/modules/WebSub/Config/Routes.php
new file mode 100644
index 0000000000000000000000000000000000000000..28ad2cc6c20c34f7fe711c60cc5d29f31077f3a3
--- /dev/null
+++ b/modules/WebSub/Config/Routes.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+$routes = service('routes');
+
+/**
+ * WebSub routes file
+ */
+
+$routes->group('', [
+    'namespace' => 'Modules\WebSub\Controllers',
+], function ($routes): void {
+    $routes->cli('scheduled-websub-publish', 'WebSubController::publish');
+});
diff --git a/modules/WebSub/Config/WebSub.php b/modules/WebSub/Config/WebSub.php
new file mode 100644
index 0000000000000000000000000000000000000000..745a0eb1eeee0a1c9c45476d6b7dd16abf0db4d9
--- /dev/null
+++ b/modules/WebSub/Config/WebSub.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\WebSub\Config;
+
+use CodeIgniter\Config\BaseConfig;
+
+class WebSub extends BaseConfig
+{
+    /**
+     * --------------------------------------------------------------------------
+     * Hubs to ping
+     * --------------------------------------------------------------------------
+     * @var string[]
+     */
+    public array $hubs = [
+        'https://pubsubhubbub.appspot.com/',
+        'https://pubsubhubbub.superfeedr.com/',
+        'https://websubhub.com/hub',
+        'https://switchboard.p3k.io/',
+    ];
+}
diff --git a/modules/WebSub/Controllers/WebSubController.php b/modules/WebSub/Controllers/WebSubController.php
new file mode 100644
index 0000000000000000000000000000000000000000..a75581720ab655dadaca5695f30b053897e359e9
--- /dev/null
+++ b/modules/WebSub/Controllers/WebSubController.php
@@ -0,0 +1,89 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\WebSub\Controllers;
+
+use App\Models\EpisodeModel;
+use App\Models\PodcastModel;
+use CodeIgniter\Controller;
+use Config\Services;
+use Exception;
+
+class WebSubController extends Controller
+{
+    public function publish(): void
+    {
+        if (ENVIRONMENT !== 'production') {
+            return;
+        }
+
+        // get all podcasts that haven't been published yet
+        // or having a published episode that hasn't been pushed yet
+        $podcastModel = new PodcastModel();
+        $podcasts = $podcastModel
+            ->distinct()
+            ->select('podcasts.*')
+            ->join('episodes', 'podcasts.id = episodes.podcast_id', 'left outer')
+            ->where('podcasts.is_published_on_hubs', false)
+            ->orGroupStart()
+            ->where('episodes.is_published_on_hubs', false)
+            ->where('`' . $podcastModel->db->getPrefix() . 'episodes`.`published_at` <= NOW()', null, false)
+            ->groupEnd()
+            ->findAll();
+
+        if ($podcasts === []) {
+            return;
+        }
+
+        $request = Services::curlrequest();
+
+        $requestOptions = [
+            'headers' => [
+                'User-Agent' => 'Castopod/' . CP_VERSION . '; +' . base_url('', 'https'),
+                'Content-Type' => 'application/x-www-form-urlencoded',
+            ],
+        ];
+
+        $hubUrls = config('WebSub')
+            ->hubs;
+
+        foreach ($podcasts as $podcast) {
+            $requestOptions['form_params'] = [
+                'hub.mode' => 'publish',
+                'hub.url' => $podcast->feed_url,
+            ];
+
+            foreach ($hubUrls as $hub) {
+                try {
+                    $request->post($hub, $requestOptions);
+                } catch (Exception $exception) {
+                    log_message(
+                        'critical',
+                        "COULD NOT PUBLISH @{$podcast->handle} ON {$hub}" . PHP_EOL . $exception->getMessage()
+                    );
+                }
+            }
+
+            // set podcast feed as having been pushed onto hubs
+            (new PodcastModel())->update($podcast->id, [
+                'is_published_on_hubs' => true,
+            ]);
+
+            // set newly published episodes as pushed onto hubs
+            (new EpisodeModel())->set('is_published_on_hubs', true)
+                ->where([
+                    'podcast_id' => $podcast->id,
+                    'is_published_on_hubs' => false,
+                ])
+                ->where('`published_at` <= NOW()', null, false)
+                ->update();
+        }
+    }
+}
diff --git a/modules/WebSub/Database/Migrations/2022-03-07-180000_add_is_published_on_hubs_to_podcasts.php b/modules/WebSub/Database/Migrations/2022-03-07-180000_add_is_published_on_hubs_to_podcasts.php
new file mode 100644
index 0000000000000000000000000000000000000000..523ffdfdfd445852d7724f9949eee41688240d03
--- /dev/null
+++ b/modules/WebSub/Database/Migrations/2022-03-07-180000_add_is_published_on_hubs_to_podcasts.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\WebSub\Database\Migrations;
+
+use CodeIgniter\Database\Migration;
+
+class AddIsPublishedOnHubsToPodcasts extends Migration
+{
+    public function up(): void
+    {
+        $prefix = $this->db->getPrefix();
+
+        $createQuery = <<<CODE_SAMPLE
+            ALTER TABLE {$prefix}podcasts
+            ADD COLUMN `is_published_on_hubs` BOOLEAN NOT NULL DEFAULT 0 AFTER `custom_rss`;
+        CODE_SAMPLE;
+
+        $this->db->query($createQuery);
+    }
+
+    public function down(): void
+    {
+        $prefix = $this->db->getPrefix();
+
+        $this->forge->dropColumn($prefix . 'podcasts', 'is_published_on_hubs');
+    }
+}
diff --git a/modules/WebSub/Database/Migrations/2022-03-07-181500_add_is_published_on_hubs_to_episodes.php b/modules/WebSub/Database/Migrations/2022-03-07-181500_add_is_published_on_hubs_to_episodes.php
new file mode 100644
index 0000000000000000000000000000000000000000..3bb719661ddfd77ac6858f7ae36d35b85f0f3818
--- /dev/null
+++ b/modules/WebSub/Database/Migrations/2022-03-07-181500_add_is_published_on_hubs_to_episodes.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\WebSub\Database\Migrations;
+
+use CodeIgniter\Database\Migration;
+
+class AddIsPublishedOnHubsToEpisodes extends Migration
+{
+    public function up(): void
+    {
+        $prefix = $this->db->getPrefix();
+
+        $createQuery = <<<CODE_SAMPLE
+            ALTER TABLE {$prefix}episodes
+            ADD COLUMN `is_published_on_hubs` BOOLEAN NOT NULL DEFAULT 0 AFTER `custom_rss`;
+        CODE_SAMPLE;
+
+        $this->db->query($createQuery);
+    }
+
+    public function down(): void
+    {
+        $prefix = $this->db->getPrefix();
+
+        $this->forge->dropColumn($prefix . 'episodes', 'is_published_on_hubs');
+    }
+}