diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index 3f14e41e5..46ed95c82 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -9245,6 +9245,27 @@ paths:
                   in: formData
                   name: filter_action
                   type: string
+                - collectionFormat: multi
+                  description: Keywords to be added (if not using id param) or updated (if using id param).
+                  in: formData
+                  items:
+                    type: string
+                  name: keywords_attributes[][keyword]
+                  type: array
+                - collectionFormat: multi
+                  description: Should each keyword consider word boundaries?
+                  in: formData
+                  items:
+                    type: boolean
+                  name: keywords_attributes[][whole_word]
+                  type: array
+                - collectionFormat: multi
+                  description: Statuses to be added to the filter.
+                  in: formData
+                  items:
+                    type: string
+                  name: statuses_attributes[][status_id]
+                  type: array
             produces:
                 - application/json
             responses:
@@ -9360,6 +9381,27 @@ paths:
                   name: title
                   required: true
                   type: string
+                - collectionFormat: multi
+                  description: Keywords to be added to the created filter.
+                  in: formData
+                  items:
+                    type: string
+                  name: keywords_attributes[][keyword]
+                  type: array
+                - collectionFormat: multi
+                  description: Should each keyword consider word boundaries?
+                  in: formData
+                  items:
+                    type: boolean
+                  name: keywords_attributes[][whole_word]
+                  type: array
+                - collectionFormat: multi
+                  description: Statuses to be added to the newly created filter.
+                  in: formData
+                  items:
+                    type: string
+                  name: statuses_attributes[][status_id]
+                  type: array
                 - collectionFormat: multi
                   description: |-
                     The contexts in which the filter should be applied.
diff --git a/internal/api/client/filters/v2/filterpost.go b/internal/api/client/filters/v2/filterpost.go
index 9e8f87fd0..732b81041 100644
--- a/internal/api/client/filters/v2/filterpost.go
+++ b/internal/api/client/filters/v2/filterpost.go
@@ -100,6 +100,30 @@ import (
 //			- warn
 //			- hide
 //		default: warn
+//	-
+//		name: keywords_attributes[][keyword]
+//		in: formData
+//		type: array
+//		items:
+//			type: string
+//		description: Keywords to be added (if not using id param) or updated (if using id param).
+//		collectionFormat: multi
+//	-
+//		name: keywords_attributes[][whole_word]
+//		in: formData
+//		type: array
+//		items:
+//			type: boolean
+//		description: Should each keyword consider word boundaries?
+//		collectionFormat: multi
+//	-
+//		name: statuses_attributes[][status_id]
+//		in: formData
+//		type: array
+//		items:
+//			type: string
+//		description: Statuses to be added to the filter.
+//		collectionFormat: multi
 //
 //	security:
 //	- OAuth2 Bearer:
@@ -176,6 +200,30 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error {
 		return err
 	}
 
+	// Parse form variant of normal filter keyword creation structs.
+	if len(form.KeywordsAttributesKeyword) > 0 {
+		form.Keywords = make([]apimodel.FilterKeywordCreateUpdateRequest, 0, len(form.KeywordsAttributesKeyword))
+		for i, keyword := range form.KeywordsAttributesKeyword {
+			formKeyword := apimodel.FilterKeywordCreateUpdateRequest{
+				Keyword: keyword,
+			}
+			if i < len(form.KeywordsAttributesWholeWord) {
+				formKeyword.WholeWord = &form.KeywordsAttributesWholeWord[i]
+			}
+			form.Keywords = append(form.Keywords, formKeyword)
+		}
+	}
+
+	// Parse form variant of normal filter status creation structs.
+	if len(form.StatusesAttributesStatusID) > 0 {
+		form.Statuses = make([]apimodel.FilterStatusCreateRequest, 0, len(form.StatusesAttributesStatusID))
+		for _, statusID := range form.StatusesAttributesStatusID {
+			form.Statuses = append(form.Statuses, apimodel.FilterStatusCreateRequest{
+				StatusID: statusID,
+			})
+		}
+	}
+
 	// Apply defaults for missing fields.
 	form.FilterAction = util.Ptr(action)
 
@@ -200,5 +248,18 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error {
 		}
 	}
 
+	// Normalize and validate new keywords and statuses.
+	for i, formKeyword := range form.Keywords {
+		if err := validate.FilterKeyword(formKeyword.Keyword); err != nil {
+			return err
+		}
+		form.Keywords[i].WholeWord = util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false))
+	}
+	for _, formStatus := range form.Statuses {
+		if err := validate.ULID(formStatus.StatusID, "status_id"); err != nil {
+			return err
+		}
+	}
+
 	return nil
 }
diff --git a/internal/api/client/filters/v2/filterpost_test.go b/internal/api/client/filters/v2/filterpost_test.go
index 6656c4b59..6e378874c 100644
--- a/internal/api/client/filters/v2/filterpost_test.go
+++ b/internal/api/client/filters/v2/filterpost_test.go
@@ -23,6 +23,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"slices"
 	"strconv"
 	"strings"
 
@@ -35,7 +36,7 @@ import (
 	"github.com/superseriousbusiness/gotosocial/testrig"
 )
 
