diff --git a/arkindex/project/openapi/patch.yml b/arkindex/project/openapi/patch.yml
index f7aa43195f526969a3062318474c8f1e995e178b..1aa64baee2a7baf4eef3a4afefd0c8608af2e77a 100644
--- a/arkindex/project/openapi/patch.yml
+++ b/arkindex/project/openapi/patch.yml
@@ -464,3 +464,9 @@ paths:
       description: >-
         Delete a group.
         Requires to have `admin` privileges on this group.
+  /api/v1/group/{id}/members/:
+    post:
+      description: >-
+        Add a user to a group.
+        The user making the request must be an admin member of this group.
+        Privilege level is a required argument (0 to 100).
diff --git a/arkindex/users/api.py b/arkindex/users/api.py
index 736fcca7d775a90d437770d452c6c24f5f307b59..e8692d5e79666c1170cfc1e05f56a6dd8f9f371c 100644
--- a/arkindex/users/api.py
+++ b/arkindex/users/api.py
@@ -33,7 +33,7 @@ from rq.job import JobStatus
 
 from arkindex.documents.models import Corpus
 from arkindex.project.permissions import IsAuthenticatedOrReadOnly, IsVerified
-from arkindex.users.models import Group, LevelRight, OAuthStatus, Scope, User, UserScope
+from arkindex.users.models import Group, OAuthStatus, Scope, User, UserScope
 from arkindex.users.providers import get_provider, oauth_providers
 from arkindex.users.serializers import (
     EmailLoginSerializer,
@@ -453,7 +453,6 @@ class OAuthCallback(UserPassesTestMixin, RedirectView):
 class GroupsList(ListCreateAPIView):
     """
     List existing groups either if they are public or the request user is a member.
-    A POST request allow to create a new group.
     """
     serializer_class = GroupSerializer
     permission_classes = (IsVerified, )
@@ -513,7 +512,7 @@ class JobRetrieve(RetrieveDestroyAPIView):
         instance.delete()
 
 
-class GroupMembershipsList(ListAPIView):
+class GroupMembershipsList(ListCreateAPIView):
     """
     List members of a group to which user belongs with their privileges
     """
@@ -524,14 +523,31 @@ class GroupMembershipsList(ListAPIView):
         'tags': ['users'],
     }
 
+    def get_object(self):
+        if not hasattr(self, '_group'):
+            self._group = get_object_or_404(Group.objects.filter(
+                # Filter groups to which user belongs
+                users__in=[self.request.user],
+                id=self.kwargs['pk']
+            ))
+        return self._group
+
+    def get_serializer_context(self):
+        context = super().get_serializer_context()
+        if self.request:
+            context['group'] = self.get_object()
+        return context
+
     def get_queryset(self):
-        group = get_object_or_404(Group.objects.filter(
-            # Filter groups to which user belongs
-            users__in=[self.request.user],
-            id=self.kwargs['pk']
-        ))
+        group = self.get_object()
         return group.memberships.order_by('user__display_name', 'id').prefetch_related('user')
 
