diff --git a/.gitleaksignore b/.gitleaksignore
index eab7926138c2b6a10fa59c2131d53976a87330c8..56652ab4dae39461372ca0edc4c8750fc870ca06 100644
--- a/.gitleaksignore
+++ b/.gitleaksignore
@@ -1,2 +1,3 @@
 7e07fe42d34916b276a7b068f4faa8bdc0ebc984:doc/architecture/blueprints/runner_tokens/index.md:gitlab-rrt:485
 f6504b498548380198ad38295d9caa71412115f0:doc/architecture/blueprints/runner_tokens/index.md:generic-api-key:506
+afedb913baf4203aa688421873fdb9f94649578e:doc/api/users.md:generic-api-key:2201
diff --git a/app/services/ci/runners/create_runner_service.rb b/app/services/ci/runners/create_runner_service.rb
index fcb664500a9b588ad3a3251c3d2e2ece747241e0..ff4a33e431be2cb73da09d7c5d454200d862bc55 100644
--- a/app/services/ci/runners/create_runner_service.rb
+++ b/app/services/ci/runners/create_runner_service.rb
@@ -29,7 +29,7 @@ def execute
 
         return ServiceResponse.success(payload: { runner: runner }) if runner.save
 
-        ServiceResponse.error(message: runner.errors.full_messages)
+        ServiceResponse.error(message: runner.errors.full_messages, reason: :save_error)
       end
 
       def normalize_params
diff --git a/doc/api/runners.md b/doc/api/runners.md
index 1a722163c4189ece9ec1cc91637e60d7ba0fde84..8d0be1c3aba35f2d6785fe60748663f3077cabea 100644
--- a/doc/api/runners.md
+++ b/doc/api/runners.md
@@ -273,10 +273,10 @@ PUT /runners/:id
 | `id`              | integer | yes      | The ID of a runner                                                                              |
 | `description`     | string  | no       | The description of the runner                                                                   |
 | `active`          | boolean | no       | Deprecated: Use `paused` instead. Flag indicating whether the runner is allowed to receive jobs |
-| `paused`          | boolean | no       | Specifies whether the runner should ignore new jobs                                             |
+| `paused`          | boolean | no       | Specifies if the runner should ignore new jobs                                             |
 | `tag_list`        | array   | no       | The list of tags for the runner                                                                 |
-| `run_untagged`    | boolean | no       | Specifies whether the runner can execute untagged jobs                                          |
-| `locked`          | boolean | no       | Specifies whether the runner is locked                                                          |
+| `run_untagged`    | boolean | no       | Specifies if the runner can execute untagged jobs                                          |
+| `locked`          | boolean | no       | Specifies if the runner is locked                                                          |
 | `access_level`    | string  | no       | The access level of the runner; `not_protected` or `ref_protected`                              |
 | `maximum_timeout` | integer | no       | Maximum timeout that limits the amount of time (in seconds) that runners can run jobs           |
 
