diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5167b4f5..ec191575f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - **Breaking**: SimplePolicy `:reject` and `:accept` allow deletions again
 - Fix follower/blocks import when nicknames starts with @
 - Filtering of push notifications on activities from blocked domains
+- Resolving Peertube accounts with Webfinger
 
 ## [unreleased-patch]
 ### Security
@@ -48,6 +49,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Logger configuration through AdminFE
 - HTTP Basic Authentication permissions issue
 - ObjectAgePolicy didn't filter out old messages
+- Transmogrifier: Keep object sensitive settings for outgoing representation (AP C2S)
 
 ### Added
 - NodeInfo: ObjectAgePolicy settings to the `federation` list.
diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex
index 215265fc9..51bb1bda9 100644
--- a/lib/pleroma/conversation/participation.ex
+++ b/lib/pleroma/conversation/participation.ex
@@ -128,7 +128,7 @@ defmodule Pleroma.Conversation.Participation do
     |> Pleroma.Pagination.fetch_paginated(params)
   end
 
-  def restrict_recipients(query, user, %{"recipients" => user_ids}) do
+  def restrict_recipients(query, user, %{recipients: user_ids}) do
     user_binary_ids =
       [user.id | user_ids]
       |> Enum.uniq()
@@ -172,7 +172,7 @@ defmodule Pleroma.Conversation.Participation do
         | last_activity_id: activity_id
       }
     end)
-    |> Enum.filter(& &1.last_activity_id)
+    |> Enum.reject(&is_nil(&1.last_activity_id))
   end
 
   def get(_, _ \\ [])
diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex
index 7cb49360f..4d61b3650 100644
--- a/lib/pleroma/filter.ex
+++ b/lib/pleroma/filter.ex
@@ -89,11 +89,10 @@ defmodule Pleroma.Filter do
     |> Repo.delete()
   end
 
-  def update(%Pleroma.Filter{} = filter) do
-    destination = Map.from_struct(filter)
-
-    Pleroma.Filter.get(filter.filter_id, %{id: filter.user_id})
-    |> cast(destination, [:phrase, :context, :hide, :expires_at, :whole_word])
+  def update(%Pleroma.Filter{} = filter, params) do
+    filter
+    |> cast(params, [:phrase, :context, :hide, :expires_at, :whole_word])
+    |> validate_required([:phrase, :context])
     |> Repo.update()
   end
 end
diff --git a/lib/pleroma/plugs/ensure_authenticated_plug.ex b/lib/pleroma/plugs/ensure_authenticated_plug.ex
index 9c8f5597f..9d5176e2b 100644
--- a/lib/pleroma/plugs/ensure_authenticated_plug.ex
+++ b/lib/pleroma/plugs/ensure_authenticated_plug.ex
@@ -19,22 +19,7 @@ defmodule Pleroma.Plugs.EnsureAuthenticatedPlug do
     conn
   end
 
-  def perform(conn, options) do
-    perform =
-      cond do
-        options[:if_func] -> options[:if_func].()
-        options[:unless_func] -> !options[:unless_func].()
-        true -> true
-      end
-
-    if perform do
-      fail(conn)
-    else
-      conn
-    end
-  end
-
-  def fail(conn) do
+  def perform(conn, _) do
     conn
     |> render_error(:forbidden, "Invalid credentials.")
     |> halt()
diff --git a/lib/pleroma/plugs/federating_plug.ex b/lib/pleroma/plugs/federating_plug.ex
index 7d947339f..09038f3c6 100644
--- a/lib/pleroma/plugs/federating_plug.ex
+++ b/lib/pleroma/plugs/federating_plug.ex
@@ -19,6 +19,9 @@ defmodule Pleroma.Web.FederatingPlug do
 
   def federating?, do: Pleroma.Config.get([:instance, :federating])
 
+  # Definition for the use in :if_func / :unless_func plug options
+  def federating?(_conn), do: federating?()
+
   defp fail(conn) do
     conn
     |> put_status(404)
diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex
index 8d2809bbb..6b3a8a41f 100644
--- a/lib/pleroma/stats.ex
+++ b/lib/pleroma/stats.ex
@@ -91,7 +91,7 @@ defmodule Pleroma.Stats do
       peers: peers,
       stats: %{
         domain_count: domain_count,
-        status_count: status_count,
+        status_count: status_count || 0,
         user_count: user_count
       }
     }
diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex
index ac77aab71..3a3b04793 100644
--- a/lib/pleroma/user/query.ex
+++ b/lib/pleroma/user/query.ex
@@ -45,6 +45,7 @@ defmodule Pleroma.User.Query do
             is_admin: boolean(),
             is_moderator: boolean(),
             super_users: boolean(),
+            exclude_service_users: boolean(),
             followers: User.t(),
             friends: User.t(),
             recipients_from_activity: [String.t()],
@@ -88,6 +89,10 @@ defmodule Pleroma.User.Query do
     where(query, [u], ilike(field(u, ^key), ^"%#{value}%"))
   end
 
+  defp compose_query({:exclude_service_users, _}, query) do
+    where(query, [u], not like(u.ap_id, "%/relay") and not like(u.ap_id, "%/internal/fetch"))
+  end
+
   defp compose_query({key, value}, query)
        when key in @equal_criteria and not_empty_string(value) do
     where(query, [u], ^[{key, value}])
@@ -98,7 +103,7 @@ defmodule Pleroma.User.Query do
   end
 
   defp compose_query({:tags, tags}, query) when is_list(tags) and length(tags) > 0 do
-    Enum.reduce(tags, query, &prepare_tag_criteria/2)
+    where(query, [u], fragment("? && ?", u.tags, ^tags))
   end
 
   defp compose_query({:is_admin, _}, query) do
@@ -192,10 +197,6 @@ defmodule Pleroma.User.Query do
 
   defp compose_query(_unsupported_param, query), do: query
 
-  defp prepare_tag_criteria(tag, query) do
-    or_where(query, [u], fragment("? = any(?)", ^tag, u.tags))
-  end
-
   defp location_query(query, local) do
     where(query, [u], u.local == ^local)
     |> where([u], not is_nil(u.nickname))
diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex
index f607931ab..ab9d1e0b7 100644
--- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex
@@ -34,7 +34,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
 
   plug(
     EnsureAuthenticatedPlug,
-    [unless_func: &FederatingPlug.federating?/0] when action not in @federating_only_actions
+    [unless_func: &FederatingPlug.federating?/1] when action not in @federating_only_actions
   )
 
   # Note: :following and :followers must be served even without authentication (as via :api)
diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex
index 581e7040b..3a4d364e7 100644
--- a/lib/pleroma/web/activity_pub/transmogrifier.ex
+++ b/lib/pleroma/web/activity_pub/transmogrifier.ex
@@ -1195,6 +1195,10 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     Map.put(object, "conversation", object["context"])
   end
 
+  def set_sensitive(%{"sensitive" => true} = object) do
+    object
+  end
+
   def set_sensitive(object) do
     tags = object["tag"] || []
     Map.put(object, "sensitive", "nsfw" in tags)
diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex
index ac661e515..da71e63d9 100644
--- a/lib/pleroma/web/admin_api/admin_api_controller.ex
+++ b/lib/pleroma/web/admin_api/admin_api_controller.ex
@@ -392,29 +392,12 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
       email: params["email"]
     }
 
-    with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)),
-         {:ok, users, count} <- filter_service_users(users, count),
-         do:
-           conn
-           |> json(
-             AccountView.render("index.json",
-               users: users,
-               count: count,
-               page_size: page_size
-             )
-           )
-  end
-
-  defp filter_service_users(users, count) do
-    filtered_users = Enum.reject(users, &service_user?/1)
-    count = if Enum.any?(users, &service_user?/1), do: length(filtered_users), else: count
-
-    {:ok, filtered_users, count}
-  end
-
-  defp service_user?(user) do
-    String.match?(user.ap_id, ~r/.*\/relay$/) or
-      String.match?(user.ap_id, ~r/.*\/internal\/fetch$/)
+    with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)) do
+      json(
+        conn,
+        AccountView.render("index.json", users: users, count: count, page_size: page_size)
+      )
+    end
   end
 
   @filters ~w(local external active deactivated is_admin is_moderator)
diff --git a/lib/pleroma/web/admin_api/search.ex b/lib/pleroma/web/admin_api/search.ex
index 29cea1f44..c28efadd5 100644
--- a/lib/pleroma/web/admin_api/search.ex
+++ b/lib/pleroma/web/admin_api/search.ex
@@ -21,6 +21,7 @@ defmodule Pleroma.Web.AdminAPI.Search do
     query =
       params
       |> Map.drop([:page, :page_size])
+      |> Map.put(:exclude_service_users, true)
       |> User.Query.build()
       |> order_by([u], u.nickname)
 
diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex
index b3c1e3ea2..79fd5f871 100644
--- a/lib/pleroma/web/api_spec.ex
+++ b/lib/pleroma/web/api_spec.ex
@@ -39,7 +39,12 @@ defmodule Pleroma.Web.ApiSpec do
               password: %OpenApiSpex.OAuthFlow{
                 authorizationUrl: "/oauth/authorize",
                 tokenUrl: "/oauth/token",
-                scopes: %{"read" => "read", "write" => "write", "follow" => "follow"}
+                scopes: %{
+                  "read" => "read",
+                  "write" => "write",
+                  "follow" => "follow",
+                  "push" => "push"
+                }
               }
             }
           }
diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex
index fe9548b1b..470fc0215 100644
--- a/lib/pleroma/web/api_spec/operations/account_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/account_operation.ex
@@ -11,6 +11,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do
   alias Pleroma.Web.ApiSpec.Schemas.ActorType
   alias Pleroma.Web.ApiSpec.Schemas.ApiError
   alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
+  alias Pleroma.Web.ApiSpec.Schemas.List
   alias Pleroma.Web.ApiSpec.Schemas.Status
   alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
 
@@ -646,28 +647,12 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do
     }
   end
 