-func (suite *FiltersTestSuite) postFilter(title *string, context *[]string, action *string, expiresIn *int, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) {
+func (suite *FiltersTestSuite) postFilter(title *string, context *[]string, action *string, expiresIn *int, keywordsAttributesKeyword *[]string, keywordsAttributesWholeWord *[]bool, statusesAttributesStatusID *[]string, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) {
 	// instantiate recorder + test context
 	recorder := httptest.NewRecorder()
 	ctx, _ := testrig.CreateGinTestContext(recorder, nil)
@@ -64,6 +65,19 @@ func (suite *FiltersTestSuite) postFilter(title *string, context *[]string, acti
 		if expiresIn != nil {
 			ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)}
 		}
+		if keywordsAttributesKeyword != nil {
+			ctx.Request.Form["keywords_attributes[][keyword]"] = *keywordsAttributesKeyword
+		}
+		if keywordsAttributesWholeWord != nil {
+			formatted := []string{}
+			for _, value := range *keywordsAttributesWholeWord {
+				formatted = append(formatted, strconv.FormatBool(value))
+			}
+			ctx.Request.Form["keywords_attributes[][whole_word]"] = formatted
+		}
+		if statusesAttributesStatusID != nil {
+			ctx.Request.Form["statuses_attributes[][status_id]"] = *statusesAttributesStatusID
+		}
 	}
 
 	// trigger the handler
@@ -111,7 +125,12 @@ func (suite *FiltersTestSuite) TestPostFilterFull() {
 	context := []string{"home", "public"}
 	action := "warn"
 	expiresIn := 86400
-	filter, err := suite.postFilter(&title, &context, &action, &expiresIn, nil, http.StatusOK, "")
+	// Checked in lexical order by keyword, so keep this sorted.
+	keywordsAttributesKeyword := []string{"GNU", "Linux"}
+	keywordsAttributesWholeWord := []bool{true, false}
+	// Checked in lexical order by status ID, so keep this sorted.
+	statusAttributesStatusID := []string{"01HEN2QRFA8H3C6QPN7RD4KSR6", "01HEWV37MHV8BAC8ANFGVRRM5D"}
+	filter, err := suite.postFilter(&title, &context, &action, &expiresIn, &keywordsAttributesKeyword, &keywordsAttributesWholeWord, &statusAttributesStatusID, nil, http.StatusOK, "")
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -126,8 +145,25 @@ func (suite *FiltersTestSuite) TestPostFilterFull() {
 	if suite.NotNil(filter.ExpiresAt) {
 		suite.NotEmpty(*filter.ExpiresAt)
 	}
-	suite.Empty(filter.Keywords)
-	suite.Empty(filter.Statuses)
+
+	if suite.Len(filter.Keywords, len(keywordsAttributesKeyword)) {
+		slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int {
+			return strings.Compare(lhs.Keyword, rhs.Keyword)
+		})
+		for i, filterKeyword := range filter.Keywords {
+			suite.Equal(keywordsAttributesKeyword[i], filterKeyword.Keyword)
+			suite.Equal(keywordsAttributesWholeWord[i], filterKeyword.WholeWord)
+		}
+	}
+
+	if suite.Len(filter.Statuses, len(statusAttributesStatusID)) {
+		slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int {
+			return strings.Compare(lhs.StatusID, rhs.StatusID)
+		})
+		for i, filterStatus := range filter.Statuses {
+			suite.Equal(statusAttributesStatusID[i], filterStatus.StatusID)
+		}
+	}
 
 	suite.checkStreamed(homeStream, true, "", stream.EventTypeFiltersChanged)
 }
