diff --git a/models/issues/issue_lock.go b/models/issues/issue_lock.go
index b21629b529..fa0d128f74 100644
--- a/models/issues/issue_lock.go
+++ b/models/issues/issue_lock.go
@@ -12,8 +12,14 @@ import (
 
 // IssueLockOptions defines options for locking and/or unlocking an issue/PR
 type IssueLockOptions struct {
-	Doer   *user_model.User
-	Issue  *Issue
+	Doer  *user_model.User
+	Issue *Issue
+
+	// Reason is the doer-provided comment message for the locked issue
+	// GitHub doesn't support changing the "reasons" by config file, so GitHub has pre-defined "reason" enum values.
+	// Gitea is not like GitHub, it allows site admin to define customized "reasons" in the config file.
+	// So the API caller might not know what kind of "reasons" are valid, and the customized reasons are not translatable.
+	// To make things clear and simple: doer have the chance to use any reason they like, we do not do validation.
 	Reason string
 }
 
diff --git a/modules/structs/issue.go b/modules/structs/issue.go
index 3682191be5..6a6b74c34e 100644
--- a/modules/structs/issue.go
+++ b/modules/structs/issue.go
@@ -266,3 +266,8 @@ type IssueMeta struct {
 	Owner string `json:"owner"`
 	Name  string `json:"repo"`
 }
+
+// LockIssueOption options to lock an issue
+type LockIssueOption struct {
+	Reason string `json:"lock_reason"`
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 54089be24a..508b4cff37 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1681,7 +1681,6 @@ issues.pin_comment = "pinned this %s"
 issues.unpin_comment = "unpinned this %s"
 issues.lock = Lock conversation
 issues.unlock = Unlock conversation
-issues.lock.unknown_reason = Cannot lock an issue with an unknown reason.
 issues.lock_duplicate = An issue cannot be locked twice.
 issues.unlock_error = Cannot unlock an issue that is not locked.
 issues.lock_with_reason = "locked as <strong>%s</strong> and limited conversation to collaborators %s"
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index e77118f4ff..58d0891ea5 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -1530,6 +1530,11 @@ func Routes() *web.Router {
 								Delete(reqToken(), reqAdmin(), repo.UnpinIssue)
 							m.Patch("/{position}", reqToken(), reqAdmin(), repo.MoveIssuePin)
 						})
+						m.Group("/lock", func() {
+							m.Combo("").
+								Put(bind(api.LockIssueOption{}), repo.LockIssue).
+								Delete(repo.UnlockIssue)
+						}, reqToken(), reqAdmin())
 					})
 				}, mustEnableIssuesOrPulls)
 				m.Group("/labels", func() {
diff --git a/routers/api/v1/repo/issue_lock.go b/routers/api/v1/repo/issue_lock.go
new file mode 100644
index 0000000000..b9e5bcf6eb
--- /dev/null
+++ b/routers/api/v1/repo/issue_lock.go
@@ -0,0 +1,152 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+	"errors"
+	"net/http"
+
+	issues_model "code.gitea.io/gitea/models/issues"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/modules/web"
+	"code.gitea.io/gitea/services/context"
+)
+
+// LockIssue lock an issue
+func LockIssue(ctx *context.APIContext) {
+	// swagger:operation PUT /repos/{owner}/{repo}/issues/{index}/lock issue issueLockIssue
+	// ---
+	// summary: Lock an issue
+	// consumes:
+	// - application/json
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// - name: index
+	//   in: path
+	//   description: index of the issue
+	//   type: integer
+	//   format: int64
+	//   required: true
+	// - name: body
+	//   in: body
+	//   schema:
+	//     "$ref": "#/definitions/LockIssueOption"
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	reason := web.GetForm(ctx).(*api.LockIssueOption).Reason
+	issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
+	if err != nil {
+		if issues_model.IsErrIssueNotExist(err) {
+			ctx.APIErrorNotFound(err)
+		} else {
+			ctx.APIErrorInternal(err)
+		}
+		return
+	}
+
+	if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
+		ctx.APIError(http.StatusForbidden, errors.New("no permission to lock this issue"))
+		return
+	}
+
+	if !issue.IsLocked {
+		opt := &issues_model.IssueLockOptions{
+			Doer:   ctx.ContextUser,
+			Issue:  issue,
+			Reason: reason,
+		}
+
+		issue.Repo = ctx.Repo.Repository
+		err = issues_model.LockIssue(ctx, opt)
+		if err != nil {
+			ctx.APIErrorInternal(err)
+			return
+		}
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
+
+// UnlockIssue unlock an issue
+func UnlockIssue(ctx *context.APIContext) {
+	// swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/lock issue issueUnlockIssue
+	// ---
+	// summary: Unlock an issue
+	// consumes:
+	// - application/json
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// - name: index
+	//   in: path
+	//   description: index of the issue
+	//   type: integer
+	//   format: int64
+	//   required: true
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
+	if err != nil {
+		if issues_model.IsErrIssueNotExist(err) {
+			ctx.APIErrorNotFound(err)
+		} else {
+			ctx.APIErrorInternal(err)
+		}
+		return
+	}
+
+	if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
+		ctx.APIError(http.StatusForbidden, errors.New("no permission to unlock this issue"))
+		return
+	}
+
+	if issue.IsLocked {
+		opt := &issues_model.IssueLockOptions{
+			Doer:  ctx.ContextUser,
+			Issue: issue,
+		}
+
+		issue.Repo = ctx.Repo.Repository
+		err = issues_model.UnlockIssue(ctx, opt)
+		if err != nil {
+			ctx.APIErrorInternal(err)
+			return
+		}
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go
index aa5990eb38..d5e042f8fa 100644
--- a/routers/api/v1/swagger/options.go
+++ b/routers/api/v1/swagger/options.go
@@ -216,4 +216,7 @@ type swaggerParameterBodies struct {
 
 	// in:body
 	UpdateVariableOption api.UpdateVariableOption
+
+	// in:body
+	LockIssueOption api.LockIssueOption
 }
diff --git a/routers/web/repo/issue_lock.go b/routers/web/repo/issue_lock.go
index 1d5fc8a5f3..bc8aabd90b 100644
--- a/routers/web/repo/issue_lock.go
+++ b/routers/web/repo/issue_lock.go
@@ -24,11 +24,6 @@ func LockIssue(ctx *context.Context) {
 		return
 	}
 
-	if !form.HasValidReason() {
-		ctx.JSONError(ctx.Tr("repo.issues.lock.unknown_reason"))
-		return
-	}
-
 	if err := issues_model.LockIssue(ctx, &issues_model.IssueLockOptions{
 		Doer:   ctx.Doer,
 		Issue:  issue,
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index 434274c174..a2827e516a 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -10,7 +10,6 @@ import (
 
 	issues_model "code.gitea.io/gitea/models/issues"
 	project_model "code.gitea.io/gitea/models/project"
-	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/web/middleware"
 	"code.gitea.io/gitea/services/context"
@@ -473,22 +472,6 @@ func (i *IssueLockForm) Validate(req *http.Request, errs binding.Errors) binding
 	return middleware.Validate(errs, ctx.Data, i, ctx.Locale)
 }
 
-// HasValidReason checks to make sure that the reason submitted in
-// the form matches any of the values in the config
-func (i IssueLockForm) HasValidReason() bool {
-	if strings.TrimSpace(i.Reason) == "" {
-		return true
-	}
-
-	for _, v := range setting.Repository.Issue.LockReasons {
-		if v == i.Reason {
-			return true
-		}
-	}
-
-	return false
-}
-
 // CreateProjectForm form for creating a project
 type CreateProjectForm struct {
 	Title        string `binding:"Required;MaxSize(100)"`
diff --git a/services/forms/repo_form_test.go b/services/forms/repo_form_test.go
index 2c5a8e2c0f..a0c67fe0f8 100644
--- a/services/forms/repo_form_test.go
+++ b/services/forms/repo_form_test.go
@@ -6,8 +6,6 @@ package forms
 import (
 	"testing"
 
-	"code.gitea.io/gitea/modules/setting"
-
 	"github.com/stretchr/testify/assert"
 )
 
@@ -39,26 +37,3 @@ func TestSubmitReviewForm_IsEmpty(t *testing.T) {
 		assert.Equal(t, v.expected, v.form.HasEmptyContent())
 	}
 }
-
-func TestIssueLock_HasValidReason(t *testing.T) {
-	// Init settings
-	_ = setting.Repository
-
-	cases := []struct {
-		form     IssueLockForm
-		expected bool
-	}{
-		{IssueLockForm{""}, true}, // an empty reason is accepted
-		{IssueLockForm{"Off-topic"}, true},
-		{IssueLockForm{"Too heated"}, true},
-		{IssueLockForm{"Spam"}, true},
-		{IssueLockForm{"Resolved"}, true},
-
-		{IssueLockForm{"ZZZZ"}, false},
-		{IssueLockForm{"I want to lock this issue"}, false},
-	}
-
-	for _, v := range cases {
-		assert.Equal(t, v.expected, v.form.HasValidReason())
-	}
-}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 97438aced9..99d3c994f9 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -10484,6 +10484,111 @@
         }
       }
     },
+    "/repos/{owner}/{repo}/issues/{index}/lock": {
+      "put": {
+        "consumes": [
+          "application/json"
+        ],
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "issue"
+        ],
+        "summary": "Lock an issue",
+        "operationId": "issueLockIssue",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "integer",
+            "format": "int64",
+            "description": "index of the issue",
+            "name": "index",
+            "in": "path",
+            "required": true
+          },
+          {
+            "name": "body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/LockIssueOption"
+            }
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      },
+      "delete": {
+        "consumes": [
+          "application/json"
+        ],
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "issue"
+        ],
+        "summary": "Unlock an issue",
+        "operationId": "issueUnlockIssue",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "integer",
+            "format": "int64",
+            "description": "index of the issue",
+            "name": "index",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/repos/{owner}/{repo}/issues/{index}/pin": {
       "post": {
         "tags": [
@@ -24338,6 +24443,17 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "LockIssueOption": {
+      "description": "LockIssueOption options to lock an issue",
+      "type": "object",
+      "properties": {
+        "lock_reason": {
+          "type": "string",
+          "x-go-name": "Reason"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "MarkdownOption": {
       "description": "MarkdownOption markdown options",
       "type": "object",
@@ -28247,7 +28363,7 @@
     "parameterBodies": {
       "description": "parameterBodies",
       "schema": {
-        "$ref": "#/definitions/UpdateVariableOption"
+        "$ref": "#/definitions/LockIssueOption"
       }
     },
     "redirect": {
diff --git a/tests/integration/api_issue_lock_test.go b/tests/integration/api_issue_lock_test.go
new file mode 100644
index 0000000000..47b1f2cf0d
--- /dev/null
+++ b/tests/integration/api_issue_lock_test.go
@@ -0,0 +1,74 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	auth_model "code.gitea.io/gitea/models/auth"
+	issues_model "code.gitea.io/gitea/models/issues"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAPILockIssue(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	t.Run("Lock", func(t *testing.T) {
+		issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+		assert.False(t, issueBefore.IsLocked)
+		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID})
+		owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+		urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/lock", owner.Name, repo.Name, issueBefore.Index)
+
+		session := loginUser(t, owner.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+		// check lock issue
+		req := NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusNoContent)
+		issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+		assert.True(t, issueAfter.IsLocked)
+
+		// check with other user
+		user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34})
+		session34 := loginUser(t, user34.Name)
+		token34 := getTokenForLoggedInUser(t, session34, auth_model.AccessTokenScopeAll)
+		req = NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token34)
+		MakeRequest(t, req, http.StatusForbidden)
+	})
+
+	t.Run("Unlock", func(t *testing.T) {
+		issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID})
+		owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+		urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/lock", owner.Name, repo.Name, issueBefore.Index)
+
+		session := loginUser(t, owner.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+		lockReq := NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token)
+		MakeRequest(t, lockReq, http.StatusNoContent)
+
+		// check unlock issue
+		req := NewRequest(t, "DELETE", urlStr).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusNoContent)
+		issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+		assert.False(t, issueAfter.IsLocked)
+
+		// check with other user
+		user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34})
+		session34 := loginUser(t, user34.Name)
+		token34 := getTokenForLoggedInUser(t, session34, auth_model.AccessTokenScopeAll)
+		req = NewRequest(t, "DELETE", urlStr).AddTokenAuth(token34)
+		MakeRequest(t, req, http.StatusForbidden)
+	})
+}