+    def create(self, request, *args, **kwargs):
+        membership = self.get_object().memberships.get(user=request.user)
+        if request.method not in SAFE_METHODS and membership.level < Group.ADMIN_LEVEL:
+            raise PermissionDenied(detail='Only members with an admin privilege may edit memberships on this group.')
+        return super().create(request, *args, **kwargs)
+
 
 class GroupDetails(RetrieveUpdateDestroyAPIView):
     """
@@ -554,12 +570,8 @@ class GroupDetails(RetrieveUpdateDestroyAPIView):
             self._member = obj.memberships.get(user=self.request.user)
         # Check the user has the right to delete or update a group
         super().check_object_permissions(request, obj)
-        user_level = LevelRight.from_level(self._member.level)
-        if self.request.method not in SAFE_METHODS and user_level is not LevelRight.Admin:
-            raise self.permission_denied(
-                request,
-                message='Only members with an admin privilege may update or delete this group.'
-            )
+        if request.method not in SAFE_METHODS and self._member.level < Group.ADMIN_LEVEL:
+            raise PermissionDenied(detail='Only members with an admin privilege may update or delete this group.')
 
     def get_queryset(self):
         # Filter groups to which user belongs only
diff --git a/arkindex/users/migrations/0008_add_membership_constraints.py b/arkindex/users/migrations/0008_add_membership_constraints.py
new file mode 100644
index 0000000000000000000000000000000000000000..7d9f736f8da42cdc67866920ac122efe98cb12d2
--- /dev/null
+++ b/arkindex/users/migrations/0008_add_membership_constraints.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.1.3 on 2020-11-24 13:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0007_user_display_name'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='membership',
+            name='level',
+            field=models.PositiveIntegerField(help_text='User privilege level.'),
+        ),
+        migrations.AlterUniqueTogether(
+            name='membership',
+            unique_together={('user', 'group')},
+        ),
+    ]
diff --git a/arkindex/users/models.py b/arkindex/users/models.py
index 38dca6309658746853f0b3b636ddda54424835e9..de844c6555f43ed33c153ff7ce5388787ee940c0 100644
--- a/arkindex/users/models.py
+++ b/arkindex/users/models.py
@@ -10,24 +10,6 @@ from arkindex.users.managers import UserManager
 from arkindex.users.providers import get_provider, oauth_providers
 
 
-class LevelRight(Enum):
-    Admin = 'admin', 100
-    Write = 'write', 50
-    Read = 'read', 10
-
-    def __new__(cls, value, level):
-        right = object.__new__(cls)
-        right._value_ = value
-        right.level = level
-        return right
-
-    @classmethod
-    def from_level(cls, level):
-        for right in cls.__iter__():
-            if right.level <= level:
-                return right
-
-
 class User(AbstractBaseUser):
     email = models.EmailField(
         verbose_name='email address',
@@ -106,18 +88,23 @@ class Group(models.Model):
     id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
     name = models.CharField(max_length=64)
     public = models.BooleanField(default=False)
-
     users = models.ManyToManyField(
         User,
         through='Membership',
         related_name='groups',
     )
+    ADMIN_LEVEL = 100
 
 
 class Membership(models.Model):
     user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='memberships')
     group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='memberships')
-    level = models.PositiveIntegerField(default=0, help_text='User privilege level.')
+    level = models.PositiveIntegerField(help_text='User privilege level.')
+
+    class Meta:
+        unique_together = (
+            ('user', 'group'),
+        )
 
 
 class OAuthStatus(Enum):
diff --git a/arkindex/users/serializers.py b/arkindex/users/serializers.py
index 270feacff6372ad1368c2e0ad8d7f069fbdf1d40..be4787cc2d5f04240e37aac61a60c4c1195771f7 100644
--- a/arkindex/users/serializers.py
+++ b/arkindex/users/serializers.py
@@ -9,7 +9,7 @@ from django.utils.http import urlsafe_base64_decode
 from rest_framework import serializers
 
 from arkindex.project.serializer_fields import EnumField
-from arkindex.users.models import Group, LevelRight, Membership, OAuthCredentials, OAuthStatus, User
+from arkindex.users.models import Group, Membership, OAuthCredentials, OAuthStatus, User
 from transkribus import TranskribusAPI
 
 logging.basicConfig(
@@ -266,23 +266,33 @@ class MemberSerializer(serializers.ModelSerializer):
     """
     Serialize a user member of a group with its privileges level
     """
-    id = serializers.StringRelatedField(source='user.id')
-    display_name = serializers.StringRelatedField(source='user.display_name')
-    email = serializers.StringRelatedField(source='user.email')
-    level = serializers.SerializerMethodField()
+    id = serializers.IntegerField(source='user.id', read_only=True)
+    email = serializers.EmailField(source='user.email')
+    display_name = serializers.StringRelatedField(source='user.display_name', read_only=True)
 
     class Meta:
         model = Membership
         fields = (
+            'level',
             'id',
             'display_name',
-            'email',
-            'level'
+            'email'
         )
 
-    def get_level(self, obj):
-        right = LevelRight.from_level(obj.level)
-        return right and right.value
+    def validate(self, data):
+        data = super().validate(data)
+        # Check only one of user identifiers is set among ID and email
+        user_data = data.pop('user')
+        try:
+            data['user'] = User.objects.get(**user_data)
+        except User.DoesNotExist:
+            raise ValidationError({'email': ['No user matching this email could be found.']})
+        if Membership.objects.filter(user=data['user'], group=self.context['group']).exists():
+            raise ValidationError({'email': ['This user is already a member of the group.']})
+        return data
+
+    def create(self, validated_data):
+        return Membership.objects.create(group=self.context['group'], **validated_data)
 
 
 class MemberGroupSerializer(serializers.ModelSerializer):
@@ -290,7 +300,6 @@ class MemberGroupSerializer(serializers.ModelSerializer):
     Serialize a group for a specific member with its privileges level
     """
     group = GroupSerializer()
-    level = serializers.SerializerMethodField()
 
     class Meta:
         model = Membership
@@ -299,7 +308,3 @@ class MemberGroupSerializer(serializers.ModelSerializer):
             'group',
             'level',
         )