@@ -141,9 +177,27 @@ func (suite *FiltersTestSuite) TestPostFilterFullJSON() {
 		"context": ["home", "public"],
 		"filter_action": "warn",
 		"whole_word": true,
-		"expires_in": 86400.1
+		"expires_in": 86400.1,
+		"keywords_attributes": [
+			{
+				"keyword": "GNU",
+				"whole_word": true
+			},
+			{
+				"keyword": "Linux",
+				"whole_word": false
+			}
+		],
+		"statuses_attributes": [
+			{
+				"status_id": "01HEN2QRFA8H3C6QPN7RD4KSR6"
+			},
+			{
+				"status_id": "01HEWV37MHV8BAC8ANFGVRRM5D"
+			}
+		]
 	}`
-	filter, err := suite.postFilter(nil, nil, nil, nil, &requestJson, http.StatusOK, "")
+	filter, err := suite.postFilter(nil, nil, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -160,8 +214,28 @@ func (suite *FiltersTestSuite) TestPostFilterFullJSON() {
 	if suite.NotNil(filter.ExpiresAt) {
 		suite.NotEmpty(*filter.ExpiresAt)
 	}
-	suite.Empty(filter.Keywords)
-	suite.Empty(filter.Statuses)
+
+	if suite.Len(filter.Keywords, 2) {
+		slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int {
+			return strings.Compare(lhs.Keyword, rhs.Keyword)
+		})
+
+		suite.Equal("GNU", filter.Keywords[0].Keyword)
+		suite.True(filter.Keywords[0].WholeWord)
+
+		suite.Equal("Linux", filter.Keywords[1].Keyword)
+		suite.False(filter.Keywords[1].WholeWord)
+	}
+
+	if suite.Len(filter.Statuses, 2) {
+		slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int {
+			return strings.Compare(lhs.StatusID, rhs.StatusID)
+		})
+
+		suite.Equal("01HEN2QRFA8H3C6QPN7RD4KSR6", filter.Statuses[0].StatusID)
+
+		suite.Equal("01HEWV37MHV8BAC8ANFGVRRM5D", filter.Statuses[1].StatusID)
+	}
 
 	suite.checkStreamed(homeStream, true, "", stream.EventTypeFiltersChanged)
 }
@@ -171,7 +245,7 @@ func (suite *FiltersTestSuite) TestPostFilterMinimal() {
 
 	title := "GNU/Linux"
 	context := []string{"home"}
-	filter, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusOK, "")
+	filter, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusOK, "")
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -193,7 +267,7 @@ func (suite *FiltersTestSuite) TestPostFilterMinimal() {
 func (suite *FiltersTestSuite) TestPostFilterEmptyTitle() {
 	title := ""
 	context := []string{"home"}
-	_, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusUnprocessableEntity, "")
+	_, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -201,7 +275,7 @@ func (suite *FiltersTestSuite) TestPostFilterEmptyTitle() {
 
 func (suite *FiltersTestSuite) TestPostFilterMissingTitle() {
 	context := []string{"home"}
-	_, err := suite.postFilter(nil, &context, nil, nil, nil, http.StatusUnprocessableEntity, "")
+	_, err := suite.postFilter(nil, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -210,7 +284,7 @@ func (suite *FiltersTestSuite) TestPostFilterMissingTitle() {
 func (suite *FiltersTestSuite) TestPostFilterEmptyContext() {
 	title := "GNU/Linux"
 	context := []string{}
-	_, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusUnprocessableEntity, "")
+	_, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -218,7 +292,7 @@ func (suite *FiltersTestSuite) TestPostFilterEmptyContext() {
 
 func (suite *FiltersTestSuite) TestPostFilterMissingContext() {
 	title := "GNU/Linux"
-	_, err := suite.postFilter(&title, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
+	_, err := suite.postFilter(&title, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -227,7 +301,7 @@ func (suite *FiltersTestSuite) TestPostFilterMissingContext() {
 // Creating another filter with the same title should fail.
 func (suite *FiltersTestSuite) TestPostFilterTitleConflict() {
 	title := suite.testFilters["local_account_1_filter_1"].Title
-	_, err := suite.postFilter(&title, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
+	_, err := suite.postFilter(&title, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
diff --git a/internal/api/client/filters/v2/filterput.go b/internal/api/client/filters/v2/filterput.go
index 24071a150..cc3531838 100644
--- a/internal/api/client/filters/v2/filterput.go
+++ b/internal/api/client/filters/v2/filterput.go
@@ -18,6 +18,7 @@
 package v2
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"strconv"
@@ -68,6 +69,30 @@ import (
 //		minLength: 1
 //		maxLength: 200
 //	-
+//		name: keywords_attributes[][keyword]
+//		in: formData
+//		type: array
+//		items:
+//			type: string
+//		description: Keywords to be added to the created filter.
+//		collectionFormat: multi
+//	-
+//		name: keywords_attributes[][whole_word]
+//		in: formData
+//		type: array
+//		items:
+//			type: boolean
+//		description: Should each keyword consider word boundaries?
+//		collectionFormat: multi
+//	-
+//		name: statuses_attributes[][status_id]
+//		in: formData
+//		type: array
+//		items:
+//			type: string
+//		description: Statuses to be added to the newly created filter.
+//		collectionFormat: multi
+//	-
 //		name: context[]
 //		in: formData
 //		required: true
@@ -183,6 +208,58 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error {
 		}
 	}
 
+	// Parse form variant of normal filter keyword update structs.
+	// All filter keyword update struct fields are optional.
+	numFormKeywords := max(
+		len(form.KeywordsAttributesID),
+		len(form.KeywordsAttributesKeyword),
+		len(form.KeywordsAttributesWholeWord),
+		len(form.KeywordsAttributesDestroy),
+	)
+	if numFormKeywords > 0 {
+		form.Keywords = make([]apimodel.FilterKeywordCreateUpdateDeleteRequest, 0, numFormKeywords)
+		for i := 0; i < numFormKeywords; i++ {
+			formKeyword := apimodel.FilterKeywordCreateUpdateDeleteRequest{}
+			if i < len(form.KeywordsAttributesID) && form.KeywordsAttributesID[i] != "" {
+				formKeyword.ID = &form.KeywordsAttributesID[i]
+			}
+			if i < len(form.KeywordsAttributesKeyword) && form.KeywordsAttributesKeyword[i] != "" {
+				formKeyword.Keyword = &form.KeywordsAttributesKeyword[i]
+			}
+			if i < len(form.KeywordsAttributesWholeWord) {
+				formKeyword.WholeWord = &form.KeywordsAttributesWholeWord[i]
+			}
+			if i < len(form.KeywordsAttributesDestroy) {
+				formKeyword.Destroy = &form.KeywordsAttributesDestroy[i]
+			}
+			form.Keywords = append(form.Keywords, formKeyword)
+		}
+	}
+
+	// Parse form variant of normal filter status update structs.
+	// All filter status update struct fields are optional.
+	numFormStatuses := max(
+		len(form.StatusesAttributesID),
+		len(form.StatusesAttributesStatusID),
+		len(form.StatusesAttributesDestroy),
+	)
+	if numFormStatuses > 0 {
+		form.Statuses = make([]apimodel.FilterStatusCreateDeleteRequest, 0, numFormStatuses)
+		for i := 0; i < numFormStatuses; i++ {
+			formStatus := apimodel.FilterStatusCreateDeleteRequest{}
+			if i < len(form.StatusesAttributesID) && form.StatusesAttributesID[i] != "" {
+				formStatus.ID = &form.StatusesAttributesID[i]
+			}
+			if i < len(form.StatusesAttributesStatusID) && form.StatusesAttributesStatusID[i] != "" {
+				formStatus.StatusID = &form.StatusesAttributesStatusID[i]
+			}
+			if i < len(form.StatusesAttributesDestroy) {
+				formStatus.Destroy = &form.StatusesAttributesDestroy[i]
+			}
+			form.Statuses = append(form.Statuses, formStatus)
+		}
+	}
+
 	// Normalize filter expiry if necessary.
 	// If we parsed this as JSON, expires_in
 	// may be either a float64 or a string.
@@ -204,5 +281,42 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error {
 		}
 	}
 
+	// Normalize and validate updates.
+	for i, formKeyword := range form.Keywords {
+		if formKeyword.Keyword != nil {
+			if err := validate.FilterKeyword(*formKeyword.Keyword); err != nil {
+				return err
+			}
+		}
+
+		destroy := util.PtrValueOr(formKeyword.Destroy, false)
+		form.Keywords[i].Destroy = &destroy
+
+		if destroy && formKeyword.ID == nil {
+			return errors.New("can't delete a filter keyword without an ID")
+		} else if formKeyword.ID == nil && formKeyword.Keyword == nil {
+			return errors.New("can't create a filter keyword without a keyword")
+		}
+	}
+	for i, formStatus := range form.Statuses {
+		if formStatus.StatusID != nil {
+			if err := validate.ULID(*formStatus.StatusID, "status_id"); err != nil {
+				return err
+			}
+		}
+
+		destroy := util.PtrValueOr(formStatus.Destroy, false)
+		form.Statuses[i].Destroy = &destroy
+
+		switch {
+		case destroy && formStatus.ID == nil:
+			return errors.New("can't delete a filter status without an ID")
+		case formStatus.ID != nil:
+			return errors.New("filter status IDs here can only be used to delete them")
+		case formStatus.StatusID == nil:
+			return errors.New("can't create a filter status without a status ID")
+		}
+	}
+
 	return nil
 }
diff --git a/internal/api/client/filters/v2/filterput_test.go b/internal/api/client/filters/v2/filterput_test.go
index 6c1c315d1..d82d84b20 100644
--- a/internal/api/client/filters/v2/filterput_test.go
+++ b/internal/api/client/filters/v2/filterput_test.go
@@ -23,6 +23,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"slices"
 	"strconv"
 	"strings"
 
@@ -35,7 +36,7 @@ import (
 	"github.com/superseriousbusiness/gotosocial/testrig"
 )
 
-func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context *[]string, action *string, expiresIn *int, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) {
+func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context *[]string, action *string, expiresIn *int, keywordsAttributesID *[]string, keywordsAttributesKeyword *[]string, keywordsAttributesWholeWord *[]bool, keywordsAttributesDestroy *[]bool, statusesAttributesID *[]string, statusesAttributesStatusID *[]string, statusesAttributesDestroy *[]bool, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) {
 	// instantiate recorder + test context
 	recorder := httptest.NewRecorder()
 	ctx, _ := testrig.CreateGinTestContext(recorder, nil)
@@ -64,6 +65,39 @@ func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context
 		if expiresIn != nil {
 			ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)}
 		}
+		if keywordsAttributesID != nil {
+			ctx.Request.Form["keywords_attributes[][id]"] = *keywordsAttributesID
+		}
+		if keywordsAttributesKeyword != nil {
+			ctx.Request.Form["keywords_attributes[][keyword]"] = *keywordsAttributesKeyword
+		}
+		if keywordsAttributesWholeWord != nil {
+			formatted := []string{}
+			for _, value := range *keywordsAttributesWholeWord {
+				formatted = append(formatted, strconv.FormatBool(value))
+			}
+			ctx.Request.Form["keywords_attributes[][whole_word]"] = formatted
+		}
+		if keywordsAttributesWholeWord != nil {
+			formatted := []string{}
+			for _, value := range *keywordsAttributesDestroy {
+				formatted = append(formatted, strconv.FormatBool(value))
+			}
+			ctx.Request.Form["keywords_attributes[][_destroy]"] = formatted
+		}
+		if statusesAttributesID != nil {
+			ctx.Request.Form["statuses_attributes[][id]"] = *statusesAttributesID
+		}
+		if statusesAttributesStatusID != nil {
+			ctx.Request.Form["statuses_attributes[][status_id]"] = *statusesAttributesStatusID
+		}
+		if statusesAttributesDestroy != nil {
+			formatted := []string{}
+			for _, value := range *statusesAttributesDestroy {
+				formatted = append(formatted, strconv.FormatBool(value))
+			}
+			ctx.Request.Form["statuses_attributes[][_destroy]"] = formatted
+		}
 	}
 
 	ctx.AddParam("id", filterID)
@@ -114,7 +148,18 @@ func (suite *FiltersTestSuite) TestPutFilterFull() {
 	context := []string{"home", "public"}
 	action := "hide"
 	expiresIn := 86400
-	filter, err := suite.putFilter(id, &title, &context, &action, &expiresIn, nil, http.StatusOK, "")
+	// Tests attributes arrays that aren't the same length, just in case.
+	keywordsAttributesID := []string{
+		suite.testFilterKeywords["local_account_1_filter_2_keyword_1"].ID,
+		suite.testFilterKeywords["local_account_1_filter_2_keyword_2"].ID,
+	}
+	keywordsAttributesKeyword := []string{"fū", "", "blah"}
+	// If using the form version of this API, you have to always set whole_word to the previous value for that keyword;
+	// there's no way to represent a nullable boolean in it.
+	keywordsAttributesWholeWord := []bool{true, false, true}
+	keywordsAttributesDestroy := []bool{false, true}
+	statusesAttributesStatusID := []string{suite.testStatuses["remote_account_1_status_2"].ID}
+	filter, err := suite.putFilter(id, &title, &context, &action, &expiresIn, &keywordsAttributesID, &keywordsAttributesKeyword, &keywordsAttributesWholeWord, &keywordsAttributesDestroy, nil, &statusesAttributesStatusID, nil, nil, http.StatusOK, "")
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -129,8 +174,29 @@ func (suite *FiltersTestSuite) TestPutFilterFull() {
 	if suite.NotNil(filter.ExpiresAt) {
 		suite.NotEmpty(*filter.ExpiresAt)
 	}
-	suite.Len(filter.Keywords, 3)
-	suite.Len(filter.Statuses, 0)
+
+	if suite.Len(filter.Keywords, 3) {
+		slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int {
+			return strings.Compare(lhs.ID, rhs.ID)
+		})
+
+		suite.Equal("fū", filter.Keywords[0].Keyword)
+		suite.True(filter.Keywords[0].WholeWord)
+
+		suite.Equal("quux", filter.Keywords[1].Keyword)
+		suite.True(filter.Keywords[1].WholeWord)
+
+		suite.Equal("blah", filter.Keywords[2].Keyword)
+		suite.True(filter.Keywords[1].WholeWord)
+	}
+
+	if suite.Len(filter.Statuses, 1) {
+		slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int {
+			return strings.Compare(lhs.ID, rhs.ID)
+		})
+
+		suite.Equal(suite.testStatuses["remote_account_1_status_2"].ID, filter.Statuses[0].StatusID)
+	}
 
 	suite.checkStreamed(homeStream, true, "", stream.EventTypeFiltersChanged)
 }
@@ -144,9 +210,28 @@ func (suite *FiltersTestSuite) TestPutFilterFullJSON() {
 		"title": "messy synoptic varblabbles",
 		"context": ["home", "public"],
 		"filter_action": "hide",
-		"expires_in": 86400.1
+		"expires_in": 86400.1,
+		"keywords_attributes": [
+			{
+				"id": "01HN277Y11ENG4EC1ERMAC9FH4",
+				"keyword": "fū"
+			},
+			{
+				"id": "01HN278494N88BA2FY4DZ5JTNS",
+				"_destroy": true
+			},
+			{
+				"keyword": "blah",
+				"whole_word": true
+			}
+		],
+		"statuses_attributes": [
+			{
+				"status_id": "01HEN2QRFA8H3C6QPN7RD4KSR6"
+			}
+		]
 	}`
