diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 838e20dfd4d7ab94ad5784b6754d759fec070c69..d36378a6f84e89bc70e581662f6226b6dd78a777 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -24,6 +24,7 @@ Added
 * [Dev] Options for filtering and sorting of GraphQL queries at the server.
 * [Dev] Managed models for instances handled by other apps.
 * [Dev] Upload slot sytem for out-of-band uploads in GraphQL clients
+* Generic endpoint for retrieving objects as JSON
 
 Changed
 ~~~~~~~
diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index 7331e7d4ca3777e81eae1d6374c5a02e85bc818f..c6c19af0a5f58a105377bb2a36502b562056739d 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -608,6 +608,11 @@ class RegistryObject:
         return cls.registered_objects_dict.get(name)
 
 
+class ObjectAuthenticator(RegistryObject):
+    def authenticate(self, request, obj):
+        raise NotImplementedError()
+
+
 class CalendarEventMixin(RegistryObject):
     """Mixin for calendar feeds.
 
@@ -755,3 +760,6 @@ class CalendarEventMixin(RegistryObject):
     def valid_feed_names(cls):
         """Return a list of valid feed names."""
         return [feed.name for feed in cls.valid_feeds]
+
+    def get_object_by_name(cls, name):
+        return cls.registered_objects_dict.get(name)
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 774e17ce5d0f221bed8fbc85b1c06a7d78511e8a..339a4bc2fee1c0546e62ca452793dfc7682e3aea 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -42,6 +42,21 @@ urlpatterns = [
     ),
     path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")),
     path("system_status/", views.SystemStatusAPIView.as_view(), name="system_status_api"),
+    path(
+        "o/<str:app_label>/<str:model>/<int:pk>/",
+        views.ObjectRepresentationView.as_view(),
+        name="object_representation_with_pk",
+    ),
+    path(
+        "o/<str:app_label>/<str:model>/",
+        views.ObjectRepresentationView.as_view(),
+        name="object_representation_with_model",
+    ),
+    path(
+        "o/",
+        views.ObjectRepresentationView.as_view(),
+        name="object_representation_anonymous",
+    ),
     path("", include("django_prometheus.urls")),
     path(
         "django/",
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index fa2404922d8a287c863151a6290718210a1c9bf6..f9e21e5b57472664e96bded0ebb471a11aff014b 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -101,6 +101,7 @@ from .mixins import (
     AdvancedDeleteView,
     AdvancedEditView,
     CalendarEventMixin,
+    ObjectAuthenticator,
     SuccessNextMixin,
 )
 from .models import (
@@ -1556,6 +1557,78 @@ class LoggingGraphQLView(GraphQLView):
         return result
 
 
+class ObjectRepresentationView(View):
+    """View with unique URL to get a JSON representation of an object."""
+
+    def get_model(self, request: HttpRequest, app_label: str, model: str):
+        """Get the model by app label and model name."""
+        try:
+            return apps.get_model(app_label, model)
+        except LookupError:
+            raise Http404()
+
+    def get_object(self, request: HttpRequest, app_label: str, model: str, pk: int):
+        """Get the object by app label, model name and primary key."""
+        if getattr(self, "model", None) is None:
+            self.model = self.get_model(request, app_label, model)
+
+        try:
+            return self.model.objects.get(pk=pk)
+        except self.model.DoesNotExist:
+            raise Http404()
+
+    def get(
+        self,
+        request: HttpRequest,
+        app_label: Optional[str] = None,
+        model: Optional[str] = None,
+        pk: Optional[int] = None,
+        *args,
+        **kwargs,
+    ) -> HttpResponse:
+        if app_label and model:
+            self.model = self.get_model(request, app_label, model)
+        else:
+            self.model = None
+
+        if app_label and model and pk:
+            self.object = self.get_object(request, app_label, model, pk)
+        else:
+            self.object = None
+
+        authenticators = request.GET.get("authenticators", "").split(",")
+        if authenticators == [""]:
+            authenticators = list(ObjectAuthenticator.registered_objects_dict.keys())
+        self.authenticate(request, authenticators)
+
+        if hasattr(self.object, "get_json"):
+            res = self.object.get_json(request)
+        else:
+            res = {"id": self.object.id}
+        res["_meta"] = {
+            "model": self.object._meta.model_name,
+            "app": self.object._meta.app_label,
+        }
+
+        return JsonResponse(res)
+
+    def authenticate(self, request: HttpRequest, authenticators: list[str]) -> bool:
+        """Authenticate the request against the given authenticators."""
+        for authenticator in authenticators:
+            authenticator_class = ObjectAuthenticator.get_object_by_name(authenticator)
+            if not authenticator_class:
+                continue
+            obj = authenticator_class().authenticate(request, self.object)
+            if obj:
+                if self.object is None:
+                    self.object = obj
+                elif obj != self.object:
+                    raise BadRequest("Ambiguous objects identified")
+                return True
+
+        raise PermissionDenied()
+
+
 class ICalFeedView(PermissionRequiredMixin, View):
     """View to generate an iCal feed for a calendar."""
 
diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js
index 0d341f37bba0b64b50d9281e7d3fe2f93540256c..772fa5f27eccf8f93f1051cd51f8aaeb6e60535c 100644
--- a/aleksis/core/vite.config.js
+++ b/aleksis/core/vite.config.js
@@ -259,7 +259,7 @@ export default defineConfig({
         directoryIndex: null,
         navigateFallbackAllowlist: [
           new RegExp(
-            "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$"
+            "^/(?!(django|admin|graphql|__icons__|oauth/authorize|o))[^.]*$"
           ),
         ],
         additionalManifestEntries: [
@@ -274,7 +274,7 @@ export default defineConfig({
         runtimeCaching: [
           {
             urlPattern: new RegExp(
-              "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$"
+              "^/(?!(django|admin|graphql|__icons__|oauth/authorize|o))[^.]*$"
             ),
             handler: "CacheFirst",
           },