diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index ad24d43db29dddc6c18f0d2ddc5f263a7a18b222..0150ee09be7964b5ca4f2743547ba5514bbb50b8 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -286,6 +286,7 @@ MIDDLEWARE = tuple(ADDITIONAL_MIDDLEWARES_BEFORE) + (
     "django.contrib.messages.middleware.MessageMiddleware",
     "funkwhale_api.users.middleware.RecordActivityMiddleware",
     "funkwhale_api.common.middleware.ThrottleStatusMiddleware",
+    "funkwhale_api.common.plugins.PluginViewMiddleware",
 )
 
 # DEBUG
@@ -555,6 +556,7 @@ Delay in seconds before uploaded but unattached attachements are pruned from the
 # ------------------------------------------------------------------------------
 ROOT_URLCONF = "config.urls"
 SPA_URLCONF = "config.spa_urls"
+PLUGINS_URLCONF = "funkwhale_api.common.plugins"
 ASGI_APPLICATION = "config.routing.application"
 
 # This ensures that Django will be able to detect a secure connection
diff --git a/api/contrib/prometheus/__init__.py b/api/contrib/prometheus/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/contrib/prometheus/main.py b/api/contrib/prometheus/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..d0d2116165254824ac8ceb8a215d22af944f602e
--- /dev/null
+++ b/api/contrib/prometheus/main.py
@@ -0,0 +1,15 @@
+import json
+
+from django import http
+
+from .plugin import PLUGIN
+
+
+@PLUGIN.register_api_view(path="prometheus")
+def prometheus(request):
+    stats = get_stats()
+    return http.HttpResponse(json.dumps(stats))
+
+
+def get_stats():
+    return {"foo": "bar"}
diff --git a/api/contrib/prometheus/plugin.py b/api/contrib/prometheus/plugin.py
new file mode 100644
index 0000000000000000000000000000000000000000..171d919415e24224c45e1622965e510f8744d416
--- /dev/null
+++ b/api/contrib/prometheus/plugin.py
@@ -0,0 +1,5 @@
+from funkwhale_api.common import plugins
+
+
+class Plugin(plugins.Plugin):
+    name = "prometheus"
diff --git a/api/funkwhale_api/common/plugins.py b/api/funkwhale_api/common/plugins.py
new file mode 100644
index 0000000000000000000000000000000000000000..c38bbc2bcf17166343a42b84189c153dae3ca5f7
--- /dev/null
+++ b/api/funkwhale_api/common/plugins.py
@@ -0,0 +1,45 @@
+from django.apps import AppConfig
+from django import urls
+from django.conf import settings
+
+
+urlpatterns = []
+
+
+class PluginViewMiddleware:
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+        response = self.get_response(request)
+        if response.status_code == 404 and request.path.startswith("/plugins/"):
+            match = urls.resolve(request.path, urlconf=settings.PLUGINS_URLCONF)
+            response = match.func(request, *match.args, **match.kwargs)
+        return response
+
+
+class Plugin(AppConfig):
+    def ready(self):
+        from . import main  # noqa
+
+        return super().ready()
+
+    def register_api_view(self, path, name=None):
+        def register(view):
+            urlpatterns.append(
+                urls.path(
+                    "plugins/{}/{}".format(self.name.replace("_", "-"), path),
+                    view,
+                    name="plugins-{}-{}".format(self.name, name),
+                )
+            ),
+
+        return register
+
+
+def reverse(name, **kwargs):
+    return urls.reverse(name, settings.PLUGINS_URLCONF, **kwargs)
+
+
+def resolve(name, **kwargs):
+    return urls.resolve(name, settings.PLUGINS_URLCONF, **kwargs)
diff --git a/api/tests/common/test_plugins.py b/api/tests/common/test_plugins.py
new file mode 100644
index 0000000000000000000000000000000000000000..3b6a85244b2ded1966c8fbbde4df5a595a01bac7
--- /dev/null
+++ b/api/tests/common/test_plugins.py
@@ -0,0 +1,57 @@
+import os
+
+import pytest
+
+from django.urls import resolvers
+
+from funkwhale_api.common import plugins
+
+
+class P(plugins.Plugin):
+    name = "test_plugin"
+    path = os.path.abspath(__file__)
+
+
+@pytest.fixture
+def plugin(settings):
+    yield P(app_name="test_plugin", app_module="tests.common.test_plugins.main.P")
+
+
+@pytest.fixture(autouse=True)
+def clear_patterns():
+    plugins.urlpatterns.clear()
+    resolvers._get_cached_resolver.cache_clear()
+    yield
+    resolvers._get_cached_resolver.cache_clear()
+
+
+def test_can_register_view(plugin, mocker, settings):
+    view = mocker.Mock()
+    plugin.register_api_view("hello", name="hello")(view)
+    expected = "/plugins/test-plugin/hello"
+    assert plugins.reverse("plugins-test_plugin-hello") == expected
+    assert plugins.resolve(expected).func == view
+
+
+def test_plugin_view_middleware_not_matching(api_client, plugin, mocker, settings):
+    view = mocker.Mock()
+    get_response = mocker.Mock()
+    middleware = plugins.PluginViewMiddleware(get_response)
+    plugin.register_api_view("hello", name="hello")(view)
+    request = mocker.Mock(path=plugins.reverse("plugins-test_plugin-hello"))
+    response = middleware(request)
+    assert response == get_response.return_value
+    view.assert_not_called()
+
+
+def test_plugin_view_middleware_matching(api_client, plugin, mocker, settings):
+    view = mocker.Mock()
+    get_response = mocker.Mock(return_value=mocker.Mock(status_code=404))
+    middleware = plugins.PluginViewMiddleware(get_response)
+    plugin.register_api_view("hello/<slug:slug>", name="hello")(view)
+    request = mocker.Mock(
+        path=plugins.reverse("plugins-test_plugin-hello", kwargs={"slug": "world"})
+    )
+    response = middleware(request)
+    assert response == view.return_value
+    view.assert_called_once_with(request, slug="world")