diff --git a/app/controllers/activity_pub/application_controller.rb b/app/controllers/activity_pub/application_controller.rb
index f9c2b14fe7701c945a7e1d3105d5518e6751401e..70cf881c857a0434f1c34d77d8a9f8b015fc0cbe 100644
--- a/app/controllers/activity_pub/application_controller.rb
+++ b/app/controllers/activity_pub/application_controller.rb
@@ -8,6 +8,8 @@ class ApplicationController < ::ApplicationController
     skip_before_action :authenticate_user!
     after_action :set_content_type
 
+    protect_from_forgery with: :null_session
+
     def can?(object, action, subject = :global)
       Ability.allowed?(object, action, subject)
     end
diff --git a/app/controllers/activity_pub/projects/releases_controller.rb b/app/controllers/activity_pub/projects/releases_controller.rb
index 7c4c2a0322be69108a07ef334f2dcdde39cc3b44..eeff96a5ef7047c9c2900cc965d47da30b286849 100644
--- a/app/controllers/activity_pub/projects/releases_controller.rb
+++ b/app/controllers/activity_pub/projects/releases_controller.rb
@@ -5,15 +5,27 @@ module Projects
     class ReleasesController < ApplicationController
       feature_category :release_orchestration
 
+      before_action :enforce_payload, only: :inbox
+
       def index
         opts = {
-          inbox: nil,
+          inbox: inbox_project_releases_url(@project),
           outbox: outbox_project_releases_url(@project)
         }
 
         render json: ActivityPub::ReleasesActorSerializer.new.represent(@project, opts)
       end
 
+      def inbox
+        service = inbox_service
+        success = service ? service.execute : true
+
+        response = { success: success }
+        response[:errors] = service.errors unless success
+
+        render json: response
+      end
+
       def outbox
         serializer = ActivityPub::ReleasesOutboxSerializer.new.with_pagination(request, response)
         render json: serializer.represent(releases)
@@ -24,6 +36,39 @@ def outbox
       def releases(params = {})
         ReleasesFinder.new(@project, current_user, params).execute
       end
+
+      def enforce_payload
+        return if payload
+
+        head :unprocessable_entity
+        false
+      end
+
+      def payload
+        @payload ||= begin
+          Gitlab::Json.parse(request.body.read)
+        rescue JSON::ParserError
+          nil
+        end
+      end
+
+      def follow?
+        payload['type'] == 'Follow'
+      end
+
+      def unfollow?
+        undo = payload['type'] == 'Undo'
+        object = payload['object']
+        follow = object.present? && object.is_a?(Hash) && object['type'] == 'Follow'
+        undo && follow
+      end
+
+      def inbox_service
+        return ReleasesFollowService.new(project, payload) if follow?
+        return ReleasesUnfollowService.new(project, payload) if unfollow?
+
+        nil
+      end
     end
   end
 end
diff --git a/app/models/activity_pub/releases_subscription.rb b/app/models/activity_pub/releases_subscription.rb
index a6304f1fc3544beac741180cf6afc3469e4a81e9..0a4293b2bdea71872e26ad9497e3f3fc50fbd4d7 100644
--- a/app/models/activity_pub/releases_subscription.rb
+++ b/app/models/activity_pub/releases_subscription.rb
@@ -11,12 +11,12 @@ class ReleasesSubscription < ApplicationRecord
     validates :payload, json_schema: { filename: 'activity_pub_follow_payload' }, allow_blank: true
     validates :subscriber_url, presence: true, uniqueness: { case_sensitive: false, scope: :project_id },
       public_url: true
-    validates :subscriber_inbox_url, uniqueness: { case_sensitive: false, scope: :project_id },
+    validates :subscriber_inbox_url, uniqueness: { case_sensitive: false, scope: :project_id, allow_nil: true },
       public_url: { allow_nil: true }
     validates :shared_inbox_url, public_url: { allow_nil: true }
 
-    def self.find_by_subscriber_url(subscriber_url)
-      find_by('LOWER(subscriber_url) = ?', subscriber_url.downcase)
+    def self.find_by_project_and_subscriber(project_id, subscriber_url)
+      find_by('project_id = ? AND LOWER(subscriber_url) = ?', project_id, subscriber_url.downcase)
     end
   end
 end
