diff --git a/swh/core/api.py b/swh/core/api.py
--- a/swh/core/api.py
+++ b/swh/core/api.py
@@ -4,6 +4,8 @@
 # See top-level LICENSE file for more information
 
 import collections
+import functools
+import inspect
 import json
 import logging
 import pickle
@@ -19,11 +21,66 @@
     pass
 
 
-class SWHRemoteAPI:
+def remote_api_endpoint(path):
+    def dec(f):
+        f._endpoint_path = path
+        return f
+    return dec
+
+
+class MetaSWHRemoteApi(type):
+    """Metaclass for SWHRemoteAPI, which adds a method for each endpoint
+    of the database it is designed to access.
+
+    See for example :class:`swh.indexer.storage.api.client.RemoteStorage`"""
+    def __new__(cls, name, bases, attributes):
+        # For each method wrapped with @remote_api_endpoint in an API backend
+        # (eg. :class:`swh.indexer.storage.IndexerStorage`), add a new
+        # method in RemoteStorage, with the same documentation.
+        #
+        # Note that, despite the usage of decorator magic (eg. functools.wrap),
+        # this never actually calls an IndexerStorage method.
+        backend_class = attributes['backend_class']
+        if backend_class:
+            for (meth_name, meth) in backend_class.__dict__.items():
+                if hasattr(meth, '_endpoint_path'):
+                    cls.__add_endpoint(meth_name, meth, attributes)
+        return super().__new__(cls, name, bases, attributes)
+
+    @staticmethod
+    def __add_endpoint(meth_name, meth, attributes):
+        wrapped_meth = inspect.unwrap(meth)
+
+        @functools.wraps(meth)  # Copy signature and doc
+        def meth_(*args, **kwargs):
+            # Match arguments and parameters
+            post_data = inspect.getcallargs(
+                    wrapped_meth, *args, **kwargs)
+
+            # Remove arguments that should not be passed
+            self = post_data.pop('self')
+            post_data.pop('cur', None)
+            post_data.pop('db', None)
+
+            # Send the request.
+            return self.post(meth._endpoint_path, post_data)
+        attributes[meth_name] = meth_
+
+
+class SWHRemoteAPI(metaclass=MetaSWHRemoteApi):
     """Proxy to an internal SWH API
 
     """
 
+    backend_class = None
+    """For each method of `backend_class` decorated with
+    :func:`remote_api_endpoint`, a method with the same prototype and
+    docstring will be added to this class. Calls to this new method will
+    be translated into HTTP requests to a remote server.
+
+    This backend class will never be instantiated, it only serves as
+    a template."""
+
     def __init__(self, api_exception, url, timeout=None):
         super().__init__()
         self.api_exception = api_exception
@@ -142,4 +199,35 @@
 
 
 class SWHServerAPIApp(Flask):
+    """For each endpoint of the given `backend_class`, tells app.route to call
+    a function that decodes the request and sends it to the backend object
+    provided by the factory.
+
+    :param Any backend_class: The class of the backend, which will be
+                              analyzed to look for API endpoints.
+    :param Callable[[], backend_class] backend_factory: A function with no
+                                                        argument that returns
+                                                        an instance of
+                                                        `backend_class`."""
     request_class = BytesRequest
+
+    def __init__(self, *args, backend_class=None, backend_factory=None,
+                 **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if backend_class is not None:
+            if backend_factory is None:
+                raise TypeError('Missing argument backend_factory')
+            for (meth_name, meth) in backend_class.__dict__.items():
+                if hasattr(meth, '_endpoint_path'):
+                    self.__add_endpoint(meth_name, meth, backend_factory)
+
+    def __add_endpoint(self, meth_name, meth, backend_factory):
+        from flask import request
+
+        @self.route('/'+meth._endpoint_path, methods=['POST'])
+        @functools.wraps(meth)  # Copy signature and doc
+        def _f():
+            # Call the actual code
+            obj_meth = getattr(backend_factory(), meth_name)
+            return encode_data_server(obj_meth(**decode_request(request)))
diff --git a/swh/core/tests/test_api.py b/swh/core/tests/test_api.py
new file mode 100644
--- /dev/null
+++ b/swh/core/tests/test_api.py
@@ -0,0 +1,84 @@
+# Copyright (C) 2018  The Software Heritage developers
+# See the AUTHORS file at the top-level directory of this distribution
+# License: GNU General Public License version 3, or any later version
+# See top-level LICENSE file for more information
+
+import unittest
+from nose.tools import istest
+
+import requests_mock
+from werkzeug.wrappers import BaseResponse
+from werkzeug.test import Client as WerkzeugTestClient
+
+from swh.core.api import (
+        error_handler, encode_data_server,
+        remote_api_endpoint, SWHRemoteAPI, SWHServerAPIApp)
+
+
+class ApiTest(unittest.TestCase):
+    @istest
+    def test_server(self):
+        testcase = self
+        nb_endpoint_calls = 0
+
+        class TestStorage:
+            @remote_api_endpoint('test_endpoint_url')
+            def test_endpoint(self, test_data, db=None, cur=None):
+                nonlocal nb_endpoint_calls
+                nb_endpoint_calls += 1
+
+                testcase.assertEqual(test_data, 'spam')
+                return 'egg'
+
+        app = SWHServerAPIApp('testapp',
+                              backend_class=TestStorage,
+                              backend_factory=lambda: TestStorage())
+
+        @app.errorhandler(Exception)
+        def my_error_handler(exception):
+            return error_handler(exception, encode_data_server)
+
+        client = WerkzeugTestClient(app, BaseResponse)
+        res = client.post('/test_endpoint_url',
+                          headers={'Content-Type': 'application/x-msgpack'},
+                          data=b'\x81\xa9test_data\xa4spam')
+
+        self.assertEqual(nb_endpoint_calls, 1)
+        self.assertEqual(b''.join(res.response), b'\xa3egg')
+
+    @istest
+    def test_client(self):
+        class TestStorage:
+            @remote_api_endpoint('test_endpoint_url')
+            def test_endpoint(self, test_data, db=None, cur=None):
+                pass
+
+        nb_http_calls = 0
+
+        def callback(request, context):
+            nonlocal nb_http_calls
+            nb_http_calls += 1
+            self.assertEqual(request.headers['Content-Type'],
+                             'application/x-msgpack')
+            self.assertEqual(request.body, b'\x81\xa9test_data\xa4spam')
+            context.headers['Content-Type'] = 'application/x-msgpack'
+            context.content = b'\xa3egg'
+            return b'\xa3egg'
+
+        adapter = requests_mock.Adapter()
+        adapter.register_uri('POST',
+                             'mock://example.com/test_endpoint_url',
+                             content=callback)
+
+        class Testclient(SWHRemoteAPI):
+            backend_class = TestStorage
+
+            def __init__(self, *args, **kwargs):
+                super().__init__(*args, **kwargs)
+                self.session.mount('mock', adapter)
+
+        c = Testclient('foo', 'mock://example.com/')
+        res = c.test_endpoint('spam')
+
+        self.assertEqual(nb_http_calls, 1)
+        self.assertEqual(res, 'egg')