diff --git a/models/user/user_system.go b/models/user/user_system.go
index ac2505dd14..ba9a2131b2 100644
--- a/models/user/user_system.go
+++ b/models/user/user_system.go
@@ -4,8 +4,10 @@
 package user
 
 import (
+	"net/url"
 	"strings"
 
+	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/structs"
 )
 
@@ -68,3 +70,28 @@ func NewActionsUser() *User {
 func (u *User) IsActions() bool {
 	return u != nil && u.ID == ActionsUserID
 }
+
+const (
+	APActorUserID   = -3
+	APActorUserName = "actor"
+	APActorEmail    = "noreply@forgejo.org"
+)
+
+func NewAPActorUser() *User {
+	return &User{
+		ID:               APActorUserID,
+		Name:             APActorUserName,
+		LowerName:        APActorUserName,
+		IsActive:         true,
+		Email:            APActorEmail,
+		KeepEmailPrivate: true,
+		LoginName:        APActorUserName,
+		Type:             UserTypeIndividual,
+		Visibility:       structs.VisibleTypePublic,
+	}
+}
+
+func APActorUserAPActorID() string {
+	path, _ := url.JoinPath(setting.AppURL, "/api/v1/activitypub/actor")
+	return path
+}
diff --git a/modules/activitypub/client.go b/modules/activitypub/client.go
index 38ccc58eb5..f07d3bc7d6 100644
--- a/modules/activitypub/client.go
+++ b/modules/activitypub/client.go
@@ -36,16 +36,19 @@ func CurrentTime() string {
 }
 
 func containsRequiredHTTPHeaders(method string, headers []string) error {
-	var hasRequestTarget, hasDate, hasDigest bool
+	var hasRequestTarget, hasDate, hasDigest, hasHost bool
 	for _, header := range headers {
 		hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget
 		hasDate = hasDate || header == "Date"
 		hasDigest = hasDigest || header == "Digest"
+		hasHost = hasHost || header == "Host"
 	}
 	if !hasRequestTarget {
 		return fmt.Errorf("missing http header for %s: %s", method, httpsig.RequestTarget)
 	} else if !hasDate {
 		return fmt.Errorf("missing http header for %s: Date", method)
+	} else if !hasHost {
+		return fmt.Errorf("missing http header for %s: Host", method)
 	} else if !hasDigest && method != http.MethodGet {
 		return fmt.Errorf("missing http header for %s: Digest", method)
 	}
@@ -99,29 +102,36 @@ func NewClient(ctx context.Context, user *user_model.User, pubID string) (c *Cli
 }
 
 // NewRequest function
-func (c *Client) NewRequest(method string, b []byte, to string) (req *http.Request, err error) {
+func (c *Client) newRequest(method string, b []byte, to string) (req *http.Request, err error) {
 	buf := bytes.NewBuffer(b)
 	req, err = http.NewRequest(method, to, buf)
 	if err != nil {
 		return nil, err
 	}
-	req.Header.Add("Content-Type", ActivityStreamsContentType)
+	req.Header.Add("Accept", "application/json, "+ActivityStreamsContentType)
 	req.Header.Add("Date", CurrentTime())
+	req.Header.Add("Host", req.URL.Host)
 	req.Header.Add("User-Agent", "Gitea/"+setting.AppVer)
-	signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime)
-	if err != nil {
-		return nil, err
-	}
-	err = signer.SignRequest(c.priv, c.pubID, req, b)
+	req.Header.Add("Content-Type", ActivityStreamsContentType)
+
 	return req, err
 }
 
 // Post function
 func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) {
 	var req *http.Request
-	if req, err = c.NewRequest(http.MethodPost, b, to); err != nil {
+	if req, err = c.newRequest(http.MethodPost, b, to); err != nil {
 		return nil, err
 	}
+
+	signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime)
+	if err != nil {
+		return nil, err
+	}
+	if err := signer.SignRequest(c.priv, c.pubID, req, b); err != nil {
+		return nil, err
+	}
+
 	resp, err = c.client.Do(req)
 	return resp, err
 }
@@ -129,10 +139,17 @@ func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) {
 // Create an http GET request with forgejo/gitea specific headers
 func (c *Client) Get(to string) (resp *http.Response, err error) {
 	var req *http.Request
-	emptyBody := []byte{0}
-	if req, err = c.NewRequest(http.MethodGet, emptyBody, to); err != nil {
+	if req, err = c.newRequest(http.MethodGet, nil, to); err != nil {
 		return nil, err
 	}
+	signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.getHeaders, httpsig.Signature, httpsigExpirationTime)
+	if err != nil {
+		return nil, err
+	}
+	if err := signer.SignRequest(c.priv, c.pubID, req, nil); err != nil {
+		return nil, err
+	}
+
 	resp, err = c.client.Do(req)
 	return resp, err
 }
diff --git a/modules/setting/federation.go b/modules/setting/federation.go
index 2bea900633..aeb30683ea 100644
--- a/modules/setting/federation.go
+++ b/modules/setting/federation.go
@@ -25,8 +25,8 @@ var (
 		MaxSize:             4,
 		Algorithms:          []string{"rsa-sha256", "rsa-sha512", "ed25519"},
 		DigestAlgorithm:     "SHA-256",
-		GetHeaders:          []string{"(request-target)", "Date"},
-		PostHeaders:         []string{"(request-target)", "Date", "Digest"},
+		GetHeaders:          []string{"(request-target)", "Date", "Host"},
+		PostHeaders:         []string{"(request-target)", "Date", "Host", "Digest"},
 	}
 )
 
