diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py
index 24a1f782e0f807cc4931c05510159e438e0a992a..af31b8c5afb7a282fb9fa35a3c4c57b551d04274 100644
--- a/api/funkwhale_api/federation/activity.py
+++ b/api/funkwhale_api/federation/activity.py
@@ -6,8 +6,10 @@ import uuid
 from django.conf import settings
 
 from funkwhale_api.common import session
+from funkwhale_api.common import utils as funkwhale_utils
 
 from . import models
+from . import serializers
 from . import signing
 
 logger = logging.getLogger(__name__)
@@ -85,66 +87,9 @@ def deliver(activity, on_behalf_of, to=[]):
         logger.debug('Remote answered with %s', response.status_code)
 
 
-def get_follow(follow_id, follower, followed):
-    return {
-        '@context': [
-            'https://www.w3.org/ns/activitystreams',
-            'https://w3id.org/security/v1',
-            {}
-        ],
-        'actor': follower.url,
-        'id': follower.url + '#follows/{}'.format(follow_id),
-        'object': followed.url,
-        'type': 'Follow'
-    }
-
-
-def get_undo(id, actor, object):
-    return {
-        '@context': [
-            'https://www.w3.org/ns/activitystreams',
-            'https://w3id.org/security/v1',
-            {}
-        ],
-        'type': 'Undo',
-        'id': id + '/undo',
-        'actor': actor.url,
-        'object': object,
-    }
-
-
-def get_accept_follow(accept_id, accept_actor, follow, follow_actor):
-    return {
-        "@context": [
-            "https://www.w3.org/ns/activitystreams",
-            "https://w3id.org/security/v1",
-            {}
-        ],
-        "id": accept_actor.url + '#accepts/follows/{}'.format(
-            accept_id),
-        "type": "Accept",
-        "actor": accept_actor.url,
-        "object": {
-            "id": follow['id'],
-            "type": "Follow",
-            "actor": follow_actor.url,
-            "object": accept_actor.url
-        },
-    }
-
-
-def accept_follow(target, follow, actor):
-    accept_uuid = uuid.uuid4()
-    accept = get_accept_follow(
-        accept_id=accept_uuid,
-        accept_actor=target,
-        follow=follow,
-        follow_actor=actor)
+def accept_follow(follow):
+    serializer = serializers.AcceptFollowSerializer(follow)
     deliver(
-        accept,
-        to=[actor.url],
-        on_behalf_of=target)
-    return models.Follow.objects.get_or_create(
-        actor=actor,
-        target=target,
-    )
+        serializer.data,
+        to=[follow.actor.url],
+        on_behalf_of=follow.target)
diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py
index bb0b99cc2851bea6781cdc65eb21c225e39670e7..5a4e917bd214083c6c6d35206883a966f14f5fe8 100644
--- a/api/funkwhale_api/federation/actors.py
+++ b/api/funkwhale_api/federation/actors.py
@@ -153,24 +153,32 @@ class SystemActor(object):
 
     def handle_follow(self, ac, sender):
         system_actor = self.get_actor_instance()
-        if self.manually_approves_followers:
-            fr, created = models.FollowRequest.objects.get_or_create(
-                actor=sender,
-                target=system_actor,
-                approved=None,
-            )
-            return fr
+        serializer = serializers.FollowSerializer(
+            data=ac, context={'follow_actor': sender})
+        if not serializer.is_valid():
+            return logger.info('Invalid follow payload')
+        approved = True if not self.manually_approves_followers else None
+        follow = serializer.save(approved=approved)
+        if follow.approved:
+            return activity.accept_follow(follow)
+
+    def handle_accept(self, ac, sender):
+        system_actor = self.get_actor_instance()
+        serializer = serializers.AcceptFollowSerializer(
+            data=ac,
+            context={'follow_target': sender, 'follow_actor': system_actor})
+        if not serializer.is_valid(raise_exception=True):
+            return logger.info('Received invalid payload')
 
-        return activity.accept_follow(
-            system_actor, ac, sender
-        )
+        serializer.save()
 
     def handle_undo_follow(self, ac, sender):
-        actor = self.get_actor_instance()
-        models.Follow.objects.filter(
-            actor=sender,
-            target=actor,
-        ).delete()
+        system_actor = self.get_actor_instance()
+        serializer = serializers.UndoFollowSerializer(
+            data=ac, context={'actor': sender, 'target': system_actor})
+        if not serializer.is_valid():
+            return logger.info('Received invalid payload')
+        serializer.save()
 
     def handle_undo(self, ac, sender):
         if ac['object']['type'] != 'Follow':
