diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 01fb19e6c8a55ce878e47c013b79cc9c4f197a69..fbe3b7045e24c67d87c2ee90441103ccc27ac57a 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -344,7 +344,12 @@ REST_FRAMEWORK = {
     ),
     'DEFAULT_PAGINATION_CLASS': 'funkwhale_api.common.pagination.FunkwhalePagination',
     'PAGE_SIZE': 25,
-
+    'DEFAULT_PARSER_CLASSES': (
+        'rest_framework.parsers.JSONParser',
+        'rest_framework.parsers.FormParser',
+        'rest_framework.parsers.MultiPartParser',
+        'funkwhale_api.federation.parsers.ActivityParser',
+    ),
     'DEFAULT_AUTHENTICATION_CLASSES': (
         'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS',
         'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py
new file mode 100644
index 0000000000000000000000000000000000000000..00140fe53ffad4d89ffa3ceb099bc66be44e1ea2
--- /dev/null
+++ b/api/funkwhale_api/federation/activity.py
@@ -0,0 +1,51 @@
+
+
+ACTIVITY_TYPES = [
+    'Accept',
+    'Add',
+    'Announce',
+    'Arrive',
+    'Block',
+    'Create',
+    'Delete',
+    'Dislike',
+    'Flag',
+    'Follow',
+    'Ignore',
+    'Invite',
+    'Join',
+    'Leave',
+    'Like',
+    'Listen',
+    'Move',
+    'Offer',
+    'Question',
+    'Reject',
+    'Read',
+    'Remove',
+    'TentativeReject',
+    'TentativeAccept',
+    'Travel',
+    'Undo',
+    'Update',
+    'View',
+]
+
+
+OBJECT_TYPES = [
+    'Article',
+    'Audio',
+    'Document',
+    'Event',
+    'Image',
+    'Note',
+    'Page',
+    'Place',
+    'Profile',
+    'Relationship',
+    'Tombstone',
+    'Video',
+]
+
+def deliver(content, on_behalf_of, to=[]):
+    pass
diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py
index 56a3fc1faf8bd5b913c5bfae59e55d72d2d4a82e..1832b08bcc0adf4cc605037d6ca311ad395ec1f0 100644
--- a/api/funkwhale_api/federation/actors.py
+++ b/api/funkwhale_api/federation/actors.py
@@ -1,14 +1,23 @@
 import requests
+import xml
 
 from django.urls import reverse
 from django.conf import settings
 
+from rest_framework.exceptions import PermissionDenied
+
 from dynamic_preferences.registries import global_preferences_registry
 
+from . import activity
 from . import models
+from . import serializers
 from . import utils
 
 
+def remove_tags(text):
+    return ''.join(xml.etree.ElementTree.fromstring(text).itertext())
+
+
 def get_actor_data(actor_url):
     response = requests.get(
         actor_url,
@@ -23,39 +32,132 @@ def get_actor_data(actor_url):
         raise ValueError(
             'Invalid actor payload: {}'.format(response.text))
 
-SYSTEM_ACTORS = {
-    'library': {
-        'get_actor': lambda: models.Actor(**get_base_system_actor_arguments('library')),
+
+class SystemActor(object):
+    additional_attributes = {}
+
+    def get_actor_instance(self):
+        a = models.Actor(
+            **self.get_instance_argument(
+                self.id,
+                name=self.name,
+                summary=self.summary,
+                **self.additional_attributes
+            )
+        )
+        a.pk = self.id
+        return a
+
+    def get_instance_argument(self, id, name, summary, **kwargs):
+        preferences = global_preferences_registry.manager()
+        p = {
+            'preferred_username': id,
+            'domain': settings.FEDERATION_HOSTNAME,
+            'type': 'Person',
+            'name': name.format(host=settings.FEDERATION_HOSTNAME),
+            'manually_approves_followers': True,
+            'url': utils.full_url(
+                reverse(
+                    'federation:instance-actors-detail',
+                    kwargs={'actor': id})),
+            'shared_inbox_url': utils.full_url(
+                reverse(
+                    'federation:instance-actors-inbox',
+                    kwargs={'actor': id})),
+            'inbox_url': utils.full_url(
+                reverse(
+                    'federation:instance-actors-inbox',
+                    kwargs={'actor': id})),
+            'outbox_url': utils.full_url(
+                reverse(
+                    'federation:instance-actors-outbox',
+                    kwargs={'actor': id})),
+            'public_key': preferences['federation__public_key'],
+            'summary': summary.format(host=settings.FEDERATION_HOSTNAME)
+        }
+        p.update(kwargs)
+        return p
+
+    def get_inbox(self, data, actor=None):
+        raise NotImplementedError
+
+    def post_inbox(self, data, actor=None):
+        raise NotImplementedError
+
+    def get_outbox(self, data, actor=None):
+        raise NotImplementedError
+
+    def post_outbox(self, data, actor=None):
+        raise NotImplementedError
+
+
+class LibraryActor(SystemActor):
+    id = 'library'
+    name = '{host}\'s library'
+    summary = 'Bot account to federate with {host}\'s library'
+    additional_attributes = {
+        'manually_approves_followers': True
     }
-}
 
 
-def get_base_system_actor_arguments(name):
-    preferences = global_preferences_registry.manager()
-    return {
-        'preferred_username': name,
-        'domain': settings.FEDERATION_HOSTNAME,
-        'type': 'Person',
-        'name': '{}\'s library'.format(settings.FEDERATION_HOSTNAME),
-        'manually_approves_followers': True,
-        'url': utils.full_url(
-            reverse(
-                'federation:instance-actors-detail',
-                kwargs={'actor': name})),
-        'shared_inbox_url': utils.full_url(
-            reverse(
-                'federation:instance-actors-inbox',
-                kwargs={'actor': name})),
-        'inbox_url': utils.full_url(
-            reverse(
-                'federation:instance-actors-inbox',
-                kwargs={'actor': name})),
-        'outbox_url': utils.full_url(
-            reverse(
-                'federation:instance-actors-outbox',
-                kwargs={'actor': name})),
-        'public_key': preferences['federation__public_key'],
-        'summary': 'Bot account to federate with {}\'s library'.format(
-            settings.FEDERATION_HOSTNAME
-        ),
+class TestActor(SystemActor):
+    id = 'test'
+    name = '{host}\'s test account'
+    summary = (
+        'Bot account to test federation with {host}. '
+        'Send me /ping and I\'ll answer you.'
+    )
+    additional_attributes = {
+        'manually_approves_followers': False
     }
+
+    def get_outbox(self, data, actor=None):
+        return {
+        	"@context": [
+        		"https://www.w3.org/ns/activitystreams",
+        		"https://w3id.org/security/v1",
+        		{}
+        	],
+        	"id": utils.full_url(
+                reverse(
+                    'federation:instance-actors-outbox',
+                    kwargs={'actor': self.id})),
+        	"type": "OrderedCollection",
+        	"totalItems": 0,
+        	"orderedItems": []
+        }
+
+    def post_inbox(self, data, actor=None):
+        if actor is None:
+            raise PermissionDenied('Actor not authenticated')
+
+        serializer = serializers.ActivitySerializer(
+            data=data, context={'actor': actor})
+        serializer.is_valid(raise_exception=True)
+
+        ac = serializer.validated_data
+        if ac['type'] == 'Create' and ac['object']['type'] == 'Note':
+            # we received a toot \o/
+            command = self.parse_command(ac['object']['content'])
+            if command == 'ping':
+                activity.deliver(
+                    content='Pong!',
+                    to=[ac['actor']],
+                    on_behalf_of=self.get_actor_instance())
+
+    def parse_command(self, message):
+        """
+        Remove any links or fancy markup to extract /command from
+        a note message.
+        """
+        raw = remove_tags(message)
+        try:
+            return raw.split('/')[1]
+        except IndexError:
+            return
+
+
+SYSTEM_ACTORS = {
+    'library': LibraryActor(),
+    'test': TestActor(),
+}
diff --git a/api/funkwhale_api/federation/authentication.py b/api/funkwhale_api/federation/authentication.py
index 980b7006bc2ca6394da8aa3fff10065214c73745..e199ef134d03e0d7026ecffbbaaa1f38e8254e02 100644
--- a/api/funkwhale_api/federation/authentication.py
+++ b/api/funkwhale_api/federation/authentication.py
@@ -45,6 +45,7 @@ class SignatureAuthentication(authentication.BaseAuthentication):
         return serializer.build()
 
     def authenticate(self, request):
+        setattr(request, 'actor', None)
         actor = self.authenticate_actor(request)
         user = AnonymousUser()
         setattr(request, 'actor', actor)
diff --git a/api/funkwhale_api/federation/parsers.py b/api/funkwhale_api/federation/parsers.py
new file mode 100644
index 0000000000000000000000000000000000000000..874d808f973dfcdf99372332b3991136c44d8605
--- /dev/null
+++ b/api/funkwhale_api/federation/parsers.py
@@ -0,0 +1,5 @@
+from rest_framework import parsers
+
+
+class ActivityParser(parsers.JSONParser):
+    media_type = 'application/activity+json'
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 6b12d51ca14e4db0381a14a5636cd0f694ef859f..2137e8d910373e0c8d23b3e38c0d9952e4a93787 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -6,6 +6,7 @@ from django.conf import settings
 from rest_framework import serializers
 from dynamic_preferences.registries import global_preferences_registry
 
+from . import activity
 from . import models
 from . import utils
 
@@ -105,3 +106,70 @@ class ActorWebfingerSerializer(serializers.ModelSerializer):
             instance.url
         ]
         return data
+
+
+class ActivitySerializer(serializers.Serializer):
+    actor = serializers.URLField()
+    id = serializers.URLField()
+    type = serializers.ChoiceField(
+        choices=[(c, c) for c in activity.ACTIVITY_TYPES])
+    object = serializers.JSONField()
+
+    def validate_object(self, value):
+        try:
+            type = value['type']
+        except KeyError:
+            raise serializers.ValidationError('Missing object type')
+
+        try:
+            object_serializer = OBJECT_SERIALIZERS[type]
+        except KeyError:
+            raise serializers.ValidationError(
+                'Unsupported type {}'.format(type))
+
+        serializer = object_serializer(data=value)
+        serializer.is_valid(raise_exception=True)
+        return serializer.data
+
+    def validate_actor(self, value):
+        request_actor = self.context.get('actor')
+        if request_actor and request_actor.url != value:
+            raise serializers.ValidationError(
+                'The actor making the request do not match'
+                ' the activity actor'
+            )
+        return value
+
+
+class ObjectSerializer(serializers.Serializer):
+    id = serializers.URLField()
+    url = serializers.URLField(required=False, allow_null=True)
+    type = serializers.ChoiceField(
+        choices=[(c, c) for c in activity.OBJECT_TYPES])
+    content = serializers.CharField(
+        required=False, allow_null=True)
+    summary = serializers.CharField(
+        required=False, allow_null=True)
+    name = serializers.CharField(
+        required=False, allow_null=True)
+    published = serializers.DateTimeField(
+        required=False, allow_null=True)
+    updated = serializers.DateTimeField(
+        required=False, allow_null=True)
+    to = serializers.ListField(
+        child=serializers.URLField(),
+        required=False, allow_null=True)
+    cc = serializers.ListField(
+        child=serializers.URLField(),
+        required=False, allow_null=True)
+    bto = serializers.ListField(
+        child=serializers.URLField(),
+        required=False, allow_null=True)
+    bcc = serializers.ListField(
+        child=serializers.URLField(),
+        required=False, allow_null=True)
+
+OBJECT_SERIALIZERS = {
+    t: ObjectSerializer
+    for t in activity.OBJECT_TYPES
+}
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index 95e421b59ff725fef59e8c7ae80289febd4a0ee4..2e3feb8d082ebdca00917e59e13d5f3cc601eb33 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -36,18 +36,35 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
             raise Http404
 
     def retrieve(self, request, *args, **kwargs):
-        actor_conf = self.get_object()
-        actor = actor_conf['get_actor']()
+        system_actor = self.get_object()
+        actor = system_actor.get_actor_instance()
         serializer = serializers.ActorSerializer(actor)
         return response.Response(serializer.data, status=200)
 
-    @detail_route(methods=['get'])
+    @detail_route(methods=['get', 'post'])
     def inbox(self, request, *args, **kwargs):
-        raise NotImplementedError()
+        system_actor = self.get_object()
+        handler = getattr(system_actor, '{}_inbox'.format(
+            request.method.lower()
+        ))
 
-    @detail_route(methods=['get'])
+        try:
+            data = handler(request.data, actor=request.actor)
+        except NotImplementedError:
+            return response.Response(status=405)
+        return response.Response(data, status=200)
+
+    @detail_route(methods=['get', 'post'])
     def outbox(self, request, *args, **kwargs):
-        raise NotImplementedError()
+        system_actor = self.get_object()
+        handler = getattr(system_actor, '{}_outbox'.format(
+            request.method.lower()
+        ))
+        try:
+            data = handler(request.data, actor=request.actor)
+        except NotImplementedError:
+            return response.Response(status=405)
+        return response.Response(data, status=200)
 
 
 class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
@@ -82,5 +99,5 @@ class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
 
     def handler_acct(self, clean_result):
         username, hostname = clean_result
-        actor = actors.SYSTEM_ACTORS[username]['get_actor']()
+        actor = actors.SYSTEM_ACTORS[username].get_actor_instance()
         return serializers.ActorWebfingerSerializer(actor).data
diff --git a/api/funkwhale_api/federation/webfinger.py b/api/funkwhale_api/federation/webfinger.py
index d698114f17f74e5d627d5e05a5f32d1074d2c889..95a51e1c05ac04d454e839532f772d353b78e651 100644
--- a/api/funkwhale_api/federation/webfinger.py
+++ b/api/funkwhale_api/federation/webfinger.py
@@ -30,7 +30,8 @@ def clean_acct(acct_string):
         raise forms.ValidationError('Invalid format')
 
     if hostname != settings.FEDERATION_HOSTNAME:
-        raise forms.ValidationError('Invalid hostname')
+        raise forms.ValidationError(
+            'Invalid hostname {}'.format(hostname))
 
     if username not in actors.SYSTEM_ACTORS:
         raise forms.ValidationError('Invalid username')
diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py
index 00e214bd18f8520046df229dcd07477ea4d86cf8..a239f4961bb7917937dd92780eaa4811a3a5f5ed 100644
--- a/api/tests/federation/test_actors.py
+++ b/api/tests/federation/test_actors.py
@@ -1,6 +1,10 @@
+import pytest
+
 from django.urls import reverse
+from rest_framework import exceptions
 
 from funkwhale_api.federation import actors
+from funkwhale_api.federation import serializers
 from funkwhale_api.federation import utils
 
 
@@ -37,10 +41,106 @@ def test_get_library(settings, preferences):
             reverse(
                 'federation:instance-actors-inbox',
                 kwargs={'actor': 'library'})),
+        'outbox_url': utils.full_url(
+            reverse(
+                'federation:instance-actors-outbox',
+                kwargs={'actor': 'library'})),
         'public_key': 'public_key',
         'summary': 'Bot account to federate with {}\'s library'.format(
         settings.FEDERATION_HOSTNAME),
     }
-    actor = actors.SYSTEM_ACTORS['library']['get_actor']()
+    actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
     for key, value in expected.items():
         assert getattr(actor, key) == value
+
+
+def test_get_test(settings, preferences):
+    preferences['federation__public_key'] = 'public_key'
+    expected = {
+        'preferred_username': 'test',
+        'domain': settings.FEDERATION_HOSTNAME,
+        'type': 'Person',
+        'name': '{}\'s test account'.format(settings.FEDERATION_HOSTNAME),
+        'manually_approves_followers': False,
+        'url': utils.full_url(
+            reverse(
+                'federation:instance-actors-detail',
+                kwargs={'actor': 'test'})),
+        'shared_inbox_url': utils.full_url(
+            reverse(
+                'federation:instance-actors-inbox',
+                kwargs={'actor': 'test'})),
+        'inbox_url': utils.full_url(
+            reverse(
+                'federation:instance-actors-inbox',
+                kwargs={'actor': 'test'})),
+        'outbox_url': utils.full_url(
+            reverse(
+                'federation:instance-actors-outbox',
+                kwargs={'actor': 'test'})),
+        'public_key': 'public_key',
+        'summary': 'Bot account to test federation with {}. Send me /ping and I\'ll answer you.'.format(
+        settings.FEDERATION_HOSTNAME),
+    }
+    actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
+    for key, value in expected.items():
+        assert getattr(actor, key) == value
+
+
+def test_test_get_outbox():
+    expected = {
+    	"@context": [
+    		"https://www.w3.org/ns/activitystreams",
+    		"https://w3id.org/security/v1",
+    		{}
+    	],
+    	"id": utils.full_url(
+            reverse(
+                'federation:instance-actors-outbox',
+                kwargs={'actor': 'test'})),
+    	"type": "OrderedCollection",
+    	"totalItems": 0,
+    	"orderedItems": []
+    }
+
+    data = actors.SYSTEM_ACTORS['test'].get_outbox({}, actor=None)
+
+    assert data == expected
+
+
+def test_test_post_inbox_requires_authenticated_actor():
+    with pytest.raises(exceptions.PermissionDenied):
+        actors.SYSTEM_ACTORS['test'].post_inbox({}, actor=None)
+
+
+def test_test_post_outbox_validates_actor(nodb_factories):
+    actor = nodb_factories['federation.Actor']()
+    data = {
+        'actor': 'noop'
+    }
+    with pytest.raises(exceptions.ValidationError) as exc_info:
+        actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
+        msg = 'The actor making the request do not match'
+        assert msg in exc_info.value
+
+
+def test_test_post_outbox_handles_create_note(mocker, factories):
+    deliver = mocker.patch(
+        'funkwhale_api.federation.activity.deliver')
+    actor = factories['federation.Actor']()
+    data = {
+        'actor': actor.url,
+        'type': 'Create',
+        'id': 'http://test.federation/activity',
+        'object': {
+            'type': 'Note',
+            'id': 'http://test.federation/object',
+            'content': '<p><a>@mention</a> /ping</p>'
+        }
+    }
+    actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
+    deliver.assert_called_once_with(
+        content='Pong!',
+        to=[actor.url],
+        on_behalf_of=actors.SYSTEM_ACTORS['test'].get_actor_instance()
+    )
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
index 5ec53279a70230d0e6153e2f8ddc542a2d181faf..0d2ac882fb25ecac154a8426ffc2949ca7f81435 100644
--- a/api/tests/federation/test_views.py
+++ b/api/tests/federation/test_views.py
@@ -10,7 +10,7 @@ from funkwhale_api.federation import webfinger
 
 @pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
 def test_instance_actors(system_actor, db, settings, api_client):
-    actor = actors.SYSTEM_ACTORS[system_actor]['get_actor']()
+    actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
     url = reverse(
         'federation:instance-actors-detail',
         kwargs={'actor': system_actor})
@@ -27,7 +27,7 @@ def test_instance_actors(system_actor, db, settings, api_client):
     ('instance-actors-detail', {'actor': 'library'}),
     ('well-known-webfinger', {}),
 ])