diff --git a/routers/api/v1/activitypub/actor.go b/routers/api/v1/activitypub/actor.go
new file mode 100644
index 0000000000..4f128e74c4
--- /dev/null
+++ b/routers/api/v1/activitypub/actor.go
@@ -0,0 +1,83 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activitypub
+
+import (
+	"net/http"
+
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/activitypub"
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/services/context"
+
+	ap "github.com/go-ap/activitypub"
+	"github.com/go-ap/jsonld"
+)
+
+// Actor function returns the instance's Actor
+func Actor(ctx *context.APIContext) {
+	// swagger:operation GET /activitypub/actor activitypub activitypubInstanceActor
+	// ---
+	// summary: Returns the instance's Actor
+	// produces:
+	// - application/json
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/ActivityPub"
+
+	link := user_model.APActorUserAPActorID()
+	actor := ap.ActorNew(ap.IRI(link), ap.ApplicationType)
+
+	actor.PreferredUsername = ap.NaturalLanguageValuesNew()
+	err := actor.PreferredUsername.Set("en", ap.Content(setting.Domain))
+	if err != nil {
+		ctx.ServerError("PreferredUsername.Set", err)
+		return
+	}
+
+	actor.URL = ap.IRI(setting.AppURL)
+
+	actor.Inbox = ap.IRI(link + "/inbox")
+	actor.Outbox = ap.IRI(link + "/outbox")
+
+	actor.PublicKey.ID = ap.IRI(link + "#main-key")
+	actor.PublicKey.Owner = ap.IRI(link)
+
+	publicKeyPem, err := activitypub.GetPublicKey(ctx, user_model.NewAPActorUser())
+	if err != nil {
+		ctx.ServerError("GetPublicKey", err)
+		return
+	}
+	actor.PublicKey.PublicKeyPem = publicKeyPem
+
+	binary, err := jsonld.WithContext(
+		jsonld.IRI(ap.ActivityBaseURI),
+		jsonld.IRI(ap.SecurityContextURI),
+	).Marshal(actor)
+	if err != nil {
+		ctx.ServerError("MarshalJSON", err)
+		return
+	}
+	ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType)
+	ctx.Resp.WriteHeader(http.StatusOK)
+	if _, err = ctx.Resp.Write(binary); err != nil {
+		log.Error("write to resp err: %v", err)
+	}
+}
+
+// ActorInbox function handles the incoming data for the instance Actor
+func ActorInbox(ctx *context.APIContext) {
+	// swagger:operation POST /activitypub/actor/inbox activitypub activitypubInstanceActorInbox
+	// ---
+	// summary: Send to the inbox
+	// produces:
+	// - application/json
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+
+	ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index fa0cd6c753..c65e738715 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -805,6 +805,10 @@ func Routes() *web.Route {
 					m.Get("", activitypub.Person)
 					m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
 				}, context.UserIDAssignmentAPI())
+				m.Group("/actor", func() {
+					m.Get("", activitypub.Actor)
+					m.Post("/inbox", activitypub.ActorInbox)
+				})
 				m.Group("/repository-id/{repository-id}", func() {
 					m.Get("", activitypub.Repository)
 					m.Post("/inbox",
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 39b92f4e79..628e8d5c99 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -23,6 +23,40 @@
   },
   "basePath": "{{AppSubUrl | JSEscape}}/api/v1",
   "paths": {
+    "/activitypub/actor": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "activitypub"
+        ],
+        "summary": "Returns the instance's Actor",
+        "operationId": "activitypubInstanceActor",
+        "responses": {
+          "200": {
+            "$ref": "#/responses/ActivityPub"
+          }
+        }
+      }
+    },
+    "/activitypub/actor/inbox": {
+      "post": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "activitypub"
+        ],
+        "summary": "Send to the inbox",
+        "operationId": "activitypubInstanceActorInbox",
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          }
+        }
+      }
+    },
     "/activitypub/repository-id/{repository-id}": {
       "get": {
         "produces": [
diff --git a/tests/integration/api_activitypub_actor_test.go b/tests/integration/api_activitypub_actor_test.go
new file mode 100644
index 0000000000..7506c786da
--- /dev/null
+++ b/tests/integration/api_activitypub_actor_test.go
@@ -0,0 +1,50 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/test"
+	"code.gitea.io/gitea/routers"
+
+	ap "github.com/go-ap/activitypub"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestActivityPubActor(t *testing.T) {
+	defer test.MockVariableValue(&setting.Federation.Enabled, true)()
+	defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+	onGiteaRun(t, func(*testing.T, *url.URL) {
+		req := NewRequest(t, "GET", "/api/v1/activitypub/actor")
+		resp := MakeRequest(t, req, http.StatusOK)
+		body := resp.Body.Bytes()
+		assert.Contains(t, string(body), "@context")
+
+		var actor ap.Actor
+		err := actor.UnmarshalJSON(body)
+		require.NoError(t, err)
+
+		assert.Equal(t, ap.ApplicationType, actor.Type)
+		assert.Equal(t, setting.Domain, actor.PreferredUsername.String())
+		keyID := actor.GetID().String()
+		assert.Regexp(t, "activitypub/actor$", keyID)
+		assert.Regexp(t, "activitypub/actor/outbox$", actor.Outbox.GetID().String())
+		assert.Regexp(t, "activitypub/actor/inbox$", actor.Inbox.GetID().String())
+
+		pubKey := actor.PublicKey
+		assert.NotNil(t, pubKey)
+		publicKeyID := keyID + "#main-key"
+		assert.Equal(t, pubKey.ID.String(), publicKeyID)
+
+		pubKeyPem := pubKey.PublicKeyPem
+		assert.NotNil(t, pubKeyPem)
+		assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem)
+	})
+}