@@ -206,20 +214,6 @@ class LibraryActor(SystemActor):
     def manually_approves_followers(self):
         return settings.FEDERATION_MUSIC_NEEDS_APPROVAL
 
-    def handle_follow(self, ac, sender):
-        system_actor = self.get_actor_instance()
-        if self.manually_approves_followers:
-            fr, created = models.FollowRequest.objects.get_or_create(
-                actor=sender,
-                target=system_actor,
-                approved=None,
-            )
-            return fr
-
-        return activity.accept_follow(
-            system_actor, ac, sender
-        )
-
     @transaction.atomic
     def handle_create(self, ac, sender):
         try:
@@ -360,15 +354,15 @@ class TestActor(SystemActor):
         super().handle_follow(ac, sender)
         # also, we follow back
         test_actor = self.get_actor_instance()
-        follow_uuid = uuid.uuid4()
-        follow = activity.get_follow(
-            follow_id=follow_uuid,
-            follower=test_actor,
-            followed=sender)
+        follow_back = models.Follow.objects.get_or_create(
+            actor=test_actor,
+            target=sender,
+            approved=None,
+        )[0]
         activity.deliver(
-            follow,
-            to=[ac['actor']],
-            on_behalf_of=test_actor)
+            serializers.FollowSerializer(follow_back).data,
+            to=[follow_back.target.url],
+            on_behalf_of=follow_back.actor)
 
     def handle_undo_follow(self, ac, sender):
         super().handle_undo_follow(ac, sender)
@@ -381,11 +375,7 @@ class TestActor(SystemActor):
             )
         except models.Follow.DoesNotExist:
             return
-        undo = activity.get_undo(
-            id=follow.get_federation_url(),
-            actor=actor,
-            object=serializers.FollowSerializer(follow).data,
-        )
+        undo = serializers.UndoFollowSerializer(follow).data
         follow.delete()
         activity.deliver(
             undo,
diff --git a/api/funkwhale_api/federation/admin.py b/api/funkwhale_api/federation/admin.py
index ce636299f22944424d586a4c6d84739ec48c3858..6a097174b552658e0f9c1d5075932300f23e3bdd 100644
--- a/api/funkwhale_api/federation/admin.py
+++ b/api/funkwhale_api/federation/admin.py
@@ -23,24 +23,13 @@ class FollowAdmin(admin.ModelAdmin):
     list_display = [
         'actor',
         'target',
+        'approved',
         'creation_date'
     ]
-    search_fields = ['actor__url', 'target__url']
-    list_select_related = True
-
-
-@admin.register(models.FollowRequest)
-class FollowRequestAdmin(admin.ModelAdmin):
-    list_display = [
-        'actor',
-        'target',
-        'creation_date',
-        'approved'
-    ]
-    search_fields = ['actor__url', 'target__url']
     list_filter = [
         'approved'
     ]
+    search_fields = ['actor__url', 'target__url']
     list_select_related = True
 
 
diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py
index b3ac72039ed91f5f9a76d447f4cfd0d8bc0e282b..1aeb733c85cc25d5d33ef89fd10e3d0510af0f56 100644
--- a/api/funkwhale_api/federation/factories.py
+++ b/api/funkwhale_api/federation/factories.py
@@ -113,15 +113,6 @@ class FollowFactory(factory.DjangoModelFactory):
         )
 
 
-@registry.register
-class FollowRequestFactory(factory.DjangoModelFactory):
-    target = factory.SubFactory(ActorFactory)
-    actor = factory.SubFactory(ActorFactory)
-
-    class Meta:
-        model = models.FollowRequest
-
-
 @registry.register
 class LibraryFactory(factory.DjangoModelFactory):
     actor = factory.SubFactory(ActorFactory)
diff --git a/api/funkwhale_api/federation/library.py b/api/funkwhale_api/federation/library.py
index f19a7a29145b229d3be71e7a616376a3157000b1..177f1475451cee06f7e0f0a091730651fa98c55c 100644
--- a/api/funkwhale_api/federation/library.py
+++ b/api/funkwhale_api/federation/library.py
@@ -38,25 +38,21 @@ def scan_from_account_name(account_name):
         actor__domain=domain,
         actor__preferred_username=username
     ).select_related('actor').first()