-def test_instance_inbox_405_if_federation_disabled(
+def test_instance_endpoints_405_if_federation_disabled(
         authenticated_actor, db, settings, api_client, route, kwargs):
     settings.FEDERATION_ENABLED = False
     url = reverse('federation:{}'.format(route), kwargs=kwargs)
@@ -53,7 +53,7 @@ def test_wellknown_webfinger_validates_resource(
 @pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
 def test_wellknown_webfinger_system(
         system_actor, db, api_client, settings, mocker):
-    actor = actors.SYSTEM_ACTORS[system_actor]['get_actor']()
+    actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
     url = reverse('federation:well-known-webfinger')
     response = api_client.get(
         url, data={'resource': 'acct:{}'.format(actor.webfinger_subject)})
diff --git a/api/tests/federation/test_webfinger.py b/api/tests/federation/test_webfinger.py
index fd1cb1d058cfb43070fed1db6c32caf121431927..96258455ae6fe1f60e330d7768a58d6e94fc91fa 100644
--- a/api/tests/federation/test_webfinger.py
+++ b/api/tests/federation/test_webfinger.py
@@ -32,7 +32,7 @@ def test_webfinger_clean_acct(settings):
 
 @pytest.mark.parametrize('resource,message', [
     ('service', 'Invalid format'),
-    ('service@test.com', 'Invalid hostname'),
+    ('service@test.com', 'Invalid hostname test.com'),
     ('noop@test.federation', 'Invalid account'),
 ])
 def test_webfinger_clean_acct_errors(resource, message, settings):