diff --git a/app/services/activity_pub/projects/releases_follow_service.rb b/app/services/activity_pub/projects/releases_follow_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3d877a1d08377bad993a6a9b4e29ea33f30a5ddb
--- /dev/null
+++ b/app/services/activity_pub/projects/releases_follow_service.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module ActivityPub
+  module Projects
+    class ReleasesFollowService < ReleasesSubscriptionService
+      def execute
+        unless subscriber_url
+          errors << "You need to provide an actor id for your subscriber"
+          return false
+        end
+
+        return true if previous_subscription.present?
+
+        subscription = ReleasesSubscription.new(
+          subscriber_url: subscriber_url,
+          subscriber_inbox_url: subscriber_inbox_url,
+          payload: payload,
+          project: project
+        )
+
+        unless subscription.save
+          errors.concat(subscription.errors.full_messages)
+          return false
+        end
+
+        enqueue_subscription(subscription)
+        true
+      end
+
+      private
+
+      def subscriber_inbox_url
+        return unless payload['actor'].is_a?(Hash)
+
+        payload['actor']['inbox']
+      end
+
+      def enqueue_subscription(subscription)
+        ReleasesSubscriptionWorker.perform_async(subscription.id)
+      end
+    end
+  end
+end
diff --git a/app/services/activity_pub/projects/releases_subscription_service.rb b/app/services/activity_pub/projects/releases_subscription_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..27d0e19a172770b592528ece1282a8ea9e9a40e0
--- /dev/null
+++ b/app/services/activity_pub/projects/releases_subscription_service.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module ActivityPub
+  module Projects
+    class ReleasesSubscriptionService
+      attr_reader :errors
+
+      def initialize(project, payload)
+        @project = project
+        @payload = payload
+        @errors = []
+      end
+
+      def execute
+        raise "not implemented: abstract class, do not use directly."
+      end
+
+      private
+
+      attr_reader :project, :payload
+
+      def subscriber_url
+        return unless payload['actor']
+        return payload['actor'] if payload['actor'].is_a?(String)
+        return unless payload['actor'].is_a?(Hash) && payload['actor']['id'].is_a?(String)
+
+        payload['actor']['id']
+      end
+
+      def previous_subscription
+        @previous_subscription ||= ReleasesSubscription.find_by_project_and_subscriber(project.id, subscriber_url)
+      end
+    end
+  end
+end
diff --git a/app/services/activity_pub/projects/releases_unfollow_service.rb b/app/services/activity_pub/projects/releases_unfollow_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..df5dcefbb872ce369a85c545c16f69bb479b71f3
--- /dev/null
+++ b/app/services/activity_pub/projects/releases_unfollow_service.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module ActivityPub
+  module Projects
+    class ReleasesUnfollowService < ReleasesSubscriptionService
+      def execute
+        unless subscriber_url
+          errors << "You need to provide an actor id for your unsubscribe activity"
+          return false
+        end
+
+        return true unless previous_subscription.present?
+
+        previous_subscription.destroy
+      end
+    end
+  end
+end
diff --git a/config/routes/activity_pub.rb b/config/routes/activity_pub.rb
index f400d722e7612c545092539673ccbdbf7cf65455..a967889a0ad4be2ead5e0ca0fe03de8423582da6 100644
--- a/config/routes/activity_pub.rb
+++ b/config/routes/activity_pub.rb
@@ -21,6 +21,7 @@
             resources :releases, only: :index do
               collection do
                 get 'outbox'
+                post 'inbox'
               end
             end
           end
diff --git a/spec/controllers/activity_pub/projects/releases_controller_spec.rb b/spec/controllers/activity_pub/projects/releases_controller_spec.rb
index 8719756b2604b355d52ef09fc512c4765a6a4f45..4102789ee431ac4582ebcae4eb6130ca842bcb19 100644
--- a/spec/controllers/activity_pub/projects/releases_controller_spec.rb
+++ b/spec/controllers/activity_pub/projects/releases_controller_spec.rb
@@ -11,13 +11,15 @@
   let_it_be(:release_1) { create(:release, project: project, released_at: Time.zone.parse('2018-10-18')) }
   let_it_be(:release_2) { create(:release, project: project, released_at: Time.zone.parse('2019-10-19')) }
 
+  let(:request_body) { '' }
+
   before_all do
     project.add_developer(developer)
   end
 
   shared_examples 'common access controls' do
     it 'renders a 200' do
-      get(action, params: params)
+      perform_action(verb, action, params, request_body)
 
       expect(response).to have_gitlab_http_status(:ok)
     end
