diff --git a/app/models/project_repository_storage_move.rb b/app/models/project_repository_storage_move.rb
index 8b2ddb22a487690bc4d1edb6d25e867735deaad6..e88cc5cfca628f6549a4437a6d3455d506c743c7 100644
--- a/app/models/project_repository_storage_move.rb
+++ b/app/models/project_repository_storage_move.rb
@@ -52,4 +52,7 @@ class ProjectRepositoryStorageMove < ApplicationRecord
     state :finished, value: 4
     state :failed, value: 5
   end
+
+  scope :order_created_at_desc, -> { order(created_at: :desc) }
+  scope :with_projects, -> { includes(project: :route) }
 end
diff --git a/changelogs/unreleased/storage_move_api.yml b/changelogs/unreleased/storage_move_api.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6147440bbc34dd8c20435d5537c5b9e0dc344e97
--- /dev/null
+++ b/changelogs/unreleased/storage_move_api.yml
@@ -0,0 +1,5 @@
+---
+title: Read only storage move API
+merge_request: 31285
+author:
+type: added
diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md
index 4c34d23dcd637cdd068e4ef11ec4fd15d5da2efd..950c353e7efb3900277462609dc74eee9c44cf5e 100644
--- a/doc/api/api_resources.md
+++ b/doc/api/api_resources.md
@@ -139,6 +139,7 @@ The following API resources are available outside of project and group contexts
 | [Notification settings](notification_settings.md)  | `/notification_settings` (also available for groups and projects)       |
 | [Pages domains](pages_domains.md)                  | `/pages/domains` (also available for projects)                          |
 | [Projects](projects.md)                            | `/users/:id/projects` (also available for projects)                     |
+| [Project Repository Storage Moves](project_repository_storage_moves.md) | `/project_repository_storage_moves`                |
 | [Runners](runners.md)                              | `/runners` (also available for projects)                                |
 | [Search](search.md)                                | `/search` (also available for groups and projects)                      |
 | [Settings](settings.md) **(CORE ONLY)**            | `/application/settings`                                                 |