-  defp list do
-    %Schema{
-      title: "List",
-      description: "Response schema for a list",
-      type: :object,
-      properties: %{
-        id: %Schema{type: :string},
-        title: %Schema{type: :string}
-      },
-      example: %{
-        "id" => "123",
-        "title" => "my list"
-      }
-    }
-  end
-
   defp array_of_lists do
     %Schema{
       title: "ArrayOfLists",
       description: "Response schema for lists",
       type: :array,
-      items: list(),
+      items: List,
       example: [
         %{"id" => "123", "title" => "my list"},
         %{"id" => "1337", "title" => "anotehr list"}
diff --git a/lib/pleroma/web/api_spec/operations/conversation_operation.ex b/lib/pleroma/web/api_spec/operations/conversation_operation.ex
new file mode 100644
index 000000000..475468893
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/conversation_operation.ex
@@ -0,0 +1,61 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.ConversationOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.Conversation
+  alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+
+  import Pleroma.Web.ApiSpec.Helpers
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["Conversations"],
+      summary: "Show conversation",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      operationId: "ConversationController.index",
+      parameters: [
+        Operation.parameter(
+          :recipients,
+          :query,
+          %Schema{type: :array, items: FlakeID},
+          "Only return conversations with the given recipients (a list of user ids)"
+        )
+        | pagination_params()
+      ],
+      responses: %{
+        200 =>
+          Operation.response("Array of Conversation", "application/json", %Schema{
+            type: :array,
+            items: Conversation,
+            example: [Conversation.schema().example]
+          })
+      }
+    }
+  end
+
+  def mark_as_read_operation do
+    %Operation{
+      tags: ["Conversations"],
+      summary: "Mark as read",
+      operationId: "ConversationController.mark_as_read",
+      parameters: [
+        Operation.parameter(:id, :path, :string, "Conversation ID",
+          example: "123",
+          required: true
+        )
+      ],
+      security: [%{"oAuth" => ["write:conversations"]}],
+      responses: %{
+        200 => Operation.response("Conversation", "application/json", Conversation)
+      }
+    }
+  end
+end
diff --git a/lib/pleroma/web/api_spec/operations/filter_operation.ex b/lib/pleroma/web/api_spec/operations/filter_operation.ex
new file mode 100644
index 000000000..53e57b46b
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/filter_operation.ex
@@ -0,0 +1,227 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.FilterOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Helpers
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["apps"],
+      summary: "View all filters",
+      operationId: "FilterController.index",
+      security: [%{"oAuth" => ["read:filters"]}],
+      responses: %{
+        200 => Operation.response("Filters", "application/json", array_of_filters())
+      }
+    }
+  end
+
+  def create_operation do
+    %Operation{
+      tags: ["apps"],
+      summary: "Create a filter",
+      operationId: "FilterController.create",
+      requestBody: Helpers.request_body("Parameters", create_request(), required: true),
+      security: [%{"oAuth" => ["write:filters"]}],
+      responses: %{200 => Operation.response("Filter", "application/json", filter())}
+    }
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["apps"],
+      summary: "View all filters",
+      parameters: [id_param()],
+      operationId: "FilterController.show",
+      security: [%{"oAuth" => ["read:filters"]}],
+      responses: %{
+        200 => Operation.response("Filter", "application/json", filter())
+      }
+    }
+  end
+
+  def update_operation do
+    %Operation{
+      tags: ["apps"],
+      summary: "Update a filter",
+      parameters: [id_param()],
+      operationId: "FilterController.update",
+      requestBody: Helpers.request_body("Parameters", update_request(), required: true),
+      security: [%{"oAuth" => ["write:filters"]}],
+      responses: %{
+        200 => Operation.response("Filter", "application/json", filter())
+      }
+    }
+  end
+
+  def delete_operation do
+    %Operation{
+      tags: ["apps"],
+      summary: "Remove a filter",
+      parameters: [id_param()],
+      operationId: "FilterController.delete",
+      security: [%{"oAuth" => ["write:filters"]}],
+      responses: %{
+        200 =>
+          Operation.response("Filter", "application/json", %Schema{
+            type: :object,
+            description: "Empty object"
+          })
+      }
+    }
+  end
+
+  defp id_param do
+    Operation.parameter(:id, :path, :string, "Filter ID", example: "123", required: true)
+  end
+
+  defp filter do
+    %Schema{
+      title: "Filter",
+      type: :object,
+      properties: %{
+        id: %Schema{type: :string},
+        phrase: %Schema{type: :string, description: "The text to be filtered"},
+        context: %Schema{
+          type: :array,
+          items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]},
+          description: "The contexts in which the filter should be applied."
+        },
+        expires_at: %Schema{
+          type: :string,
+          format: :"date-time",
+          description:
+            "When the filter should no longer be applied. String (ISO 8601 Datetime), or null if the filter does not expire.",
+          nullable: true
+        },
+        irreversible: %Schema{
+          type: :boolean,
+          description:
+            "Should matching entities in home and notifications be dropped by the server?"
+        },
+        whole_word: %Schema{
+          type: :boolean,
+          description: "Should the filter consider word boundaries?"
+        }
+      },
+      example: %{
+        "id" => "5580",
+        "phrase" => "@twitter.com",
+        "context" => [
+          "home",
+          "notifications",
+          "public",
+          "thread"
+        ],
+        "whole_word" => false,
+        "expires_at" => nil,
+        "irreversible" => true
+      }
+    }
+  end
+
+  defp array_of_filters do
+    %Schema{
+      title: "ArrayOfFilters",
+      description: "Array of Filters",
+      type: :array,
+      items: filter(),
+      example: [
+        %{
+          "id" => "5580",
+          "phrase" => "@twitter.com",
+          "context" => [
+            "home",
+            "notifications",
+            "public",
+            "thread"
+          ],
+          "whole_word" => false,
+          "expires_at" => nil,
+          "irreversible" => true
+        },
+        %{
+          "id" => "6191",
+          "phrase" => ":eurovision2019:",
+          "context" => [
+            "home"
+          ],
+          "whole_word" => true,
+          "expires_at" => "2019-05-21T13:47:31.333Z",
+          "irreversible" => false
+        }
+      ]
+    }
+  end
+
+  defp create_request do
+    %Schema{
+      title: "FilterCreateRequest",
+      allOf: [
+        update_request(),
+        %Schema{
+          type: :object,
+          properties: %{
+            irreversible: %Schema{
+              type: :bolean,
+              description:
+                "Should the server irreversibly drop matching entities from home and notifications?",
+              default: false
+            }
+          }
+        }
+      ],
+      example: %{
+        "phrase" => "knights",
+        "context" => ["home"]
+      }
+    }
+  end
+
+  defp update_request do
+    %Schema{
+      title: "FilterUpdateRequest",
+      type: :object,
+      properties: %{
+        phrase: %Schema{type: :string, description: "The text to be filtered"},
+        context: %Schema{
+          type: :array,
+          items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]},
+          description:
+            "Array of enumerable strings `home`, `notifications`, `public`, `thread`. At least one context must be specified."
+        },
+        irreversible: %Schema{
+          type: :bolean,
+          description:
+            "Should the server irreversibly drop matching entities from home and notifications?"
+        },
+        whole_word: %Schema{
+          type: :bolean,
+          description: "Consider word boundaries?",
+          default: true
+        }
+        # TODO: probably should implement filter expiration
+        # expires_in: %Schema{
+        #   type: :string,
+        #   format: :"date-time",
+        #   description:
+        #     "ISO 8601 Datetime for when the filter expires. Otherwise,
+        #  null for a filter that doesn't expire."
+        # }
+      },
+      required: [:phrase, :context],
+      example: %{
+        "phrase" => "knights",
+        "context" => ["home"]
+      }
+    }
+  end
+end
diff --git a/lib/pleroma/web/api_spec/operations/follow_request_operation.ex b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex
new file mode 100644
index 000000000..ac4aee6da
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex
@@ -0,0 +1,65 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.FollowRequestOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.Account
+  alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["Follow Requests"],
+      summary: "Pending Follows",
+      security: [%{"oAuth" => ["read:follows", "follow"]}],
+      operationId: "FollowRequestController.index",
+      responses: %{
+        200 =>
+          Operation.response("Array of Account", "application/json", %Schema{
+            type: :array,
+            items: Account,
+            example: [Account.schema().example]
+          })
+      }
+    }
+  end
+
+  def authorize_operation do
+    %Operation{
+      tags: ["Follow Requests"],
+      summary: "Accept Follow",
+      operationId: "FollowRequestController.authorize",
+      parameters: [id_param()],
+      security: [%{"oAuth" => ["follow", "write:follows"]}],
+      responses: %{
+        200 => Operation.response("Relationship", "application/json", AccountRelationship)
+      }
+    }
+  end
+
+  def reject_operation do
+    %Operation{
+      tags: ["Follow Requests"],
+      summary: "Reject Follow",
+      operationId: "FollowRequestController.reject",
+      parameters: [id_param()],
+      security: [%{"oAuth" => ["follow", "write:follows"]}],
+      responses: %{
+        200 => Operation.response("Relationship", "application/json", AccountRelationship)
+      }
+    }
+  end
+
+  defp id_param do
+    Operation.parameter(:id, :path, :string, "Conversation ID",
+      example: "123",
+      required: true
+    )
+  end
+end
diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex
new file mode 100644
index 000000000..880bd3f1b
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex
@@ -0,0 +1,169 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.InstanceOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["Instance"],
+      summary: "Fetch instance",
+      description: "Information about the server",
+      operationId: "InstanceController.show",
+      responses: %{
+        200 => Operation.response("Instance", "application/json", instance())
+      }
+    }
+  end
+
+  def peers_operation do
+    %Operation{
+      tags: ["Instance"],
+      summary: "List of known hosts",
+      operationId: "InstanceController.peers",
+      responses: %{
+        200 => Operation.response("Array of domains", "application/json", array_of_domains())
+      }
+    }
+  end
+
+  defp instance do
+    %Schema{
+      type: :object,
+      properties: %{
+        uri: %Schema{type: :string, description: "The domain name of the instance"},
+        title: %Schema{type: :string, description: "The title of the website"},
+        description: %Schema{
+          type: :string,
+          description: "Admin-defined description of the Pleroma site"
+        },
+        version: %Schema{
+          type: :string,
+          description: "The version of Pleroma installed on the instance"
+        },
+        email: %Schema{
+          type: :string,
+          description: "An email that may be contacted for any inquiries",
+          format: :email
+        },
+        urls: %Schema{
+          type: :object,
+          description: "URLs of interest for clients apps",
+          properties: %{
+            streaming_api: %Schema{
+              type: :string,
+              description: "Websockets address for push streaming"
+            }
+          }
+        },
+        stats: %Schema{
+          type: :object,
+          description: "Statistics about how much information the instance contains",
+          properties: %{
+            user_count: %Schema{
+              type: :integer,
+              description: "Users registered on this instance"
+            },
+            status_count: %Schema{
+              type: :integer,
+              description: "Statuses authored by users on instance"
+            },
+            domain_count: %Schema{
+              type: :integer,
+              description: "Domains federated with this instance"
+            }
+          }
+        },
+        thumbnail: %Schema{
+          type: :string,
+          description: "Banner image for the website",
+          nullable: true
+        },
+        languages: %Schema{
+          type: :array,
+          items: %Schema{type: :string},
+          description: "Primary langauges of the website and its staff"
+        },
+        registrations: %Schema{type: :boolean, description: "Whether registrations are enabled"},
+        # Extra (not present in Mastodon):
+        max_toot_chars: %Schema{
+          type: :integer,
+          description: ": Posts character limit (CW/Subject included in the counter)"
+        },
+        poll_limits: %Schema{
+          type: :object,
+          description: "A map with poll limits for local polls",
+          properties: %{
+            max_options: %Schema{
+              type: :integer,
+              description: "Maximum number of options."
+            },
+            max_option_chars: %Schema{
+              type: :integer,
+              description: "Maximum number of characters per option."
+            },
+            min_expiration: %Schema{
+              type: :integer,
+              description: "Minimum expiration time (in seconds)."
+            },
+            max_expiration: %Schema{
+              type: :integer,
+              description: "Maximum expiration time (in seconds)."
+            }
+          }
+        },
+        upload_limit: %Schema{
+          type: :integer,
+          description: "File size limit of uploads (except for avatar, background, banner)"
+        },
+        avatar_upload_limit: %Schema{type: :integer, description: "The title of the website"},
+        background_upload_limit: %Schema{type: :integer, description: "The title of the website"},
+        banner_upload_limit: %Schema{type: :integer, description: "The title of the website"}
+      },
+      example: %{
+        "avatar_upload_limit" => 2_000_000,
+        "background_upload_limit" => 4_000_000,
+        "banner_upload_limit" => 4_000_000,
+        "description" => "A Pleroma instance, an alternative fediverse server",
+        "email" => "lain@lain.com",
+        "languages" => ["en"],
+        "max_toot_chars" => 5000,
+        "poll_limits" => %{
+          "max_expiration" => 31_536_000,
+          "max_option_chars" => 200,
+          "max_options" => 20,
+          "min_expiration" => 0
+        },
+        "registrations" => false,
+        "stats" => %{
+          "domain_count" => 2996,
+          "status_count" => 15_802,
+          "user_count" => 5
+        },
+        "thumbnail" => "https://lain.com/instance/thumbnail.jpeg",
+        "title" => "lain.com",
+        "upload_limit" => 16_000_000,
+        "uri" => "https://lain.com",
+        "urls" => %{
+          "streaming_api" => "wss://lain.com"
+        },
+        "version" => "2.7.2 (compatible; Pleroma 2.0.50-536-g25eec6d7-develop)"
+      }
+    }
+  end
+
+  defp array_of_domains do
+    %Schema{
+      type: :array,
+      items: %Schema{type: :string},
+      example: ["pleroma.site", "lain.com", "bikeshed.party"]
+    }
+  end
+end
diff --git a/lib/pleroma/web/api_spec/operations/list_operation.ex b/lib/pleroma/web/api_spec/operations/list_operation.ex
new file mode 100644
index 000000000..c88ed5dd0
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/list_operation.ex
@@ -0,0 +1,188 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.ListOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.Account
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+  alias Pleroma.Web.ApiSpec.Schemas.List
+
+  import Pleroma.Web.ApiSpec.Helpers
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Show user's lists",
+      description: "Fetch all lists that the user owns",
+      security: [%{"oAuth" => ["read:lists"]}],
+      operationId: "ListController.index",
+      responses: %{
+        200 => Operation.response("Array of List", "application/json", array_of_lists())
+      }
+    }
+  end
+
+  def create_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Create  a list",
+      description: "Fetch the list with the given ID. Used for verifying the title of a list.",
+      operationId: "ListController.create",
+      requestBody: create_update_request(),
+      security: [%{"oAuth" => ["write:lists"]}],
+      responses: %{
+        200 => Operation.response("List", "application/json", List),
+        400 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Show a single list",
+      description: "Fetch the list with the given ID. Used for verifying the title of a list.",
+      operationId: "ListController.show",
+      parameters: [id_param()],
+      security: [%{"oAuth" => ["read:lists"]}],
+      responses: %{
+        200 => Operation.response("List", "application/json", List),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def update_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Update a list",
+      description: "Change the title of a list",
+      operationId: "ListController.update",
+      parameters: [id_param()],
+      requestBody: create_update_request(),
+      security: [%{"oAuth" => ["write:lists"]}],
+      responses: %{
+        200 => Operation.response("List", "application/json", List),
+        422 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def delete_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Delete a list",
+      operationId: "ListController.delete",
+      parameters: [id_param()],
+      security: [%{"oAuth" => ["write:lists"]}],
+      responses: %{
+        200 => Operation.response("Empty object", "application/json", %Schema{type: :object})
+      }
+    }
+  end
+
+  def list_accounts_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "View accounts in list",
+      operationId: "ListController.list_accounts",
+      parameters: [id_param()],
+      security: [%{"oAuth" => ["read:lists"]}],
+      responses: %{
+        200 =>
+          Operation.response("Array of Account", "application/json", %Schema{
+            type: :array,
+            items: Account
+          })
+      }
+    }
+  end
+
+  def add_to_list_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Add accounts to list",
+      description: "Add accounts to the given list.",
+      operationId: "ListController.add_to_list",
+      parameters: [id_param()],
+      requestBody: add_remove_accounts_request(),
+      security: [%{"oAuth" => ["write:lists"]}],
+      responses: %{
+        200 => Operation.response("Empty object", "application/json", %Schema{type: :object})
+      }
+    }
+  end
+
+  def remove_from_list_operation do
+    %Operation{
+      tags: ["Lists"],
+      summary: "Remove accounts from list",
+      operationId: "ListController.remove_from_list",
+      parameters: [id_param()],
+      requestBody: add_remove_accounts_request(),
+      security: [%{"oAuth" => ["write:lists"]}],
+      responses: %{
+        200 => Operation.response("Empty object", "application/json", %Schema{type: :object})
+      }
+    }
+  end
+
+  defp array_of_lists do
+    %Schema{
+      title: "ArrayOfLists",
+      description: "Response schema for lists",
+      type: :array,
+      items: List,
+      example: [
+        %{"id" => "123", "title" => "my list"},
+        %{"id" => "1337", "title" => "another list"}
+      ]
+    }
+  end
+
+  defp id_param do
+    Operation.parameter(:id, :path, :string, "List ID",
+      example: "123",
+      required: true
+    )
+  end
+
+  defp create_update_request do
+    request_body(
+      "Parameters",
+      %Schema{
+        description: "POST body for creating or updating a List",
+        type: :object,
+        properties: %{
+          title: %Schema{type: :string, description: "List title"}
+        },
+        required: [:title]
+      },
+      required: true
+    )
+  end
+
+  defp add_remove_accounts_request do
+    request_body(
+      "Parameters",
+      %Schema{
+        description: "POST body for adding/removing accounts to/from a List",
+        type: :object,
+        properties: %{
+          account_ids: %Schema{type: :array, description: "Array of account IDs", items: FlakeID}
+        },
+        required: [:account_ids]
+      },
+      required: true
+    )
+  end
+end
diff --git a/lib/pleroma/web/api_spec/operations/marker_operation.ex b/lib/pleroma/web/api_spec/operations/marker_operation.ex
new file mode 100644
index 000000000..06620492a
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/marker_operation.ex
@@ -0,0 +1,140 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.MarkerOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Helpers
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["Markers"],
+      summary: "Get saved timeline position",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      operationId: "MarkerController.index",
+      parameters: [
+        Operation.parameter(
+          :timeline,
+          :query,
+          %Schema{
+            type: :array,
+            items: %Schema{type: :string, enum: ["home", "notifications"]}
+          },
+          "Array of markers to fetch. If not provided, an empty object will be returned."
+        )
+      ],
+      responses: %{
+        200 => Operation.response("Marker", "application/json", response()),
+        403 => Operation.response("Error", "application/json", api_error())
+      }
+    }
+  end
+
+  def upsert_operation do
+    %Operation{
+      tags: ["Markers"],
+      summary: "Save position in timeline",
+      operationId: "MarkerController.upsert",
+      requestBody: Helpers.request_body("Parameters", upsert_request(), required: true),
+      security: [%{"oAuth" => ["follow", "write:blocks"]}],
+      responses: %{
+        200 => Operation.response("Marker", "application/json", response()),
+        403 => Operation.response("Error", "application/json", api_error())
+      }
+    }
+  end
+
+  defp marker do
+    %Schema{
+      title: "Marker",
+      description: "Schema for a marker",
+      type: :object,
+      properties: %{
+        last_read_id: %Schema{type: :string},
+        version: %Schema{type: :integer},
+        updated_at: %Schema{type: :string},
+        pleroma: %Schema{
+          type: :object,
+          properties: %{
+            unread_count: %Schema{type: :integer}
+          }
+        }
+      },
+      example: %{
+        "last_read_id" => "35098814",
+        "version" => 361,
+        "updated_at" => "2019-11-26T22:37:25.239Z",
+        "pleroma" => %{"unread_count" => 5}
+      }
+    }
+  end
+
+  defp response do
+    %Schema{
+      title: "MarkersResponse",
+      description: "Response schema for markers",
+      type: :object,
+      properties: %{
+        notifications: %Schema{allOf: [marker()], nullable: true},
+        home: %Schema{allOf: [marker()], nullable: true}
+      },
+      items: %Schema{type: :string},
+      example: %{
+        "notifications" => %{
+          "last_read_id" => "35098814",
+          "version" => 361,
+          "updated_at" => "2019-11-26T22:37:25.239Z",
+          "pleroma" => %{"unread_count" => 0}
+        },
+        "home" => %{
+          "last_read_id" => "103206604258487607",
+          "version" => 468,
+          "updated_at" => "2019-11-26T22:37:25.235Z",
+          "pleroma" => %{"unread_count" => 10}
+        }
+      }
+    }
+  end
+
+  defp upsert_request do
+    %Schema{
+      title: "MarkersUpsertRequest",
+      description: "Request schema for marker upsert",
+      type: :object,
+      properties: %{
+        notifications: %Schema{
+          type: :object,
+          properties: %{
+            last_read_id: %Schema{type: :string}
+          }
+        },
+        home: %Schema{
+          type: :object,
+          properties: %{
+            last_read_id: %Schema{type: :string}
+          }
+        }
+      },
+      example: %{
+        "home" => %{
+          "last_read_id" => "103194548672408537",
+          "version" => 462,
+          "updated_at" => "2019-11-24T19:39:39.337Z"
+        }
+      }
+    }
+  end
+
+  defp api_error do
+    %Schema{
+      type: :object,
+      properties: %{error: %Schema{type: :string}}
+    }
+  end
+end
diff --git a/lib/pleroma/web/api_spec/operations/subscription_operation.ex b/lib/pleroma/web/api_spec/operations/subscription_operation.ex
new file mode 100644
index 000000000..663b8fa11
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/subscription_operation.ex
@@ -0,0 +1,188 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.SubscriptionOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Helpers
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.PushSubscription
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def create_operation do
+    %Operation{
+      tags: ["Push Subscriptions"],
+      summary: "Subscribe to push notifications",
+      description:
+        "Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted.",
+      operationId: "SubscriptionController.create",
+      security: [%{"oAuth" => ["push"]}],
+      requestBody: Helpers.request_body("Parameters", create_request(), required: true),
+      responses: %{
+        200 => Operation.response("Push Subscription", "application/json", PushSubscription),
+        400 => Operation.response("Error", "application/json", ApiError),
+        403 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["Push Subscriptions"],
+      summary: "Get current subscription",
+      description: "View the PushSubscription currently associated with this access token.",
+      operationId: "SubscriptionController.show",
+      security: [%{"oAuth" => ["push"]}],
+      responses: %{
+        200 => Operation.response("Push Subscription", "application/json", PushSubscription),
+        403 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def update_operation do
+    %Operation{
+      tags: ["Push Subscriptions"],
+      summary: "Change types of notifications",
+      description:
+        "Updates the current push subscription. Only the data part can be updated. To change fundamentals, a new subscription must be created instead.",
+      operationId: "SubscriptionController.update",
+      security: [%{"oAuth" => ["push"]}],
+      requestBody: Helpers.request_body("Parameters", update_request(), required: true),
+      responses: %{
+        200 => Operation.response("Push Subscription", "application/json", PushSubscription),
+        403 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def delete_operation do
+    %Operation{
+      tags: ["Push Subscriptions"],
+      summary: "Remove current subscription",
+      description: "Removes the current Web Push API subscription.",
+      operationId: "SubscriptionController.delete",
+      security: [%{"oAuth" => ["push"]}],
+      responses: %{
+        200 => Operation.response("Empty object", "application/json", %Schema{type: :object}),
+        403 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp create_request do
+    %Schema{
+      title: "SubscriptionCreateRequest",
+      description: "POST body for creating a push subscription",
+      type: :object,
+      properties: %{
+        subscription: %Schema{
+          type: :object,
+          properties: %{
+            endpoint: %Schema{
+              type: :string,
+              description: "Endpoint URL that is called when a notification event occurs."
+            },
+            keys: %Schema{
+              type: :object,
+              properties: %{
+                p256dh: %Schema{
+                  type: :string,
+                  description:
+                    "User agent public key. Base64 encoded string of public key of ECDH key using `prime256v1` curve."
+                },
+                auth: %Schema{
+                  type: :string,
+                  description: "Auth secret. Base64 encoded string of 16 bytes of random data."
+                }
+              },
+              required: [:p256dh, :auth]
+            }
+          },
+          required: [:endpoint, :keys]
+        },
+        data: %Schema{
+          type: :object,
+          properties: %{
+            alerts: %Schema{
+              type: :object,
+              properties: %{
+                follow: %Schema{type: :boolean, description: "Receive follow notifications?"},
+                favourite: %Schema{
+                  type: :boolean,
+                  description: "Receive favourite notifications?"
+                },
+                reblog: %Schema{type: :boolean, description: "Receive reblog notifications?"},
+                mention: %Schema{type: :boolean, description: "Receive mention notifications?"},
+                poll: %Schema{type: :boolean, description: "Receive poll notifications?"}
+              }
+            }
+          }
+        }
+      },
+      required: [:subscription],
+      example: %{
+        "subscription" => %{
+          "endpoint" => "https://example.com/example/1234",
+          "keys" => %{
+            "auth" => "8eDyX_uCN0XRhSbY5hs7Hg==",
+            "p256dh" =>
+              "BCIWgsnyXDv1VkhqL2P7YRBvdeuDnlwAPT2guNhdIoW3IP7GmHh1SMKPLxRf7x8vJy6ZFK3ol2ohgn_-0yP7QQA="
+          }
+        },
+        "data" => %{
+          "alerts" => %{
+            "follow" => true,
+            "mention" => true,
+            "poll" => false
+          }
+        }
+      }
+    }
+  end
+
+  defp update_request do
+    %Schema{
+      title: "SubscriptionUpdateRequest",
+      type: :object,
+      properties: %{
+        data: %Schema{
+          type: :object,
+          properties: %{
+            alerts: %Schema{
+              type: :object,
+              properties: %{
+                follow: %Schema{type: :boolean, description: "Receive follow notifications?"},
+                favourite: %Schema{
+                  type: :boolean,
+                  description: "Receive favourite notifications?"
+                },
+                reblog: %Schema{type: :boolean, description: "Receive reblog notifications?"},
+                mention: %Schema{type: :boolean, description: "Receive mention notifications?"},
+                poll: %Schema{type: :boolean, description: "Receive poll notifications?"}
+              }
+            }
+          }
+        }
+      },
+      example: %{
+        "data" => %{
+          "alerts" => %{
+            "follow" => true,
+            "favourite" => true,
+            "reblog" => true,
+            "mention" => true,
+            "poll" => true
+          }
+        }
+      }
+    }
+  end
+end
diff --git a/lib/pleroma/web/api_spec/schemas/conversation.ex b/lib/pleroma/web/api_spec/schemas/conversation.ex
new file mode 100644
index 000000000..d8ff5ba26
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/conversation.ex
@@ -0,0 +1,41 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.Conversation do
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.Account
+  alias Pleroma.Web.ApiSpec.Schemas.Status
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "Conversation",
+    description: "Represents a conversation with \"direct message\" visibility.",
+    type: :object,
+    required: [:id, :accounts, :unread],
+    properties: %{
+      id: %Schema{type: :string},
+      accounts: %Schema{
+        type: :array,
+        items: Account,
+        description: "Participants in the conversation"
+      },
+      unread: %Schema{
+        type: :boolean,
+        description: "Is the conversation currently marked as unread?"
+      },
+      # last_status: Status
+      last_status: %Schema{
+        allOf: [Status],
+        description: "The last status in the conversation, to be used for optional display"
+      }
+    },
+    example: %{
+      "id" => "418450",
+      "unread" => true,
+      "accounts" => [Account.schema().example],
+      "last_status" => Status.schema().example
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/list.ex b/lib/pleroma/web/api_spec/schemas/list.ex
new file mode 100644
index 000000000..b7d1685c9
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/list.ex
@@ -0,0 +1,23 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.List do
+  alias OpenApiSpex.Schema
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "List",
+    description: "Represents a list of users",
+    type: :object,
+    properties: %{
+      id: %Schema{type: :string, description: "The internal database ID of the list"},
+      title: %Schema{type: :string, description: "The user-defined title of the list"}
+    },
+    example: %{
+      "id" => "12249",
+      "title" => "Friends"
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/push_subscription.ex b/lib/pleroma/web/api_spec/schemas/push_subscription.ex
new file mode 100644
index 000000000..cc91b95b8
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/push_subscription.ex
@@ -0,0 +1,66 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.PushSubscription do
+  alias OpenApiSpex.Schema
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "PushSubscription",
+    description: "Response schema for a push subscription",
+    type: :object,
+    properties: %{
+      id: %Schema{
+        anyOf: [%Schema{type: :string}, %Schema{type: :integer}],
+        description: "The id of the push subscription in the database."
+      },
+      endpoint: %Schema{type: :string, description: "Where push alerts will be sent to."},
+      server_key: %Schema{type: :string, description: "The streaming server's VAPID key."},
+      alerts: %Schema{
+        type: :object,
+        description: "Which alerts should be delivered to the endpoint.",
+        properties: %{
+          follow: %Schema{
+            type: :boolean,
+            description: "Receive a push notification when someone has followed you?"
+          },
+          favourite: %Schema{
+            type: :boolean,
+            description:
+              "Receive a push notification when a status you created has been favourited by someone else?"
+          },
+          reblog: %Schema{
+            type: :boolean,
+            description:
+              "Receive a push notification when a status you created has been boosted by someone else?"
+          },
+          mention: %Schema{
+            type: :boolean,
+            description:
+              "Receive a push notification when someone else has mentioned you in a status?"
+          },
+          poll: %Schema{
+            type: :boolean,
+            description:
+              "Receive a push notification when a poll you voted in or created has ended? "
+          }
+        }
+      }
+    },
+    example: %{
+      "id" => "328_183",
+      "endpoint" => "https://yourdomain.example/listener",
+      "alerts" => %{
+        "follow" => true,
+        "favourite" => true,
+        "reblog" => true,
+        "mention" => true,
+        "poll" => true
+      },
+      "server_key" =>
+        "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M="
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex
index d44636a48..7a804461f 100644
--- a/lib/pleroma/web/api_spec/schemas/status.ex
+++ b/lib/pleroma/web/api_spec/schemas/status.ex
@@ -72,7 +72,12 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
         properties: %{
           content: %Schema{type: :object, additionalProperties: %Schema{type: :string}},
           conversation_id: %Schema{type: :integer},
-          direct_conversation_id: %Schema{type: :string, nullable: true},
+          direct_conversation_id: %Schema{
+            type: :integer,
+            nullable: true,
+            description:
+              "The ID of the Mastodon direct message conversation the status is associated with (if any)"
+          },
           emoji_reactions: %Schema{
             type: :array,
             items: %Schema{
diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex
index e27f85929..1b72e23dc 100644
--- a/lib/pleroma/web/feed/user_controller.ex
+++ b/lib/pleroma/web/feed/user_controller.ex
@@ -27,7 +27,7 @@ defmodule Pleroma.Web.Feed.UserController do
       when format in ["json", "activity+json"] do
     with %{halted: false} = conn <-
            Pleroma.Plugs.EnsureAuthenticatedPlug.call(conn,
-             unless_func: &Pleroma.Web.FederatingPlug.federating?/0
+             unless_func: &Pleroma.Web.FederatingPlug.federating?/1
            ) do
       ActivityPubController.call(conn, :user)
     end
diff --git a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex
index c44641526..f35ec3596 100644
--- a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex
@@ -13,9 +13,12 @@ defmodule Pleroma.Web.MastodonAPI.ConversationController do
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
   plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action == :index)
   plug(OAuthScopesPlug, %{scopes: ["write:conversations"]} when action != :index)
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ConversationOperation
+
   @doc "GET /api/v1/conversations"
   def index(%{assigns: %{user: user}} = conn, params) do
     participations = Participation.for_user_with_last_activity_id(user, params)
@@ -26,7 +29,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationController do
   end
 
   @doc "POST /api/v1/conversations/:id/read"
-  def mark_as_read(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do
+  def mark_as_read(%{assigns: %{user: user}} = conn, %{id: participation_id}) do
     with %Participation{} = participation <-
            Repo.get_by(Participation, id: participation_id, user_id: user.id),
          {:ok, participation} <- Participation.mark_as_read(participation) do
diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
index 7fd0562c9..abbf0ce02 100644
--- a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
@@ -10,6 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do
 
   @oauth_read_actions [:show, :index]
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
   plug(OAuthScopesPlug, %{scopes: ["read:filters"]} when action in @oauth_read_actions)
 
   plug(
@@ -17,60 +18,60 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do
     %{scopes: ["write:filters"]} when action not in @oauth_read_actions
   )
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FilterOperation
+
   @doc "GET /api/v1/filters"
   def index(%{assigns: %{user: user}} = conn, _) do
     filters = Filter.get_filters(user)
 
-    render(conn, "filters.json", filters: filters)
+    render(conn, "index.json", filters: filters)
   end
 
   @doc "POST /api/v1/filters"
-  def create(
-        %{assigns: %{user: user}} = conn,
-        %{"phrase" => phrase, "context" => context} = params
-      ) do
+  def create(%{assigns: %{user: user}, body_params: params} = conn, _) do
     query = %Filter{
       user_id: user.id,
-      phrase: phrase,
-      context: context,
-      hide: Map.get(params, "irreversible", false),
-      whole_word: Map.get(params, "boolean", true)
-      # expires_at
+      phrase: params.phrase,
+      context: params.context,
+      hide: params.irreversible,
+      whole_word: params.whole_word
+      # TODO: support `expires_in` parameter (as in Mastodon API)
     }
 
     {:ok, response} = Filter.create(query)
 
-    render(conn, "filter.json", filter: response)
+    render(conn, "show.json", filter: response)
   end
 
   @doc "GET /api/v1/filters/:id"
-  def show(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do
+  def show(%{assigns: %{user: user}} = conn, %{id: filter_id}) do
     filter = Filter.get(filter_id, user)
 
-    render(conn, "filter.json", filter: filter)
+    render(conn, "show.json", filter: filter)
   end
 
   @doc "PUT /api/v1/filters/:id"
   def update(
-        %{assigns: %{user: user}} = conn,
-        %{"phrase" => phrase, "context" => context, "id" => filter_id} = params
+        %{assigns: %{user: user}, body_params: params} = conn,
+        %{id: filter_id}
       ) do
-    query = %Filter{
-      user_id: user.id,
-      filter_id: filter_id,
-      phrase: phrase,
-      context: context,
-      hide: Map.get(params, "irreversible", nil),
-      whole_word: Map.get(params, "boolean", true)
-      # expires_at
-    }
+    params =
+      params
+      |> Map.delete(:irreversible)
+      |> Map.put(:hide, params[:irreversible])
+      |> Enum.reject(fn {_key, value} -> is_nil(value) end)
+      |> Map.new()
 
-    {:ok, response} = Filter.update(query)
-    render(conn, "filter.json", filter: response)
+    # TODO: support `expires_in` parameter (as in Mastodon API)
+
+    with %Filter{} = filter <- Filter.get(filter_id, user),
+         {:ok, %Filter{} = filter} <- Filter.update(filter, params) do
+      render(conn, "show.json", filter: filter)
+    end
   end
 
   @doc "DELETE /api/v1/filters/:id"
-  def delete(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do
+  def delete(%{assigns: %{user: user}} = conn, %{id: filter_id}) do
     query = %Filter{
       user_id: user.id,
       filter_id: filter_id
diff --git a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex
index 25f2269b9..748b6b475 100644
--- a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex
@@ -10,6 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do
   alias Pleroma.Web.CommonAPI
 
   plug(:put_view, Pleroma.Web.MastodonAPI.AccountView)
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
   plug(:assign_follower when action != :index)
 
   action_fallback(:errors)
@@ -21,6 +22,8 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do
     %{scopes: ["follow", "write:follows"]} when action != :index
   )
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FollowRequestOperation
+
   @doc "GET /api/v1/follow_requests"
   def index(%{assigns: %{user: followed}} = conn, _params) do
     follow_requests = User.get_follow_requests(followed)
@@ -42,7 +45,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do
     end
   end
 
-  defp assign_follower(%{params: %{"id" => id}} = conn, _) do
+  defp assign_follower(%{params: %{id: id}} = conn, _) do
     case User.get_cached_by_id(id) do
       %User{} = follower -> assign(conn, :follower, follower)
       nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt()
diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex
index 237f85677..d8859731d 100644
--- a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex
@@ -5,12 +5,16 @@
 defmodule Pleroma.Web.MastodonAPI.InstanceController do
   use Pleroma.Web, :controller
 
+  plug(OpenApiSpex.Plug.CastAndValidate)
+
   plug(
     :skip_plug,
     [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug]
     when action in [:show, :peers]
   )
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.InstanceOperation
+
   @doc "GET /api/v1/instance"
   def show(conn, _params) do
     render(conn, "show.json")
diff --git a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex
index bfe856025..acdc76fd2 100644
--- a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex
@@ -9,20 +9,17 @@ defmodule Pleroma.Web.MastodonAPI.ListController do
   alias Pleroma.User
   alias Pleroma.Web.MastodonAPI.AccountView
 
-  plug(:list_by_id_and_user when action not in [:index, :create])
-
   @oauth_read_actions [:index, :show, :list_accounts]
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+  plug(:list_by_id_and_user when action not in [:index, :create])
   plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action in @oauth_read_actions)
-
-  plug(
-    OAuthScopesPlug,
-    %{scopes: ["write:lists"]}
-    when action not in @oauth_read_actions
-  )
+  plug(OAuthScopesPlug, %{scopes: ["write:lists"]} when action not in @oauth_read_actions)
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ListOperation
+
   # GET /api/v1/lists
   def index(%{assigns: %{user: user}} = conn, opts) do
     lists = Pleroma.List.for_user(user, opts)
@@ -30,7 +27,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do
   end
 
   # POST /api/v1/lists
-  def create(%{assigns: %{user: user}} = conn, %{"title" => title}) do
+  def create(%{assigns: %{user: user}, body_params: %{title: title}} = conn, _) do
     with {:ok, %Pleroma.List{} = list} <- Pleroma.List.create(title, user) do
       render(conn, "show.json", list: list)
     end
@@ -42,7 +39,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do
   end
 
   # PUT /api/v1/lists/:id
-  def update(%{assigns: %{list: list}} = conn, %{"title" => title}) do
+  def update(%{assigns: %{list: list}, body_params: %{title: title}} = conn, _) do
     with {:ok, list} <- Pleroma.List.rename(list, title) do
       render(conn, "show.json", list: list)
     end
@@ -65,7 +62,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do
   end
 
   # POST /api/v1/lists/:id/accounts
-  def add_to_list(%{assigns: %{list: list}} = conn, %{"account_ids" => account_ids}) do
+  def add_to_list(%{assigns: %{list: list}, body_params: %{account_ids: account_ids}} = conn, _) do
     Enum.each(account_ids, fn account_id ->
       with %User{} = followed <- User.get_cached_by_id(account_id) do
         Pleroma.List.follow(list, followed)
@@ -76,7 +73,10 @@ defmodule Pleroma.Web.MastodonAPI.ListController do
   end
 
   # DELETE /api/v1/lists/:id/accounts
-  def remove_from_list(%{assigns: %{list: list}} = conn, %{"account_ids" => account_ids}) do
+  def remove_from_list(
+        %{assigns: %{list: list}, body_params: %{account_ids: account_ids}} = conn,
+        _
+      ) do
     Enum.each(account_ids, fn account_id ->
       with %User{} = followed <- User.get_cached_by_id(account_id) do
         Pleroma.List.unfollow(list, followed)
@@ -86,7 +86,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do
     json(conn, %{})
   end
 
-  defp list_by_id_and_user(%{assigns: %{user: user}, params: %{"id" => id}} = conn, _) do
+  defp list_by_id_and_user(%{assigns: %{user: user}, params: %{id: id}} = conn, _) do
     case Pleroma.List.get(id, user) do
       %Pleroma.List{} = list -> assign(conn, :list, list)
       nil -> conn |> render_error(:not_found, "List not found") |> halt()
diff --git a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex
index 9f9d4574e..85310edfa 100644
--- a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex
@@ -6,6 +6,8 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do
   use Pleroma.Web, :controller
   alias Pleroma.Plugs.OAuthScopesPlug
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
   plug(
     OAuthScopesPlug,
     %{scopes: ["read:statuses"]}
@@ -16,14 +18,18 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MarkerOperation
+
   # GET /api/v1/markers
   def index(%{assigns: %{user: user}} = conn, params) do
-    markers = Pleroma.Marker.get_markers(user, params["timeline"])
+    markers = Pleroma.Marker.get_markers(user, params[:timeline])
     render(conn, "markers.json", %{markers: markers})
   end
 
   # POST /api/v1/markers
-  def upsert(%{assigns: %{user: user}} = conn, params) do
+  def upsert(%{assigns: %{user: user}, body_params: params} = conn, _) do
+    params = Map.new(params, fn {key, value} -> {to_string(key), value} end)
+
     with {:ok, result} <- Pleroma.Marker.upsert(user, params),
          markers <- Map.values(result) do
       render(conn, "markers.json", %{markers: markers})
diff --git a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex
index d184ea1d0..34eac97c5 100644
--- a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex
@@ -11,14 +11,16 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do
 
   action_fallback(:errors)
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+  plug(:restrict_push_enabled)
   plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["push"]})
 
-  plug(:restrict_push_enabled)
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SubscriptionOperation
 
   # Creates PushSubscription
   # POST /api/v1/push/subscription
   #
-  def create(%{assigns: %{user: user, token: token}} = conn, params) do
+  def create(%{assigns: %{user: user, token: token}, body_params: params} = conn, _) do
     with {:ok, _} <- Subscription.delete_if_exists(user, token),
          {:ok, subscription} <- Subscription.create(user, token, params) do
       render(conn, "show.json", subscription: subscription)
@@ -28,7 +30,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do
   # Gets PushSubscription
   # GET /api/v1/push/subscription
   #
-  def get(%{assigns: %{user: user, token: token}} = conn, _params) do
+  def show(%{assigns: %{user: user, token: token}} = conn, _params) do
     with {:ok, subscription} <- Subscription.get(user, token) do
       render(conn, "show.json", subscription: subscription)
     end
@@ -37,7 +39,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do
   # Updates PushSubscription
   # PUT /api/v1/push/subscription
   #
-  def update(%{assigns: %{user: user, token: token}} = conn, params) do
+  def update(%{assigns: %{user: user, token: token}, body_params: params} = conn, _) do
     with {:ok, subscription} <- Subscription.update(user, token, params) do
       render(conn, "show.json", subscription: subscription)
     end
@@ -66,7 +68,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do
   def errors(conn, {:error, :not_found}) do
     conn
     |> put_status(:not_found)
-    |> json(dgettext("errors", "Not found"))
+    |> json(%{error: dgettext("errors", "Record not found")})
   end
 
   def errors(conn, _) do
diff --git a/lib/pleroma/web/mastodon_api/views/filter_view.ex b/lib/pleroma/web/mastodon_api/views/filter_view.ex
index 97fd1e83f..aeff646f5 100644
--- a/lib/pleroma/web/mastodon_api/views/filter_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/filter_view.ex
@@ -7,11 +7,11 @@ defmodule Pleroma.Web.MastodonAPI.FilterView do
   alias Pleroma.Web.CommonAPI.Utils
   alias Pleroma.Web.MastodonAPI.FilterView
 
-  def render("filters.json", %{filters: filters} = opts) do
-    render_many(filters, FilterView, "filter.json", opts)
+  def render("index.json", %{filters: filters}) do
+    render_many(filters, FilterView, "show.json")
   end
 
-  def render("filter.json", %{filter: filter}) do
+  def render("show.json", %{filter: filter}) do
     expires_at =
       if filter.expires_at do
         Utils.to_masto_date(filter.expires_at)
diff --git a/lib/pleroma/web/mastodon_api/views/marker_view.ex b/lib/pleroma/web/mastodon_api/views/marker_view.ex
index 415dae93b..21d535d54 100644
--- a/lib/pleroma/web/mastodon_api/views/marker_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/marker_view.ex
@@ -6,15 +6,16 @@ defmodule Pleroma.Web.MastodonAPI.MarkerView do
   use Pleroma.Web, :view
 
   def render("markers.json", %{markers: markers}) do
-    Enum.reduce(markers, %{}, fn m, acc ->
-      Map.put_new(acc, m.timeline, %{
-        last_read_id: m.last_read_id,
-        version: m.lock_version,
-        updated_at: NaiveDateTime.to_iso8601(m.updated_at),
-        pleroma: %{
-          unread_count: m.unread_count
-        }
-      })
+    Map.new(markers, fn m ->
+      {m.timeline,
+       %{
+         last_read_id: m.last_read_id,
+         version: m.lock_version,
+         updated_at: NaiveDateTime.to_iso8601(m.updated_at),
+         pleroma: %{
+           unread_count: m.unread_count
+         }
+       }}
     end)
   end
 end
diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex
index 6fd3cfce5..6971cd9f8 100644
--- a/lib/pleroma/web/ostatus/ostatus_controller.ex
+++ b/lib/pleroma/web/ostatus/ostatus_controller.ex
@@ -17,7 +17,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do
   alias Pleroma.Web.Router
 
   plug(Pleroma.Plugs.EnsureAuthenticatedPlug,
-    unless_func: &Pleroma.Web.FederatingPlug.federating?/0
+    unless_func: &Pleroma.Web.FederatingPlug.federating?/1
   )
 
   plug(
diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex
index b99b0c5fb..3e401a490 100644
--- a/lib/pleroma/web/push/subscription.ex
+++ b/lib/pleroma/web/push/subscription.ex
@@ -25,9 +25,9 @@ defmodule Pleroma.Web.Push.Subscription do
     timestamps()
   end
 
-  @supported_alert_types ~w[follow favourite mention reblog]
+  @supported_alert_types ~w[follow favourite mention reblog]a
 
-  defp alerts(%{"data" => %{"alerts" => alerts}}) do
+  defp alerts(%{data: %{alerts: alerts}}) do
     alerts = Map.take(alerts, @supported_alert_types)
     %{"alerts" => alerts}
   end
@@ -44,9 +44,9 @@ defmodule Pleroma.Web.Push.Subscription do
         %User{} = user,
         %Token{} = token,
         %{
-          "subscription" => %{
-            "endpoint" => endpoint,
-            "keys" => %{"auth" => key_auth, "p256dh" => key_p256dh}
+          subscription: %{
+            endpoint: endpoint,
+            keys: %{auth: key_auth, p256dh: key_p256dh}
           }
         } = params
       ) do
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index ef2239d59..281516bb8 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -427,7 +427,7 @@ defmodule Pleroma.Web.Router do
     post("/statuses/:id/unmute", StatusController, :unmute_conversation)
 
     post("/push/subscription", SubscriptionController, :create)
-    get("/push/subscription", SubscriptionController, :get)
+    get("/push/subscription", SubscriptionController, :show)
     put("/push/subscription", SubscriptionController, :update)
     delete("/push/subscription", SubscriptionController, :delete)
 
diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex
index 7a35238d7..c3efb6651 100644
--- a/lib/pleroma/web/static_fe/static_fe_controller.ex
+++ b/lib/pleroma/web/static_fe/static_fe_controller.ex
@@ -18,7 +18,7 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do
   plug(:assign_id)
 
   plug(Pleroma.Plugs.EnsureAuthenticatedPlug,
-    unless_func: &Pleroma.Web.FederatingPlug.federating?/0
+    unless_func: &Pleroma.Web.FederatingPlug.federating?/1
   )
 
   @page_keys ["max_id", "min_id", "limit", "since_id", "order"]
diff --git a/lib/pleroma/web/web.ex b/lib/pleroma/web/web.ex
index 08e42a7e5..4f9281851 100644
--- a/lib/pleroma/web/web.ex
+++ b/lib/pleroma/web/web.ex
@@ -200,11 +200,17 @@ defmodule Pleroma.Web do
 
       @impl Plug
       @doc """
-      If marked as skipped, returns `conn`, otherwise calls `perform/2`.
+      Before-plug hook that
+        * ensures the plug is not skipped
+        * processes `:if_func` / `:unless_func` functional pre-run conditions
+        * adds plug to the list of called plugs and calls `perform/2` if checks are passed
+
       Note: multiple invocations of the same plug (with different or same options) are allowed.
       """
       def call(%Plug.Conn{} = conn, options) do
-        if PlugHelper.plug_skipped?(conn, __MODULE__) do
+        if PlugHelper.plug_skipped?(conn, __MODULE__) ||
+             (options[:if_func] && !options[:if_func].(conn)) ||
+             (options[:unless_func] && options[:unless_func].(conn)) do
           conn
         else
           conn =
diff --git a/lib/pleroma/web/web_finger/web_finger.ex b/lib/pleroma/web/web_finger/web_finger.ex
index 84ece1be2..71ccf251a 100644
--- a/lib/pleroma/web/web_finger/web_finger.ex
+++ b/lib/pleroma/web/web_finger/web_finger.ex
@@ -161,13 +161,15 @@ defmodule Pleroma.Web.WebFinger do
           URI.parse(account).host
       end
 
+    encoded_account = URI.encode("acct:#{account}")
+
     address =
       case find_lrdd_template(domain) do
         {:ok, template} ->
-          String.replace(template, "{uri}", URI.encode(account))
+          String.replace(template, "{uri}", encoded_account)
 
         _ ->
-          "https://#{domain}/.well-known/webfinger?resource=acct:#{account}"
+          "https://#{domain}/.well-known/webfinger?resource=#{encoded_account}"
       end
 
     with response <-
diff --git a/test/filter_test.exs b/test/filter_test.exs
index b2a8330ee..63a30c736 100644
--- a/test/filter_test.exs
+++ b/test/filter_test.exs
@@ -141,17 +141,15 @@ defmodule Pleroma.FilterTest do
       context: ["home"]
     }
 
-    query_two = %Pleroma.Filter{
-      user_id: user.id,
-      filter_id: 1,
+    changes = %{
       phrase: "who",
       context: ["home", "timeline"]
     }
 
     {:ok, filter_one} = Pleroma.Filter.create(query_one)
-    {:ok, filter_two} = Pleroma.Filter.update(query_two)
+    {:ok, filter_two} = Pleroma.Filter.update(filter_one, changes)
     assert filter_one != filter_two
-    assert filter_two.phrase == query_two.phrase
-    assert filter_two.context == query_two.context
+    assert filter_two.phrase == changes.phrase
+    assert filter_two.context == changes.context
   end
 end
diff --git a/test/plugs/ensure_authenticated_plug_test.exs b/test/plugs/ensure_authenticated_plug_test.exs
index 689fe757f..4e6142aab 100644
--- a/test/plugs/ensure_authenticated_plug_test.exs
+++ b/test/plugs/ensure_authenticated_plug_test.exs
@@ -27,8 +27,8 @@ defmodule Pleroma.Plugs.EnsureAuthenticatedPlugTest do
   describe "with :if_func / :unless_func options" do
     setup do
       %{
-        true_fn: fn -> true end,
-        false_fn: fn -> false end
+        true_fn: fn _conn -> true end,
+        false_fn: fn _conn -> false end
       }
     end
 
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index fa30a0c41..91c03b1a8 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -74,7 +74,7 @@ defmodule Pleroma.Web.ConnCase do
         status = Plug.Conn.Status.code(status)
 
         unless lookup[op_id].responses[status] do
-          err = "Response schema not found for #{conn.status} #{conn.method} #{conn.request_path}"
+          err = "Response schema not found for #{status} #{conn.method} #{conn.request_path}"
           flunk(err)
         end
 
diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex
index 9624cb0f7..3a95e92da 100644
--- a/test/support/http_request_mock.ex
+++ b/test/support/http_request_mock.ex
@@ -211,7 +211,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "https://squeet.me/xrd/?uri=lain@squeet.me",
+        "https://squeet.me/xrd/?uri=acct:lain@squeet.me",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]
@@ -870,7 +870,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "https://social.heldscal.la/.well-known/webfinger?resource=shp@social.heldscal.la",
+        "https://social.heldscal.la/.well-known/webfinger?resource=acct:shp@social.heldscal.la",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]
@@ -883,7 +883,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "https://social.heldscal.la/.well-known/webfinger?resource=invalid_content@social.heldscal.la",
+        "https://social.heldscal.la/.well-known/webfinger?resource=acct:invalid_content@social.heldscal.la",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]
@@ -900,7 +900,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "http://framatube.org/main/xrd?uri=framasoft@framatube.org",
+        "http://framatube.org/main/xrd?uri=acct:framasoft@framatube.org",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]
@@ -959,7 +959,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "https://gerzilla.de/xrd/?uri=kaniini@gerzilla.de",
+        "https://gerzilla.de/xrd/?uri=acct:kaniini@gerzilla.de",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]
@@ -1155,7 +1155,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=lain@zetsubou.xn--q9jyb4c",
+        "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=acct:lain@zetsubou.xn--q9jyb4c",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]
@@ -1168,7 +1168,7 @@ defmodule HttpRequestMock do
   end
 
   def get(
-        "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=https://zetsubou.xn--q9jyb4c/users/lain",
+        "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=acct:https://zetsubou.xn--q9jyb4c/users/lain",
         _,
         _,
         [{"accept", "application/xrd+xml,application/jrd+json"}]
diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs
index a8f1f0e26..5c8d20ac4 100644
--- a/test/web/activity_pub/activity_pub_controller_test.exs
+++ b/test/web/activity_pub/activity_pub_controller_test.exs
@@ -820,21 +820,29 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
       activity: activity
     } do
       user = insert(:user)
+      conn = assign(conn, :user, user)
       object = Map.put(activity["object"], "sensitive", true)
       activity = Map.put(activity, "object", object)
 
-      result =
+      response =
         conn
-        |> assign(:user, user)
         |> put_req_header("content-type", "application/activity+json")
         |> post("/users/#{user.nickname}/outbox", activity)
         |> json_response(201)
 
-      assert Activity.get_by_ap_id(result["id"])
-      assert result["object"]
-      assert %Object{data: object} = Object.normalize(result["object"])
-      assert object["sensitive"] == activity["object"]["sensitive"]
-      assert object["content"] == activity["object"]["content"]
+      assert Activity.get_by_ap_id(response["id"])
+      assert response["object"]
+      assert %Object{data: response_object} = Object.normalize(response["object"])
+      assert response_object["sensitive"] == true
+      assert response_object["content"] == activity["object"]["content"]
+
+      representation =
+        conn
+        |> put_req_header("accept", "application/activity+json")
+        |> get(response["id"])
+        |> json_response(200)
+
+      assert representation["object"]["sensitive"] == true
     end
 
     test "it rejects an incoming activity with bogus type", %{conn: conn, activity: activity} do
diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs
index c3f3ad051..78c79bb07 100644
--- a/test/web/admin_api/admin_api_controller_test.exs
+++ b/test/web/admin_api/admin_api_controller_test.exs
@@ -19,6 +19,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
   alias Pleroma.Tests.ObanHelpers
   alias Pleroma.User
   alias Pleroma.UserInviteToken
+  alias Pleroma.Web
   alias Pleroma.Web.ActivityPub.Relay
   alias Pleroma.Web.CommonAPI
   alias Pleroma.Web.MediaProxy
@@ -737,6 +738,39 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
              }
     end
 
+    test "pagination works correctly with service users", %{conn: conn} do
+      service1 = insert(:user, ap_id: Web.base_url() <> "/relay")
+      service2 = insert(:user, ap_id: Web.base_url() <> "/internal/fetch")
+      insert_list(25, :user)
+
+      assert %{"count" => 26, "page_size" => 10, "users" => users1} =
+               conn
+               |> get("/api/pleroma/admin/users?page=1&filters=", %{page_size: "10"})
+               |> json_response(200)
+
+      assert Enum.count(users1) == 10
+      assert service1 not in [users1]
+      assert service2 not in [users1]
+
+      assert %{"count" => 26, "page_size" => 10, "users" => users2} =
+               conn
+               |> get("/api/pleroma/admin/users?page=2&filters=", %{page_size: "10"})
+               |> json_response(200)
+
+      assert Enum.count(users2) == 10
+      assert service1 not in [users2]
+      assert service2 not in [users2]
+
+      assert %{"count" => 26, "page_size" => 10, "users" => users3} =
+               conn
+               |> get("/api/pleroma/admin/users?page=3&filters=", %{page_size: "10"})
+               |> json_response(200)
+
+      assert Enum.count(users3) == 6
+      assert service1 not in [users3]
+      assert service2 not in [users3]
+    end
+
     test "renders empty array for the second page", %{conn: conn} do
       insert(:user)
 
@@ -3545,7 +3579,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
     end
 
     test "success", %{conn: conn} do
-      base_url = Pleroma.Web.base_url()
+      base_url = Web.base_url()
       app_name = "Trusted app"
 
       response =
@@ -3566,7 +3600,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
     end
 
     test "with trusted", %{conn: conn} do
-      base_url = Pleroma.Web.base_url()
+      base_url = Web.base_url()
       app_name = "Trusted app"
 
       response =
diff --git a/test/web/mastodon_api/controllers/conversation_controller_test.exs b/test/web/mastodon_api/controllers/conversation_controller_test.exs
index 801b0259b..04695572e 100644
--- a/test/web/mastodon_api/controllers/conversation_controller_test.exs
+++ b/test/web/mastodon_api/controllers/conversation_controller_test.exs
@@ -36,7 +36,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do
 
     res_conn = get(conn, "/api/v1/conversations")
 
-    assert response = json_response(res_conn, 200)
+    assert response = json_response_and_validate_schema(res_conn, 200)
 
     assert [
              %{
@@ -91,18 +91,18 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do
         "visibility" => "direct"
       })
 
-    [conversation1, conversation2] =
-      conn
-      |> get("/api/v1/conversations", %{"recipients" => [user_two.id]})
-      |> json_response(200)
+    assert [conversation1, conversation2] =
+             conn
+             |> get("/api/v1/conversations?recipients[]=#{user_two.id}")
+             |> json_response_and_validate_schema(200)
 
     assert conversation1["last_status"]["id"] == direct5.id
     assert conversation2["last_status"]["id"] == direct1.id
 
     [conversation1] =
       conn
-      |> get("/api/v1/conversations", %{"recipients" => [user_two.id, user_three.id]})
-      |> json_response(200)
+      |> get("/api/v1/conversations?recipients[]=#{user_two.id}&recipients[]=#{user_three.id}")
+      |> json_response_and_validate_schema(200)
 
     assert conversation1["last_status"]["id"] == direct3.id
   end
@@ -126,7 +126,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do
     [%{"last_status" => res_last_status}] =
       conn
       |> get("/api/v1/conversations")
-      |> json_response(200)
+      |> json_response_and_validate_schema(200)
 
     assert res_last_status["id"] == direct_reply.id
   end
@@ -154,12 +154,12 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do
     [%{"id" => direct_conversation_id, "unread" => true}] =
       user_two_conn
       |> get("/api/v1/conversations")
-      |> json_response(200)
+      |> json_response_and_validate_schema(200)
 
     %{"unread" => false} =
       user_two_conn
       |> post("/api/v1/conversations/#{direct_conversation_id}/read")
-      |> json_response(200)
+      |> json_response_and_validate_schema(200)
 
     assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0
     assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0
@@ -175,7 +175,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do
     [%{"unread" => true}] =
       conn
       |> get("/api/v1/conversations")
-      |> json_response(200)
+      |> json_response_and_validate_schema(200)
 
     assert User.get_cached_by_id(user_one.id).unread_conversation_count == 1
     assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0
diff --git a/test/web/mastodon_api/controllers/filter_controller_test.exs b/test/web/mastodon_api/controllers/filter_controller_test.exs
index 97ab005e0..f29547d13 100644
--- a/test/web/mastodon_api/controllers/filter_controller_test.exs
+++ b/test/web/mastodon_api/controllers/filter_controller_test.exs
@@ -15,9 +15,12 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
       context: ["home"]
     }
 
-    conn = post(conn, "/api/v1/filters", %{"phrase" => filter.phrase, context: filter.context})
+    conn =
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> post("/api/v1/filters", %{"phrase" => filter.phrase, context: filter.context})
 
-    assert response = json_response(conn, 200)
+    assert response = json_response_and_validate_schema(conn, 200)
     assert response["phrase"] == filter.phrase
     assert response["context"] == filter.context
     assert response["irreversible"] == false
@@ -48,12 +51,12 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
     response =
       conn
       |> get("/api/v1/filters")
-      |> json_response(200)
+      |> json_response_and_validate_schema(200)
 
     assert response ==
              render_json(
                FilterView,
-               "filters.json",
+               "index.json",
                filters: [filter_two, filter_one]
              )
   end
@@ -72,7 +75,7 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
 
     conn = get(conn, "/api/v1/filters/#{filter.filter_id}")
 
-    assert _response = json_response(conn, 200)
+    assert response = json_response_and_validate_schema(conn, 200)
   end
 
   test "update a filter" do
@@ -82,7 +85,8 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
       user_id: user.id,
       filter_id: 2,
       phrase: "knight",
-      context: ["home"]
+      context: ["home"],
+      hide: true
     }
 
     {:ok, _filter} = Pleroma.Filter.create(query)
@@ -93,14 +97,17 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
     }
 
     conn =
-      put(conn, "/api/v1/filters/#{query.filter_id}", %{
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> put("/api/v1/filters/#{query.filter_id}", %{
         phrase: new.phrase,
         context: new.context
       })
 
-    assert response = json_response(conn, 200)
+    assert response = json_response_and_validate_schema(conn, 200)
     assert response["phrase"] == new.phrase
     assert response["context"] == new.context
+    assert response["irreversible"] == true
   end
 
   test "delete a filter" do
@@ -117,7 +124,6 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
 
     conn = delete(conn, "/api/v1/filters/#{filter.filter_id}")
 
-    assert response = json_response(conn, 200)
-    assert response == %{}
+    assert json_response_and_validate_schema(conn, 200) == %{}
   end
 end
diff --git a/test/web/mastodon_api/controllers/follow_request_controller_test.exs b/test/web/mastodon_api/controllers/follow_request_controller_test.exs
index d8dbe4800..44e12d15a 100644
--- a/test/web/mastodon_api/controllers/follow_request_controller_test.exs
+++ b/test/web/mastodon_api/controllers/follow_request_controller_test.exs
@@ -27,7 +27,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do
 
       conn = get(conn, "/api/v1/follow_requests")
 
-      assert [relationship] = json_response(conn, 200)
+      assert [relationship] = json_response_and_validate_schema(conn, 200)
       assert to_string(other_user.id) == relationship["id"]
     end
 
@@ -44,7 +44,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do
 
       conn = post(conn, "/api/v1/follow_requests/#{other_user.id}/authorize")
 
-      assert relationship = json_response(conn, 200)
+      assert relationship = json_response_and_validate_schema(conn, 200)
       assert to_string(other_user.id) == relationship["id"]
 
       user = User.get_cached_by_id(user.id)
@@ -62,7 +62,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do
 
       conn = post(conn, "/api/v1/follow_requests/#{other_user.id}/reject")
 
-      assert relationship = json_response(conn, 200)
+      assert relationship = json_response_and_validate_schema(conn, 200)
       assert to_string(other_user.id) == relationship["id"]
 
       user = User.get_cached_by_id(user.id)
diff --git a/test/web/mastodon_api/controllers/instance_controller_test.exs b/test/web/mastodon_api/controllers/instance_controller_test.exs
index 2c7fd9fd0..90840d5ab 100644
--- a/test/web/mastodon_api/controllers/instance_controller_test.exs
+++ b/test/web/mastodon_api/controllers/instance_controller_test.exs
@@ -10,7 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
 
   test "get instance information", %{conn: conn} do
     conn = get(conn, "/api/v1/instance")
-    assert result = json_response(conn, 200)
+    assert result = json_response_and_validate_schema(conn, 200)
 
     email = Pleroma.Config.get([:instance, :email])
     # Note: not checking for "max_toot_chars" since it's optional
@@ -56,7 +56,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
 
     conn = get(conn, "/api/v1/instance")
 
-    assert result = json_response(conn, 200)
+    assert result = json_response_and_validate_schema(conn, 200)
 
     stats = result["stats"]
 
@@ -74,7 +74,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
 
     conn = get(conn, "/api/v1/instance/peers")
 
-    assert result = json_response(conn, 200)
+    assert result = json_response_and_validate_schema(conn, 200)
 
     assert ["peer1.com", "peer2.com"] == Enum.sort(result)
   end
diff --git a/test/web/mastodon_api/controllers/list_controller_test.exs b/test/web/mastodon_api/controllers/list_controller_test.exs
index c9c4cbb49..57a9ef4a4 100644
--- a/test/web/mastodon_api/controllers/list_controller_test.exs
+++ b/test/web/mastodon_api/controllers/list_controller_test.exs
@@ -12,37 +12,44 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
   test "creating a list" do
     %{conn: conn} = oauth_access(["write:lists"])
 
-    conn = post(conn, "/api/v1/lists", %{"title" => "cuties"})
-
-    assert %{"title" => title} = json_response(conn, 200)
-    assert title == "cuties"
+    assert %{"title" => "cuties"} =
+             conn
+             |> put_req_header("content-type", "application/json")
+             |> post("/api/v1/lists", %{"title" => "cuties"})
+             |> json_response_and_validate_schema(:ok)
   end
 
   test "renders error for invalid params" do
     %{conn: conn} = oauth_access(["write:lists"])
 
-    conn = post(conn, "/api/v1/lists", %{"title" => nil})
+    conn =
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> post("/api/v1/lists", %{"title" => nil})
 
-    assert %{"error" => "can't be blank"} == json_response(conn, :unprocessable_entity)
+    assert %{"error" => "title - null value where string expected."} =
+             json_response_and_validate_schema(conn, 400)
   end
 
   test "listing a user's lists" do
     %{conn: conn} = oauth_access(["read:lists", "write:lists"])
 
     conn
+    |> put_req_header("content-type", "application/json")
     |> post("/api/v1/lists", %{"title" => "cuties"})
-    |> json_response(:ok)
+    |> json_response_and_validate_schema(:ok)
 
     conn
+    |> put_req_header("content-type", "application/json")
     |> post("/api/v1/lists", %{"title" => "cofe"})
-    |> json_response(:ok)
+    |> json_response_and_validate_schema(:ok)
 
     conn = get(conn, "/api/v1/lists")
 
     assert [
              %{"id" => _, "title" => "cofe"},
              %{"id" => _, "title" => "cuties"}
-           ] = json_response(conn, :ok)
+           ] = json_response_and_validate_schema(conn, :ok)
   end
 
   test "adding users to a list" do
@@ -50,9 +57,12 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
     other_user = insert(:user)
     {:ok, list} = Pleroma.List.create("name", user)
 
-    conn = post(conn, "/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
+    assert %{} ==
+             conn
+             |> put_req_header("content-type", "application/json")
+             |> post("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
+             |> json_response_and_validate_schema(:ok)
 
-    assert %{} == json_response(conn, 200)
     %Pleroma.List{following: following} = Pleroma.List.get(list.id, user)
     assert following == [other_user.follower_address]
   end
@@ -65,9 +75,12 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
     {:ok, list} = Pleroma.List.follow(list, other_user)
     {:ok, list} = Pleroma.List.follow(list, third_user)
 
-    conn = delete(conn, "/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
+    assert %{} ==
+             conn
+             |> put_req_header("content-type", "application/json")
+             |> delete("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
+             |> json_response_and_validate_schema(:ok)
 
-    assert %{} == json_response(conn, 200)
     %Pleroma.List{following: following} = Pleroma.List.get(list.id, user)
     assert following == [third_user.follower_address]
   end
@@ -83,7 +96,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
       |> assign(:user, user)
       |> get("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
 
-    assert [%{"id" => id}] = json_response(conn, 200)
+    assert [%{"id" => id}] = json_response_and_validate_schema(conn, 200)
     assert id == to_string(other_user.id)
   end
 
@@ -96,7 +109,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
       |> assign(:user, user)
       |> get("/api/v1/lists/#{list.id}")
 
-    assert %{"id" => id} = json_response(conn, 200)
+    assert %{"id" => id} = json_response_and_validate_schema(conn, 200)
     assert id == to_string(list.id)
   end
 
@@ -105,17 +118,18 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
 
     conn = get(conn, "/api/v1/lists/666")
 
-    assert %{"error" => "List not found"} = json_response(conn, :not_found)
+    assert %{"error" => "List not found"} = json_response_and_validate_schema(conn, :not_found)
   end
 
   test "renaming a list" do
     %{user: user, conn: conn} = oauth_access(["write:lists"])
     {:ok, list} = Pleroma.List.create("name", user)
 
-    conn = put(conn, "/api/v1/lists/#{list.id}", %{"title" => "newname"})
-
-    assert %{"title" => name} = json_response(conn, 200)
-    assert name == "newname"
+    assert %{"title" => "newname"} =
+             conn
+             |> put_req_header("content-type", "application/json")
+             |> put("/api/v1/lists/#{list.id}", %{"title" => "newname"})
+             |> json_response_and_validate_schema(:ok)
   end
 
   test "validates title when renaming a list" do
@@ -125,9 +139,11 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
     conn =
       conn
       |> assign(:user, user)
+      |> put_req_header("content-type", "application/json")
       |> put("/api/v1/lists/#{list.id}", %{"title" => "  "})
 
-    assert %{"error" => "can't be blank"} == json_response(conn, :unprocessable_entity)
+    assert %{"error" => "can't be blank"} ==
+             json_response_and_validate_schema(conn, :unprocessable_entity)
   end
 
   test "deleting a list" do
@@ -136,7 +152,7 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
 
     conn = delete(conn, "/api/v1/lists/#{list.id}")
 
-    assert %{} = json_response(conn, 200)
+    assert %{} = json_response_and_validate_schema(conn, 200)
     assert is_nil(Repo.get(Pleroma.List, list.id))
   end
 end
diff --git a/test/web/mastodon_api/controllers/marker_controller_test.exs b/test/web/mastodon_api/controllers/marker_controller_test.exs
index 7280abd10..6dd40fb4a 100644
--- a/test/web/mastodon_api/controllers/marker_controller_test.exs
+++ b/test/web/mastodon_api/controllers/marker_controller_test.exs
@@ -23,8 +23,8 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
         conn
         |> assign(:user, user)
         |> assign(:token, token)
-        |> get("/api/v1/markers", %{timeline: ["notifications"]})
-        |> json_response(200)
+        |> get("/api/v1/markers?timeline[]=notifications")
+        |> json_response_and_validate_schema(200)
 
       assert response == %{
                "notifications" => %{
@@ -47,7 +47,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
         |> assign(:user, user)
         |> assign(:token, token)
         |> get("/api/v1/markers", %{timeline: ["notifications"]})
-        |> json_response(403)
+        |> json_response_and_validate_schema(403)
 
       assert response == %{"error" => "Insufficient permissions: read:statuses."}
     end
@@ -62,11 +62,12 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
         conn
         |> assign(:user, user)
         |> assign(:token, token)
+        |> put_req_header("content-type", "application/json")
         |> post("/api/v1/markers", %{
           home: %{last_read_id: "777"},
           notifications: %{"last_read_id" => "69420"}
         })
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       assert %{
                "notifications" => %{
@@ -92,11 +93,12 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
         conn
         |> assign(:user, user)
         |> assign(:token, token)
+        |> put_req_header("content-type", "application/json")
         |> post("/api/v1/markers", %{
           home: %{last_read_id: "777"},
           notifications: %{"last_read_id" => "69888"}
         })
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       assert response == %{
                "notifications" => %{
@@ -116,11 +118,12 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
         conn
         |> assign(:user, user)
         |> assign(:token, token)
+        |> put_req_header("content-type", "application/json")
         |> post("/api/v1/markers", %{
           home: %{last_read_id: "777"},
           notifications: %{"last_read_id" => "69420"}
         })
-        |> json_response(403)
+        |> json_response_and_validate_schema(403)
 
       assert response == %{"error" => "Insufficient permissions: write:statuses."}
     end
diff --git a/test/web/mastodon_api/controllers/subscription_controller_test.exs b/test/web/mastodon_api/controllers/subscription_controller_test.exs
index 5682498c0..4aa260663 100644
--- a/test/web/mastodon_api/controllers/subscription_controller_test.exs
+++ b/test/web/mastodon_api/controllers/subscription_controller_test.exs
@@ -6,6 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
   use Pleroma.Web.ConnCase
 
   import Pleroma.Factory
+
   alias Pleroma.Web.Push
   alias Pleroma.Web.Push.Subscription
 
@@ -27,6 +28,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       build_conn()
       |> assign(:user, user)
       |> assign(:token, token)
+      |> put_req_header("content-type", "application/json")
 
     %{conn: conn, user: user, token: token}
   end
@@ -47,8 +49,8 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
     test "returns error when push disabled ", %{conn: conn} do
       assert_error_when_disable_push do
         conn
-        |> post("/api/v1/push/subscription", %{})
-        |> json_response(403)
+        |> post("/api/v1/push/subscription", %{subscription: @sub})
+        |> json_response_and_validate_schema(403)
       end
     end
 
@@ -59,7 +61,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
           "data" => %{"alerts" => %{"mention" => true, "test" => true}},
           "subscription" => @sub
         })
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       [subscription] = Pleroma.Repo.all(Subscription)
 
@@ -77,7 +79,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       assert_error_when_disable_push do
         conn
         |> get("/api/v1/push/subscription", %{})
-        |> json_response(403)
+        |> json_response_and_validate_schema(403)
       end
     end
 
@@ -85,9 +87,9 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       res =
         conn
         |> get("/api/v1/push/subscription", %{})
-        |> json_response(404)
+        |> json_response_and_validate_schema(404)
 
-      assert "Not found" == res
+      assert %{"error" => "Record not found"} == res
     end
 
     test "returns a user subsciption", %{conn: conn, user: user, token: token} do
@@ -101,7 +103,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       res =
         conn
         |> get("/api/v1/push/subscription", %{})
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       expect = %{
         "alerts" => %{"mention" => true},
@@ -130,7 +132,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       assert_error_when_disable_push do
         conn
         |> put("/api/v1/push/subscription", %{data: %{"alerts" => %{"mention" => false}}})
-        |> json_response(403)
+        |> json_response_and_validate_schema(403)
       end
     end
 
@@ -140,7 +142,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
         |> put("/api/v1/push/subscription", %{
           data: %{"alerts" => %{"mention" => false, "follow" => true}}
         })
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       expect = %{
         "alerts" => %{"follow" => true, "mention" => false},
@@ -158,7 +160,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       assert_error_when_disable_push do
         conn
         |> delete("/api/v1/push/subscription", %{})
-        |> json_response(403)
+        |> json_response_and_validate_schema(403)
       end
     end
 
@@ -166,9 +168,9 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       res =
         conn
         |> delete("/api/v1/push/subscription", %{})
-        |> json_response(404)
+        |> json_response_and_validate_schema(404)
 
-      assert "Not found" == res
+      assert %{"error" => "Record not found"} == res
     end
 
     test "returns empty result and delete user subsciption", %{
@@ -186,7 +188,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
       res =
         conn
         |> delete("/api/v1/push/subscription", %{})
-        |> json_response(200)
+        |> json_response_and_validate_schema(200)
 
       assert %{} == res
       refute Pleroma.Repo.get(Subscription, subscription.id)
diff --git a/test/web/plugs/plug_test.exs b/test/web/plugs/plug_test.exs
new file mode 100644
index 000000000..943e484e7
--- /dev/null
+++ b/test/web/plugs/plug_test.exs
@@ -0,0 +1,91 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.PlugTest do
+  @moduledoc "Tests for the functionality added via `use Pleroma.Web, :plug`"
+
+  alias Pleroma.Plugs.ExpectAuthenticatedCheckPlug
+  alias Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug
+  alias Pleroma.Plugs.PlugHelper
+
+  import Mock
+
+  use Pleroma.Web.ConnCase
+
+  describe "when plug is skipped, " do
+    setup_with_mocks(
+      [
+        {ExpectPublicOrAuthenticatedCheckPlug, [:passthrough], []}
+      ],
+      %{conn: conn}
+    ) do
+      conn = ExpectPublicOrAuthenticatedCheckPlug.skip_plug(conn)
+      %{conn: conn}
+    end
+
+    test "it neither adds plug to called plugs list nor calls `perform/2`, " <>
+           "regardless of :if_func / :unless_func options",
+         %{conn: conn} do
+      for opts <- [%{}, %{if_func: fn _ -> true end}, %{unless_func: fn _ -> false end}] do
+        ret_conn = ExpectPublicOrAuthenticatedCheckPlug.call(conn, opts)
+
+        refute called(ExpectPublicOrAuthenticatedCheckPlug.perform(:_, :_))
+        refute PlugHelper.plug_called?(ret_conn, ExpectPublicOrAuthenticatedCheckPlug)
+      end
+    end
+  end
+
+  describe "when plug is NOT skipped, " do
+    setup_with_mocks([{ExpectAuthenticatedCheckPlug, [:passthrough], []}]) do
+      :ok
+    end
+
+    test "with no pre-run checks, adds plug to called plugs list and calls `perform/2`", %{
+      conn: conn
+    } do
+      ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{})
+
+      assert called(ExpectAuthenticatedCheckPlug.perform(ret_conn, :_))
+      assert PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+    end
+
+    test "when :if_func option is given, calls the plug only if provided function evals tru-ish",
+         %{conn: conn} do
+      ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{if_func: fn _ -> false end})
+
+      refute called(ExpectAuthenticatedCheckPlug.perform(:_, :_))
+      refute PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+
+      ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{if_func: fn _ -> true end})
+
+      assert called(ExpectAuthenticatedCheckPlug.perform(ret_conn, :_))
+      assert PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+    end
+
+    test "if :unless_func option is given, calls the plug only if provided function evals falsy",
+         %{conn: conn} do
+      ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{unless_func: fn _ -> true end})
+
+      refute called(ExpectAuthenticatedCheckPlug.perform(:_, :_))
+      refute PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+
+      ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{unless_func: fn _ -> false end})
+
+      assert called(ExpectAuthenticatedCheckPlug.perform(ret_conn, :_))
+      assert PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+    end
+
+    test "allows a plug to be called multiple times (even if it's in called plugs list)", %{
+      conn: conn
+    } do
+      conn = ExpectAuthenticatedCheckPlug.call(conn, %{an_option: :value1})
+      assert called(ExpectAuthenticatedCheckPlug.perform(conn, %{an_option: :value1}))
+
+      assert PlugHelper.plug_called?(conn, ExpectAuthenticatedCheckPlug)
+
+      conn = ExpectAuthenticatedCheckPlug.call(conn, %{an_option: :value2})
+      assert called(ExpectAuthenticatedCheckPlug.perform(conn, %{an_option: :value2}))
+    end
+  end
+end