diff --git a/api/funkwhale_api/common/apps.py b/api/funkwhale_api/common/apps.py
index cd671be291395b438ebd15a9caa42f53a81a51c6..d9ab45c2bbb66fe712f8813a18bacec1dec81e49 100644
--- a/api/funkwhale_api/common/apps.py
+++ b/api/funkwhale_api/common/apps.py
@@ -1,6 +1,59 @@
+"""
+Ideal API:
+
+# myplugin/apps.py
+
+from funkwhale_api import plugins
+
+class Plugin(plugins.Plugin):
+    name = 'scrobbler'
+    config_options = [
+        {
+            'id': 'user_agent',
+            'verbose_name': 'User agent string',
+            'help_text': 'The user agent string used by this plugin for external HTTP request',
+            'default': None,
+        },
+        {
+            'id': 'timeout',
+            'type': 'int',
+            'verbose_name': 'Timeout (in seconds)'
+            'help_text': 'Max timeout for HTTP calls',
+            'default': 10,
+        },
+    ]
+
+    def get_user_options(self):
+        from . import options
+        return [
+            options.ListenBrainz,
+            options.LastFm,
+        ]
+
+
+# myplugin/hooks.py
+
+from .apps import Plugin
+
+
+@Plugin.register_action('history.listening_created')
+def scrobble(plugin, user, listening, **kwargs):
+    user_options = plugin.get_options(user)
+
+    if len(options) == 0:
+        return
+
+    for option in user_options:
+        if option.id == 'listenbrainz':
+            broadcast_to_listenbrainz()
+
+
+
+"""
 from django.apps import AppConfig, apps
 
 from . import mutations
+from . import plugins
 
 
 class CommonConfig(AppConfig):
@@ -11,3 +64,12 @@ class CommonConfig(AppConfig):
 
         app_names = [app.name for app in apps.app_configs.values()]
         mutations.registry.autodiscover(app_names)