-	filter, err := suite.putFilter(id, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
+	filter, err := suite.putFilter(id, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -163,8 +248,29 @@ func (suite *FiltersTestSuite) TestPutFilterFullJSON() {
 	if suite.NotNil(filter.ExpiresAt) {
 		suite.NotEmpty(*filter.ExpiresAt)
 	}
-	suite.Len(filter.Keywords, 3)
-	suite.Len(filter.Statuses, 0)
+
+	if suite.Len(filter.Keywords, 3) {
+		slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int {
+			return strings.Compare(lhs.ID, rhs.ID)
+		})
+
+		suite.Equal("fū", filter.Keywords[0].Keyword)
+		suite.True(filter.Keywords[0].WholeWord)
+
+		suite.Equal("quux", filter.Keywords[1].Keyword)
+		suite.True(filter.Keywords[1].WholeWord)
+
+		suite.Equal("blah", filter.Keywords[2].Keyword)
+		suite.True(filter.Keywords[1].WholeWord)
+	}
+
+	if suite.Len(filter.Statuses, 1) {
+		slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int {
+			return strings.Compare(lhs.ID, rhs.ID)
+		})
+
+		suite.Equal("01HEN2QRFA8H3C6QPN7RD4KSR6", filter.Statuses[0].StatusID)
+	}
 
 	suite.checkStreamed(homeStream, true, "", stream.EventTypeFiltersChanged)
 }