diff --git a/doc/api/project_repository_storage_moves.md b/doc/api/project_repository_storage_moves.md
new file mode 100644
index 0000000000000000000000000000000000000000..d3d2f109b02646c00f57deb276e1eb9d7ddfd7af
--- /dev/null
+++ b/doc/api/project_repository_storage_moves.md
@@ -0,0 +1,80 @@
+# Project repository storage move API
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/31285) in GitLab 13.0.
+
+Project repository storage can be moved. To retrieve project repository storage moves using the API, you must [authenticate yourself](README.md#authentication) as an administrator.
+
+## Retrieve all project repository storage moves
+
+```text
+GET /project_repository_storage_moves
+```
+
+By default, `GET` requests return 20 results at a time because the API results
+are [paginated](README.md#pagination).
+
+Example request:
+
+```shell
+curl --header "PRIVATE-TOKEN: <your_access_token>" 'https://primary.example.com/api/v4/project_repository_storage_moves'
+```
+
+Example response:
+
+```json
+[
+  {
+    "id": 1,
+    "created_at": "2020-05-07T04:27:17.234Z",
+    "state": "scheduled",
+    "source_storage_name": "default",
+    "destination_storage_name": "storage2",
+    "project": {
+      "id": 1,
+      "description": null,
+      "name": "project1",
+      "name_with_namespace": "John Doe2 / project1",
+      "path": "project1",
+      "path_with_namespace": "namespace1/project1",
+      "created_at": "2020-05-07T04:27:17.016Z"
+  }
+]
+```
+
+## Get a single project repository storage move
+
+```text
+GET /project_repository_storage_moves/:id
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer | yes | The ID of the project repository storage move |
+
+Example request:
+
+```shell
+curl --header "PRIVATE-TOKEN: <your_access_token>" 'https://primary.example.com/api/v4/project_repository_storage_moves/1'
+```
+
+Example response:
+
+```json
+{
+  "id": 1,
+  "created_at": "2020-05-07T04:27:17.234Z",
+  "state": "scheduled",
+  "source_storage_name": "default",
+  "destination_storage_name": "storage2",
+  "project": {
+    "id": 1,
+    "description": null,
+    "name": "project1",
+    "name_with_namespace": "John Doe2 / project1",
+    "path": "project1",
+    "path_with_namespace": "namespace1/project1",
+    "created_at": "2020-05-07T04:27:17.016Z"
+}
+```
diff --git a/lib/api/api.rb b/lib/api/api.rb
index fe8f101e7b5f65e84000ab97d9750eb2f21f86dd..c553630cbd3ff0703cec67ef8514a69674fc10d9 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -180,6 +180,7 @@ class API < Grape::API
       mount ::API::ProjectImport
       mount ::API::ProjectHooks
       mount ::API::ProjectMilestones
+      mount ::API::ProjectRepositoryStorageMoves
       mount ::API::Projects
       mount ::API::ProjectSnapshots
       mount ::API::ProjectSnippets
diff --git a/lib/api/entities/project_repository_storage_move.rb b/lib/api/entities/project_repository_storage_move.rb
new file mode 100644
index 0000000000000000000000000000000000000000..25643651a14a310701524af48edaa31a603a7dee
--- /dev/null
+++ b/lib/api/entities/project_repository_storage_move.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module API
+  module Entities
+    class ProjectRepositoryStorageMove < Grape::Entity
+      expose :id
+      expose :created_at
+      expose :human_state_name, as: :state
+      expose :source_storage_name
+      expose :destination_storage_name
+      expose :project, using: Entities::ProjectIdentity
+    end
+  end
+end
diff --git a/lib/api/project_repository_storage_moves.rb b/lib/api/project_repository_storage_moves.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1a63e984fbf467eeea0ea09c0428867807bcbfa5
--- /dev/null
+++ b/lib/api/project_repository_storage_moves.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module API
+  class ProjectRepositoryStorageMoves < Grape::API
+    include PaginationParams
+
+    before { authenticated_as_admin! }
+
+    resource :project_repository_storage_moves do
+      desc 'Get a list of all project repository storage moves' do
+        detail 'This feature was introduced in GitLab 13.0.'
+        success Entities::ProjectRepositoryStorageMove
+      end
+      params do
+        use :pagination
+      end
+      get do
+        storage_moves = ProjectRepositoryStorageMove.with_projects.order_created_at_desc
+
+        present paginate(storage_moves), with: Entities::ProjectRepositoryStorageMove, current_user: current_user
+      end
+
+      desc 'Get a project repository storage move' do
+        detail 'This feature was introduced in GitLab 13.0.'
+        success Entities::ProjectRepositoryStorageMove
+      end
+      get ':id' do
+        storage_move = ProjectRepositoryStorageMove.find(params[:id])
+
+        present storage_move, with: Entities::ProjectRepositoryStorageMove, current_user: current_user
+      end
+    end
+  end
+end
diff --git a/spec/fixtures/api/schemas/public_api/v4/project_repository_storage_move.json b/spec/fixtures/api/schemas/public_api/v4/project_repository_storage_move.json
new file mode 100644
index 0000000000000000000000000000000000000000..6f8a2ff58e5cbf8db3c9776c3e7910de152adac7
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/project_repository_storage_move.json
@@ -0,0 +1,20 @@
+{
+  "type": "object",
+  "required": [
+    "id",
+    "created_at",
+    "state",
+    "source_storage_name",
+    "destination_storage_name",
+    "project"
+  ],
+  "properties" : {
+    "id": { "type": "integer" },
+    "created_at": { "type": "date" },
+    "state": { "type": "string" },
+    "source_storage_name": { "type": "string" },
+    "destination_storage_name": { "type": "string" },
+    "project": { "type": "object" }
+  },
+  "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/project_repository_storage_moves.json b/spec/fixtures/api/schemas/public_api/v4/project_repository_storage_moves.json
new file mode 100644
index 0000000000000000000000000000000000000000..b2de185fbfe1904fcb8ef46871c227d560bb14dc
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/project_repository_storage_moves.json
@@ -0,0 +1,6 @@
+{
+  "type": "array",
+  "items": {
+    "$ref": "./project_repository_storage_move.json"
+  }
+}
diff --git a/spec/lib/api/entities/project_repository_storage_move_spec.rb b/spec/lib/api/entities/project_repository_storage_move_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1c38c8231d462ad5d4e19de4cf9f8b52b645d5fb
--- /dev/null
+++ b/spec/lib/api/entities/project_repository_storage_move_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe API::Entities::ProjectRepositoryStorageMove do
+  describe '#as_json' do
+    subject { entity.as_json }
+
+    let(:storage_move) { build(:project_repository_storage_move, :scheduled, destination_storage_name: 'test_second_storage') }
+    let(:entity) { described_class.new(storage_move) }
+
+    it 'includes basic fields' do
+      is_expected.to include(
+        state: 'scheduled',
+        source_storage_name: 'default',
+        destination_storage_name: 'test_second_storage',
+        project: a_kind_of(Hash)
+      )
+    end
+  end
+end
diff --git a/spec/requests/api/project_repository_storage_moves_spec.rb b/spec/requests/api/project_repository_storage_moves_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..7ceea0178f33bc2f01ea085a3ae4d8785659e9a4
--- /dev/null
+++ b/spec/requests/api/project_repository_storage_moves_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe API::ProjectRepositoryStorageMoves do
+  include AccessMatchersForRequest
+
+  let(:user) { create(:admin) }
+  let!(:storage_move) { create(:project_repository_storage_move, :scheduled) }
+
+  describe 'GET /project_repository_storage_moves' do
+    def get_project_repository_storage_moves
+      get api('/project_repository_storage_moves', user)
+    end
+
+    it 'returns project repository storage moves' do
+      get_project_repository_storage_moves
+
+      expect(response).to have_gitlab_http_status(:ok)
+      expect(response).to include_pagination_headers
+      expect(response).to match_response_schema('public_api/v4/project_repository_storage_moves')
+      expect(json_response.size).to eq(1)
+      expect(json_response.first['id']).to eq(storage_move.id)
+      expect(json_response.first['state']).to eq(storage_move.human_state_name)
+    end
+
+    it 'avoids N+1 queries', :request_store do
+      # prevent `let` from polluting the control
+      get_project_repository_storage_moves
+
+      control = ActiveRecord::QueryRecorder.new { get_project_repository_storage_moves }
+
+      create(:project_repository_storage_move, :scheduled)
+
+      expect { get_project_repository_storage_moves }.not_to exceed_query_limit(control)
+    end
+
+    it 'returns the most recently created first' do
+      storage_move_oldest = create(:project_repository_storage_move, :scheduled, created_at: 2.days.ago)
+      storage_move_middle = create(:project_repository_storage_move, :scheduled, created_at: 1.day.ago)
+
+      get api('/project_repository_storage_moves', user)
+
+      json_ids = json_response.map {|storage_move| storage_move['id'] }
+      expect(json_ids).to eq([
+        storage_move.id,
+        storage_move_middle.id,
+        storage_move_oldest.id
+      ])
+    end
+
+    describe 'permissions' do
+      it { expect { get_project_repository_storage_moves }.to be_allowed_for(:admin) }
+      it { expect { get_project_repository_storage_moves }.to be_denied_for(:user) }
+    end
+  end
+
+  describe 'GET /project_repository_storage_moves/:id' do
+    let(:project_repository_storage_move_id) { storage_move.id }
+
+    def get_project_repository_storage_move
+      get api("/project_repository_storage_moves/#{project_repository_storage_move_id}", user)
+    end
+
+    it 'returns a project repository storage move' do
+      get_project_repository_storage_move
+
+      expect(response).to have_gitlab_http_status(:ok)
+      expect(response).to match_response_schema('public_api/v4/project_repository_storage_move')
+      expect(json_response['id']).to eq(storage_move.id)
+      expect(json_response['state']).to eq(storage_move.human_state_name)
+    end
+
+    context 'non-existent project repository storage move' do
+      let(:project_repository_storage_move_id) { non_existing_record_id }
+
+      it 'returns not found' do
+        get_project_repository_storage_move
+
+        expect(response).to have_gitlab_http_status(:not_found)
+      end
+    end
+
+    describe 'permissions' do
+      it { expect { get_project_repository_storage_move }.to be_allowed_for(:admin) }
+      it { expect { get_project_repository_storage_move }.to be_denied_for(:user) }
+    end
+  end
+end