@@ -27,7 +29,7 @@
 
       context 'when user is not logged in' do
         it 'renders a 404' do
-          get(action, params: params)
+          perform_action(verb, action, params, request_body)
 
           expect(response).to have_gitlab_http_status(:not_found)
         end
@@ -39,7 +41,7 @@
         end
 
         it 'still renders a 404' do
-          get(action, params: params)
+          perform_action(verb, action, params, request_body)
 
           expect(response).to have_gitlab_http_status(:not_found)
         end
@@ -52,7 +54,7 @@
       end
 
       it 'renders a 404' do
-        get(action, params: params)
+        perform_action(verb, action, params, request_body)
 
         expect(response).to have_gitlab_http_status(:not_found)
       end
@@ -64,7 +66,7 @@
       end
 
       it 'renders a 404' do
-        get(action, params: params)
+        perform_action(verb, action, params, request_body)
 
         expect(response).to have_gitlab_http_status(:not_found)
       end
@@ -83,9 +85,10 @@
 
   describe 'GET #index' do
     before do
-      get(action, params: params)
+      perform_action(verb, action, params)
     end
 
+    let(:verb) { :get }
     let(:action) { :index }
     let(:params) { { namespace_id: project.namespace, project_id: project } }
 
@@ -99,9 +102,10 @@
 
   describe 'GET #outbox' do
     before do
-      get(action, params: params)
+      perform_action(verb, action, params)
     end
 
+    let(:verb) { :get }
     let(:action) { :outbox }
     let(:params) { { namespace_id: project.namespace, project_id: project, page: page } }
 
@@ -131,4 +135,172 @@
       end
     end
   end
+
+  describe 'POST #inbox' do
+    before do
+      allow(ActivityPub::Projects::ReleasesFollowService).to receive(:new) { follow_service }
+      allow(ActivityPub::Projects::ReleasesUnfollowService).to receive(:new) { unfollow_service }
+    end
+
+    let(:verb) { :post }
+    let(:action) { :inbox }
+    let(:params) { { namespace_id: project.namespace, project_id: project } }
+
+    let(:follow_service) do
+      instance_double(ActivityPub::Projects::ReleasesFollowService, execute: true, errors: ['an error'])
+    end
+
+    let(:unfollow_service) do
+      instance_double(ActivityPub::Projects::ReleasesUnfollowService, execute: true, errors: ['an error'])
+    end
+
+    context 'with a follow activity' do
+      before do
+        perform_action(verb, action, params, request_body)
+      end
+
+      let(:request_body) do
+        {
+          "@context": "https://www.w3.org/ns/activitystreams",
+          id: "http://localhost:3001/6233e6c2-d285-4aa4-bd71-ddf1824d87f8",
+          type: "Follow",
+          actor: "http://localhost:3001/users/admin",
+          object: "http://127.0.0.1:3000/flightjs/Flight/-/releases"
+        }.to_json
+      end
+
+      it_behaves_like 'common access controls'
+
+      context 'with successful subscription initialization' do
+        it 'calls the subscription service' do
+          expect(follow_service).to have_received :execute
+        end
+
+        it 'returns a successful response' do
+          expect(json_response['success']).to be_truthy
+        end
+
+        it 'does not fill any error' do
+          expect(json_response).not_to have_key 'errors'
+        end
+      end
+
+      context 'with unsuccessful subscription initialization' do
+        let(:follow_service) do
+          instance_double(ActivityPub::Projects::ReleasesFollowService, execute: false, errors: ['an error'])
+        end
+
+        it 'calls the subscription service' do
+          expect(follow_service).to have_received :execute
+        end
+
+        it 'returns a successful response' do
+          expect(json_response['success']).to be_falsey
+        end
+
+        it 'fills an error' do
+          expect(json_response['errors']).to include 'an error'
+        end
+      end
+    end
+
+    context 'with an unfollow activity' do
+      before do
+        perform_action(verb, action, params, request_body)
+      end
+
+      let(:unfollow_service) do
+        instance_double(ActivityPub::Projects::ReleasesSubscriptionService, execute: true, errors: ['an error'])
+      end
+
+      let(:request_body) do
+        {
+          "@context": "https://www.w3.org/ns/activitystreams",
+          id: "http://localhost:3001/users/admin#follows/8/undo",
+          type: "Undo",
+          actor: "http://localhost:3001/users/admin",
+          object: {
+            id: "http://localhost:3001/d4358269-71a9-4746-ac16-9a909f12ee5b",
+            type: "Follow",
+            actor: "http://localhost:3001/users/admin",
+            object: "http://127.0.0.1:3000/flightjs/Flight/-/releases"
+          }
+        }.to_json
+      end
+
+      it_behaves_like 'common access controls'
+
+      context 'with successful unfollow' do
+        it 'calls the subscription service' do
+          expect(unfollow_service).to have_received :execute
+        end
+
+        it 'returns a successful response' do
+          expect(json_response['success']).to be_truthy
+        end
+
+        it 'does not fill any error' do
+          expect(json_response).not_to have_key 'errors'
+        end
+      end
+
+      context 'with unsuccessful unfollow' do
+        let(:unfollow_service) do
+          instance_double(ActivityPub::Projects::ReleasesUnfollowService, execute: false, errors: ['an error'])
+        end
+
+        it 'calls the subscription service' do
+          expect(unfollow_service).to have_received :execute
+        end
+
+        it 'returns a successful response' do
+          expect(json_response['success']).to be_falsey
+        end
+
+        it 'fills an error' do
+          expect(json_response['errors']).to include 'an error'
+        end
+      end
+    end
+
+    context 'with an unknown activity' do
+      before do
+        perform_action(verb, action, params, request_body)
+      end
+
+      let(:request_body) do
+        {
+          "@context": "https://www.w3.org/ns/activitystreams",
+          id: "http://localhost:3001/6233e6c2-d285-4aa4-bd71-ddf1824d87f8",
+          type: "Like",
+          actor: "http://localhost:3001/users/admin",
+          object: "http://127.0.0.1:3000/flightjs/Flight/-/releases"
+        }.to_json
+      end
+
+      it 'does not call the subscription service' do
+        expect(follow_service).not_to have_received :execute
+        expect(unfollow_service).not_to have_received :execute
+      end
+
+      it 'returns a successful response' do
+        expect(json_response['success']).to be_truthy
+      end
+
+      it 'does not fill any error' do
+        expect(json_response).not_to have_key 'errors'
+      end
+    end
+
+    context 'with no activity' do
+      it 'renders a 422' do
+        perform_action(verb, action, params, request_body)
+        expect(response).to have_gitlab_http_status(:unprocessable_entity)
+      end
+    end
+  end
+end
+
+def perform_action(verb, action, params, body = nil)
+  send(verb, action, params: params, body: body)
 end