@@ -175,7 +281,7 @@ func (suite *FiltersTestSuite) TestPutFilterMinimal() {
 	id := suite.testFilters["local_account_1_filter_1"].ID
 	title := "GNU/Linux"
 	context := []string{"home"}
-	filter, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusOK, "")
+	filter, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusOK, "")
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -196,7 +302,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyTitle() {
 	id := suite.testFilters["local_account_1_filter_1"].ID
 	title := ""
 	context := []string{"home"}
-	_, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter title must be provided, and must be no more than 200 chars"}`)
+	_, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter title must be provided, and must be no more than 200 chars"}`)
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -206,7 +312,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyContext() {
 	id := suite.testFilters["local_account_1_filter_1"].ID
 	title := "GNU/Linux"
 	context := []string{}
-	_, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: at least one filter context is required"}`)
+	_, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: at least one filter context is required"}`)
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -216,7 +322,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyContext() {
 func (suite *FiltersTestSuite) TestPutFilterTitleConflict() {
 	id := suite.testFilters["local_account_1_filter_1"].ID
 	title := suite.testFilters["local_account_1_filter_2"].Title
-	_, err := suite.putFilter(id, &title, nil, nil, nil, nil, http.StatusConflict, `{"error":"Conflict: you already have a filter with this title"}`)
+	_, err := suite.putFilter(id, &title, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusConflict, `{"error":"Conflict: you already have a filter with this title"}`)
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -226,7 +332,7 @@ func (suite *FiltersTestSuite) TestPutAnotherAccountsFilter() {
 	id := suite.testFilters["local_account_2_filter_1"].ID
 	title := "GNU/Linux"
 	context := []string{"home"}
-	_, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
+	_, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -236,7 +342,7 @@ func (suite *FiltersTestSuite) TestPutNonexistentFilter() {
 	id := "not_even_a_real_ULID"
 	phrase := "GNU/Linux"
 	context := []string{"home"}
-	_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
+	_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
diff --git a/internal/api/model/filterv2.go b/internal/api/model/filterv2.go
index 51dabacb2..242c569dc 100644
--- a/internal/api/model/filterv2.go
+++ b/internal/api/model/filterv2.go
@@ -135,9 +135,21 @@ type FilterCreateRequestV2 struct {
 	//
 	// Example: 86400
 	ExpiresInI interface{} `json:"expires_in"`
+
+	// Keywords to be added to the newly created filter.
+	Keywords []FilterKeywordCreateUpdateRequest `form:"-" json:"keywords_attributes" xml:"keywords_attributes"`
+	// Form data version of Keywords[].Keyword.
+	KeywordsAttributesKeyword []string `form:"keywords_attributes[][keyword]" json:"-" xml:"-"`
+	// Form data version of Keywords[].WholeWord.
+	KeywordsAttributesWholeWord []bool `form:"keywords_attributes[][whole_word]" json:"-" xml:"-"`
+
+	// Statuses to be added to the newly created filter.
+	Statuses []FilterStatusCreateRequest `form:"-" json:"statuses_attributes" xml:"statuses_attributes"`
+	// Form data version of Statuses[].StatusID.
+	StatusesAttributesStatusID []string `form:"statuses_attributes[][status_id]" json:"-" xml:"-"`
 }
 
-// FilterKeywordCreateUpdateRequest captures params for creating or updating a filter keyword.
+// FilterKeywordCreateUpdateRequest captures params for creating or updating a filter keyword while creating a v2 filter or as a standalone operation.
 //
 // swagger:ignore
 type FilterKeywordCreateUpdateRequest struct {
@@ -152,7 +164,7 @@ type FilterKeywordCreateUpdateRequest struct {
 	WholeWord *bool `form:"whole_word" json:"whole_word" xml:"whole_word"`
 }
 
-// FilterStatusCreateRequest captures params for creating a filter status.
+// FilterStatusCreateRequest captures params for a status while creating a v2 filter or filter status.
 //
 // swagger:ignore
 type FilterStatusCreateRequest struct {
@@ -188,4 +200,57 @@ type FilterUpdateRequestV2 struct {
 	//
 	// Example: 86400
 	ExpiresInI interface{} `json:"expires_in"`
+
+	// Keywords to be added to the filter, modified, or removed.
+	Keywords []FilterKeywordCreateUpdateDeleteRequest `form:"-" json:"keywords_attributes" xml:"keywords_attributes"`
+	// Form data version of Keywords[].ID.
+	KeywordsAttributesID []string `form:"keywords_attributes[][id]" json:"-" xml:"-"`
+	// Form data version of Keywords[].Keyword.
+	KeywordsAttributesKeyword []string `form:"keywords_attributes[][keyword]" json:"-" xml:"-"`
+	// Form data version of Keywords[].WholeWord.
+	KeywordsAttributesWholeWord []bool `form:"keywords_attributes[][whole_word]" json:"-" xml:"-"`
+	// Form data version of Keywords[].Destroy.
+	KeywordsAttributesDestroy []bool `form:"keywords_attributes[][_destroy]" json:"-" xml:"-"`
+
+	// Statuses to be added to the filter, or removed.
+	Statuses []FilterStatusCreateDeleteRequest `form:"-" json:"statuses_attributes" xml:"statuses_attributes"`
+	// Form data version of Statuses[].ID.
+	StatusesAttributesID []string `form:"statuses_attributes[][id]" json:"-" xml:"-"`
+	// Form data version of Statuses[].ID.
+	StatusesAttributesStatusID []string `form:"statuses_attributes[][status_id]" json:"-" xml:"-"`
+	// Form data version of Statuses[].Destroy.
+	StatusesAttributesDestroy []bool `form:"statuses_attributes[][_destroy]" json:"-" xml:"-"`
+}
+
+// FilterKeywordCreateUpdateDeleteRequest captures params for creating, updating, or deleting a keyword while updating a v2 filter.
+//
+// swagger:ignore
+type FilterKeywordCreateUpdateDeleteRequest struct {
+	// The ID of the filter keyword entry in the database.
+	// Optional: use to modify or delete an existing keyword instead of adding a new one.
+	ID *string `json:"id" xml:"id"`
+	// The text to be filtered.
+	//
+	// Example: fnord
+	// Maximum length: 40
+	Keyword *string `json:"keyword" xml:"keyword"`
+	// Should the filter keyword consider word boundaries?
+	//
+	// Example: true
+	WholeWord *bool `json:"whole_word" xml:"whole_word"`
+	// Remove this filter keyword. Requires an ID.
+	Destroy *bool `json:"_destroy" xml:"_destroy"`
+}
+
+// FilterStatusCreateDeleteRequest captures params for creating or deleting a status while updating a v2 filter.
+//
+// swagger:ignore
+type FilterStatusCreateDeleteRequest struct {
+	// The ID of the filter status entry in the database.
+	// Optional: use to delete an existing status instead of adding a new one.
+	ID *string `json:"id" xml:"id"`
+	// The status ID to be filtered.
+	StatusID *string `json:"status_id" xml:"status_id"`
+	// Remove this filter status. Requires an ID.
+	Destroy *bool `json:"_destroy" xml:"_destroy"`
 }
diff --git a/internal/processing/filters/v2/create.go b/internal/processing/filters/v2/create.go
index d429e1139..7095a643c 100644
--- a/internal/processing/filters/v2/create.go
+++ b/internal/processing/filters/v2/create.go
@@ -63,6 +63,29 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
 		}
 	}
 
+	for _, formKeyword := range form.Keywords {
+		filterKeyword := &gtsmodel.FilterKeyword{
+			ID:        id.NewULID(),
+			AccountID: account.ID,
+			FilterID:  filter.ID,
+			Filter:    filter,
+			Keyword:   formKeyword.Keyword,
+			WholeWord: formKeyword.WholeWord,
+		}
+		filter.Keywords = append(filter.Keywords, filterKeyword)
+	}
+
+	for _, formStatus := range form.Statuses {
+		filterStatus := &gtsmodel.FilterStatus{
+			ID:        id.NewULID(),
+			AccountID: account.ID,
+			FilterID:  filter.ID,
+			Filter:    filter,
+			StatusID:  formStatus.StatusID,
+		}
+		filter.Statuses = append(filter.Statuses, filterStatus)
+	}
+
 	if err := p.state.DB.PutFilter(ctx, filter); err != nil {
 		if errors.Is(err, db.ErrAlreadyExists) {
 			err = errors.New("duplicate title, keyword, or status")
diff --git a/internal/processing/filters/v2/update.go b/internal/processing/filters/v2/update.go
index 5322f63d9..d8297de38 100644
--- a/internal/processing/filters/v2/update.go
+++ b/internal/processing/filters/v2/update.go
@@ -27,6 +27,7 @@ import (
 	"github.com/superseriousbusiness/gotosocial/internal/db"
 	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/id"
 	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
 	"github.com/superseriousbusiness/gotosocial/internal/util"
 )
@@ -39,6 +40,8 @@ func (p *Processor) Update(
 	filterID string,
 	form *apimodel.FilterUpdateRequestV2,
 ) (*apimodel.FilterV2, gtserror.WithCode) {
+	var errWithCode gtserror.WithCode
+
 	// Get the filter by ID, with existing keywords and statuses.
 	filter, err := p.state.DB.GetFilterByID(ctx, filterID)
 	if err != nil {
@@ -103,13 +106,17 @@ func (p *Processor) Update(
 		}
 	}
 
-	// Temporarily detach keywords and statuses from filter, since we're not updating them below.
-	filterKeywords := filter.Keywords
-	filterStatuses := filter.Statuses
-	filter.Keywords = nil
-	filter.Statuses = nil
+	filterKeywordColumns, deleteFilterKeywordIDs, errWithCode := applyKeywordChanges(filter, form.Keywords)
+	if err != nil {
+		return nil, errWithCode
+	}
 
-	if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, nil, nil, nil); err != nil {
+	deleteFilterStatusIDs, errWithCode := applyStatusChanges(filter, form.Statuses)
+	if err != nil {
+		return nil, errWithCode
+	}
+
+	if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, filterKeywordColumns, deleteFilterKeywordIDs, deleteFilterStatusIDs); err != nil {
 		if errors.Is(err, db.ErrAlreadyExists) {
 			err = errors.New("you already have a filter with this title")
 			return nil, gtserror.NewErrorConflict(err, err.Error())
@@ -117,10 +124,6 @@ func (p *Processor) Update(
 		return nil, gtserror.NewErrorInternalError(err)
 	}
 
-	// Re-attach keywords and statuses before returning.
-	filter.Keywords = filterKeywords
-	filter.Statuses = filterStatuses
-
 	apiFilter, errWithCode := p.apiFilter(ctx, filter)
 	if errWithCode != nil {
 		return nil, errWithCode
@@ -131,3 +134,131 @@ func (p *Processor) Update(
 
 	return apiFilter, nil
 }
+
+// applyKeywordChanges applies the provided changes to the filter's keywords in place,
+// and returns a list of lists of filter columns to update, and a list of filter keyword IDs to delete.
+func applyKeywordChanges(filter *gtsmodel.Filter, formKeywords []apimodel.FilterKeywordCreateUpdateDeleteRequest) ([][]string, []string, gtserror.WithCode) {
+	if len(formKeywords) == 0 {
+		// Detach currently existing keywords from the filter so we don't change them.
+		filter.Keywords = nil
+		return nil, nil, nil
+	}
+
+	deleteFilterKeywordIDs := []string{}
+	filterKeywordsByID := map[string]*gtsmodel.FilterKeyword{}
+	filterKeywordColumnsByID := map[string][]string{}
+	for _, filterKeyword := range filter.Keywords {
+		filterKeywordsByID[filterKeyword.ID] = filterKeyword
+	}
+
+	for _, formKeyword := range formKeywords {
+		if formKeyword.ID != nil {
+			id := *formKeyword.ID
+			filterKeyword, ok := filterKeywordsByID[id]
+			if !ok {
+				return nil, nil, gtserror.NewErrorNotFound(
+					fmt.Errorf("couldn't find filter keyword '%s' to update or delete", id),
+				)
+			}
+
+			// Process deletes.
+			if *formKeyword.Destroy {
+				delete(filterKeywordsByID, id)
+				deleteFilterKeywordIDs = append(deleteFilterKeywordIDs, id)
+				continue
+			}
+
+			// Process updates.
+			columns := make([]string, 0, 2)
+			if formKeyword.Keyword != nil {
+				columns = append(columns, "keyword")
+				filterKeyword.Keyword = *formKeyword.Keyword
+			}
+			if formKeyword.WholeWord != nil {
+				columns = append(columns, "whole_word")
+				filterKeyword.WholeWord = formKeyword.WholeWord
+			}
+			filterKeywordColumnsByID[id] = columns
+			continue
+		}
+
+		// Process creates.
+		filterKeyword := &gtsmodel.FilterKeyword{
+			ID:        id.NewULID(),
+			AccountID: filter.AccountID,
+			FilterID:  filter.ID,
+			Filter:    filter,
+			Keyword:   *formKeyword.Keyword,
+			WholeWord: util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false)),
+		}
+		filterKeywordsByID[filterKeyword.ID] = filterKeyword
+		// Don't need to set columns, as we're using all of them.
+	}
+
+	// Replace the filter's keywords list with our updated version.
+	filterKeywordColumns := [][]string{}
+	filter.Keywords = nil
+	for id, filterKeyword := range filterKeywordsByID {
+		filter.Keywords = append(filter.Keywords, filterKeyword)
+		// Okay to use the nil slice zero value for entries being created instead of updated.
+		filterKeywordColumns = append(filterKeywordColumns, filterKeywordColumnsByID[id])
+	}
+
+	return filterKeywordColumns, deleteFilterKeywordIDs, nil
+}
+
+// applyKeywordChanges applies the provided changes to the filter's keywords in place,
+// and returns a list of filter status IDs to delete.
+func applyStatusChanges(filter *gtsmodel.Filter, formStatuses []apimodel.FilterStatusCreateDeleteRequest) ([]string, gtserror.WithCode) {
+	if len(formStatuses) == 0 {
+		// Detach currently existing statuses from the filter so we don't change them.
+		filter.Statuses = nil
+		return nil, nil
+	}
+
+	deleteFilterStatusIDs := []string{}
+	filterStatusesByID := map[string]*gtsmodel.FilterStatus{}
+	for _, filterStatus := range filter.Statuses {
+		filterStatusesByID[filterStatus.ID] = filterStatus
+	}
+
+	for _, formStatus := range formStatuses {
+		if formStatus.ID != nil {
+			id := *formStatus.ID
+			_, ok := filterStatusesByID[id]
+			if !ok {
+				return nil, gtserror.NewErrorNotFound(
+					fmt.Errorf("couldn't find filter status '%s' to delete", id),
+				)
+			}
+
+			// Process deletes.
+			if *formStatus.Destroy {
+				delete(filterStatusesByID, id)
+				deleteFilterStatusIDs = append(deleteFilterStatusIDs, id)
+				continue
+			}
+
+			// Filter statuses don't have updates.
+			continue
+		}
+
+		// Process creates.
+		filterStatus := &gtsmodel.FilterStatus{
+			ID:        id.NewULID(),
+			AccountID: filter.AccountID,
+			FilterID:  filter.ID,
+			Filter:    filter,
+			StatusID:  *formStatus.StatusID,
+		}
+		filterStatusesByID[filterStatus.ID] = filterStatus
+	}
+
+	// Replace the filter's keywords list with our updated version.
+	filter.Statuses = nil
+	for _, filterStatus := range filterStatusesByID {
+		filter.Statuses = append(filter.Statuses, filterStatus)
+	}
+
+	return deleteFilterStatusIDs, nil
+}