-
-    def get_level(self, obj):
-        right = LevelRight.from_level(obj.level)
-        return right and right.value
diff --git a/arkindex/users/tests/test_membership.py b/arkindex/users/tests/test_membership.py
index 9c1aff8e23385ed39874d893aea77cd2fbdc8c59..c053cd6c93c690522acb97fe0cd198b4b3449978 100644
--- a/arkindex/users/tests/test_membership.py
+++ b/arkindex/users/tests/test_membership.py
@@ -1,8 +1,10 @@
+import uuid
+
 from django.urls import reverse
 from rest_framework import status
 
 from arkindex.project.tests import FixtureAPITestCase
-from arkindex.users.models import Group, LevelRight, User
+from arkindex.users.models import Group, User
 
 
 class TestMembership(FixtureAPITestCase):
@@ -15,9 +17,18 @@ class TestMembership(FixtureAPITestCase):
         super().setUpTestData()
         cls.unverified = User.objects.create_user('user@address.com', 'P4$5w0Rd')
         cls.admin = User.objects.create_superuser('admin@address.com', 'P4$5w0Rd')
+        cls.non_admin = User.objects.filter(
+            memberships__group=cls.group,
+            memberships__level__lt=Group.ADMIN_LEVEL
+        ).first()
+        cls.non_admin.verified_email = True
+        cls.non_admin.save()
 