-    follow_request = None
-    if library:
-        data['local']['following'] = True
-        data['local']['awaiting_approval'] = True
-
-    else:
-        follow_request = models.FollowRequest.objects.filter(
+    data['local'] = {
+        'following': False,
+        'awaiting_approval': False,
+    }
+    try:
+        follow = models.Follow.objects.get(
             target__preferred_username=username,
             target__domain=username,
             actor=system_library,
-        ).first()
-        data['local'] = {
-            'following': False,
-            'awaiting_approval': False,
-        }
-        if follow_request:
-            data['awaiting_approval'] = follow_request.approved is None
+        )
+        data['local']['awaiting_approval'] = not bool(follow.approved)
+        data['local']['following'] = True
+    except models.Follow.DoesNotExist:
+        pass
 
-    follow_request = models.Follow
     try:
         data['webfinger'] = webfinger.get_resource(
             'acct:{}'.format(account_name))
diff --git a/api/funkwhale_api/federation/migrations/0004_auto_20180410_1624.py b/api/funkwhale_api/federation/migrations/0004_auto_20180410_1624.py
new file mode 100644
index 0000000000000000000000000000000000000000..b199706aaf380b91a8977a100aca33053264c303
--- /dev/null
+++ b/api/funkwhale_api/federation/migrations/0004_auto_20180410_1624.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.0.3 on 2018-04-10 16:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('federation', '0003_auto_20180407_1010'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='followrequest',
+            name='actor',
+        ),
+        migrations.RemoveField(
+            model_name='followrequest',
+            name='target',
+        ),
+        migrations.AddField(
+            model_name='follow',
+            name='approved',
+            field=models.NullBooleanField(default=None),
+        ),
+        migrations.DeleteModel(
+            name='FollowRequest',
+        ),
+    ]
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
index bf1e5d830c875a00467d3476c0528338970cd094..201463066802a526e4978bb73d00344a16b56f1a 100644
--- a/api/funkwhale_api/federation/models.py
+++ b/api/funkwhale_api/federation/models.py
@@ -109,6 +109,7 @@ class Follow(models.Model):
     creation_date = models.DateTimeField(default=timezone.now)
     modification_date = models.DateTimeField(
         auto_now=True)
+    approved = models.NullBooleanField(default=None)
 
     class Meta:
         unique_together = ['actor', 'target']
@@ -117,49 +118,6 @@ class Follow(models.Model):
         return '{}#follows/{}'.format(self.actor.url, self.uuid)
 
 