diff --git a/spec/models/activity_pub/releases_subscription_spec.rb b/spec/models/activity_pub/releases_subscription_spec.rb
index 0c873a5c18ae1dc3077912e9cfc0331c6165917c..0633f293971b674a663c662e9700b91a22426877 100644
--- a/spec/models/activity_pub/releases_subscription_spec.rb
+++ b/spec/models/activity_pub/releases_subscription_spec.rb
@@ -55,23 +55,37 @@
     end
   end
 
-  describe '.find_by_subscriber_url' do
+  describe '.find_by_project_and_subscriber' do
     let_it_be(:subscription) { create(:activity_pub_releases_subscription) }
 
     it 'returns a record if arguments match' do
-      result = described_class.find_by_subscriber_url(subscription.subscriber_url)
+      result = described_class.find_by_project_and_subscriber(subscription.project_id,
+        subscription.subscriber_url)
 
       expect(result).to eq(subscription)
     end
 
-    it 'returns a record if arguments match case insensitively' do
-      result = described_class.find_by_subscriber_url(subscription.subscriber_url.upcase)
+    it 'returns a record if subscriber url matches case insensitively' do
+      result = described_class.find_by_project_and_subscriber(subscription.project_id,
+        subscription.subscriber_url.upcase)
 
       expect(result).to eq(subscription)
     end
 
+    it 'returns nil if project and url do not match' do
+      result = described_class.find_by_project_and_subscriber(0, 'I really should not exist')
+
+      expect(result).to be(nil)
+    end
+
     it 'returns nil if project does not match' do
-      result = described_class.find_by_subscriber_url('I really should not exist')
+      result = described_class.find_by_project_and_subscriber(0, subscription.subscriber_url)
+
+      expect(result).to be(nil)
+    end
+
+    it 'returns nil if url does not match' do
+      result = described_class.find_by_project_and_subscriber(subscription.project_id, 'I really should not exist')
 
       expect(result).to be(nil)
     end