-        cls.admin_group = cls.admin.groups.create(name="Admin group", public=True)
-        cls.admin_group.memberships.update(level=LevelRight.Admin.level)
+        cls.admin_group = cls.admin.groups.create(
+            name="Admin group",
+            public=True,
+            through_defaults={'level': Group.ADMIN_LEVEL}
+        )
 
     def test_verified_user_group_create(self):
         """
@@ -61,7 +72,7 @@ class TestMembership(FixtureAPITestCase):
         })
         self.assertCountEqual(
             group.memberships.values_list('user', 'level'),
-            [(self.user.id, LevelRight.Admin.level)]
+            [(self.user.id, Group.ADMIN_LEVEL)]
         )
 
     def test_crate_group_duplicated_name(self):
@@ -129,18 +140,18 @@ class TestMembership(FixtureAPITestCase):
                 {
                     'display_name': 'Test user',
                     'email': admin_member.user.email,
-                    'id': str(admin_member.user.id),
-                    'level': 'admin'
+                    'id': admin_member.user.id,
+                    'level': Group.ADMIN_LEVEL
                 }, {
                     'display_name': 'Test user read',
                     'email': read_member.user.email,
-                    'id': str(read_member.user.id),
-                    'level': 'read'
+                    'id': read_member.user.id,
+                    'level': read_member.level
                 }, {
                     'display_name': 'Test user write',
                     'email': write_member.user.email,
-                    'id': str(write_member.user.id),
-                    'level': 'write'
+                    'id': write_member.user.id,
+                    'level': write_member.level
                 }
             ]
         })
@@ -163,8 +174,8 @@ class TestMembership(FixtureAPITestCase):
                 {
                     'display_name': self.user.display_name,
                     'email': self.user.email,
-                    'id': str(self.user.id),
-                    'level': None
+                    'id': self.user.id,
+                    'level': 1
                 }
             ]
         })
@@ -194,16 +205,12 @@ class TestMembership(FixtureAPITestCase):
         """
         User must be a group administrator to edit its properties
         """
-        non_admin = User.objects.filter(
-            memberships__group=self.group,
-            memberships__level__lt=LevelRight.Admin.level
-        ).first()
-        self.client.force_login(non_admin)
-        with self.assertNumQueries(2):
+        self.client.force_login(self.non_admin)
+        with self.assertNumQueries(4):
             response = self.client.patch(reverse('api:group-details', kwargs={'pk': str(self.group.id)}), {'name': 'A'})
         self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
         self.assertDictEqual(response.json(), {
-            'detail': 'You do not have permission to perform this action.'
+            'detail': 'Only members with an admin privilege may update or delete this group.'
         })
 
     def test_update_group(self):
@@ -231,13 +238,7 @@ class TestMembership(FixtureAPITestCase):
         })
 
     def test_delete_group_no_admin(self):
-        non_admin = User.objects.filter(
-            memberships__group=self.group,
-            memberships__level__lt=LevelRight.Admin.level
-        ).first()
-        non_admin.verified_email = True
-        non_admin.save()
-        self.client.force_login(non_admin)
+        self.client.force_login(self.non_admin)
         with self.assertNumQueries(4):
             response = self.client.delete(reverse('api:group-details', kwargs={'pk': str(self.group.id)}))
         self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@@ -253,7 +254,7 @@ class TestMembership(FixtureAPITestCase):
         user = group.users.create(
             email='user42@test.test',
             verified_email=True,
-            through_defaults={'level': LevelRight.Admin.level}
+            through_defaults={'level': Group.ADMIN_LEVEL}
         )
         self.client.force_login(user)
         with self.assertNumQueries(6):
@@ -276,7 +277,9 @@ class TestMembership(FixtureAPITestCase):
         """
         List groups for which the user is a member
         """
-        a_group = self.user.groups.create(name='Another group', through_defaults={'level': LevelRight.Read.level})
+        new_group = self.user.groups.create(name='Another group', through_defaults={'level': 10})
+        new_group_member = new_group.memberships.get(user=self.user)
+        member = self.group.memberships.get(user=self.user)
         self.client.force_login(self.user)
         with self.assertNumQueries(5):
             response = self.client.get(reverse('api:user-memberships'))
@@ -290,13 +293,13 @@ class TestMembership(FixtureAPITestCase):
                 {
                     'group':
                         {
-                            'id': str(a_group.id),
+                            'id': str(new_group.id),
                             'members_count': 1,
                             'name': 'Another group',
                             'public': False
                         },
-                    'id': a_group.memberships.get(user=self.user).id,
-                    'level': 'read'
+                    'id': new_group_member.id,
+                    'level': new_group_member.level
                 },
                 {
                     'group':
@@ -306,8 +309,110 @@ class TestMembership(FixtureAPITestCase):
                             'name': self.group.name,
                             'public': self.group.public
                         },
-                    'id': self.group.memberships.get(user=self.user).id,
-                    'level': 'admin'
+                    'id': member.id,
+                    'level': member.level
                 }
             ]
         })
+
+    def test_add_member_non_existing_group(self):
+        """
+        Cannot add a member to a non existing group
+        """
+        self.client.force_login(self.user)
+        with self.assertNumQueries(3):
+            response = self.client.post(reverse('api:group-members', kwargs={'pk': str(uuid.uuid4())}), {})
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def test_add_member_requires_login(self):
+        with self.assertNumQueries(0):
+            response = self.client.post(reverse('api:group-members', kwargs={'pk': str(self.group.id)}), {})
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+        self.assertDictEqual(response.json(), {'detail': 'Authentication credentials were not provided.'})
+
+    def test_add_member_requires_verfified(self):
+        self.client.force_login(self.unverified)
+        with self.assertNumQueries(2):
+            response = self.client.post(reverse('api:group-members', kwargs={'pk': str(self.group.id)}), {})
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+        self.assertDictEqual(response.json(), {'detail': 'You do not have permission to perform this action.'})
+
+    def test_add_member_requires_group_admin(self):
+        """
+        Only group admins have the ability to add a new member
+        """
+        self.client.force_login(self.non_admin)
+        with self.assertNumQueries(4):
+            response = self.client.post(
+                reverse('api:group-members', kwargs={'pk': str(self.group.id)}),
+                {'id': self.admin.id, 'level': 10}
+            )
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+        self.assertDictEqual(response.json(), {
+            'detail': 'Only members with an admin privilege may edit memberships on this group.'
+        })
+
+    def test_add_member_already_member(self):
+        """
+        A member cannot be added twice
+        """
+        self.client.force_login(self.user)
+        with self.assertNumQueries(6):
+            response = self.client.post(
+                reverse('api:group-members', kwargs={'pk': str(self.group.id)}),
+                {'email': self.non_admin.email, 'level': 10}
+            )
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertDictEqual(response.json(), {
+            'email': ['This user is already a member of the group.']
+        })
+
+    def test_add_member_non_existing_user(self):
+        """
+        Cannot add a non existing user
+        """
+        self.client.force_login(self.user)
+        with self.assertNumQueries(5):
+            response = self.client.post(
+                reverse('api:group-members', kwargs={'pk': str(self.group.id)}),
+                {'email': 'undefined@nowhe.re' , 'level': 10}
+            )
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertDictEqual(response.json(), {
+            'email': ['No user matching this email could be found.']
+        })
+
+    def test_add_member_required_fields(self):
+        """
+        Member email and level are required fields
+        """
+        self.client.force_login(self.user)
+        with self.assertNumQueries(4):
+            response = self.client.post(
+                reverse('api:group-members', kwargs={'pk': str(self.group.id)}),
+                {'id': 42, 'access': 50}
+            )
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertDictEqual(response.json(), {
+            'email': ['This field is required.'],
+            'level': ['This field is required.']
+        })
+
+    def test_add_member_by_email(self):
+        """
+        Adds a new member referenced by its email
+        """
+        user = User.objects.create_user('test@test.de', 'Pa$$w0rd')
+        self.client.force_login(self.user)
+        with self.assertNumQueries(7):
+            response = self.client.post(
+                reverse('api:group-members', kwargs={'pk': str(self.group.id)}),
+                {'email': user.email, 'level': 10}
+            )
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertDictEqual(response.json(), {
+            'display_name': user.display_name,
+            'email': user.email,
+            'id': user.id,
+            'level': 10
+        })