-class FollowRequest(models.Model):
-    uuid = models.UUIDField(default=uuid.uuid4, unique=True)
-    actor = models.ForeignKey(
-        Actor,
-        related_name='emmited_follow_requests',
-        on_delete=models.CASCADE,
-    )
-    target = models.ForeignKey(
-        Actor,
-        related_name='received_follow_requests',
-        on_delete=models.CASCADE,
-    )
-    creation_date = models.DateTimeField(default=timezone.now)
-    modification_date = models.DateTimeField(
-        auto_now=True)
-    approved = models.NullBooleanField(default=None)
-
-    def approve(self):
-        from . import activity
-        from . import serializers
-        self.approved = True
-        self.save(update_fields=['approved'])
-        Follow.objects.get_or_create(
-            target=self.target,
-            actor=self.actor
-        )
-        if self.target.is_local:
-            follow = {
-                '@context': serializers.AP_CONTEXT,
-                'actor': self.actor.url,
-                'id': self.actor.url + '#follows/{}'.format(uuid.uuid4()),
-                'object': self.target.url,
-                'type': 'Follow'
-            }
-            activity.accept_follow(
-                self.target, follow, self.actor
-            )
-
-    def refuse(self):
-        self.approved = False
-        self.save(update_fields=['approved'])
-
-
 class Library(models.Model):
     creation_date = models.DateTimeField(default=timezone.now)
     modification_date = models.DateTimeField(
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 704ad63645012c8642060bd328bdcb8b0c29e78e..f0d1e35fd1aa2d810b26087d87db8ae92e0587a8 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -121,28 +121,132 @@ class LibraryActorSerializer(ActorSerializer):
         return validated_data
 
 
-class FollowSerializer(serializers.ModelSerializer):
-    # left maps to activitypub fields, right to our internal models
-    id = serializers.URLField(source='get_federation_url')
-    object = serializers.URLField(source='target.url')
-    actor = serializers.URLField(source='actor.url')
-    type = serializers.CharField(source='ap_type')
+class FollowSerializer(serializers.Serializer):
+    id = serializers.URLField()
+    object = serializers.URLField()
+    actor = serializers.URLField()
+    type = serializers.ChoiceField(choices=['Follow'])
 
-    class Meta:
-        model = models.Actor
-        fields = [
-            'id',
-            'object',
-            'actor',
-            'type'
-        ]
+    def validate_object(self, v):
+        expected = self.context.get('follow_target')
+        if expected and expected.url != v:
+            raise serializers.ValidationError('Invalid target')
+        try:
+            return models.Actor.objects.get(url=v)
+        except models.Actor.DoesNotExist:
+            raise serializers.ValidationError('Target not found')
+
+    def validate_actor(self, v):
+        expected = self.context.get('follow_actor')
+        if expected and expected.url != v:
+            raise serializers.ValidationError('Invalid actor')
+        try:
+            return models.Actor.objects.get(url=v)
+        except models.Actor.DoesNotExist:
+            raise serializers.ValidationError('Actor not found')
+
+    def save(self, **kwargs):
+        return models.Follow.objects.get_or_create(
+            actor=self.validated_data['actor'],
+            target=self.validated_data['object'],
+            **kwargs,
+        )[0]
 
     def to_representation(self, instance):
-        ret = super().to_representation(instance)
-        ret['@context'] = AP_CONTEXT
+        return {
+            '@context': AP_CONTEXT,
+            'actor': instance.actor.url,
+            'id': instance.get_federation_url(),
+            'object': instance.target.url,
+            'type': 'Follow'
+        }
         return ret
 
 
+class AcceptFollowSerializer(serializers.Serializer):
+    id = serializers.URLField()
+    actor = serializers.URLField()
+    object = FollowSerializer()
+    type = serializers.ChoiceField(choices=['Accept'])
+
+    def validate_actor(self, v):
+        expected = self.context.get('follow_target')
+        if expected and expected.url != v:
+            raise serializers.ValidationError('Invalid actor')
+        try:
+            return models.Actor.objects.get(url=v)
+        except models.Actor.DoesNotExist:
+            raise serializers.ValidationError('Actor not found')
+
+    def validate(self, validated_data):
+        # we ensure the accept actor actually match the follow target
+        if validated_data['actor'] != validated_data['object']['object']:
+            raise serializers.ValidationError('Actor mismatch')
+        try:
+            validated_data['follow'] = models.Follow.objects.filter(
+                target=validated_data['actor'],
+                actor=validated_data['object']['actor']
+            ).exclude(approved=True).get()
+        except models.Follow.DoesNotExist:
+            raise serializers.ValidationError('No follow to accept')
+        return validated_data
+
+    def to_representation(self, instance):
+        return {
+            "@context": AP_CONTEXT,
+            "id": instance.get_federation_url() + '/accept',
+            "type": "Accept",
+            "actor": instance.target.url,
+            "object": FollowSerializer(instance).data
+        }
+
+    def save(self):
+        self.validated_data['follow'].approved = True
+        self.validated_data['follow'].save()
+        return self.validated_data['follow']
+
+
+class UndoFollowSerializer(serializers.Serializer):
+    id = serializers.URLField()
+    actor = serializers.URLField()
+    object = FollowSerializer()
+    type = serializers.ChoiceField(choices=['Undo'])
+
+    def validate_actor(self, v):
+        expected = self.context.get('follow_target')
+        if expected and expected.url != v:
+            raise serializers.ValidationError('Invalid actor')
+        try:
+            return models.Actor.objects.get(url=v)
+        except models.Actor.DoesNotExist:
+            raise serializers.ValidationError('Actor not found')
+
+    def validate(self, validated_data):
+        # we ensure the accept actor actually match the follow actor
+        if validated_data['actor'] != validated_data['object']['actor']:
+            raise serializers.ValidationError('Actor mismatch')
+        try:
+            validated_data['follow'] = models.Follow.objects.filter(
+                actor=validated_data['actor'],
+                target=validated_data['object']['object']
+            ).get()
+        except models.Follow.DoesNotExist:
+            raise serializers.ValidationError('No follow to remove')
+        return validated_data
+
+    def to_representation(self, instance):
+        return {
+            "@context": AP_CONTEXT,
+            "id": instance.get_federation_url() + '/undo',
+            "type": "Undo",
+            "actor": instance.actor.url,
+            "object": FollowSerializer(instance).data
+        }
+
+    def save(self):
+        self.validated_data['follow'].delete()
+
+
 class ActorWebfingerSerializer(serializers.Serializer):
     subject = serializers.CharField()
     aliases = serializers.ListField(child=serializers.URLField())
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index aaab343e40350cd13980d4111c9d0015d94a747b..2d422047298d382a603952d5d9b6c1cab33ef73e 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -1,6 +1,7 @@
 from django import forms
 from django.conf import settings
 from django.core import paginator
+from django.db import transaction
 from django.http import HttpResponse
 from django.urls import reverse
 
@@ -9,9 +10,12 @@ from rest_framework import response
 from rest_framework import views
 from rest_framework import viewsets
 from rest_framework.decorators import list_route, detail_route
+from rest_framework.serializers import ValidationError
 
+from funkwhale_api.common import utils as funkwhale_utils
 from funkwhale_api.music.models import TrackFile
 
+from . import activity
 from . import actors
 from . import authentication
 from . import library
@@ -172,3 +176,29 @@ class LibraryViewSet(viewsets.GenericViewSet):
 
         data = library.scan_from_account_name(account)
         return response.Response(data)
+
+    @transaction.atomic
+    def create(self, request, *args, **kwargs):
+        try:
+            actor_url = request.data['actor_url']
+        except KeyError:
+            raise ValidationError('Missing actor_url')
+
+        try:
+            actor = actors.get_actor(actor_url)
+            library_data = library.get_library_data(actor.url)
+        except Exception as e:
+            raise ValidationError('Error while fetching actor and library')
+
+        library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
+        follow, created = models.Follow.objects.get_or_create(
+            actor=library_actor,
+            target=actor,
+        )
+        serializer = serializers.FollowSerializer(follow)
+        activity.deliver(
+            serializer.data,
+            on_behalf_of=library_actor,
+            to=[actor.url]
+        )
+        return response.Response({}, status=201)
diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py
index 09c5e3bf7226fc4f36d9e92d01cf6a43106ed3a7..dbd60bbd7cee0ad363a21275083eafffc3949de7 100644
--- a/api/tests/federation/test_activity.py
+++ b/api/tests/federation/test_activity.py
@@ -1,6 +1,7 @@
 import uuid
 
 from funkwhale_api.federation import activity
+from funkwhale_api.federation import serializers
 
 
 def test_deliver(nodb_factories, r_mock, mocker):
@@ -38,37 +39,9 @@ def test_deliver(nodb_factories, r_mock, mocker):
 def test_accept_follow(mocker, factories):
     deliver = mocker.patch(
         'funkwhale_api.federation.activity.deliver')
-    actor = factories['federation.Actor']()
-    target = factories['federation.Actor'](local=True)
-    follow = {
-        'actor': actor.url,
-        'type': 'Follow',
-        'id': 'http://test.federation/user#follows/267',
-        'object': target.url,
-    }
-    uid = uuid.uuid4()
-    mocker.patch('uuid.uuid4', return_value=uid)
-    expected_accept = {
-        "@context": [
-            "https://www.w3.org/ns/activitystreams",
-            "https://w3id.org/security/v1",
-            {}
-        ],
-        "id": target.url + '#accepts/follows/{}'.format(uid),
-        "type": "Accept",
-        "actor": target.url,
-        "object": {
-            "id": follow['id'],
-            "type": "Follow",
-            "actor": actor.url,
-            "object": target.url
-        },
-    }
-    activity.accept_follow(
-        target, follow, actor
-    )
+    follow = factories['federation.Follow'](approved=None)
+    expected_accept = serializers.AcceptFollowSerializer(follow).data
+    activity.accept_follow(follow)
     deliver.assert_called_once_with(
-        expected_accept, to=[actor.url], on_behalf_of=target
+        expected_accept, to=[follow.actor.url], on_behalf_of=follow.target
     )
-    follow_instance = actor.emitted_follows.first()
-    assert follow_instance.target == target
diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py
index 090d9b03fb4d9339ccd4121edc3cf502b32de54d..fe70cc6e5cc5d8f97386e61c39f52147463972b6 100644
--- a/api/tests/federation/test_actors.py
+++ b/api/tests/federation/test_actors.py
@@ -7,6 +7,7 @@ from django.utils import timezone
 
 from rest_framework import exceptions
 
+from funkwhale_api.federation import activity
 from funkwhale_api.federation import actors
 from funkwhale_api.federation import models
 from funkwhale_api.federation import serializers
@@ -261,8 +262,6 @@ def test_test_actor_handles_follow(
     deliver = mocker.patch(
         'funkwhale_api.federation.activity.deliver')
     actor = factories['federation.Actor']()
-    now = timezone.now()
-    mocker.patch('django.utils.timezone.now', return_value=now)
     accept_follow = mocker.patch(
         'funkwhale_api.federation.activity.accept_follow')
     test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
@@ -272,28 +271,15 @@ def test_test_actor_handles_follow(
         'id': 'http://test.federation/user#follows/267',
         'object': test_actor.url,
     }
-    uid = uuid.uuid4()
-    mocker.patch('uuid.uuid4', return_value=uid)
-    expected_follow = {
-        '@context': serializers.AP_CONTEXT,
-        'actor': test_actor.url,
-        'id': test_actor.url + '#follows/{}'.format(uid),
-        'object': actor.url,
-        'type': 'Follow'
-    }
-
     actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
-    accept_follow.assert_called_once_with(
-        test_actor, data, actor
+    follow = models.Follow.objects.get(target=test_actor, approved=True)
+    follow_back = models.Follow.objects.get(actor=test_actor, approved=None)
+    accept_follow.assert_called_once_with(follow)
+    deliver.assert_called_once_with(
+        serializers.FollowSerializer(follow_back).data,
+        on_behalf_of=test_actor,
+        to=[actor.url]
     )
-    expected_calls = [
-        mocker.call(
-            expected_follow,
-            to=[actor.url],
-            on_behalf_of=test_actor,
-        )
-    ]
-    deliver.assert_has_calls(expected_calls)
 
 
 def test_test_actor_handles_undo_follow(
@@ -346,12 +332,10 @@ def test_library_actor_handles_follow_manual_approval(
     }
 
     library_actor.system_conf.post_inbox(data, actor=actor)
-    fr = library_actor.received_follow_requests.first()
+    follow = library_actor.received_follows.first()
 
-    assert library_actor.received_follow_requests.count() == 1
-    assert fr.target == library_actor
-    assert fr.actor == actor
-    assert fr.approved is None
+    assert follow.actor == actor
+    assert follow.approved is None
 
 
 def test_library_actor_handles_follow_auto_approval(
@@ -369,10 +353,27 @@ def test_library_actor_handles_follow_auto_approval(
     }
     library_actor.system_conf.post_inbox(data, actor=actor)
 
-    assert library_actor.received_follow_requests.count() == 0
-    accept_follow.assert_called_once_with(
-        library_actor, data, actor
+    follow = library_actor.received_follows.first()
+
+    assert follow.actor == actor
+    assert follow.approved is True
+
+
+def test_library_actor_handles_accept(
+        mocker, factories):
+    library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
+    actor = factories['federation.Actor']()
+    pending_follow = factories['federation.Follow'](
+        actor=library_actor,
+        target=actor,
+        approved=None,
     )
+    serializer = serializers.AcceptFollowSerializer(pending_follow)
+    library_actor.system_conf.post_inbox(serializer.data, actor=actor)
+
+    pending_follow.refresh_from_db()
+
+    assert pending_follow.approved is True
 
 
 def test_library_actor_handle_create_audio_no_library(mocker, factories):
diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py
index b17b6eb65a46da037191c5697015526f0ea0084c..ae158e659fc105bf18a96264cc6968fdab387719 100644
--- a/api/tests/federation/test_models.py
+++ b/api/tests/federation/test_models.py
@@ -35,50 +35,6 @@ def test_follow_federation_url(factories):
     assert follow.get_federation_url() == expected
 
 
-def test_follow_request_approve(mocker, factories):
-    uid = uuid.uuid4()
-    mocker.patch('uuid.uuid4', return_value=uid)
-    accept_follow = mocker.patch(
-        'funkwhale_api.federation.activity.accept_follow')
-    fr = factories['federation.FollowRequest'](target__local=True)
-    fr.approve()
-
-    follow = {
-        '@context': serializers.AP_CONTEXT,
-        'actor': fr.actor.url,
-        'id': fr.actor.url + '#follows/{}'.format(uid),
-        'object': fr.target.url,
-        'type': 'Follow'
-    }
-
-    assert fr.approved is True
-    assert list(fr.target.followers.all()) == [fr.actor]
-    accept_follow.assert_called_once_with(
-        fr.target, follow, fr.actor
-    )
-
-
-def test_follow_request_approve_non_local(mocker, factories):
-    uid = uuid.uuid4()
-    mocker.patch('uuid.uuid4', return_value=uid)
-    accept_follow = mocker.patch(
-        'funkwhale_api.federation.activity.accept_follow')
-    fr = factories['federation.FollowRequest']()
-    fr.approve()
-
-    assert fr.approved is True
-    assert list(fr.target.followers.all()) == [fr.actor]
-    accept_follow.assert_not_called()
-
-
-def test_follow_request_refused(mocker, factories):
-    fr = factories['federation.FollowRequest']()
-    fr.refuse()
-
-    assert fr.approved is False
-    assert fr.target.followers.count() == 0
-
-
 def test_library_model_unique_per_actor(factories):
     library = factories['federation.Library']()
     with pytest.raises(db.IntegrityError):
diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py
index 7b7dda33cbffc46eb32199c86d2a8976f3fb8198..e6eca0a42c98724b5c23233bcfc3cf00b4371402 100644
--- a/api/tests/federation/test_serializers.py
+++ b/api/tests/federation/test_serializers.py
@@ -1,4 +1,5 @@
 import arrow
+import pytest
 
 from django.urls import reverse
 from django.core.paginator import Paginator
@@ -170,6 +171,184 @@ def test_follow_serializer_to_ap(factories):
     assert serializer.data == expected
 
 
+def test_follow_serializer_save(factories):
+    actor = factories['federation.Actor']()
+    target = factories['federation.Actor']()
+
+    data = expected = {
+        'id': 'https://test.follow',
+        'type': 'Follow',
+        'actor': actor.url,
+        'object': target.url,
+    }
+    serializer = serializers.FollowSerializer(data=data)
+
+    assert serializer.is_valid(raise_exception=True)
+
+    follow = serializer.save()
+
+    assert follow.pk is not None
+    assert follow.actor == actor
+    assert follow.target == target
+    assert follow.approved is None
+
+
+def test_follow_serializer_save_validates_on_context(factories):
+    actor = factories['federation.Actor']()
+    target = factories['federation.Actor']()
+    impostor = factories['federation.Actor']()
+
+    data = expected = {
+        'id': 'https://test.follow',
+        'type': 'Follow',
+        'actor': actor.url,
+        'object': target.url,
+    }
+    serializer = serializers.FollowSerializer(
+        data=data,
+        context={'follow_actor': impostor, 'follow_target': impostor})
+
+    assert serializer.is_valid() is False
+
+    assert 'actor' in serializer.errors
+    assert 'object' in serializer.errors
+
+
+def test_accept_follow_serializer_representation(factories):
+    follow = factories['federation.Follow'](approved=None)
+
+    expected = {
+        '@context': [
+            'https://www.w3.org/ns/activitystreams',
+            'https://w3id.org/security/v1',
+            {},
+        ],
+        'id': follow.get_federation_url() + '/accept',
+        'type': 'Accept',
+        'actor': follow.target.url,
+        'object': serializers.FollowSerializer(follow).data,
+    }
+
+    serializer = serializers.AcceptFollowSerializer(follow)
+
+    assert serializer.data == expected
+
+
+def test_accept_follow_serializer_save(factories):
+    follow = factories['federation.Follow'](approved=None)
+
+    data = {
+        '@context': [
+            'https://www.w3.org/ns/activitystreams',
+            'https://w3id.org/security/v1',
+            {},
+        ],
+        'id': follow.get_federation_url() + '/accept',
+        'type': 'Accept',
+        'actor': follow.target.url,
+        'object': serializers.FollowSerializer(follow).data,
+    }
+
+    serializer = serializers.AcceptFollowSerializer(data=data)
+    assert serializer.is_valid(raise_exception=True)
+    serializer.save()
+
+    follow.refresh_from_db()
+
+    assert follow.approved is True
+
+
+def test_accept_follow_serializer_validates_on_context(factories):
+    follow = factories['federation.Follow'](approved=None)
+    impostor = factories['federation.Actor']()
+    data = {
+        '@context': [
+            'https://www.w3.org/ns/activitystreams',
+            'https://w3id.org/security/v1',
+            {},
+        ],
+        'id': follow.get_federation_url() + '/accept',
+        'type': 'Accept',
+        'actor': impostor.url,
+        'object': serializers.FollowSerializer(follow).data,
+    }
+
+    serializer = serializers.AcceptFollowSerializer(
+        data=data,
+        context={'follow_actor': impostor, 'follow_target': impostor})
+
+    assert serializer.is_valid() is False
+    assert 'actor' in serializer.errors['object']
+    assert 'object' in serializer.errors['object']
+
+
+def test_undo_follow_serializer_representation(factories):
+    follow = factories['federation.Follow'](approved=True)
+
+    expected = {
+        '@context': [
+            'https://www.w3.org/ns/activitystreams',
+            'https://w3id.org/security/v1',
+            {},
+        ],
+        'id': follow.get_federation_url() + '/undo',
+        'type': 'Undo',
+        'actor': follow.actor.url,
+        'object': serializers.FollowSerializer(follow).data,
+    }
+
+    serializer = serializers.UndoFollowSerializer(follow)
+
+    assert serializer.data == expected
+
+
+def test_undo_follow_serializer_save(factories):
+    follow = factories['federation.Follow'](approved=True)
+
+    data = {
+        '@context': [
+            'https://www.w3.org/ns/activitystreams',
+            'https://w3id.org/security/v1',
+            {},
+        ],
+        'id': follow.get_federation_url() + '/undo',
+        'type': 'Undo',
+        'actor': follow.actor.url,
+        'object': serializers.FollowSerializer(follow).data,
+    }
+
+    serializer = serializers.UndoFollowSerializer(data=data)
+    assert serializer.is_valid(raise_exception=True)
+    serializer.save()
+
+    with pytest.raises(models.Follow.DoesNotExist):
+        follow.refresh_from_db()
+
+
+def test_undo_follow_serializer_validates_on_context(factories):
+    follow = factories['federation.Follow'](approved=True)
+    impostor = factories['federation.Actor']()
+    data = {
+        '@context': [
+            'https://www.w3.org/ns/activitystreams',
+            'https://w3id.org/security/v1',
+            {},
+        ],
+        'id': follow.get_federation_url() + '/undo',
+        'type': 'Undo',
+        'actor': impostor.url,
+        'object': serializers.FollowSerializer(follow).data,
+    }
+
+    serializer = serializers.UndoFollowSerializer(
+        data=data,
+        context={'follow_actor': impostor, 'follow_target': impostor})
+
+    assert serializer.is_valid() is False
+    assert 'actor' in serializer.errors['object']
+    assert 'object' in serializer.errors['object']
+
+
 def test_paginated_collection_serializer(factories):
     tfs = factories['music.TrackFile'].create_batch(size=5)
     actor = factories['federation.Actor'](local=True)
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
index 0b58e20f145d5a78acd7768e01ab31c3da21352b..bd174f721b5fa133be4b80926fd7855a462002b5 100644
--- a/api/tests/federation/test_views.py
+++ b/api/tests/federation/test_views.py
@@ -4,6 +4,7 @@ from django.core.paginator import Paginator
 import pytest
 
 from funkwhale_api.federation import actors
+from funkwhale_api.federation import models
 from funkwhale_api.federation import serializers
 from funkwhale_api.federation import utils
 from funkwhale_api.federation import webfinger
@@ -179,3 +180,35 @@ def test_can_scan_library(superuser_api_client, mocker):
     assert response.status_code == 200
     assert response.data == result
     scan.assert_called_once_with('test@test.library')
+
+
+def test_follow_library_manually(superuser_api_client, mocker, factories):
+    library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
+    actor = factories['federation.Actor'](manually_approves_followers=True)
+    follow = {'test': 'follow'}
+    deliver = mocker.patch(
+        'funkwhale_api.federation.activity.deliver')
+    actor_get = mocker.patch(
+        'funkwhale_api.federation.actors.get_actor',
+        return_value=actor)
+    library_get = mocker.patch(
+        'funkwhale_api.federation.library.get_library_data',
+        return_value={})
+
+    url = reverse('api:v1:federation:libraries-list')
+    response = superuser_api_client.post(
+        url, {'actor_url': actor.url})
+
+    assert response.status_code == 201
+
+    follow = models.Follow.objects.get(
+        actor=library_actor,
+        target=actor,
+        approved=None,
+    )
+
+    deliver.assert_called_once_with(
+        serializers.FollowSerializer(follow).data,
+        on_behalf_of=library_actor,
+        to=[actor.url]
+    )