@@ -656,20 +656,20 @@ Register a new runner for the instance.
 POST /runners
 ```
 
-| Attribute          | Type         | Required | Description                                                                                                                                                   |
-|--------------------|--------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `token`            | string       | yes      | [Registration token](#registration-and-authentication-tokens) |
-| `description`      | string       | no       | Runner's description |
+| Attribute          | Type         | Required | Description                                                                                                                                                                                    |
+|--------------------|--------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `token`            | string       | yes      | [Registration token](#registration-and-authentication-tokens)                                                                                                                                  |
+| `description`      | string       | no       | Description of the runner                                                                                                                                                                      |
 | `info`             | hash         | no       | Runner's metadata. You can include `name`, `version`, `revision`, `platform`, and `architecture`, but only `version`, `platform`, and `architecture` are displayed in the Admin Area of the UI |
-| `active`           | boolean      | no       | Deprecated: Use `paused` instead. Specifies whether the runner is allowed to receive new jobs |
-| `paused`           | boolean      | no       | Specifies whether the runner should ignore new jobs |
-| `locked`           | boolean      | no       | Specifies whether the runner should be locked for the current project |
-| `run_untagged`     | boolean      | no       | Specifies whether the runner should handle untagged jobs |
-| `tag_list`         | string array | no       | A list of runner tags |
-| `access_level`     | string       | no       | The access level of the runner; `not_protected` or `ref_protected` |
-| `maximum_timeout`  | integer      | no       | Maximum timeout that limits the amount of time (in seconds) that runners can run jobs |
-| `maintainer_note`  | string       | no       | [Deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/350730), see `maintenance_note` |
-| `maintenance_note` | string       | no       | Free-form maintenance notes for the runner (1024 characters) |
+| `active`           | boolean      | no       | Deprecated: Use `paused` instead. Specifies if the runner is allowed to receive new jobs                                                                                                       |
+| `paused`           | boolean      | no       | Specifies if the runner should ignore new jobs                                                                                                                                                 |
+| `locked`           | boolean      | no       | Specifies if the runner should be locked for the current project                                                                                                                               |
+| `run_untagged`     | boolean      | no       | Specifies if the runner should handle untagged jobs                                                                                                                                            |
+| `tag_list`         | string array | no       | A list of runner tags                                                                                                                                                                          |
+| `access_level`     | string       | no       | The access level of the runner; `not_protected` or `ref_protected`                                                                                                                             |
+| `maximum_timeout`  | integer      | no       | Maximum timeout that limits the amount of time (in seconds) that runners can run jobs                                                                                                          |
+| `maintainer_note`  | string       | no       | [Deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/350730), see `maintenance_note`                                                                                                     |
+| `maintenance_note` | string       | no       | Free-form maintenance notes for the runner (1024 characters)                                                                                                                                   |
 
 ```shell
 curl --request POST "https://gitlab.example.com/api/v4/runners" \
diff --git a/doc/api/users.md b/doc/api/users.md
index dba1c30f7e89b6f686d88e94be23bccdc9986b59..bb261cea682984a5941ef1d737305ed8b60042d4 100644
--- a/doc/api/users.md
+++ b/doc/api/users.md
@@ -2196,3 +2196,42 @@ Returns:
 - `400 Bad request` if two factor authentication is not enabled for the specified user.
 - `403 Forbidden` if not authenticated as an administrator.
 - `404 User Not Found` if user cannot be found.
+
+## Create a CI runner **(FREE SELF)**
+
+It creates a new runner, linked to the current user.
+
+Requires administrator access or ownership of the target namespace or project. Token values are returned once. Make sure you save it because you can't access
+it again.
+
+```plaintext
+POST /user/runners
+```
+
+| Attribute          | Type         | Required | Description                                                                                                                             |
+|--------------------|--------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------|
+| `runner_type`      | string       | yes      | Specifies the scope of the runner; `instance_type`, `group_type`, or `project_type`.                                                    |
+| `namespace_id`     | integer      | no       | The ID of the project or group that the runner is created in. Required if `runner_type` is `group_type` or `project_type`.              |
+| `description`      | string       | no       | Description of the runner.                                                                                                              |
+| `paused`           | boolean      | no       | Specifies if the runner should ignore new jobs.                                                                                         |
+| `locked`           | boolean      | no       | Specifies if the runner should be locked for the current project.                                                                       |
+| `run_untagged`     | boolean      | no       | Specifies if the runner should handle untagged jobs.                                                                                    |
+| `tag_list`         | string array | no       | A list of runner tags.                                                                                                                  |
+| `access_level`     | string       | no       | The access level of the runner; `not_protected` or `ref_protected`.                                                                     |
+| `maximum_timeout`  | integer      | no       | Maximum timeout that limits the amount of time (in seconds) that runners can run jobs.                                                  |
+| `maintenance_note` | string       | no       | Free-form maintenance notes for the runner (1024 characters).                                                                           |
+
+```shell
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --data "runner_type=instance_type" \
+     "https://gitlab.example.com/api/v4/user/runners"
+```
+
+Example response:
+
+```json
+{
+    "id": 9171,
+    "token": "glrt-kyahzxLaj4Dc1jQf4xjX",
+    "token_expires_at": null
+}
+```
diff --git a/lib/api/ci/runner.rb b/lib/api/ci/runner.rb
index 1ecd498c6a830965dd741fb81ff01a6525778e72..d61171ea9f4c0dade23b0eda2ab6706904a3ffba 100644
--- a/lib/api/ci/runner.rb
+++ b/lib/api/ci/runner.rb
@@ -15,7 +15,7 @@ class Runner < ::API::Base
         end
         params do
           requires :token, type: String, desc: 'Registration token'