+
+        plugins.init(
+            plugins.registry,
+            [
+                app
+                for app in apps.app_configs.values()
+                if getattr(app, "_is_funkwhale_plugin", False) is True
+            ],
+        )
diff --git a/api/funkwhale_api/common/plugins.py b/api/funkwhale_api/common/plugins.py
new file mode 100644
index 0000000000000000000000000000000000000000..3a8d3c5c449677ed9e4970400aa6dbf22193e7a0
--- /dev/null
+++ b/api/funkwhale_api/common/plugins.py
@@ -0,0 +1,111 @@
+import logging
+import persisting_theory
+from django.apps import AppConfig
+
+
+logger = logging.getLogger("funkwhale.plugins")
+
+
+class PluginsRegistry(persisting_theory.Registry):
+    look_into = "hooks"
+
+    def prepare_name(self, data, name=None):
+        return data.name
+
+    def prepare_data(self, data):
+        data.plugins_registry = self
+        return data
+
+    def dispatch_action(self, action_name, **kwargs):
+        logger.debug("Dispatching plugin action %s", action_name)
+        for plugin in self.values():
+            try:
+                handler = plugin.hooked_actions[action_name]
+            except KeyError:
+                continue
+
+            logger.debug("Hook found for plugin %s", plugin.name)
+            try:
+                handler(plugin=plugin, **kwargs)
+            except Exception:
+                logger.exception(
+                    "Hook for action %s from plugin %s failed. The plugin may be misconfigured.",
+                    action_name,
+                    plugin.name,
+                )
+            else:
+                logger.info(
+                    "Hook for action %s from plugin %s successful",
+                    action_name,
+                    plugin.name,
+                )
+
+
+registry = PluginsRegistry()
+
+
+class Plugin(AppConfig):
+    _is_funkwhale_plugin = True
+    is_initialized = False
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.config = {}
+        self.hooked_actions = {}
+
+    def get_config(self, config):
+        """
+        Called with config options extracted from env vars, if any specified
+        Returns a transformed dict
+        """
+        return config
+
+    def set_config(self, config):
+        """
+        Simply persist the given config on the plugin
+        """
+        self.config = config
+
+    def initialize(self):
+        pass
+
+    def register_action(self, action_name, func):
+        logger.debug(
+            "Registered hook for action %s via plugin %s", action_name, self.name
+        )
+        self.hooked_actions[action_name] = func
+
+
+def init(registry, plugins):
+    logger.debug("Initializing plugins...")
+    for plugin in plugins:
+        logger.info("Initializing plugin %s", plugin.name)
+        try:
+            config = plugin.get_config({})
+        except Exception:
+            logger.exception(
+                "Error while getting configuration, plugin %s disabled", plugin.name
+            )
+            continue
+
+        try:
+            plugin.set_config(config)
+        except Exception:
+            logger.exception(
+                "Error while setting configuration, plugin %s disabled", plugin.name
+            )
+            continue
+
+        try:
+            plugin.initialize()
+        except Exception:
+            logger.exception(
+                "Error while initializing, plugin %s disabled", plugin.name
+            )
+            continue
+
+        plugin.is_initialized = True
+
+    # initialization complete, now we can log the "hooks.py" file in each
+    # plugin directory
+    registry.autodiscover([p.name for p in plugins if p.is_initialized])
diff --git a/api/tests/common/test_plugins.py b/api/tests/common/test_plugins.py
new file mode 100644
index 0000000000000000000000000000000000000000..6f26b711ceb68dc14382b2ccb0784d1c0e8a5015
--- /dev/null
+++ b/api/tests/common/test_plugins.py
@@ -0,0 +1,106 @@
+from funkwhale_api.common import plugins
+
+# setup code to populate plugins registry
+# plugin-to-user -> enable and configure
+# plugin preferences
+
+
+def test_plugin_register(plugins_registry):
+    class TestPlugin(plugins.Plugin):
+        name = "scrobbler"
+        verbose_name = "Audio Scrobbler"
+
+    inst = TestPlugin(app_name="scrobbler", app_module="")
+    plugins_registry.register(inst)
+
+    assert inst.plugins_registry == plugins_registry
+    assert inst.is_initialized is False
+    assert plugins_registry["scrobbler"] == inst
+    assert inst.config == {}
+
+
+def test_plugin_get_config(plugins_registry):
+    class TestPlugin(plugins.Plugin):
+        name = "scrobbler"
+        verbose_name = "Audio Scrobbler"
+
+    plugin = TestPlugin(app_name="", app_module="")
+    assert plugin.get_config({"hello": "world"}) == {"hello": "world"}
+
+
+def test_plugin_set_config(plugins_registry):
+    class TestPlugin(plugins.Plugin):
+        name = "scrobbler"
+        verbose_name = "Audio Scrobbler"
+
+    plugin = TestPlugin(app_name="", app_module="")
+    plugin.set_config({"hello": "world"})
+    assert plugin.config == {"hello": "world"}
+
+
+def test_plugin_initialize(plugins_registry):
+    class TestPlugin(plugins.Plugin):
+        name = "scrobbler"
+        verbose_name = "Audio Scrobbler"
+
+    plugin = TestPlugin(app_name="", app_module="")
+    assert plugin.initialize() is None
+
+
+def test_action(mocker, plugins_registry):
+    class TestPlugin(plugins.Plugin):
+        name = "scrobbler"
+        verbose_name = "Audio Scrobbler"
+
+    inst = TestPlugin(app_name="scrobbler", app_module="")
+    plugins_registry.register(inst)
+
+    stub = mocker.stub()
+
+    # nothing hooked, so stub is not called
+    plugins_registry.dispatch_action("hello", user="test", arg1="value1", arg2="value2")
+    stub.assert_not_called()
+
+    # now we hook the stub on the action
+    inst.register_action("hello", stub)
+    assert inst.hooked_actions == {"hello": stub}
+    plugins_registry.dispatch_action("hello", user="test", arg1="value1", arg2="value2")
+
+    stub.assert_called_once_with(plugin=inst, user="test", arg1="value1", arg2="value2")
+
+
+def test_plugins_init(plugins_registry, mocker):
+    class TestPlugin1(plugins.Plugin):
+        name = "scrobbler"
+        verbose_name = "Audio Scrobbler"
+
+    class TestPlugin2(plugins.Plugin):
+        name = "webhooks"
+        verbose_name = "Webhooks"
+
+    plugin1 = TestPlugin1(app_name="scrobbler", app_module="")
+    plugin2 = TestPlugin2(app_name="webhooks", app_module="")
+
+    mocks = {}
+    for plugin in [plugin1, plugin2]:
+        d = {
+            "get_config": mocker.patch.object(plugin, "get_config"),
+            "set_config": mocker.patch.object(plugin, "set_config"),
+            "initialize": mocker.patch.object(plugin, "initialize"),
+        }
+        mocks[plugin.name] = d
+
+    autodiscover = mocker.patch.object(plugins_registry, "autodiscover")
+    plugins.init(plugins_registry, [plugin1, plugin2])
+
+    autodiscover.assert_called_once_with([plugin1.name, plugin2.name])
+
+    for mock_conf in mocks.values():
+        mock_conf["get_config"].assert_called_once_with({})
+        mock_conf["set_config"].assert_called_once_with(
+            mock_conf["get_config"].return_value
+        )
+        mock_conf["initialize"].assert_called_once_with()
+
+    assert plugin1.is_initialized is True
+    assert plugin2.is_initialized is True
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index d5b87e724fbfd1f23c60c70dc1f96c77f6a59608..014541d42b9baf4ec21a39debadd80199379c657 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -28,6 +28,7 @@ from rest_framework import fields as rest_fields
 from rest_framework.test import APIClient, APIRequestFactory
 
 from funkwhale_api.activity import record
+from funkwhale_api.common import plugins
 from funkwhale_api.federation import actors
 from funkwhale_api.moderation import mrf
 
@@ -437,3 +438,12 @@ def mrf_outbox_registry(mocker):
     registry = mrf.Registry()
     mocker.patch("funkwhale_api.moderation.mrf.outbox", registry)
     return registry
+
+
+@pytest.fixture
+def plugins_registry(mocker):
+    mocker.patch.dict(plugins.registry, {})
+    mocker.patch.object(
+        plugins.Plugin, "_path_from_module", return_value="/tmp/dummypath"
+    )
+    return plugins.registry