diff --git a/modules/structs/repo.go b/modules/structs/repo.go
index a50cddaf7e..f6cc9803a4 100644
--- a/modules/structs/repo.go
+++ b/modules/structs/repo.go
@@ -115,6 +115,16 @@ type Repository struct {
 	RepoTransfer  *RepoTransfer `json:"repo_transfer"`
 }
 
+// GetName implements the gitrepo.Repository interface
+func (r Repository) GetName() string {
+	return r.Name
+}
+
+// GetOwnerName implements the gitrepo.Repository interface
+func (r Repository) GetOwnerName() string {
+	return r.Owner.UserName
+}
+
 // CreateRepoOption options when creating repository
 // swagger:model
 type CreateRepoOption struct {
diff --git a/modules/webhook/type.go b/modules/webhook/type.go
index 0d2aef5e15..865f30c926 100644
--- a/modules/webhook/type.go
+++ b/modules/webhook/type.go
@@ -73,18 +73,19 @@ type HookType = string
 
 // Types of webhooks
 const (
-	FORGEJO    HookType = "forgejo"
-	GITEA      HookType = "gitea"
-	GOGS       HookType = "gogs"
-	SLACK      HookType = "slack"
-	DISCORD    HookType = "discord"
-	DINGTALK   HookType = "dingtalk"
-	TELEGRAM   HookType = "telegram"
-	MSTEAMS    HookType = "msteams"
-	FEISHU     HookType = "feishu"
-	MATRIX     HookType = "matrix"
-	WECHATWORK HookType = "wechatwork"
-	PACKAGIST  HookType = "packagist"
+	FORGEJO          HookType = "forgejo"
+	GITEA            HookType = "gitea"
+	GOGS             HookType = "gogs"
+	SLACK            HookType = "slack"
+	DISCORD          HookType = "discord"
+	DINGTALK         HookType = "dingtalk"
+	TELEGRAM         HookType = "telegram"
+	MSTEAMS          HookType = "msteams"
+	FEISHU           HookType = "feishu"
+	MATRIX           HookType = "matrix"
+	WECHATWORK       HookType = "wechatwork"
+	PACKAGIST        HookType = "packagist"
+	SOURCEHUT_BUILDS HookType = "sourcehut_builds" //nolint:revive
 )
 
 // HookStatus is the status of a web hook
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 3a871b2eb8..21b157aaa0 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -640,6 +640,8 @@ target_branch_not_exist = Target branch does not exist.
 
 admin_cannot_delete_self = You cannot delete yourself when you are an admin. Please remove your admin privileges first.
 
+required_prefix = Input must start with "%s"
+
 [user]
 change_avatar = Change your avatar…
 joined_on = Joined on %s
@@ -2267,6 +2269,7 @@ settings.delete_team_tip = This team has access to all repositories and can't be
 settings.remove_team_success = The team's access to the repository has been removed.
 settings.add_webhook = Add webhook
 settings.add_webhook.invalid_channel_name = Webhook channel name cannot be empty and cannot contain only a # character.
+settings.add_webhook.invalid_path = Path must not contain a part that is "." or ".." or the empty string. It cannot start or end with a slash.
 settings.hooks_desc = Webhooks automatically make HTTP POST requests to a server when certain Forgejo events trigger. Read more in the <a target="_blank" rel="noopener noreferrer" href="%s">webhooks guide</a>.
 settings.webhook_deletion = Remove webhook
 settings.webhook_deletion_desc = Removing a webhook deletes its settings and delivery history. Continue?
@@ -2382,6 +2385,12 @@ settings.web_hook_name_packagist = Packagist
 settings.packagist_username = Packagist username
 settings.packagist_api_token = API token
 settings.packagist_package_url = Packagist package URL
+settings.web_hook_name_sourcehut_builds = SourceHut Builds
+settings.sourcehut_builds.manifest_path = Build manifest path
+settings.sourcehut_builds.graphql_url = GraphQL URL (e.g. https://builds.sr.ht/query)
+settings.sourcehut_builds.visibility = Job visibility
+settings.sourcehut_builds.secrets = Secrets
+settings.sourcehut_builds.secrets_helper = Give the job access to the build secrets (requires the SECRETS:RO grant)
 settings.deploy_keys = Deploy keys
 settings.add_deploy_key = Add deploy key
 settings.deploy_key_desc = Deploy keys have read-only pull access to the repository.
diff --git a/public/assets/img/sourcehut.svg b/public/assets/img/sourcehut.svg
new file mode 100644
index 0000000000..a2a08d77d0
--- /dev/null
+++ b/public/assets/img/sourcehut.svg
@@ -0,0 +1,7 @@
+<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
+  <style>
+  path { fill: black; }
+  @media (prefers-color-scheme: dark) { path { fill: white; } }
+  </style>
+  <path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200z"/>
+</svg>
\ No newline at end of file
diff --git a/services/webhook/shared/payloader.go b/services/webhook/shared/payloader.go
index da7424dc20..cf0bfa82cb 100644
--- a/services/webhook/shared/payloader.go
+++ b/services/webhook/shared/payloader.go
@@ -9,6 +9,7 @@ import (
 	"crypto/sha1"
 	"crypto/sha256"
 	"encoding/hex"
+	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -19,6 +20,8 @@ import (
 	webhook_module "code.gitea.io/gitea/modules/webhook"
 )
 
+var ErrPayloadTypeNotSupported = errors.New("unsupported webhook event")
+
 // PayloadConvertor defines the interface to convert system payload to webhook payload
 type PayloadConvertor[T any] interface {
 	Create(*api.CreatePayload) (T, error)
diff --git a/services/webhook/sourcehut/builds.go b/services/webhook/sourcehut/builds.go
new file mode 100644
index 0000000000..1561b9e6e6
--- /dev/null
+++ b/services/webhook/sourcehut/builds.go
@@ -0,0 +1,312 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package sourcehut
+
+import (
+	"cmp"
+	"context"
+	"fmt"
+	"html/template"
+	"io/fs"
+	"net/http"
+	"strings"
+
+	webhook_model "code.gitea.io/gitea/models/webhook"
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/gitrepo"
+	"code.gitea.io/gitea/modules/json"
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
+	api "code.gitea.io/gitea/modules/structs"
+	webhook_module "code.gitea.io/gitea/modules/webhook"
+	gitea_context "code.gitea.io/gitea/services/context"
+	"code.gitea.io/gitea/services/forms"
+	"code.gitea.io/gitea/services/webhook/shared"
+
+	"gitea.com/go-chi/binding"
+	"gopkg.in/yaml.v3"
+)
+
+type BuildsHandler struct{}
+
+func (BuildsHandler) Type() webhook_module.HookType { return webhook_module.SOURCEHUT_BUILDS }
+func (BuildsHandler) Metadata(w *webhook_model.Webhook) any {
+	s := &BuildsMeta{}
+	if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
+		log.Error("sourcehut.BuildsHandler.Metadata(%d): %v", w.ID, err)
+	}
+	return s
+}
+
+func (BuildsHandler) Icon(size int) template.HTML {
+	return shared.ImgIcon("sourcehut.svg", size)
+}
+
+type buildsForm struct {
+	forms.WebhookCoreForm
+	PayloadURL   string `binding:"Required;ValidUrl"`
+	ManifestPath string `binding:"Required"`
+	Visibility   string `binding:"Required;In(PUBLIC,UNLISTED,PRIVATE)"`
+	Secrets      bool
+}
+
+var _ binding.Validator = &buildsForm{}
+
+// Validate implements binding.Validator.
+func (f *buildsForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
+	ctx := gitea_context.GetWebContext(req)
+	if !fs.ValidPath(f.ManifestPath) {
+		errs = append(errs, binding.Error{
+			FieldNames:     []string{"ManifestPath"},
+			Classification: "",
+			Message:        ctx.Locale.TrString("repo.settings.add_webhook.invalid_path"),
+		})
+	}
+	if !strings.HasPrefix(f.AuthorizationHeader, "Bearer ") {
+		errs = append(errs, binding.Error{
+			FieldNames:     []string{"AuthorizationHeader"},
+			Classification: "",
+			Message:        ctx.Locale.TrString("form.required_prefix", "Bearer "),
+		})
+	}
+	return errs
+}
+
+func (BuildsHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+	var form buildsForm
+	bind(&form)
+
+	return forms.WebhookForm{
+		WebhookCoreForm: form.WebhookCoreForm,
+		URL:             form.PayloadURL,
+		ContentType:     webhook_model.ContentTypeJSON,
+		Secret:          "",
+		HTTPMethod:      http.MethodPost,
+		Metadata: &BuildsMeta{
+			ManifestPath: form.ManifestPath,
+			Visibility:   form.Visibility,
+			Secrets:      form.Secrets,
+		},
+	}
+}
+
+type (
+	graphqlPayload[V any] struct {
+		Query     string `json:"query,omitempty"`
+		Error     string `json:"error,omitempty"`
+		Variables V      `json:"variables,omitempty"`
+	}
+	// buildsVariables according to https://man.sr.ht/builds.sr.ht/graphql.md
+	buildsVariables struct {
+		Manifest   string   `json:"manifest"`
+		Tags       []string `json:"tags"`
+		Note       string   `json:"note"`
+		Secrets    bool     `json:"secrets"`
+		Execute    bool     `json:"execute"`
+		Visibility string   `json:"visibility"`
+	}
+
+	// BuildsMeta contains the metadata for the webhook
+	BuildsMeta struct {
+		ManifestPath string `json:"manifest_path"`
+		Visibility   string `json:"visibility"`
+		Secrets      bool   `json:"secrets"`
+	}
+)
+
+type sourcehutConvertor struct {
+	ctx  context.Context
+	meta BuildsMeta
+}
+
+var _ shared.PayloadConvertor[graphqlPayload[buildsVariables]] = sourcehutConvertor{}
+
+func (BuildsHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
+	meta := BuildsMeta{}
+	if err := json.Unmarshal([]byte(w.Meta), &meta); err != nil {
+		return nil, nil, fmt.Errorf("newSourcehutRequest meta json: %w", err)
+	}
+	pc := sourcehutConvertor{
+		ctx:  ctx,
+		meta: meta,
+	}
+	return shared.NewJSONRequest(pc, w, t, false)
+}
+
+// Create implements PayloadConvertor Create method
+func (pc sourcehutConvertor) Create(p *api.CreatePayload) (graphqlPayload[buildsVariables], error) {
+	return pc.newPayload(p.Repo, p.Sha, p.Ref, p.RefType+" "+git.RefName(p.Ref).ShortName()+" created", true)
+}
+
+// Delete implements PayloadConvertor Delete method
+func (pc sourcehutConvertor) Delete(_ *api.DeletePayload) (graphqlPayload[buildsVariables], error) {
+	return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// Fork implements PayloadConvertor Fork method
+func (pc sourcehutConvertor) Fork(_ *api.ForkPayload) (graphqlPayload[buildsVariables], error) {
+	return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// Push implements PayloadConvertor Push method
+func (pc sourcehutConvertor) Push(p *api.PushPayload) (graphqlPayload[buildsVariables], error) {
+	return pc.newPayload(p.Repo, p.HeadCommit.ID, p.Ref, p.HeadCommit.Message, true)
+}
+
+// Issue implements PayloadConvertor Issue method
+func (pc sourcehutConvertor) Issue(_ *api.IssuePayload) (graphqlPayload[buildsVariables], error) {
+	return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// IssueComment implements PayloadConvertor IssueComment method
+func (pc sourcehutConvertor) IssueComment(_ *api.IssueCommentPayload) (graphqlPayload[buildsVariables], error) {
+	return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// PullRequest implements PayloadConvertor PullRequest method
+func (pc sourcehutConvertor) PullRequest(_ *api.PullRequestPayload) (graphqlPayload[buildsVariables], error) {
+	// TODO
+	return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// Review implements PayloadConvertor Review method
+func (pc sourcehutConvertor) Review(_ *api.PullRequestPayload, _ webhook_module.HookEventType) (graphqlPayload[buildsVariables], error) {
+	return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// Repository implements PayloadConvertor Repository method
+func (pc sourcehutConvertor) Repository(_ *api.RepositoryPayload) (graphqlPayload[buildsVariables], error) {
+	return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// Wiki implements PayloadConvertor Wiki method
+func (pc sourcehutConvertor) Wiki(_ *api.WikiPayload) (graphqlPayload[buildsVariables], error) {
+	return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// Release implements PayloadConvertor Release method
+func (pc sourcehutConvertor) Release(_ *api.ReleasePayload) (graphqlPayload[buildsVariables], error) {
+	return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+func (pc sourcehutConvertor) Package(_ *api.PackagePayload) (graphqlPayload[buildsVariables], error) {
+	return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported
+}
+
+// mustBuildManifest adjusts the manifest to submit to the builds service
+//
+// in case of an error the Error field will be set, to be visible by the end-user under recent deliveries
+func (pc sourcehutConvertor) newPayload(repo *api.Repository, commitID, ref, note string, trusted bool) (graphqlPayload[buildsVariables], error) {
+	manifest, err := pc.buildManifest(repo, commitID, ref)
+	if err != nil {
+		if len(manifest) == 0 {
+			return graphqlPayload[buildsVariables]{}, err
+		}
+		// the manifest contains an error for the user: log the actual error and construct the payload
+		// the error will be visible under the "recent deliveries" of the webhook settings.
+		log.Warn("sourcehut.builds: could not construct manifest for %s: %v", repo.FullName, err)
+		msg := fmt.Sprintf("%s:%s %s", repo.FullName, ref, manifest)
+		return graphqlPayload[buildsVariables]{
+			Error: msg,
+		}, nil
+	}
+
+	gitRef := git.RefName(ref)
+	return graphqlPayload[buildsVariables]{
+		Query: `mutation (
+	$manifest: String!
+	$tags: [String!]
+	$note: String!
+	$secrets: Boolean!
+	$execute: Boolean!
+	$visibility: Visibility!
+) {
+	submit(
+		manifest: $manifest
+		tags: $tags
+		note: $note
+		secrets: $secrets
+		execute: $execute
+		visibility: $visibility
+	) {
+		id
+	}
+}`, Variables: buildsVariables{
+			Manifest:   string(manifest),
+			Tags:       []string{repo.FullName, gitRef.RefType() + "/" + gitRef.ShortName(), pc.meta.ManifestPath},
+			Note:       note,
+			Secrets:    pc.meta.Secrets && trusted,
+			Execute:    trusted,
+			Visibility: cmp.Or(pc.meta.Visibility, "PRIVATE"),
+		},
+	}, nil
+}
+
+// buildManifest adjusts the manifest to submit to the builds service
+// in case of an error the []byte might contain an error that can be displayed to the user
+func (pc sourcehutConvertor) buildManifest(repo *api.Repository, commitID, gitRef string) ([]byte, error) {
+	gitRepo, err := gitrepo.OpenRepository(pc.ctx, repo)
+	if err != nil {
+		msg := "could not open repository"
+		return []byte(msg), fmt.Errorf(msg+": %w", err)
+	}
+	defer gitRepo.Close()
+
+	commit, err := gitRepo.GetCommit(commitID)
+	if err != nil {
+		msg := fmt.Sprintf("could not get commit %q", commitID)
+		return []byte(msg), fmt.Errorf(msg+": %w", err)
+	}
+	entry, err := commit.GetTreeEntryByPath(pc.meta.ManifestPath)
+	if err != nil {
+		msg := fmt.Sprintf("could not open manifest %q", pc.meta.ManifestPath)
+		return []byte(msg), fmt.Errorf(msg+": %w", err)
+	}
+	r, err := entry.Blob().DataAsync()
+	if err != nil {
+		msg := fmt.Sprintf("could not read manifest %q", pc.meta.ManifestPath)
+		return []byte(msg), fmt.Errorf(msg+": %w", err)
+	}
+	defer r.Close()
+	var manifest struct {
+		Image        string              `yaml:"image"`
+		Arch         string              `yaml:"arch,omitempty"`
+		Packages     []string            `yaml:"packages,omitempty"`
+		Repositories map[string]string   `yaml:"repositories,omitempty"`
+		Artifacts    []string            `yaml:"artifacts,omitempty"`
+		Shell        bool                `yaml:"shell,omitempty"`
+		Sources      []string            `yaml:"sources"`
+		Tasks        []map[string]string `yaml:"tasks"`
+		Triggers     []string            `yaml:"triggers,omitempty"`
+		Environment  map[string]string   `yaml:"environment"`
+		Secrets      []string            `yaml:"secrets,omitempty"`
+		Oauth        string              `yaml:"oauth,omitempty"`
+	}
+	if err := yaml.NewDecoder(r).Decode(&manifest); err != nil {
+		msg := fmt.Sprintf("could not decode manifest %q", pc.meta.ManifestPath)
+		return []byte(msg), fmt.Errorf(msg+": %w", err)
+	}
+
+	if manifest.Environment == nil {
+		manifest.Environment = make(map[string]string)
+	}
+	manifest.Environment["BUILD_SUBMITTER"] = "forgejo"
+	manifest.Environment["BUILD_SUBMITTER_URL"] = setting.AppURL
+	manifest.Environment["GIT_REF"] = gitRef
+
+	source := repo.CloneURL + "#" + commitID
+	found := false
+	for i, s := range manifest.Sources {
+		if s == repo.CloneURL {
+			manifest.Sources[i] = source
+			found = true
+			break
+		}
+	}
+	if !found {
+		manifest.Sources = append(manifest.Sources, source)
+	}
+
+	return yaml.Marshal(manifest)
+}
diff --git a/services/webhook/sourcehut/builds_test.go b/services/webhook/sourcehut/builds_test.go
new file mode 100644
index 0000000000..9ab018df72
--- /dev/null
+++ b/services/webhook/sourcehut/builds_test.go
@@ -0,0 +1,440 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package sourcehut
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"code.gitea.io/gitea/models/db"
+	repo_model "code.gitea.io/gitea/models/repo"
+	unit_model "code.gitea.io/gitea/models/unit"
+	user_model "code.gitea.io/gitea/models/user"
+	webhook_model "code.gitea.io/gitea/models/webhook"
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/json"
+	"code.gitea.io/gitea/modules/setting"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/modules/test"
+	webhook_module "code.gitea.io/gitea/modules/webhook"
+	repo_service "code.gitea.io/gitea/services/repository"
+	files_service "code.gitea.io/gitea/services/repository/files"
+	"code.gitea.io/gitea/services/webhook/shared"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func gitInit(t testing.TB) {
+	if setting.Git.HomePath != "" {
+		return
+	}
+	t.Cleanup(test.MockVariableValue(&setting.Git.HomePath, t.TempDir()))
+	assert.NoError(t, git.InitSimple(context.Background()))
+}
+
+func TestSourcehutBuildsPayload(t *testing.T) {
+	gitInit(t)
+	defer test.MockVariableValue(&setting.RepoRootPath, ".")()
+	defer test.MockVariableValue(&setting.AppURL, "https://example.forgejo.org/")()
+
+	repo := &api.Repository{
+		HTMLURL:  "http://localhost:3000/testdata/repo",
+		Name:     "repo",
+		FullName: "testdata/repo",
+		Owner: &api.User{
+			UserName: "testdata",
+		},
+		CloneURL: "http://localhost:3000/testdata/repo.git",
+	}
+
+	pc := sourcehutConvertor{
+		ctx: git.DefaultContext,
+		meta: BuildsMeta{
+			ManifestPath: "adjust me in each test",
+			Visibility:   "UNLISTED",
+			Secrets:      true,
+		},
+	}
+	t.Run("Create/branch", func(t *testing.T) {
+		p := &api.CreatePayload{
+			Sha:     "58771003157b81abc6bf41df0c5db4147a3e3c83",
+			Ref:     "refs/heads/test",
+			RefType: "branch",
+			Repo:    repo,
+		}
+
+		pc.meta.ManifestPath = "simple.yml"
+		pl, err := pc.Create(p)
+		require.NoError(t, err)
+		assert.Equal(t, buildsVariables{
+			Manifest: `image: alpine/edge
+sources:
+    - http://localhost:3000/testdata/repo.git#58771003157b81abc6bf41df0c5db4147a3e3c83
+tasks:
+    - say-hello: |
+        echo hello
+    - say-world: echo world
+environment:
+    BUILD_SUBMITTER: forgejo
+    BUILD_SUBMITTER_URL: https://example.forgejo.org/
+    GIT_REF: refs/heads/test
+`,
+			Note:       "branch test created",
+			Tags:       []string{"testdata/repo", "branch/test", "simple.yml"},
+			Secrets:    true,
+			Execute:    true,
+			Visibility: "UNLISTED",
+		}, pl.Variables)
+	})
+	t.Run("Create/tag", func(t *testing.T) {
+		p := &api.CreatePayload{
+			Sha:     "58771003157b81abc6bf41df0c5db4147a3e3c83",
+			Ref:     "refs/tags/v1.0.0",
+			RefType: "tag",
+			Repo:    repo,
+		}
+
+		pc.meta.ManifestPath = "simple.yml"
+		pl, err := pc.Create(p)
+		require.NoError(t, err)
+		assert.Equal(t, buildsVariables{
+			Manifest: `image: alpine/edge
+sources:
+    - http://localhost:3000/testdata/repo.git#58771003157b81abc6bf41df0c5db4147a3e3c83
+tasks:
+    - say-hello: |
+        echo hello
+    - say-world: echo world
+environment:
+    BUILD_SUBMITTER: forgejo
+    BUILD_SUBMITTER_URL: https://example.forgejo.org/
+    GIT_REF: refs/tags/v1.0.0
+`,
+			Note:       "tag v1.0.0 created",
+			Tags:       []string{"testdata/repo", "tag/v1.0.0", "simple.yml"},
+			Secrets:    true,
+			Execute:    true,
+			Visibility: "UNLISTED",
+		}, pl.Variables)
+	})
+
+	t.Run("Delete", func(t *testing.T) {
+		p := &api.DeletePayload{}
+
+		pl, err := pc.Delete(p)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+	})
+
+	t.Run("Fork", func(t *testing.T) {
+		p := &api.ForkPayload{}
+
+		pl, err := pc.Fork(p)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+	})
+
+	t.Run("Push/simple", func(t *testing.T) {
+		p := &api.PushPayload{
+			Ref: "refs/heads/main",
+			HeadCommit: &api.PayloadCommit{
+				ID:      "58771003157b81abc6bf41df0c5db4147a3e3c83",
+				Message: "add simple",
+			},
+			Repo: repo,
+		}
+
+		pc.meta.ManifestPath = "simple.yml"
+		pl, err := pc.Push(p)
+		require.NoError(t, err)
+
+		assert.Equal(t, buildsVariables{
+			Manifest: `image: alpine/edge
+sources:
+    - http://localhost:3000/testdata/repo.git#58771003157b81abc6bf41df0c5db4147a3e3c83
+tasks:
+    - say-hello: |
+        echo hello
+    - say-world: echo world
+environment:
+    BUILD_SUBMITTER: forgejo
+    BUILD_SUBMITTER_URL: https://example.forgejo.org/
+    GIT_REF: refs/heads/main
+`,
+			Note:       "add simple",
+			Tags:       []string{"testdata/repo", "branch/main", "simple.yml"},
+			Secrets:    true,
+			Execute:    true,
+			Visibility: "UNLISTED",
+		}, pl.Variables)
+	})
+	t.Run("Push/complex", func(t *testing.T) {
+		p := &api.PushPayload{
+			Ref: "refs/heads/main",
+			HeadCommit: &api.PayloadCommit{
+				ID:      "69b217caa89166a02b8cd368b64fb83a44720e14",
+				Message: "replace simple with complex",
+			},
+			Repo: repo,
+		}
+
+		pc.meta.ManifestPath = "complex.yaml"
+		pc.meta.Visibility = "PRIVATE"
+		pc.meta.Secrets = false
+		pl, err := pc.Push(p)
+		require.NoError(t, err)
+
+		assert.Equal(t, buildsVariables{
+			Manifest: `image: archlinux
+packages:
+    - nodejs
+    - npm
+    - rsync
+sources:
+    - http://localhost:3000/testdata/repo.git#69b217caa89166a02b8cd368b64fb83a44720e14
+tasks: []
+environment:
+    BUILD_SUBMITTER: forgejo
+    BUILD_SUBMITTER_URL: https://example.forgejo.org/
+    GIT_REF: refs/heads/main
+    deploy: synapse@synapse-bt.org
+secrets:
+    - 7ebab768-e5e4-4c9d-ba57-ec41a72c5665
+`,
+			Note:       "replace simple with complex",
+			Tags:       []string{"testdata/repo", "branch/main", "complex.yaml"},
+			Secrets:    false,
+			Execute:    true,
+			Visibility: "PRIVATE",
+		}, pl.Variables)
+	})
+
+	t.Run("Push/error", func(t *testing.T) {
+		p := &api.PushPayload{
+			Ref: "refs/heads/main",
+			HeadCommit: &api.PayloadCommit{
+				ID:      "58771003157b81abc6bf41df0c5db4147a3e3c83",
+				Message: "add simple",
+			},
+			Repo: repo,
+		}
+
+		pc.meta.ManifestPath = "non-existing.yml"
+		pl, err := pc.Push(p)
+		require.NoError(t, err)
+
+		assert.Equal(t, graphqlPayload[buildsVariables]{
+			Error: "testdata/repo:refs/heads/main could not open manifest \"non-existing.yml\"",
+		}, pl)
+	})
+
+	t.Run("Issue", func(t *testing.T) {
+		p := &api.IssuePayload{}
+
+		p.Action = api.HookIssueOpened
+		pl, err := pc.Issue(p)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+
+		p.Action = api.HookIssueClosed
+		pl, err = pc.Issue(p)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+	})
+
+	t.Run("IssueComment", func(t *testing.T) {
+		p := &api.IssueCommentPayload{}
+
+		pl, err := pc.IssueComment(p)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+	})
+
+	t.Run("PullRequest", func(t *testing.T) {
+		p := &api.PullRequestPayload{}
+
+		pl, err := pc.PullRequest(p)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+	})
+
+	t.Run("PullRequestComment", func(t *testing.T) {
+		p := &api.IssueCommentPayload{
+			IsPull: true,
+		}
+
+		pl, err := pc.IssueComment(p)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+	})
+
+	t.Run("Review", func(t *testing.T) {
+		p := &api.PullRequestPayload{}
+		p.Action = api.HookIssueReviewed
+
+		pl, err := pc.Review(p, webhook_module.HookEventPullRequestReviewApproved)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+	})
+
+	t.Run("Repository", func(t *testing.T) {
+		p := &api.RepositoryPayload{}
+
+		pl, err := pc.Repository(p)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+	})
+
+	t.Run("Package", func(t *testing.T) {
+		p := &api.PackagePayload{}
+
+		pl, err := pc.Package(p)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+	})
+
+	t.Run("Wiki", func(t *testing.T) {
+		p := &api.WikiPayload{}
+
+		p.Action = api.HookWikiCreated
+		pl, err := pc.Wiki(p)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+
+		p.Action = api.HookWikiEdited
+		pl, err = pc.Wiki(p)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+
+		p.Action = api.HookWikiDeleted
+		pl, err = pc.Wiki(p)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+	})
+
+	t.Run("Release", func(t *testing.T) {
+		p := &api.ReleasePayload{}
+
+		pl, err := pc.Release(p)
+		require.Equal(t, err, shared.ErrPayloadTypeNotSupported)
+		require.Equal(t, pl, graphqlPayload[buildsVariables]{})
+	})
+}
+
+func TestSourcehutJSONPayload(t *testing.T) {
+	gitInit(t)
+	defer test.MockVariableValue(&setting.RepoRootPath, ".")()
+	defer test.MockVariableValue(&setting.AppURL, "https://example.forgejo.org/")()
+
+	repo := &api.Repository{
+		HTMLURL:  "http://localhost:3000/testdata/repo",
+		Name:     "repo",
+		FullName: "testdata/repo",
+		Owner: &api.User{
+			UserName: "testdata",
+		},
+		CloneURL: "http://localhost:3000/testdata/repo.git",
+	}
+
+	p := &api.PushPayload{
+		Ref: "refs/heads/main",
+		HeadCommit: &api.PayloadCommit{
+			ID:      "58771003157b81abc6bf41df0c5db4147a3e3c83",
+			Message: "json test",
+		},
+		Repo: repo,
+	}
+	data, err := p.JSONPayload()
+	require.NoError(t, err)
+
+	hook := &webhook_model.Webhook{
+		RepoID:   3,
+		IsActive: true,
+		Type:     webhook_module.MATRIX,
+		URL:      "https://sourcehut.example.com/api/jobs",
+		Meta:     `{"manifest_path":"simple.yml"}`,
+	}
+	task := &webhook_model.HookTask{
+		HookID:         hook.ID,
+		EventType:      webhook_module.HookEventPush,
+		PayloadContent: string(data),
+		PayloadVersion: 2,
+	}
+
+	req, reqBody, err := BuildsHandler{}.NewRequest(context.Background(), hook, task)
+	require.NoError(t, err)
+	require.NotNil(t, req)
+	require.NotNil(t, reqBody)
+
+	assert.Equal(t, "POST", req.Method)
+	assert.Equal(t, "/api/jobs", req.URL.Path)
+	assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+	var body graphqlPayload[buildsVariables]
+	err = json.NewDecoder(req.Body).Decode(&body)
+	assert.NoError(t, err)
+	assert.Equal(t, "json test", body.Variables.Note)
+}
+
+func CreateDeclarativeRepo(t *testing.T, owner *user_model.User, name string, enabledUnits, disabledUnits []unit_model.Type, files []*files_service.ChangeRepoFile) (*repo_model.Repository, string) {
+	t.Helper()
+
+	// Create a new repository
+	repo, err := repo_service.CreateRepository(db.DefaultContext, owner, owner, repo_service.CreateRepoOptions{
+		Name:          name,
+		Description:   "Temporary Repo",
+		AutoInit:      true,
+		Gitignores:    "",
+		License:       "WTFPL",
+		Readme:        "Default",
+		DefaultBranch: "main",
+	})
+	assert.NoError(t, err)
+	assert.NotEmpty(t, repo)
+	t.Cleanup(func() {
+		repo_service.DeleteRepository(db.DefaultContext, owner, repo, false)
+	})
+
+	if enabledUnits != nil || disabledUnits != nil {
+		units := make([]repo_model.RepoUnit, len(enabledUnits))
+		for i, unitType := range enabledUnits {
+			units[i] = repo_model.RepoUnit{
+				RepoID: repo.ID,
+				Type:   unitType,
+			}
+		}
+
+		err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, units, disabledUnits)
+		assert.NoError(t, err)
+	}
+
+	var sha string
+	if len(files) > 0 {
+		resp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, owner, &files_service.ChangeRepoFilesOptions{
+			Files:     files,
+			Message:   "add files",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				Name:  owner.Name,
+				Email: owner.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				Name:  owner.Name,
+				Email: owner.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, resp)
+
+		sha = resp.Commit.SHA
+	}
+
+	return repo, sha
+}
diff --git a/services/webhook/sourcehut/testdata/repo.git/HEAD b/services/webhook/sourcehut/testdata/repo.git/HEAD
new file mode 100644
index 0000000000..b870d82622
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/services/webhook/sourcehut/testdata/repo.git/config b/services/webhook/sourcehut/testdata/repo.git/config
new file mode 100644
index 0000000000..07d359d07c
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/config
@@ -0,0 +1,4 @@
+[core]
+	repositoryformatversion = 0
+	filemode = true
+	bare = true
diff --git a/services/webhook/sourcehut/testdata/repo.git/description b/services/webhook/sourcehut/testdata/repo.git/description
new file mode 100644
index 0000000000..498b267a8c
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/services/webhook/sourcehut/testdata/repo.git/info/exclude b/services/webhook/sourcehut/testdata/repo.git/info/exclude
new file mode 100644
index 0000000000..a5196d1be8
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/01/16b5e2279b70e5f5b98240e8896331248f4463 b/services/webhook/sourcehut/testdata/repo.git/objects/01/16b5e2279b70e5f5b98240e8896331248f4463
new file mode 100644
index 0000000000..c06eb842be
Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/01/16b5e2279b70e5f5b98240e8896331248f4463 differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/3c/3d4b799b3933ba687b263eeef2034300a5315e b/services/webhook/sourcehut/testdata/repo.git/objects/3c/3d4b799b3933ba687b263eeef2034300a5315e
new file mode 100644
index 0000000000..f03b45d3f9
Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/3c/3d4b799b3933ba687b263eeef2034300a5315e differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/56/f276169b9766f805e5198fe7fb6698153fdb03 b/services/webhook/sourcehut/testdata/repo.git/objects/56/f276169b9766f805e5198fe7fb6698153fdb03
new file mode 100644
index 0000000000..dca1d23ce9
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/56/f276169b9766f805e5198fe7fb6698153fdb03
@@ -0,0 +1 @@
+x�NKj�0�Z�x�B��ɶ���z���Q�[FQ�?"=A3�Ѳmk#���*@��L3&�)��'D$�#�Β�搊�Ѽ,#/��8�Ov��zIN<�u'��[;�J��~��{�#'�e;.x���輋#[K��[k�y���ASq\DA��kƵ�������؝~P�k���VO�
\ No newline at end of file
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/58/771003157b81abc6bf41df0c5db4147a3e3c83 b/services/webhook/sourcehut/testdata/repo.git/objects/58/771003157b81abc6bf41df0c5db4147a3e3c83
new file mode 100644
index 0000000000..e9ff0d0bd9
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/58/771003157b81abc6bf41df0c5db4147a3e3c83
@@ -0,0 +1,2 @@
+x=���0D=�+�nB�X����h�Vk�%�?_P�m���̔b��C�̠��D�{�
+;��F�&��q��m���<�5e8�|�[����/���
O��5��	GYK)��\�iO�KJ3�PƝ�j��U>��V���X���܃絈7\p;�
\ No newline at end of file
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/69/b217caa89166a02b8cd368b64fb83a44720e14 b/services/webhook/sourcehut/testdata/repo.git/objects/69/b217caa89166a02b8cd368b64fb83a44720e14
new file mode 100644
index 0000000000..1aed81107b
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/69/b217caa89166a02b8cd368b64fb83a44720e14
@@ -0,0 +1 @@
+x=��n� �{�)�^�Z,EUN}��&T�A��y���6�a��T�=w����̂�Ģ5��O� �\�m\�uFT��G�׈F;���NQ�^�[��֓a��Q��o��kiW~+p��p��u�i�h��a����3�J?�:7([��VK��|���͙�T��I�7����u�İӑ��>s��P���=�C}ˢO�
\ No newline at end of file
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/9e/4b777f81b316a1c75a0797b33add68ee49b0d0 b/services/webhook/sourcehut/testdata/repo.git/objects/9e/4b777f81b316a1c75a0797b33add68ee49b0d0
new file mode 100644
index 0000000000..081cfcd5ba
Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/9e/4b777f81b316a1c75a0797b33add68ee49b0d0 differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/aa/3905af404394f576f88f00e7f0919b4b97453f b/services/webhook/sourcehut/testdata/repo.git/objects/aa/3905af404394f576f88f00e7f0919b4b97453f
new file mode 100644
index 0000000000..cc96171c1c
Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/aa/3905af404394f576f88f00e7f0919b4b97453f differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/bd/d74dba3f34542e480d56c2a91c9e2463180d3c b/services/webhook/sourcehut/testdata/repo.git/objects/bd/d74dba3f34542e480d56c2a91c9e2463180d3c
new file mode 100644
index 0000000000..639f5c4784
Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/bd/d74dba3f34542e480d56c2a91c9e2463180d3c differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/c2/30778a03511f546f532e79c8c14917c260e750 b/services/webhook/sourcehut/testdata/repo.git/objects/c2/30778a03511f546f532e79c8c14917c260e750
new file mode 100644
index 0000000000..4a952fb0b2
Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/c2/30778a03511f546f532e79c8c14917c260e750 differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/c9/fa8be8c6f230e9529f8e546a3e6a354cbaf313 b/services/webhook/sourcehut/testdata/repo.git/objects/c9/fa8be8c6f230e9529f8e546a3e6a354cbaf313
new file mode 100644
index 0000000000..291f0a422c
Binary files /dev/null and b/services/webhook/sourcehut/testdata/repo.git/objects/c9/fa8be8c6f230e9529f8e546a3e6a354cbaf313 differ
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/cb/80b2628b69c86b6baea464e5f9fc28405fde4b b/services/webhook/sourcehut/testdata/repo.git/objects/cb/80b2628b69c86b6baea464e5f9fc28405fde4b
new file mode 100644
index 0000000000..891ace4651
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/cb/80b2628b69c86b6baea464e5f9fc28405fde4b
@@ -0,0 +1 @@
+x=�Kn�0D��)�`�k�@Pd�{P2���-AQ���]�Y��Ie�sm�KoD��)�����8�p gg44�l��FQ����F9�˜��V�,�[�U�Τ`~�[i�Vڕ���������4�+(��0Y)�$��"���Ԡl��Z-e��5��w�ԦʸN���Y�?V4�&���t����C9�=a����,P�
\ No newline at end of file
diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/d2/e0862c8b8097ba4bdd72946c20479751d307a0 b/services/webhook/sourcehut/testdata/repo.git/objects/d2/e0862c8b8097ba4bdd72946c20479751d307a0
new file mode 100644
index 0000000000..f57ab8a70d
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/objects/d2/e0862c8b8097ba4bdd72946c20479751d307a0
@@ -0,0 +1,4 @@
+xENIn�0�Y�����D��#ȁ�	ۍ,
+"$�����\f��9ئ9~,+L�-�㒶�ɀ�=o�g��#�&�OU��o߷�j�U!�,�꺮�DGP�
+e>L����狡t[
+������#?���C�~�z�2!,��qCt�Q�Z<�.@78�������\�I
\ No newline at end of file
diff --git a/services/webhook/sourcehut/testdata/repo.git/refs/heads/main b/services/webhook/sourcehut/testdata/repo.git/refs/heads/main
new file mode 100644
index 0000000000..4e693a7464
--- /dev/null
+++ b/services/webhook/sourcehut/testdata/repo.git/refs/heads/main
@@ -0,0 +1 @@
+69b217caa89166a02b8cd368b64fb83a44720e14
diff --git a/services/webhook/webhook.go b/services/webhook/webhook.go
index 75962db605..dc68cae84d 100644
--- a/services/webhook/webhook.go
+++ b/services/webhook/webhook.go
@@ -25,6 +25,7 @@ import (
 	"code.gitea.io/gitea/modules/util"
 	webhook_module "code.gitea.io/gitea/modules/webhook"
 	"code.gitea.io/gitea/services/forms"
+	"code.gitea.io/gitea/services/webhook/sourcehut"
 
 	"github.com/gobwas/glob"
 )
@@ -53,6 +54,7 @@ var webhookHandlers = []Handler{
 	matrixHandler{},
 	wechatworkHandler{},
 	packagistHandler{},
+	sourcehut.BuildsHandler{},
 }
 
 // GetWebhookHandler return the handler for a given webhook type (nil if not found)
diff --git a/templates/webhook/new.tmpl b/templates/webhook/new.tmpl
index 8afdb1fa5d..a3fd89655c 100644
--- a/templates/webhook/new.tmpl
+++ b/templates/webhook/new.tmpl
@@ -36,6 +36,8 @@
 			{{template "webhook/new/wechatwork" .}}
 		{{else if eq .HookType "packagist"}}
 			{{template "webhook/new/packagist" .}}
+		{{else if eq .HookType "sourcehut_builds"}}
+			{{template "webhook/new/sourcehut_builds" .}}
 		{{end}}
 	{{end}}
 </div>
diff --git a/templates/webhook/new/sourcehut_builds.tmpl b/templates/webhook/new/sourcehut_builds.tmpl
new file mode 100644
index 0000000000..1d6333fe79
--- /dev/null
+++ b/templates/webhook/new/sourcehut_builds.tmpl
@@ -0,0 +1,33 @@
+<p>{{ctx.Locale.Tr "repo.settings.add_web_hook_desc" "https://sourcehut.org/" (ctx.Locale.Tr "repo.settings.web_hook_name_sourcehut_builds")}}</p>
+<form class="ui form" action="{{.BaseLink}}/{{or .Webhook.ID "sourcehut_builds/new"}}" method="post">
+	{{.CsrfTokenHtml}}
+	<div class="required field {{if .Err_PayloadURL}}error{{end}}">
+		<label for="payload_url">{{ctx.Locale.Tr "repo.settings.sourcehut_builds.graphql_url"}}</label>
+		<input id="payload_url" name="payload_url" type="url" value="{{.Webhook.URL}}" autofocus required>
+	</div>
+	<div class="required field {{if .Err_ManifestPath}}error{{end}}">
+		<label for="manifest_path">{{ctx.Locale.Tr "repo.settings.sourcehut_builds.manifest_path"}}</label>
+		<input id="manifest_path" name="manifest_path" type="text" value="{{.HookMetadata.ManifestPath}}" required>
+	</div>
+	<div class="field">
+		<label>{{ctx.Locale.Tr "repo.settings.sourcehut_builds.visibility"}}</label>
+		<div class="ui selection dropdown">
+			<input type="hidden" id="visibility" name="visibility" value="{{if .HookMetadata.Visibility}}{{.HookMetadata.Visibility}}{{else}}PRIVATE{{end}}">
+			<div class="default text"></div>
+			{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+			<div class="menu">
+				<div class="item" data-value="PUBLIC">PUBLIC</div>
+				<div class="item" data-value="UNLISTED">UNLISTED</div>
+				<div class="item" data-value="PRIVATE">PRIVATE</div>
+			</div>
+		</div>
+	</div>
+	<div class="field">
+		<div class="ui checkbox">
+			<input name="secrets" type="checkbox" {{if .HookMetadata.Secrets}}checked{{end}}>
+			<label>{{ctx.Locale.Tr "repo.settings.sourcehut_builds.secrets"}}</label>
+			<span class="help">{{ctx.Locale.Tr "repo.settings.sourcehut_builds.secrets_helper"}}</span>
+		</div>
+	</div>
+	{{template "repo/settings/webhook/settings" .}}
+</form>
diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go
index 15da511758..3375c0f1ed 100644
--- a/tests/integration/repo_webhook_test.go
+++ b/tests/integration/repo_webhook_test.go
@@ -238,6 +238,34 @@ func TestWebhookForms(t *testing.T) {
 		"branch_filter":        "packagist/*",
 		"authorization_header": "Bearer 123456",
 	}))
+
+	t.Run("sourcehut_builds/required", testWebhookForms("sourcehut_builds", session, map[string]string{
+		"payload_url":          "https://sourcehut_builds.example.com",
+		"manifest_path":        ".build.yml",
+		"visibility":           "PRIVATE",
+		"authorization_header": "Bearer 123456",
+	}, map[string]string{
+		"authorization_header": "",
+	}, map[string]string{
+		"authorization_header": "token ",
+	}, map[string]string{
+		"manifest_path": "",
+	}, map[string]string{
+		"manifest_path": "/absolute",
+	}, map[string]string{
+		"visibility": "",
+	}, map[string]string{
+		"visibility": "INVALID",
+	}))
+	t.Run("sourcehut_builds/optional", testWebhookForms("sourcehut_builds", session, map[string]string{
+		"payload_url":   "https://sourcehut_builds.example.com",
+		"manifest_path": ".build.yml",
+		"visibility":    "PRIVATE",
+		"secrets":       "on",
+
+		"branch_filter":        "srht/*",
+		"authorization_header": "Bearer 123456",
+	}))
 }
 
 func assertInput(t testing.TB, form *goquery.Selection, name string) string {
@@ -247,7 +275,15 @@ func assertInput(t testing.TB, form *goquery.Selection, name string) string {
 		t.Log(form.Html())
 		t.Errorf("field <input name=%q /> found %d times, expected once", name, input.Length())
 	}
-	return input.AttrOr("value", "")
+	switch input.AttrOr("type", "") {
+	case "checkbox":
+		if _, checked := input.Attr("checked"); checked {
+			return "on"
+		}
+		return ""
+	default:
+		return input.AttrOr("value", "")
+	}
 }
 
 func testWebhookForms(name string, session *TestSession, validFields map[string]string, invalidPatches ...map[string]string) func(t *testing.T) {