diff --git a/greg/api/filters.py b/greg/api/filters.py
index ef45de2a5eab93a75cabb53a2dfb046d827bd78a..b8384d7960ddb9398d564830e4b7d9b7e9e443e4 100644
--- a/greg/api/filters.py
+++ b/greg/api/filters.py
@@ -11,6 +11,7 @@ from greg.models import (
     Person,
     Role,
     Identity,
+    Consent,
 )
 
 
@@ -60,3 +61,9 @@ class IdentityFilter(FilterSet):
     class Meta:
         model = Identity
         fields = ["type", "verified_by_id"]
+
+
+class PersonConsentFilter(FilterSet):
+    class Meta:
+        model = Consent
+        fields = ["id"]
diff --git a/greg/api/urls.py b/greg/api/urls.py
index 9f0fb910eddf6ee1e7db3602fbd0dd5ae45f38dd..55fc742f2bc2ff41fa11d87f4f4251ecf58faee9 100644
--- a/greg/api/urls.py
+++ b/greg/api/urls.py
@@ -9,6 +9,7 @@ from greg.api.views.person import (
     RoleViewSet,
     PersonViewSet,
     IdentityViewSet,
+    ConsentViewSet,
 )
 from greg.api.views.role_type import RoleTypeViewSet
 from greg.api.views.sponsor import (
@@ -51,6 +52,18 @@ urlpatterns += [
         ),
         name="person_identity-detail",
     ),