diff --git a/spec/services/activity_pub/projects/releases_follow_service_spec.rb b/spec/services/activity_pub/projects/releases_follow_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..6d0d400b9c66a66d09f1e3a08dced1922200ae03
--- /dev/null
+++ b/spec/services/activity_pub/projects/releases_follow_service_spec.rb
@@ -0,0 +1,145 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ActivityPub::Projects::ReleasesFollowService, feature_category: :release_orchestration do
+  let_it_be(:project) { create(:project, :public) }
+  let_it_be_with_reload(:existing_subscription) { create(:activity_pub_releases_subscription, project: project) }
+
+  describe '#execute' do
+    let(:service) { described_class.new(project, payload) }
+    let(:payload) { nil }
+
+    before do
+      allow(ActivityPub::Projects::ReleasesSubscriptionWorker).to receive(:perform_async)
+    end
+
+    context 'with a valid payload' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'https://example.com/new-actor#follow-1',
+          type: 'Follow',
+          actor: actor,
+          object: 'https://localhost/our/project/-/releases'
+        }.with_indifferent_access
+      end
+
+      let(:actor) { 'https://example.com/new-actor' }
+
+      context 'when there is no subscription for that actor' do
+        before do
+          allow(ActivityPub::ReleasesSubscription).to receive(:find_by_project_and_subscriber).and_return(nil)
+        end
+
+        it 'sets the subscriber url' do
+          service.execute
+          expect(ActivityPub::ReleasesSubscription.last.subscriber_url).to eq 'https://example.com/new-actor'
+        end
+
+        it 'sets the payload' do
+          service.execute
+          expect(ActivityPub::ReleasesSubscription.last.payload).to eq payload
+        end
+
+        it 'sets the project' do
+          service.execute
+          expect(ActivityPub::ReleasesSubscription.last.project_id).to eq project.id
+        end
+
+        it 'saves the subscription' do
+          expect { service.execute }.to change { ActivityPub::ReleasesSubscription.count }.by(1)
+        end
+
+        it 'queues the subscription job' do
+          service.execute
+          expect(ActivityPub::Projects::ReleasesSubscriptionWorker).to have_received(:perform_async)
+        end
+
+        it 'returns true' do
+          expect(service.execute).to be_truthy
+        end
+      end
+
+      context 'when there is already a subscription for that actor' do
+        before do
+          allow(ActivityPub::ReleasesSubscription).to receive(:find_by_project_and_subscriber) { existing_subscription }
+        end
+
+        it 'does not save the subscription' do
+          expect { service.execute }.not_to change { ActivityPub::ReleasesSubscription.count }
+        end
+
+        it 'does not queue the subscription job' do
+          service.execute
+          expect(ActivityPub::Projects::ReleasesSubscriptionWorker).not_to have_received(:perform_async)
+        end
+
+        it 'returns true' do
+          expect(service.execute).to be_truthy
+        end
+      end
+    end
+
+    shared_examples 'invalid follow request' do
+      it 'does not save the subscription' do
+        expect { service.execute }.not_to change { ActivityPub::ReleasesSubscription.count }
+      end
+
+      it 'does not queue the subscription job' do
+        service.execute
+        expect(ActivityPub::Projects::ReleasesSubscriptionWorker).not_to have_received(:perform_async)
+      end
+
+      it 'sets an error' do
+        service.execute
+        expect(service.errors).not_to be_empty
+      end
+
+      it 'returns false' do
+        expect(service.execute).to be_falsey
+      end
+    end
+
+    context 'when actor is missing' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'https://example.com/new-actor',
+          type: 'Follow',
+          object: 'https://localhost/our/project/-/releases'
+        }.with_indifferent_access
+      end
+
+      it_behaves_like 'invalid follow request'
+    end
+
+    context 'when actor is an object with no id attribute' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'https://example.com/new-actor',
+          type: 'Follow',
+          actor: { type: 'Person' },
+          object: 'https://localhost/our/project/-/releases'
+        }.with_indifferent_access
+      end
+
+      it_behaves_like 'invalid follow request'
+    end
+
+    context 'when actor is neither a string nor an object' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'https://example.com/new-actor',
+          type: 'Follow',
+          actor: 27.13,
+          object: 'https://localhost/our/project/-/releases'
+        }.with_indifferent_access
+      end
+
+      it_behaves_like 'invalid follow request'
+    end
+  end
+end
diff --git a/spec/services/activity_pub/projects/releases_unfollow_service_spec.rb b/spec/services/activity_pub/projects/releases_unfollow_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c732d82a2addc149a3b401720df8756858714ef6
--- /dev/null
+++ b/spec/services/activity_pub/projects/releases_unfollow_service_spec.rb
@@ -0,0 +1,152 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ActivityPub::Projects::ReleasesUnfollowService, feature_category: :release_orchestration do
+  let_it_be(:project) { create(:project, :public) }
+  let_it_be_with_reload(:existing_subscription) { create(:activity_pub_releases_subscription, project: project) }
+
+  describe '#execute' do
+    let(:service) { described_class.new(project, payload) }
+    let(:payload) { nil }
+
+    context 'with a valid payload' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'https://example.com/new-actor#unfollow-1',
+          type: 'Undo',
+          actor: actor,
+          object: {
+            id: 'https://example.com/new-actor#follow-1',
+            type: 'Follow',
+            actor: actor,
+            object: 'https://localhost/our/project/-/releases'
+          }
+        }.with_indifferent_access
+      end
+
+      let(:actor) { existing_subscription.subscriber_url }
+
+      context 'when there is a subscription for this actor' do
+        it 'deletes the subscription' do
+          service.execute
+          expect(ActivityPub::ReleasesSubscription.where(id: existing_subscription.id).first).to be_nil
+        end
+
+        it 'returns true' do
+          expect(service.execute).to be_truthy
+        end
+      end
+
+      context 'when there is no subscription for this actor' do
+        before do
+          allow(ActivityPub::ReleasesSubscription).to receive(:find_by_project_and_subscriber).and_return(nil)
+        end
+
+        it 'does not delete anything' do
+          expect { service.execute }.not_to change { ActivityPub::ReleasesSubscription.count }
+        end
+
+        it 'returns true' do
+          expect(service.execute).to be_truthy
+        end
+      end
+    end
+
+    shared_examples 'invalid unfollow request' do
+      it 'does not delete anything' do
+        expect { service.execute }.not_to change { ActivityPub::ReleasesSubscription.count }
+      end
+
+      it 'sets an error' do
+        service.execute
+        expect(service.errors).not_to be_empty
+      end
+
+      it 'returns false' do
+        expect(service.execute).to be_falsey
+      end
+    end
+
+    context 'when actor is missing' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'https://example.com/new-actor#unfollow-1',
+          type: 'Undo',
+          object: {
+            id: 'https://example.com/new-actor#follow-1',
+            type: 'Follow',
+            object: 'https://localhost/our/project/-/releases'
+          }
+        }.with_indifferent_access
+      end
+
+      it_behaves_like 'invalid unfollow request'
+    end
+
+    context 'when actor is an object with no id attribute' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'https://example.com/new-actor#unfollow-1',
+          actor: { type: 'Person' },
+          type: 'Undo',
+          object: {
+            id: 'https://example.com/new-actor#follow-1',
+            type: 'Follow',
+            actor: { type: 'Person' },
+            object: 'https://localhost/our/project/-/releases'
+          }
+        }.with_indifferent_access
+      end
+
+      it_behaves_like 'invalid unfollow request'
+    end
+
+    context 'when actor is neither a string nor an object' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'https://example.com/new-actor#unfollow-1',
+          actor: 27.13,
+          type: 'Undo',
+          object: {
+            id: 'https://example.com/new-actor#follow-1',
+            type: 'Follow',
+            actor: 27.13,
+            object: 'https://localhost/our/project/-/releases'
+          }
+        }.with_indifferent_access
+      end
+
+      it_behaves_like 'invalid unfollow request'
+    end
+
+    context "when actor tries to delete someone else's subscription" do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'https://example.com/actor#unfollow-1',
+          type: 'Undo',
+          actor: 'https://example.com/nasty-actor',
+          object: {
+            id: 'https://example.com/actor#follow-1',
+            type: 'Follow',
+            actor: existing_subscription.subscriber_url,
+            object: 'https://localhost/our/project/-/releases'
+          }
+        }.with_indifferent_access
+      end
+
+      it 'does not delete anything' do
+        expect { service.execute }.not_to change { ActivityPub::ReleasesSubscription.count }
+      end
+
+      it 'returns true' do
+        expect(service.execute).to be_truthy
+      end
+    end
+  end
+end