-          optional :description, type: String, desc: %q(Runner's description)
+          optional :description, type: String, desc: %q(Description of the runner)
           optional :maintainer_note, type: String, desc: %q(Deprecated: see `maintenance_note`)
           optional :maintenance_note, type: String,
                                       desc: %q(Free-form maintenance notes for the runner (1024 characters))
@@ -27,13 +27,13 @@ class Runner < ::API::Base
             optional :architecture, type: String, desc: %q(Runner's architecture)
           end
           optional :active, type: Boolean,
-                            desc: 'Deprecated: Use `paused` instead. Specifies whether the runner is allowed ' \
+                            desc: 'Deprecated: Use `paused` instead. Specifies if the runner is allowed ' \
                                   'to receive new jobs'
-          optional :paused, type: Boolean, desc: 'Specifies whether the runner should ignore new jobs'
-          optional :locked, type: Boolean, desc: 'Specifies whether the runner should be locked for the current project'
+          optional :paused, type: Boolean, desc: 'Specifies if the runner should ignore new jobs'
+          optional :locked, type: Boolean, desc: 'Specifies if the runner should be locked for the current project'
           optional :access_level, type: String, values: ::Ci::Runner.access_levels.keys,
                                   desc: 'The access level of the runner'
-          optional :run_untagged, type: Boolean, desc: 'Specifies whether the runner should handle untagged jobs'
+          optional :run_untagged, type: Boolean, desc: 'Specifies if the runner should handle untagged jobs'
           optional :tag_list, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce,
                               desc: %q(A list of runner tags)
           optional :maximum_timeout, type: Integer,
diff --git a/lib/api/ci/runners.rb b/lib/api/ci/runners.rb
index f2f0f32261af05c09bc7468a9423b84f8bd4971b..42817c782f486a10e07343399b8b1e11cda45ca5 100644
--- a/lib/api/ci/runners.rb
+++ b/lib/api/ci/runners.rb
@@ -158,11 +158,11 @@ def authenticate_list_runners_jobs!(runner)
           requires :id, type: Integer, desc: 'The ID of a runner'
           optional :description, type: String, desc: 'The description of the runner'
           optional :active, type: Boolean, desc: 'Deprecated: Use `paused` instead. Flag indicating whether the runner is allowed to receive jobs'
-          optional :paused, type: Boolean, desc: 'Specifies whether the runner should ignore new jobs'
+          optional :paused, type: Boolean, desc: 'Specifies if the runner should ignore new jobs'
           optional :tag_list, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce,
                               desc: 'The list of tags for a runner', documentation: { example: "['macos', 'shell']" }
-          optional :run_untagged, type: Boolean, desc: 'Specifies whether the runner can execute untagged jobs'
-          optional :locked, type: Boolean, desc: 'Specifies whether the runner is locked'
+          optional :run_untagged, type: Boolean, desc: 'Specifies if the runner can execute untagged jobs'
+          optional :locked, type: Boolean, desc: 'Specifies if the runner is locked'
           optional :access_level, type: String, values: ::Ci::Runner.access_levels.keys,
                                   desc: 'The access level of the runner'
           optional :maximum_timeout, type: Integer,
diff --git a/lib/api/users.rb b/lib/api/users.rb
index 0c118767bc415a20ac71401791ad4646b2c72752..8d34be362f44873c7113776419163edbb685f645 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -1365,6 +1365,61 @@ def set_user_status(include_missing_params:)
       get 'status', feature_category: :user_profile do
         present current_user.status || {}, with: Entities::UserStatus
       end
+
+      desc 'Create a runner owned by currently authenticated user' do
+        detail 'Create a new runner'
+        success Entities::Ci::RunnerRegistrationDetails
+        failure [[400, 'Bad Request'], [403, 'Forbidden']]
+        tags %w[user runners]
+      end
+      params do
+        requires :runner_type, type: String, values: ::Ci::Runner.runner_types.keys,
+                 desc: %q(Specifies the scope of the runner)
+        given runner_type: ->(runner_type) { %i[group_type project_type].include? runner_type } do
+          requires :namespace_id, type: Integer,
+                   desc: 'The ID of the project or group that the runner is created in',
+                   documentation: { example: 1 }
+        end
+        optional :description, type: String, desc: %q(Description of the runner)
+        optional :maintenance_note, type: String,
+                 desc: %q(Free-form maintenance notes for the runner (1024 characters))
+        optional :paused, type: Boolean, desc: 'Specifies if the runner should ignore new jobs (defaults to false)'
+        optional :locked, type: Boolean,
+          desc: 'Specifies if the runner should be locked for the current project (defaults to false)'
+        optional :access_level, type: String, values: ::Ci::Runner.access_levels.keys,
+          desc: 'The access level of the runner'
+        optional :run_untagged, type: Boolean,
+          desc: 'Specifies if the runner should handle untagged jobs  (defaults to true)'
+        optional :tag_list, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce,
+                 desc: %q(A list of runner tags)
+        optional :maximum_timeout, type: Integer,
+                 desc: 'Maximum timeout that limits the amount of time (in seconds) that runners can run jobs'
+      end
+      post 'runners', urgency: :low, feature_category: :runner_fleet do
+        attributes = attributes_for_keys(
+          %i[runner_type namespace_id description maintenance_note paused locked run_untagged tag_list
+            access_level maximum_timeout]
+        )
+
+        namespace_id = attributes.delete(:namespace_id)
+        if namespace_id
+          case attributes[:runner_type]
+          when 'group_type'
+            attributes[:scope] = ::Group.find(namespace_id)
+          when 'project_type'
+            attributes[:scope] = ::Project.find(namespace_id)
+          end
+        end
+
+        result = ::Ci::Runners::CreateRunnerService.new(user: current_user, params: attributes).execute
+        if result.error?
+          message = result.errors.to_sentence
+          forbidden!(message) if result.reason == :forbidden
+          bad_request!(message)
+        end
+
+        present result.payload[:runner], with: Entities::Ci::RunnerRegistrationDetails
+      end
     end
   end
 end
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 6ff5cd7e100f0d85c21a5ec6819f7845b5b6e345..522452656bce0ccf0d8a91e64d4aab3e4f15112f 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -4632,4 +4632,158 @@ def update_password(user, admin, password = User.random_password)
     let(:attributable) { user }
     let(:other_attributable) { admin }
   end
+
+  describe 'POST /user/runners', feature_category: :runner_fleet do
+    subject(:request) { post api('/user/runners', current_user, **post_args), params: runner_attrs }
+
+    let_it_be(:group_owner) { create(:user) }
+    let_it_be(:group) { create(:group) }
+    let_it_be(:project) { create(:project, namespace: group) }
+
+    let(:post_args) { { admin_mode: true } }
+    let(:runner_attrs) { { runner_type: 'instance_type' } }
+
+    before do
+      group.add_owner(group_owner)
+    end
+
+    shared_context 'returns forbidden when user does not have sufficient permissions' do
+      let(:current_user) { admin }
+      let(:post_args) { { admin_mode: false } }
+
+      it 'does not create a runner' do
+        expect do
+          request
+
+          expect(response).to have_gitlab_http_status(:forbidden)
+        end.not_to change { Ci::Runner.count }
+      end
+    end
+
+    shared_examples 'creates a runner' do
+      it 'creates a runner' do
+        expect do
+          request
+
+          expect(response).to have_gitlab_http_status(:created)
+        end.to change { Ci::Runner.count }.by(1)
+      end
+    end
+
+    shared_examples 'fails to create runner with :bad_request' do
+      it 'does not create runner' do
+        expect do
+          request
+
+          expect(response).to have_gitlab_http_status(:bad_request)
+          expect(json_response['message']).to include(expected_error)
+        end.not_to change { Ci::Runner.count }
+      end
+    end
+
+    context 'when runner_type is :instance_type' do
+      let(:runner_attrs) { { runner_type: 'instance_type' } }
+
+      context 'when user has sufficient permissions' do
+        let(:current_user) { admin }
+
+        it_behaves_like 'creates a runner'
+      end
+
+      it_behaves_like 'returns forbidden when user does not have sufficient permissions'
+
+      context 'when model validation fails' do
+        let(:runner_attrs) { { runner_type: 'instance_type', run_untagged: false, tag_list: [] } }
+        let(:current_user) { admin }
+
+        it_behaves_like 'fails to create runner with :bad_request' do
+          let(:expected_error) { 'Tags list can not be empty' }
+        end
+      end
+    end
+
+    context 'when runner_type is :group_type' do
+      let(:post_args) { {} }
+
+      context 'when namespace_id is specified' do
+        let(:runner_attrs) { { runner_type: 'group_type', namespace_id: group.id } }
+
+        context 'when user has sufficient permissions' do
+          let(:current_user) { group_owner }
+
+          it_behaves_like 'creates a runner'
+        end
+
+        it_behaves_like 'returns forbidden when user does not have sufficient permissions'
+      end
+
+      context 'when namespace_id is not specified' do
+        let(:runner_attrs) { { runner_type: 'group_type' } }
+        let(:current_user) { group_owner }
+
+        it_behaves_like 'fails to create runner with :bad_request' do
+          let(:expected_error) { 'Missing/invalid scope' }
+        end
+      end
+    end
+
+    context 'when runner_type is :project_type' do
+      let(:post_args) { {} }
+
+      context 'when namespace_id is specified' do
+        let(:runner_attrs) { { runner_type: 'project_type', namespace_id: project.id } }
+
+        context 'when user has sufficient permissions' do
+          let(:current_user) { group_owner }
+
+          it_behaves_like 'creates a runner'
+        end
+
+        it_behaves_like 'returns forbidden when user does not have sufficient permissions'
+      end
+
+      context 'when namespace_id is not specified' do
+        let(:runner_attrs) { { runner_type: 'project_type' } }
+        let(:current_user) { group_owner }
+
+        it_behaves_like 'fails to create runner with :bad_request' do
+          let(:expected_error) { 'Missing/invalid scope' }
+        end
+      end
+    end
+
+    context 'with missing runner_type' do
+      let(:runner_attrs) { {} }
+      let(:current_user) { admin }
+
+      it 'fails to create runner with :bad_request' do
+        expect do
+          request
+
+          expect(response).to have_gitlab_http_status(:bad_request)
+          expect(json_response['error']).to eq('runner_type is missing, runner_type does not have a valid value')
+        end.not_to change { Ci::Runner.count }
+      end
+    end
+
+    context 'with unknown runner_type' do
+      let(:runner_attrs) { { runner_type: 'unknown' } }
+      let(:current_user) { admin }
+
+      it 'fails to create runner with :bad_request' do
+        expect do
+          request
+
+          expect(response).to have_gitlab_http_status(:bad_request)
+          expect(json_response['error']).to eq('runner_type does not have a valid value')
+        end.not_to change { Ci::Runner.count }
+      end
+    end
+
+    it 'returns a 401 error if unauthorized' do
+      post api('/user/runners'), params: runner_attrs
+
+      expect(response).to have_gitlab_http_status(:unauthorized)
+    end
+  end
 end
diff --git a/spec/services/ci/runners/create_runner_service_spec.rb b/spec/services/ci/runners/create_runner_service_spec.rb
index 886411ec5fb2b6a70acb889f83c5b6a9f9952fbe..db337b0b005f292d0323af63d4a1657ac9d5638d 100644
--- a/spec/services/ci/runners/create_runner_service_spec.rb
+++ b/spec/services/ci/runners/create_runner_service_spec.rb
@@ -179,6 +179,17 @@
 
           it_behaves_like 'it cannot create a runner'
         end
+
+        context 'when model validation fails' do
+          let(:params) { { runner_type: 'instance_type', run_untagged: false, tag_list: [] } }
+
+          it_behaves_like 'it cannot create a runner'
+
+          it 'returns error message and reason', :aggregate_failures do
+            expect(execute.reason).to eq(:save_error)
+            expect(execute.message).to contain_exactly(a_string_including('Tags list can not be empty'))
+          end
+        end
       end
     end
   end