+    re_path(
+        r"^persons/(?P<person_id>[0-9]+)/consents$",
+        ConsentViewSet.as_view({"get": "list", "post": "create"}),
+        name="person_consent-list",
+    ),
+    re_path(
+        r"^persons/(?P<person_id>[0-9]+)/consents/(?P<id>[0-9]+)$",
+        ConsentViewSet.as_view(
+            {"get": "retrieve", "delete": "destroy", "patch": "partial_update"}
+        ),
+        name="person_consent-detail",
+    ),
     re_path(
         r"^sponsors/(?P<sponsor_id>[0-9]+)/guests$",
         SponsorGuestsViewSet.as_view({"get": "list"}),
diff --git a/greg/api/views/person.py b/greg/api/views/person.py
index 8d0000e167d217d934f6c70e09f0c9e006066281..12cff657d6719d5e77520ea7a541190d1ab87262 100644
--- a/greg/api/views/person.py
+++ b/greg/api/views/person.py
@@ -1,17 +1,24 @@
 from django.core.exceptions import ValidationError
 from django_filters import rest_framework as filters
 from drf_spectacular.utils import extend_schema, OpenApiParameter
+from rest_framework import serializers
 from rest_framework import viewsets, status, permissions
 from rest_framework.response import Response
 
-from greg.api.filters import PersonFilter, RoleFilter, IdentityFilter
+from greg.api.filters import (
+    PersonFilter,
+    RoleFilter,
+    IdentityFilter,
+    PersonConsentFilter,
+)
 from greg.api.pagination import PrimaryKeyCursorPagination
+from greg.api.serializers.consent import ConsentSerializerBrief
 from greg.api.serializers.person import (
     PersonSerializer,
     IdentitySerializer,
 )
 from greg.api.serializers.role import RoleSerializer, RoleWriteSerializer
-from greg.models import Person, Role, Identity
+from greg.models import Person, Role, Identity, Consent, ConsentChoice, ConsentType
 
 
 class PersonViewSet(viewsets.ModelViewSet):
@@ -129,3 +136,106 @@ class IdentityViewSet(viewsets.ModelViewSet):
         return Response(
             serializer.data, status=status.HTTP_201_CREATED, headers=headers
         )
+
+
+class ConsentViewSet(viewsets.ModelViewSet):
+    """
+    Person consent API
+    """
+
+    queryset = Consent.objects.all().order_by("id")
+    pagination_class = PrimaryKeyCursorPagination
+    permission_classes = (permissions.IsAdminUser,)
+    filter_backends = (filters.DjangoFilterBackend,)
+    filterset_class = PersonConsentFilter
+    lookup_field = "id"
+
+    def get_serializer_class(self):
+        """
+        Fetch different serializer depending on http method.
+
+        """
+        if self.request.method in ("POST", "PATCH"):
+            return PersonConsentSerializer
+
+        # Use the same format as in the person endpoint when using a get
+        return ConsentSerializerBrief
+
+    def get_queryset(self):
+        qs = self.queryset
+        if not self.kwargs:
+            return qs.none()
+
+        # The ID of the person consent mapping is actually unique globally,
+        # so the person ID is not really necessary, but we have the convention in
+        # the API that the person ID should be specified as well
+        person_id = self.kwargs["person_id"]
+
+        consent_id = self.kwargs.get("id")
+        qs = qs.filter(person_id=person_id)
+        if consent_id:
+            qs = qs.filter(id=consent_id)
+        return qs
+
+    def create(self, request, *args, **kwargs):
+        try:
+            serializer = self.get_serializer(data=self._transform_input(request))
+        except (ValidationError, ConsentChoice.DoesNotExist):
+            return Response(status=status.HTTP_400_BAD_REQUEST)
+
+        serializer.is_valid(raise_exception=True)
+
+        self.perform_create(serializer)
+        headers = self.get_success_headers(serializer.data)
+        return Response(
+            serializer.data, status=status.HTTP_201_CREATED, headers=headers
+        )
+
+    def update(self, request, *args, **kwargs):
+        try:
+            transformed_input = self._transform_input(request)
+        except (ValidationError, ConsentChoice.DoesNotExist):
+            return Response(status=status.HTTP_400_BAD_REQUEST)
+
+        partial = kwargs.pop("partial", False)
+        instance = self.get_object()
+        serializer = self.get_serializer(
+            instance, data=transformed_input, partial=partial
+        )
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+
+        return Response(serializer.data)
+
+    def _transform_input(self, request):
+        # Transform and augment in the request so that the serializer can handle it
+
+        # Want to get the person id which is part of the API path and then
+        # include this with the data used to create the identity for the person
+        person_id = self.kwargs["person_id"]
+
+        if person_id is None:
+            # Should not happen, the person ID is part of the API path
+            raise ValidationError("No person id")
+
+        input_data = request.data.copy()
+        input_data["person"] = int(person_id)
+
+        consent_type = ConsentType.objects.get(identifier=input_data["type"])
+        consent_choice = input_data["choice"]
+
+        choice = ConsentChoice.objects.get(
+            consent_type=consent_type, value=consent_choice
+        )
+
+        input_data["type"] = consent_type.id
+        input_data["choice"] = choice.id
+
+        return input_data
+
+
+class PersonConsentSerializer(serializers.ModelSerializer):
+    # Not much logic defined here, the validation is done implicitly in the view
+    class Meta:
+        model = Consent
+        fields = ["person", "type", "choice"]
diff --git a/greg/tests/api/test_person.py b/greg/tests/api/test_person.py
index 9be933358048289b9f7ee98d3856c417be9ec669..8787731449a157f73a4a4ff1654aa6015e3b1479 100644
--- a/greg/tests/api/test_person.py
+++ b/greg/tests/api/test_person.py
@@ -498,3 +498,99 @@ def test_filter_active_value_false(
     response = client.get(url, {"active": False})
     results = response.json()["results"]
     assert len(results) == 1
+
+
+@pytest.mark.django_db
+def test_person_consents_get(
+    client: APIClient, person: Person, consent_fixture_choice_yes: Consent
+):
+    url = reverse("v1:person_consent-list", kwargs={"person_id": person.id})
+    consents_for_person = client.get(url).json()["results"]
+
+    assert consents_for_person[0]["type"]["identifier"] == "foo"
+    assert not consents_for_person[0]["type"]["mandatory"]
+    assert consents_for_person[0]["choice"] == "yes"
+
+
+@pytest.mark.django_db
+def test_person_consent_add(
+    client: APIClient, person_foo: Person, consent_fixture: Consent
+):
+    url = reverse("v1:person_consent-list", kwargs={"person_id": person_foo.id})
+    consents_for_person = client.get(url).json()["results"]
+
+    assert len(consents_for_person) == 0
+
+    consent_data = {"type": "foo", "choice": "yes"}
+    response = client.post(url, consent_data)
+
+    assert response.status_code == status.HTTP_201_CREATED
+
+    consents_for_person = client.get(url).json()["results"]
+
+    assert consents_for_person[0]["type"]["identifier"] == "foo"
+    assert not consents_for_person[0]["type"]["mandatory"]
+    assert consents_for_person[0]["choice"] == "yes"
+
+
+@pytest.mark.django_db
+def test_person_consent_patch(
+    client: APIClient, person: Person, consent_fixture_choice_yes: Consent
+):
+    consent_id = person.consents.get().id
+    url = reverse(
+        "v1:person_consent-detail", kwargs={"person_id": person.id, "id": consent_id}
+    )
+
+    consent_data = {"type": "foo", "choice": "no"}
+    response = client.patch(url, consent_data)
+
+    assert response.status_code == status.HTTP_200_OK
+
+    get_url = reverse("v1:person_consent-list", kwargs={"person_id": person.id})
+    consents_for_person = client.get(get_url).json()["results"]
+
+    assert consents_for_person[0]["type"]["identifier"] == "foo"
+    assert not consents_for_person[0]["type"]["mandatory"]
+    assert consents_for_person[0]["choice"] == "no"
+
+
+@pytest.mark.django_db
+def test_person_consent_delete(
+    client: APIClient, person: Person, consent_fixture_choice_yes: Consent
+):
+    consent_id = person.consents.get().id
+    get_url = reverse("v1:person_consent-list", kwargs={"person_id": person.id})
+
+    consents_for_person = client.get(get_url).json()["results"]
+    assert len(consents_for_person) == 1
+
+    delete_url = reverse(
+        "v1:person_consent-detail", kwargs={"person_id": person.id, "id": consent_id}
+    )
+    response = client.delete(delete_url)
+
+    assert response.status_code == status.HTTP_204_NO_CONTENT
+
+    consents_for_person = client.get(get_url).json()["results"]
+    assert len(consents_for_person) == 0
+
+
+@pytest.mark.django_db
+def test_person_consent_add_invalid_choice_fails(
+    client: APIClient, person_foo: Person, consent_fixture: Consent
+):
+    url = reverse("v1:person_consent-list", kwargs={"person_id": person_foo.id})
+    consents_for_person = client.get(url).json()["results"]
+
+    assert len(consents_for_person) == 0
+
+    # Try with blue as a choice
+    consent_data = {"type": "foo", "choice": "blue"}
+    response = client.post(url, consent_data)
+
+    assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+    # No consent should have been added to the person
+    consents_for_person = client.get(url).json()["results"]
+    assert len(consents_for_person) == 0
diff --git a/greg/tests/conftest.py b/greg/tests/conftest.py
index bd45a28353221005e5372e3d20cb990f0bb0f993..fd6e8b0954b34547729a6bc4ce7e61f569e06d58 100644
--- a/greg/tests/conftest.py
+++ b/greg/tests/conftest.py
@@ -230,6 +230,18 @@ def consent_fixture(person, consent_type_foo):
     return Consent.objects.get(id=1)
 
 
+@pytest.fixture
+def consent_fixture_choice_yes(person, consent_type_foo):
+    consent_given_date = "2021-06-20"
+    Consent.objects.create(
+        person=person,
+        type=consent_type_foo,
+        consent_given_at=consent_given_date,
+        choice=consent_type_foo.choices.get(value="yes"),
+    )
+    return Consent.objects.get(id=1)
+
+
 @pytest.fixture
 def consent_type_foo() -> ConsentType:
     consent_foo = ConsentType.objects.create(
diff --git a/gregui/api/views/invitation.py b/gregui/api/views/invitation.py
index e35626b11823d8ea2345220cf3c41fd9975b527c..1d4fb1ee617e219e26e2e997609ebb97631f79e4 100644
--- a/gregui/api/views/invitation.py
+++ b/gregui/api/views/invitation.py
@@ -337,8 +337,7 @@ class InvitedGuestView(GenericAPIView):
             return optional_response
 
         with transaction.atomic():
-            # Note this only serializes data for the person, it does not look at other sections
-            # in the response that happen to be there
+            # Serialize data for the person, this includes identity and consent information
             serializer = self.get_serializer(
                 instance=person, data=request.data["person"]
             )